When I mention dependency injection to folks who have Java or .NET backgrounds it usually invokes a few sets of questions — to which my usual response is:
It’s likely not as sophisticated as what you’re thinking — and it doesn’t have to be!
While there are methods to use more sophisticated approaches in Go, starting off projects the simplest way possible is still the preferred approach.
Dependency injection represents the D in SOLID principles. Quoting from wikipedia:
In object-oriented computer programming, SOLID is a mnemonic acronym for five design principles intended to make software designs more understandable, flexible, and maintainable.
Why is dependency injection important in practice?
The answer depends — and it really depends how much of your domain requires very heavy dependencies.
Let’s use a hypothetical application which we can sum up as:
allow customers to store their code and run them in some sort of containerized environment.
In this product, our ubiquitous language might have the following entities:
- Code — represents a customer’s arbitrary code.
In addition, we might require the following service abstractions:
- Repository — provides an abstraction of data.
- Runtime — allows you to execute arbitrary code.
And a classical router, controller, or command layer to receive requests and act on them:
- Server — can be considered as the Command layer as in CQRS.
Sketching a minimal example of this:
Playing out what tech we’ll use
If we stick to boring technology:
- Repository — will likely just be backed by postgres.
- Runtime — we can explore using lambda (or openwhisk, cloudflare workers, the list goes on).
How will we test these?
If we want to have a good testing experience, we’ll strive for the
# just works without any dependencies at all.
go test ./…# does integration testing using env vars or flags.
env $(cat .env) go test /… -tags integration
So what does this mean if we want to be able to test without any postgres dependency? Likely it means we either don’t test the postgres implementation of the Repository at all. That’s probably fine, but then would we also skip testing the Server layer because it depends on
Similarly, if our Runtime implementation relies on lambda, how will we test that reliably?
This is where interfaces, and good design helps!
It’s all about the interface
If we changed the code above to instead rely on an interface:
Then we can at least test the Server layer by providing a mock implementation for our dependency-less testing.
We can then do
-tags integration style testing in CI to provide a real database, and a test AWS environment for us to interact with lambda so we’re guaranteed that any change we do hits the real thing all the time — but we still empower developers with a faster feedback loop on average.
So what about dependency injection?
Ultimately, because we’ve designed our system to take in well defined services instead of concrete implementations, we can simply change how we initialize our main file:
And then in our test, we can imagine providing all the mock / stub
implementations as necessary.
Will we end up writing a lot of boilerplate mock code?
Probably yes — and it really depends how much of your domain requires very heavy dependencies.
If your domain is really just interacting with a database — maybe you can argue that in order for your devs to work with the code, a local postgres must be available (or one in docker) — and that’s fine! Ultimately it’s all about trade-offs.
Once the set of dependencies becomes a bit too heavy though, we’d want to keep the developer feedback loop as fast as possible, while answering the following questions:
1. How much of the project business logic involves these heavy dependencies? Is it 10%? 50%?
2. How often do developers modify these external heavy dependencies? (In the example we have, how much of the work becomes modifying lambda code, vs modifying PostgreSQL code, vs modifying the server code?)
As is often the case with software engineering, it’s all about trade-offs — and when you need to reach for dependency injection it’s just right there in your tool belt.
- In certain cases, certain companies have even gone and implemented an entire mock implementation for their customers to use. For example, LetsEncrypt shipped pebble which is a miniaturized version of their real service offering. Hashicorp ships with an in-memory vault for easy testing as well.
- When should you reach for sophisticated tools like wire? I personally would try to avoid those tools as they add a lot of unnecessary complexity in terms of reasoning about your code — but again, YMMV.