Dependency management for the dependency injection pattern: a Golang kata
Remember I made a kata on the dependency injection pattern? Well, the way I managed the dependencies was fine at that time. However, in a real life project, when the amount of dependencies grows, this may, and will turn into a hassle. Especially prominently it manifests during unit test writing, or adding a new dependency: every time when a service is initialized, it is required to import and initialize its dependencies. The amount of imports and the boilerplate size becomes crazy high, and it basically gets repeated in every file!
This is where a dependency manager comes into play.
The idea is simple: for every dependency there is a factory function, that returns an instance of that dependency. Should that instance also have some dependencies, the corresponding factory functions are called recursively. This kind of dependency nesting can be as deep as I want, the only condition is: the dependency graph must be acyclic. However, this is true by the definition of the pattern.
The calling code just needs to invoke one function, and the manager then unwraps the dependency tree.
One more cool thing is the fact, that Go actually allows a function to return an interface. That means I am, potentially, able to mock parts of the dependency tree. This is useful in unit testing for services, when repositories are actually mocked. But this is another story.
Another important thing is: from the perspective of the dependencies nothing actually changes: it is still old good dependency injection pattern in place. It's just the amount of work to be done externally reduces drastically.
So I took the repos and services from the kata mentioned above.
For the repository I've defined an interface:
package interfacesimport ("dependencymanager/internal/domain/database/book")type BookRepository interface {GetBooks(filter string, page int32) (books []*book.Book, err error)GetBookCount(filter string) (count int64, err error)}
Same for the service:
package interfacesimport "dependencymanager/internal/domain/business/book"type BookService interface {GetBooks(filter string, page int32) (result *book.GetBooksResult, err error)}
Splitting the manager into two parts (one for repos and the other for services) makes mocking the repositories possible.
package repositoryimport ("dependencymanager/internal/interfaces""dependencymanager/internal/repository/book""gorm.io/gorm")type Manager struct {session *gorm.DBbookRepository *book.Repository}func New(session *gorm.DB) *Manager {return &Manager{session: session,}}func (m *Manager) GetBookRepository() interfaces.BookRepository {if m.bookRepository == nil {m.bookRepository = book.New(m.session)}return m.bookRepository}
And then for the service:
package serviceimport ("dependencymanager/internal/interfaces""dependencymanager/internal/manager/repository"bookService "dependencymanager/internal/service/book""gorm.io/gorm")type Manager struct {session *gorm.DBrepositoryManager *repository.ManagerbookService *bookService.Service}func New(session *gorm.DB, repositoryManager *repository.Manager) *Manager {return &Manager{session: session,repositoryManager: repositoryManager,}}func (m *Manager) GetBookService() interfaces.BookService {if m.bookService == nil {m.bookService = bookService.New(m.session, m.repositoryManager.GetBookRepository())}return m.bookService}
Now, putting all that together saves some space in the main.go file, as I only need to create two managers and one service.
package mainimport ("context""fmt""net""net/http""os""sync""dependencymanager/internal/controller/book""dependencymanager/internal/manager/repository""dependencymanager/internal/manager/service""dependencymanager/internal/util/db")const (keyServerAddress = "serverAddress")func main() {session, err := db.Connect()if err != nil {panic(err)}svcManager := service.New(session, repository.New(session))// no need to create the books repository manualy nowbookController := book.Controller{BookService: svcManager.GetBookService(),}// ... the rest of the code is the same as before}
The manual solution explained in the article can be easily integrated into already existing codebase. However, for a newly-started project I encourage you (and myself) to try some libraries that already can do that kind of stuff. Take a look:
That's it for now folks. The benefits are obvious: should you decide to add or remove a dependency, there is no need to f**k around the code anymore, changing the New() function in 100 places, adding more and more imports along the way. Just go to the manager, add the dependency there, and it will be available everywhere.
The code is, as usual, here. Enjoy.
Sergei Gannochenko
Golang, React, TypeScript, Docker, AWS, Jamstack.
19+ years in dev.