Skip to content

DI Container Madness

Dependency Inversion is a useful principle to keep software maintainable. But when applied without thinking, it is a dementor in disguise that ruthlessly drains all simplicity from your application.

Dependency Inversion is a useful principle to keep software maintainable. SOLID‘s last letter ensures that components are loosely coupled. That makes it easier to understand them, to reason about them, to test them and to maintain them.

When you properly implement Dependency Inversion, you get Inversion of Control (IoC), which is a design pattern in which custom-written portions of a computer program receive the flow of control from a generic framework.

So far so good. So how do you properly implement Dependency Inversion? A popular way is by means of Dependency Injection. That is a programming technique in which an object or function receives other objects or functions that it requires, as opposed to creating them internally.

There are three ways of Dependency Injection: constructor injection, setter injection and interface injection. In this post we focus on the most popular approach, which is constructor injection.

Constructor injection works by providing dependencies as arguments to a client’s class constructor.

Manual DI

A simple way of doing this is through manual injection.

const database = new PostgressDatabase( process.env.CONNECTION_STRING );
const client = new DatabaseClient( database );

This snippet contains an example of manual depencency injection. It is called manual because we ‘manually’ (programmatically) create the dependency (the database object) and manually (programmatically) pass it to the constructor of the DatabaseClient object.

Manual dependency injection is the simplest form of dependency injection. But for some reason (of which I am not convinced, as I will discuss later in this post) it is not the most popular.

DI Containers

