Exploring Dependency Injection in Go
Introduction
There is a lot of material available about the pros and cons of Dependency Injection. This post is less about the pattern itself and more about its implementation design and it’s side-effects w.r.t Go
. Let’s setup a context for Go
users : As a clean programming practice, the theory of dependency injection is quite simple across several languages:
A dependency is passed to an object as an argument rather than the object creating or finding it.
It plainly means that the dependencies of an object are passed to it as it’s initial state. This is in contrast with using globals as dependencies wherein the same global resource is shared across multiple objects. It also means the object doesn’t self-initialize its dependencies.
|
|
Instead PostService
could explicitly depend on the user.Service
resource. While initializing the PostService
object, we would be injecting
it’s dependencies.
|
|
Designing for Dependency Injection
Goals
- No Global State: No package level variables, no package level func init. Refer: theory of modern go
- Support Multi-Mode Services: Configure a service to enable/disable capabilities.
- Better Testability: Easy testing/mocking.
- Better Refractor-ability: Design for code refractors.
- Better Readability: Explicit and readable dependency graph.
First, let’s look at a more involved example :
|
|
Though the concept of DI itself is simple, it takes a bit of premeditated design to avoid a convoluted injection code. In the above example, where user.Service
has multiple modes, the struct user.Service{...}
initialization does not make clear what dependents where required to make that happen. One can imagine this initialization pattern to get even more messy with changing requirements. Service usability and behavior could change based on:
- The service has multiple modes or capabilities. e.g:
limitedaccess
,maintenance
,readonly
etc. - The service has a commonly used
default
mode. - The service adds/removes dependencies over time(i.e capabilities).
The following conventions could be adopted to leverage the power of DI while side-stepping a few
of it’s pitfalls:
1. Use interfaces
To support multi-mode services(with a general default
mode), declaring interfaces is the way to go. Declaring interfaces allows us to have multiple implementations of the same service. When used as a dependency, clients don’t need to change the contract when a different mode of the service is passed.This is also regularly useful for testing .
|
|
Here, AppHandler
will accept any service(mode) which satisfies the user.Service
interface. We will see how it’s done, further ahead.
2. Prefer construction over initialization
In the previous example, we used struct initializers to inject dependencies. While this is good enough for a few dependencies, it gets hard to manage them once the service starts adding new capabilities. It also reduces readability and requires prior implicit knowledge of the dependencies of a service. Moreover, initializing a service using user.Service{}
limits our ability to enable/disable and extend service capabilities. I would recommend constructor functions
over struct initializers
to eliminate these problems. Let’s see. Possible approaches to construct a service object using functions:
New
. e.g:NewReadOnlyService(...)
,NewPrivilegedService(...)
Config
:type Config struct{}...
.NewService(c *Config)
.NewService(nil)
nil
would mean a defaultConfig
- Variadic
Config
.NewService(c ...Config)
. - Functional Options:
NewService(options ...func(Service)
The above ideas have been sourced from the excellent post by Dave Cheney. Would highly recommend a nice, slow read: https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis. Actually I will be right here, while you do that. The functional options
pattern is reasonable enough. But still doesn’t scale if you have a large number of dependencies and multiple modes. Consider the following:
|
|
We end up with a situation where again, there are many arguments passed into the New
function. Also it doesn’t make a lot of sense to have to declare default
behavior as a mode. Ideally, for a service we would want a default
behavior which we can override to create a new mode.
Functional Config Options
So, instead we use a combo of functional options
and Config
patterns to get around this complexity.
|
|
Note that we didn’t pass *Config
as defaultConfig
as we wouldn’t want the caller to hold the reference to it. Additional attributes/overrides to the defaultConfig
is done via configOptions
. The following snippets of code condenses the ideas we have talked about:
|
|
|
|
Testing/Mocking
Easier testing/mocking is a benefit which just falls out of implementing the dependency injection pattern. Since we already have a Service
interface, writing a mock implementation is a no-brainer. Our test code would now look something like this.
|
|
|
|
Refactoring Code
A sorted out dependency graph makes splitting our code into new services trivial.
|
|
|
|
Dependency Graph Builders
There are various other ways to build a sensible dependency graph. Packages like facebookgo/inject, codegangsta/inject are available to help. Unfortunately, I haven’t tried them yet.
Takeaway
Here is a tl;dr for the reader:
|
|
Dependency Injection in other languages consider more factors specific to the ecosystem. It’s important to evaluate which of the patterns emanating from those factors are applicable in Go. Hopefully I have presented a strong case for a sensible dependency injection pattern in Go.