Skip to main content

Dependency Injection

Dependency Injection is a big topic but central to Hades development.

If you're unsure about why you should care, the definitive resource is Dependency Injection by MarkSeeman but feel free to just google it.

The DI container described in this document is based on Inversify.js, but the concepts are the same for just about any modern container.

What is it?

DI is all about fetching instances of things:

  • Instances of some concrete type
  • Instances of some concrete type which implements some interface

After telling the DI container how create instances of our types, it takes on the role of passing them to the code they're needed by.

Your code depends on instances of those types. The container injects them.

↳ "dependency injection"

Container Configuration

Configuring the DI container consists of telling it how to make instances of types and interfaces.

If you'll ever want an instance of Foo, you have to, ahead of time, tell the container how to provide it. If you want an instance of some type which extends Useful, then you have to previously have told the container how to provide it.

The way you tell the container how to do this is by "binding" the desired types to the way of making obtaining them:

The basic pattern: container.bind(WHAT).to(HOW)

Constant values:

With constant values, we provide the value that will be bound to the type.

container.bind(Foo).toConstantValue(new Foo());

Constructors:

With constructors, we let the container call the type's constructor to create the new instance.

container.bind(Foo).toSelf();

container // from base class to implementor
.bind(Useful)
.to(Foo);

There are actually many ways to do binding in Inversify.js. Check out the docs..

Marking Types as Injectable

In order for our container bindings to work, we need to add the @injectable() decorator to our class:

@injectable()
class Foo {
/* ... */
}

Requesting Instances

Once the container knows how to produce instances of our @injectable() decorated types, your code can request them. If your code depended on using an instance of Foo, it could ask the container for one:

var foo = container.get(Foo);

Similarly, if your code depended on having an instance of Useful but didn't care which implementation is used, it can again ask the container:

var useful = container.get(Useful)

In this case, the container would create an instance of Foo since we told it to bind Useful to Foo.

Injecting Instances

For types with no constructor parameters (no dependencies), the container can easily instantiate it by calling the constructor.

But what if Foo has its own dependencies?

You can tell the container how to resolve Foo's dependencies by decorating its constructor's parameters with @inject():

@injectable()
class Foo {
constructor(
@inject(ILogger) logger: ILogger,
@inject(Number) randomNum: Number,
) {
// ...
}
}

The container can now call Foo's constructor as we've told it which types the parameters are bound to. The container simply makes instances of those first, and then passes them to Foos constructor. If the parameters have constructor parameters of their own, the container can in turn satisfy those dependencies too -- as long as all the required types and interfaces have been bound and marked.

Properly configured, a DI container can produce your program's entire object graph

Dynamic Resolvers

In a way, a concrete type's constructor can be thought of a factory - in that new instances can be made by calling it.

However, what if the container can't provide all of the constructor parameters for a given type? Say we don't have a binding to use with @inject()? Instead, we must provide a factory function that helps the container do the work of providing those unbound dependencies.

If Foo takes an ILogger and an Number we can assume the ILogger interface is bound usefully. However, instead of binding Number in the container, we can instead bind Foo to a dynamic resolver, which is just a simple lambda function:

container
.bind(Foo)
.toDynamicValue((context: Context) => {
var di = context.container;
var logger = container.get(ILogger);
return new Foo(logger, randomNumber());
})
.inSingletonScope();

When the container must produce an instance of Foo it will call this function. The function uses the container to resolve the ILogger dependency. But we're telling it how to provide the Number dependency (randomNumber()).

In this case, the container will only ever call the function once, to produce a single instance, and always return that one. This is thanks to binding Foo with inSingletonScope() which we'll cover in the next section.

Lifetimes

You can specify the "scope" or "lifetime" of a binding with an extra method after the .to() clause of a binding:

  • .inTransientScope() : A new instance is created for every need
  • .inSingletonScope() : A single instance is used for every need

In the above example, by changing the lifetime to transient, a new random number is produced each time an instance of Foo is provided:

container
.bind(Foo)
.toDynamicValue((context: Context) => {
var di = context.container;
var logger = container.get(ILogger);
return new Foo(logger, randomNumber());
})
.inTransientScope();

Transient is the default however, so this is unnecessary.

Targetted Bindings

Another way to inject Foo with a Number is by targetting a specific binding to it:

container
.bind(Number)
.toDynamicValue(context: Context => randomNumber())
.whenInjectedInto(Foo);

In this case, we've created a binding for Number which is only used when satisfying the dependency for Foo.