Go: Сomparing dependency injection approaches
In this article, I wanted to discuss such a development pattern called dependency injection.
And on the example of the Go language, compare two approaches, namely, a couple of libraries that implement these approaches.
Dependency injection (DI) is the process of providing external dependency to a software component.
Let me quickly explain why this approach is needed for those who may not have heard about it.
Suppose we have a certain service that is configured by a certain config and works with the database through the repository abstraction:
type Service struct {
config *Config
repo Repository
}
We create an instance of this service for further work with it:
func NewService() *Service {
cfg:=LoadCfgFromFile("cfg.yml")
db,_:= DbConnect(cfg)
return &Service{
config: cfg,
repo: NewRepository(db)
}
}
Everything is fine. To create a new service you do not need anything except a configuration file!
But, what if tomorrow we want to get the configuration from the environment variables or Consul. Have to rewrite the service.
Then think about how we will test the repository? We will have to connect to a real database, instead of mock-testing!
As we all know, in order to write high-quality and well-tested code, we need to break the tight link between the software component
and required dependencies.
In our case, the dependencies will be passed to the specific component as the parameters of its constructor:
func NewService(config *Config, repo Repository) *Service {
return &Service{config: config, repo: repo}
}
And now, instead of the repository, we can pass the mock-object and easily test the service without connecting to a real database.
We can also fill out our config anywhere and anytime.
Now everything is convenient, but time is running out, our service is expanding, becoming more complicated, and similar services with their dependencies are being added to it.
From now on, you need to deal with the creation of all these dependencies in the right sequence and in the right quantities.
Create a configuration, create a connection to the database and pass it to the repository designer. Then all this is transferred to the constructor of the service.
This becomes a problem when the number of dependencies and the services themselves grows.
But happily, these problems have already been solved, we can only choose approach.
Uber’s reflection-based approach
The main thing to know here is the concept of a container -
a place where providers our objects are added methods that create configurations and repositories objects and from where you will receive a Service object.
In dig, these are the provide and invoke methods, respectively.
The used technique is as follows:
- Create a container
container := dig.New()
- Add providers
container.Provide(NewConfig)
container.Provide(DbConnect)
container.Provide(NewRepository)
container.Provide(NewService)
- Provide a finished object of Service
container.Invoke(func(server *Service) {
service.Start()
})
Google’s code generation approach
https://github.com/google/wire
Here the approach is similar, but there are differences in implementation:
- We create wire.go in which we describe everything that we need to create our service
func InitializeService() *Service {
wire.Build(NewService, NewConfig, DbConnect, NewRepository)
return &Service{}
}
- Using the wire utility, we generate everything that we needed to write with our hands
// wire_gen.go
func InitializeService() *Service {
cfg:= NewConfig()
db:= DbConnect(cfg)
repo:=NewRepository(db)
return NewService(cfg,repo)
}
Conclusion
You should choose, but in my opinion, the use of code generation has some advantages over reflection.
We learn about all the problems at the compilation time and we will also do all the dirty work in advance!
Rob Pike: Reflection is never clear
I advise you to turn to the Google library. Their detailed instructions will help you start using it as quickly as possible.