Lets learn Dependency Injection
— 5 minI believe a lot in the saying “learning by doing”, and often the best thing I can do to better understand a specific problem, topic, library or paradigm, is to actually implement it myself.
Often its enough to only think about how I would implement it, but sometimes its good to also write it down, so that’s what I will do here.
So this specific journey started already a few month ago, when at my previous job, we started to embrace DI (dependency injection), more specifically in the form of nestjs. The specific question I wanted answered was: Why does nestjs come with its own module system while JS already has modules, and the nestjs modules, depending on your project size only ever have one Service/Provider in it. The concept didn’t immediately click for me, I only got it after I thought about how I would implement a DI solution myself.
So lets go!
Actually the concept behind DI is very simple. In my own words, what it does is to decouple the what from the how.
That is also how we can think about the moving parts. The central part in DI is
called the Container
. What you do is ask the container to give you the thing
you want, the what, which essentially boils down to just a type. This is
most commonly the type of your service, but you can also use it with primitives
such as string
or number
if you wan’t to manage configuration via DI.
typedi calls this a Token
:
// The class is empty, its only purpose is to hold the type `T`, the **what**.
class Token<T> {}
interface Service {
foo: string;
}
// Define some specific things you want to expose.
const MyConfig = new Token<string>();
const MyService = new Token<Service>();
But how do we actually construct the things that we want, the how?
Really, the DI container itself does not really need to know itself how things
are created. It just delegates this to any kind of function that does so, which
is called the Provider
.
type Provider<T> = (container: Container) => T;
In my example, I want to keep things as simple as possible from the container
point of view, which means it is the responsibility of the Provider
to:
- initialize any dependency and
- cache/memoize things. Thus, we arrive at this very simple
Container
:
class Container {
/** The registry holds the `Provider`s keyed by `Token`s. */
private registry = new Map<Token<any>, Provider<any>>();
/** Register a new Provider for a Token, the **how**. */
public register<T>(token: Token<T>, provider: Provider<T>): this {
this.registry.set(token, provider);
return this;
}
/** This will give you **what** you want, you don’t need to care **how**. */
public get<T>(token: Token<T>): T {
return this.registry.get(token)!(this); // NOTE: this may throw!
}
}
This is essentially a very simple but working DI Container in 12 lines of code! Lets use it!
class ConcreteService {
constructor(public foo: string) {}
}
const container = new Container();
// We can use a static value
container.register(MyConfig, () => "my config value");
// Here, our `Provider` constructs a new value matching the `Service` interface,
// and uses the DI container to get any dependency value.
// Again, neither the DI container itself nor the user does not need to care.
container.register(
MyService,
// If we want to have a singleton, we can just wrap this function with some
// kind of `memoize`, which is left as an excercise for the reader.
container => new ConcreteService(container.get(MyConfig))
);
// This will lazily create a new instance:
const myService = container.get(MyService);
You can also play with the complete example in the TypeScript Playground.
# Making it more useful
Obviously, the example is optimized for simplicity and has some obvious problems:
- As noted in the comment, using
get
without previouslyregister
-ing aProvider
will throw. - It will run into infinite recursion when you have circular dependencies. I would argue to avoid circular dependencies in general. They work only in very specific circumstances and will blow up and burn your house down when you don’t take very good care.
- We can easily make this
async
as well. - It might be a good idea to actually bake more sophisticated knowledge about
dependencies into the
Container
itself, to optimize your dependency graph. - The example is also completely missing things like scopes and inheritance.
- You might want to be able to use a
Service
both asToken
and asProvider
.
But even implemented like this, it very clearly highlights the main selling
point of DI:
Neither you as a programmer, nor any of your Provider
s need to know how
other values are constructed. It just works.
This makes it very easy to override one of your Provider
s to construct a
mock object for your unit tests. Or to delegate to two different specific
implementations of a service, depending on your configuration, etc.
# Circling back to modules
Coming back to my original question about modules. We don’t see them in this very simple example. So lets think a bit about what happens when we start to scale this, when we have a lot more Providers to worry about, tens, or even hundreds of them.
We would need to call the register
function of our Container
for every
single one, which gets tedious very quickly. Also, we want to both have some
kind of encapsulation, and to not have to care about what specific Provider
s
there are.
So how can we simplify that? Lets add an extremely simple Module definition,
and extend our register
method to deal with it:
// Tokens and Providers go hand-in-hand, let’s call it a `Definition`.
interface Definition<T> {
token: Token<T>;
provider: Provider<T>;
}
// A `Module` is essentially just a list of definitions.
type Module = Array<Definition<any>>;
class Container {
public register<T>(token: Token<T>, provider: Provider<T>): this;
public register(module: Module): this;
public register<T>(modOrToken: Module | Token<T>, provider?: Provider<T>) {
if (Array.isArray(modOrToken)) {
for (const def of modOrToken) {
this.registry.set(def.token, def.provider);
}
} else {
this.registry.set(token, provider);
}
return this;
}
}
// And then we can create and use a `Module`:
const ConfigModule = [
{ token: MyConfig, provider: () => "module config value" }
];
container.register(ConfigModule);
Here is another Playground Link.
There you go! A Module
here is just an opaque set of Providers. Again, the
benefit is that you don’t need to care about what is actually in it.
You can use it to group multiple configuration values together, or to mock all the things at once.
There is one last pitfall though: we only have one registry per container, and the way it is implemented mains that whatever you get now depends on the order in which you register things. And there could be potentially nasty surprises depending on your modules, because well, modules are supposed to be opaque. In some more complex examples you would still have to manually optimize the order of things.
Anyway, this is it. Actually writing this blog post showed me even more how simple the basic concepts behind DI actually are!
It does get more complicated if you want a richer API, which is async
,
supports circular dependencies (please don’t), and if you want to handle
dependencies and memoization in the Container
itself.