The most popular form of dependency injection (especially in feature-rich languages like C# and Java) is by means of a DI Container. A DI Container is part of a DI Framework and automates the process of dependency injection, thus eliminating the hard-wired (programatically) manual depencency injection.

DI Containers typically work by annotating injectable objects (so that the container knows which objects are available for automatic injection) and client objects (that receive injectables as their constructor arguments) and/or discovering them automatically by means of RTTI.

Scoping

Most DI Containers offer advanced features such as scoping, Scoping defines when new instances should be generated, and how long they should live. Typical scopes are:

  • Singleton – Only one instance is created during application startup. It is destroyed when the application ends.
  • Scoped – For every scope (like a request), a new instance is created for the lifetime of the scope.
  • Transient – Everytime a call is made to the DI container, a new instance is created.

Configuration

Many DI Containers also support configuration of dependencies through configuration files. That makes it very flexible to change the structure (and thus the working) of an application.

So, what’s the DI Container Madness all about?

DI Container are a great tool for some situations. When software projects become really complex, they may offer some benefits. But most projects are not that complex.

The use of DI Containers has 3 major disadvantages, and a whole bunch of proclaimed advantages that I dare to challenge.

Let start with the disadvantages of the use of DI Containers.

#1 Lack of simplicity

Experienced engineers know that the simplest solution is often the best. Manual DI is simple. Creating the injectable is a one-liner, namely calling a constructor. Every programmer must be able to do that and understand it. Creating the client that receives the injectable is also a one-liner, unless the client needs so many injectables that it is better to use multiple lines or to supply the dependencies as an options object. Again, this is simply invoking an object constrctor, with which every programmer must be familiar.

DI Containers are not simple. At least, not in comparison with manual DI. Every framework works differently, although the generic working is the same. Where it gets difficult is to understand which injectable is used for which client. This becomes especially tricky and complex when there are multiple implementations for the same interface, when there are multiple instances of the same implementation but with different parameters and/or when DI configuration comes from configuration files.

Because everything happens automatically, it is relatively complicated to adjust the behaviour, and for programmers that join the project at a later moment, to understand and find out where all of this is happening. It is possible to find some clues, but it is almost impossible to really prove that what you think will be happening is really happening without actually running the application and see what happens.

#2 Coupling with code

Dependency Injection is there to realize decoupling between objects. But also, many DI Container frameworks require that objects (both injectables and clients) are annotated/decorated with framework specific tags. Some DI frameworks even do not support certain types (like interfaces in TypeScript, which are only existant in TypeScript, but not in the compiled JavaScript code) which causes domain objects to have to be implemented differently (for example, as abstract classes instead of interfaces) just because of the DI framework.

A DI Container must decouple code. It is the world upside-down when code becomes coupled to the DI framework and even has to be implemented differently because of the framework.

#3 Scoping and implicit domain logic

Many DI frameworks support scoping, and when you give a programmer a tool, he will use the tool. Although it is really tempting to use the DI Container as a source of runtime objects, it binds your domain objects to the DI Container. The DI Container effectively acts as a factory for runtime objects. Of course you can abstract this dependency away by wrapping the DI Container in yet another factory, but the idea is still the same: domain logic depends on the DI framework for working properly.

So, what should you do for runtime objects? Just follow the simplest solution. You do not need a DI Container for this.

  • For newable objects like Entities, just create them via their constructor. When you already know which class you need to instantiate, and when there are no cases when you would need another class, even not from unit tests because the class is basically internal to your domain logic, there really is no reason to use a DI Container or even a factory for this.
  • For injectable objects like Services, first consider whether they can become constant (created during application startup, and destroyed during application shutdown). Perhaps they can be reused by implementing them such that they can handle multiple requests in parallel.
    If that is not possible, the factory pattern can be used. The factory is created at application startup and then manually injected into the service.

So, these were some disadvantages to the use of DI Containers. So what do they bring?

#4 Lifecycle management

We already discussed this before. Lifecycle management should not be part of a DI Container. It should be part of the domain logic. It should be explicit where programmers can read how an object is created: via a call to new or via a factory.

Lifecycle management is mainly there for lazy programmers that do not want to take the effort to create and implement a factory. Which, in a language like JavaScript, is nothing more than just a lamda function in the form (args) => Instance. You do not even need an interface and implementing object for that.

#5 Simple object instantiation

No need to manually write factories. Well.. what is simpler: invoking a factory, or figuring out where the dependencies come from? How can DI become simpler than writing our a constructor call or a factory call? Yes, creating factories may be a bit more work. But the simplicity it provides is worth double the effort.

#6 Less boilerplate and repetitive code

DI Containers allow you to define how a dependency should be resolved only once. That is true. But the same holds for manual DI:

const injectable = new MyInjectable();
const clientA = new MyClient(injectable);
const clientB = new MyClient(injectable);

Where is the repetitive code? Where is the boilerplate code? When done properly, manual DI does not produce repetitive or boilerplate code.

#7 Possibility to start using Aspect Oriented Programming

There are DI Containers that make it possible to add aspects to the methods of the interfaces they return. A client can request such an aspect (like automatic tracing or transaction management) to the DI Container, and the DI container silently wraps all interface methods with the logic that implements the aspect (like logging every call to the method). Or, alternatively, the client simply performs a regular request to the DI Container, but the DI Container is preconfigured to return the mentioned aspects.

This mechanism is explained in greater detail in this blog post. The same post also presents an impressive list of bads and uglies. I have nothing to add and let the bads and the ugly speak for themselves.

#8 Pin implementations to specific clients.

With DI Containers, it is possible to configure that all instances of a certain client use one type of depencency, whereas all instances of another client type (possible from another module) use a different type of dependency. Like a CustomerManager that would use a RedisRepository, whereas a SalesManager would use a PostgressRepository.

It is trivial to configure this with manual DI:

const postgresRepo = new PostgresRepo();
const redisRepo = new RedisRepo();
const salesManager = new SalesManager(postgresRepo);
const customerManager = new CustomerManager(reditRepo);

It is of course possible to split it up by module if you like:

function composeSalesModule() {
    const repo = new PostgresRepo();
    return new SalesManager(repo);
}

function composeCustomerModule() {
    const repo = new RedisRepo();
    return new CustomerManager(repo);
}

const salesManager = composeSalesModule();
const customerManager = composeCustomerModule();

A few lines more code perhaps, but very clear, readable, manageable and simple.

#9 It is declarative, not imperative

That’s certainly true. But is it an advantage? Or a prerequisite? It is much simpler to look at one place in your application (the Composition Root) for your imperatively defined dependencies, than to look all over the place in code and configuration files for declarative dependencies.

#10 Unit testing

One common reason for using DI Containers is to facilitate unit testing. In unit testing, one often wants to mock dependencies and a DI Container can do that.

But direct DI can do that just as well.

const mockRepo: ICustomerRepo = {
  getById: (id: string) => {
    return {
      id: id,
      name: 'Name for ' + id
    } 
  }
};

const myClient = new CustomerClient(mockRepo);
expect(myClient.findClient('123').name).toBe('Name for 123');

So, why are DI Containers Mad?

DI Containers are Mad because they ruin simplicity, introduce coupling with code, and tend to become integral and critical part of the domain logic, whilst offering no real benefits over direct DI for all but the largest and most complicatest of all projects.

When a piece of technology reduces simplicity without offering clear additional value, it is just madness to use the technology.

Tips and take-aways

To end this post, here are some tips and take-aways:

  • Use manual DI unless you have a really good reason for something else.
  • Create newables by directly invoking their constructor. Use factories for injectables.
  • Define a Composition Root close to the entry point of your application. This is a function or object (or for larger applications, a set of functions of objects) that manually creates all the dependencies and wires them together.
  • Only compose your object graph from within the Composition Root. All other parts of your application should not be concerned with DI nor should they make runtime calls to any sort of DI framework or container.
  • Read out your configuration from within the Composition Root, and pass configuration via constructor arguments (possible in the form of options objects) to your objects. Do not have your objects depend on a configuration provider, as that would ruin your beautiful decoupling.
  • When necessary, use the configuration from within your Composition Root, together with if statements, to compose a different object graph.
  • When you need a factory, think again. When you really need one because there are multiple implementations of the injectables that you need, or when the injectables have to be preconfigured by the factory, just write one manually. Don’t be tempted to use a DI Container for that. It’s not worth it.
  • For unit tests, use a different Composition Root than the bloated one of your main application. In essence, every test (suite) should have its own Composition Root.
  • When you are not yet convinced, consider reading one of my other blogs to find out whether it could be that you prefer fitting over true engineering and how to deal with that.

Leave a Reply

Your email address will not be published. Required fields are marked *