Automatic Dependency Injection
In the composition root, instances of the objects in our program are created in an order determined by their dependency relations. When writing the composition root, we must work out this ordering. And of course this code will also need to be maintained as the structure of our program changes over time.
Donât despair, thereâs a generalized solution for automating all this :)
DI Container Basics
A Dependency Injection Container is a software pattern that allows us to avoid having to manually write our programâs composition root.
What is a DI Container?
Fundamentally a DI Container just a fancy key-value map. Yes, really!
đĄ A DI Container is a key-value map:
âa kind of thingâ â âa way of making that kind of thingâ
A DI Container maintains this mapping from âkind of thingâ to âways of making that kind of thingâ.
How is the DI Container configured?
DI Containers typically utilize the builder pattern for creating associations:
const container = new Container()
container
.bind(Foo)
.to(Foo)
In this case, we associate Foo , a kind of thing, with the Foo constructor, a way of making them.
DI Containers will usually have many ways of specifying mechanisms for construction.
In this example, we give the container an arrow-function to call on each request:
container
.bind(Foo)
.toDynamicValue(() => new Foo())
As a final example, we could tell the container to always return the same statically constructed instance:
container
.bind(Foo)
.toConstantValue(new Foo())
What is a DI Container for?
After binding some things into the container, we can then ask the container for instances of those things:
const foo = container.get(Foo)
In this case, we have supplied the Foo class as the âkind of thingâ we are interested in.
What we get back will depend on how we bound Foo into the container:
container.bind(Foo).to(Foo)
const foo1 = container.get(Foo) // new Foo constructed
const foo2 = container.get(Foo) // new Foo constructed
foo1 === foo2 // false!
Alternatively:
container.bind(Foo).toConstantValue(new Foo())
const foo1 = container.get(Foo) // Foo constructed above
const foo2 = container.get(Foo) // save Foo constructed above
foo1 === foo2 // true!
Recursive Dependency Resolution
So far, the DI Container may seem a little silly. Why use this pattern just to avoid calling new ourselves?
In all of the examples above, Foo didnât have any dependencies of its own.
However, if we reintroduce the dependency graph from the last chapter we can see the DI Containerâs real trick:
App -> Foo -> Bar -> Baz
\ -> Boz
Letâs tell the container about these types:
container.bind(App).to(App)
container.bind(Foo).to(Foo)
container.bind(Bar).to(Bar)
container.bind(Baz).to(Baz)
container.bind(Boz).to(Boz)
const app = container.get(App)
app.start()
This is not much different than our original composition root:
const baz = new Baz()
const boz = new Boz()
const bar = new Bar(baz, boz)
const foo = new Foo(bar)
const app = new App(foo)
app.start()
However, there are some important key differences!
Ordering Doesnât Matter
The order of the binding statements donât matter. They could be reordered arbitrarily.
The container doesnât need to be told about the topological order because it figures it out automatically.
We Donât Need To Know As Much
In the composition root, we need to have intimate knowledge of all our types and their constructor parameters.
We also have to work out the order in which they must be composed. Now we donât need to know any of those things.
We Can Skip Binding By Using Decorators
Later on weâll learn that we can slap decorators on our types to automate binding the container:
@provide(Foo)
class Foo { ... }
How does the container work?
When we ask the container for an instance of App the container follows a process like this:
graph TB
Get{Ask for App} --> Lookup[Do I have it?]
Lookup -- yes --> Method[Which mechanism?]
Lookup -- no --> Error
Method -- const --> Const[Return const value]
Method -- lambda --> Lambda[Return lambda return-value]
Method -- constructor --> Constructor[For each dependency]
Constructor -- Unresolved dependency --> Lookup
Constructor -- All dependencies resolved --> Call[Call constru
Call --> Return[Return constructed instance]
Summary
In this chapter, we learned:
- A DI Container is basically a fancy key-value map
- How to tell the container about our components
- That we can ask the container for instances of our components
- The container recursively satisfies the dependencies of our components
- Using a container reduces the upfront and maintenance burden compared to a manual composition-root
Next Chapter
đś CH6: DI Container Patterns
In the next chapter weâll take a look at some advanced DI Container concepts including how we can use it to make good on the architectural ideas we learned in earlier chapters.