What is a Dependency Injection Container and why use one

design pattern di ioc

This post explains what dependency injection is (aka. inversion of control) and how it can improve the code that defines your business logic. It also shows how the use of dependency injection may lead to the use of a dependency injection framework in your application.

The core concepts of a dependency injection container will be explained and a minimalist container will be created. Examples are in typescript because it is a language that is easy to understand. They illustrates the concepts and not a specific implementation. They could be translated in any other object oriented language.

Entities and Services

Business logic can be separated between entities and services.

Entities hold data. They are structures with values and they represent the state of your application. They can be associated with real objects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class House {
    address: string
    floorArea: number
}

class Car {
    brand: string
    color: string
}

class User {
    name: string
    email: string
}

class Email {
    id: int
    subject: string
    message: string
}

These entities may be saved in a database, but not necessarily.

Services, on the other hand, contain the business logic that defines the actions around the entities. These sets of actions can be represented by an interface.

1
2
3
interface EmailSenderInterface {
    send(user: User, email: Email)
}

An interface can be implemented by several classes to provide different variations of a service. For example, the EmailSenderInterface could be implemented by a SynchronousEmailSender, by an AsynchronousEmailSender, and for the tests by a DummyEmailSender.

Dependency injection is about services and how to make their implementations less coupled. Loose coupling makes the services more easily reusable and more maintainable. It also greatly improve their testability.

Note that entities can have methods with logic too, but it is better to stick to the bare minimum. Otherwise, your main entities could vampirize the code of the application and become gigantic. It is better to separate this logic into services with a clear purpose.

Services and Dependencies

Services can be classes that you have written, but they can also be classes from imported libraries. For example it can be a logger or a database connection. So, even if you can write a service that works alone without any external help, you will probably quickly reach a point where one of your services will have to use the code of another service.

Let’s see this in a small example.

We will create an EmailSender. This class will be used to send an email. It will have to write in a database that the email has been sent, and also log the errors that may occur.

The EmailSender will depend on three other services: an SmtpClient to send the email, an EmailRepository for the interactions with the database, and a Logger to log the errors.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
interface SmtpClientInterface {
    send(toName: string, toEmail: string, subject: string, message: string)
}

interface EmailRepositoryInterface {
    insertEmail(address: string, email: Email, status: string)
    updateEmailStatus(id: number, status: string)
}

interface LoggerInterface {
    error(message: string)
}

class EmailSender {
    client: SmtpClientInterface
    repo: EmailRepositoryInterface
    logger: LoggerInterface
    
    send(user: User, email: Email) {
        try {
            this.repo.insertEmail(user.email, email, "sending")
            this.client.send(user.email, user.name, email.subject, email.message)
            this.repo.updateEmailStatus(email.id, "sent")
        } catch(e) {
            this.logger.error(e.toString())
        }
    }
}

The three dependencies of the EmailSender are declared as attributes. The idea with dependency injection is that the EmailSender should not have the responsibility to create its dependencies. They should be injected from the outside. The details of their configuration should be unknown to the EmailSender.

To inject these dependencies you need to use a setter. You could add the setSmtpClient(), setEmailRepository() and setLogger() methods on the EmailSender.

1
2
3
4
5
// inject dependencies with setters
sender = new EmailSender()
sender.setSmtpClient(client)
sender.setEmailRepository(repo)
sender.setLogger(logger)

But the dependencies do not necessarily need to be private with an associated setter to benefit from dependency injection. If the dependencies were public, it would also work.

The constructor is a good place to call all the necessary setters. It ensures that once the object is created, it works as expected and no dependencies have been forgotten. In our case, we would have:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class EmailSender {
    // ... dependencies and other attributes ...
    
    constructor(client: SmtpClientInterface, repo: EmailRepositoryInterface, logger: LoggerInterface) {
        this.client = client
        this.repo = repo
        this.logger = logger
    }
    
    // ... methods ...
}

When it is possible, I prefer to set the dependencies in the constructor. But you should use the method that you think is the best suited for your application.

Dependency Injection and Decoupling

The key concept of dependency injection is decoupling. Services should not bind their dependencies to a specific implementation. They would lose their reusability. Thus that would make them harder to maintain and harder to test.

To achieve this loose coupling, the dependencies need to be created outside the service. For example the dependencies may be provided to the constructor.

The following constructor is a bad example:

1
2
3
constructor() {
    this.client = new SmtpClient()
}

Here you are forced to use a specific client. Its configuration can not be changed. Even if your application does not need more in production, it may be problematic in development, and it will certainly be problematic in the tests. Generally, the use of new or static methods in a constructor can be seen as a code smell and goes against the dependency injection principle.

What should be done instead:

1
2
3
constructor(client: SmtpClientInterface) {
    this.client = client
}

This way you are free to use the implementation you want. For example, in development you would use a client that sends all the emails to the developer. And in the tests you would be able to replace it with a mock that does not really send the emails.

Even if you do not write your tests before your code (test driven development), it is always important to think about them when you write your services. Some things can be hard to manage, like the queries to a database (or any external api), the use of random, or the use of the current time. These things should probably be encapsulated in another service, so that they can be replaced by a different implementation in the tests.

That being said, the constructor parameters do not necessarily need to be interfaces:

1
2
3
constructor(client: SmtpClient) {
    this.smtp = smtp
}

This constructor may be good enough if the SmtpClient can be configured in a way that let you test the service. Interfaces are great, but they can make your code harder to read. If you want to avoid over-engineered code, it can be a good approach to start with classes and replace them with interfaces when it becomes necessary.

Service Creation

Now you understand that it can be beneficial to have the services unaware of how their dependencies are instantiated. Nevertheless the dependencies should still be created before they can be injected. The question that remains is to define where the dependencies should be created.

The code of your application can probably be split into two parts: the framework and the business logic. The framework defines the environment in which your business logic will be used. It can be a terminal command, a web application, a desktop application. Anyway, the framework part and the business part should be as separated as possible. This would allow you to reuse your business logic with another framework.

The creation of your services should also be separated. Actually it belongs to the code that links the framework to the business logic.

Most applications will have the same workflow. Something that can be called a controller.

  • step 1 The framework reacts to an event coming from the user (cli command, http request, click on GUI, …)
  • step 2 The event is parsed to match the service input
  • step 3 The service for this request is created among with its dependencies
  • step 4 The service method is called
  • step 5 The response is sent back to the framework so it can be propagated to the user (message in terminal, http response, …)

This way the business logic is set aside inside step 4. step 3 creates the services, so the controller is the only place where services are bound to an implementation.

The framework can change but you will keep your business logic. The services can stay decoupled and be easily unit tested. On the other hand, controllers are more difficult to unit test, but they are good candidates for functional tests.

From now on, we will focus on the service creation, and see how it can be handled by a dependency injection container.

Application Context

Creating the services is not a painless process. It is because the dependencies can become complex very quickly.

Let’s illustrate this by designing a service called NewsletterSender. This service should be able to send a newsletter to some users. It requires an EmailSender to send the emails and a UserManager to determine who has subscribed to the newsletter.

The dependency tree would look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
NewsletterSender
 ├─ Logger
 ├─ EmailSender
 │   ├─ Logger
 │   └─ EmailRepository
 │       └─ DatabaseConnection
 └─ UserManager
     ├─ Logger
     └─ UserRepository
         └─ DatabaseConnection

The dependencies can not be created in any order. Some of them need to be constructed before others. In this example, the DatabaseConnection and the Logger should be built first. Then the repositories can be created, followed by the EmailSender and the UserManager. It is only once all these objects have been instantiated that the NewsletterSender can be generated.

With this in mind you can write a function that creates the NewsletterSender service. Let’s call a factory, a function whose role is to build a service. A factory is somehow like a constructor. But it is allowed to do more. It can create and bind the dependencies.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function buildNewsletterSender(): NewsletterSender {
    let logger = new Logger()
    let conn = new DatabaseConnection()
    let userRepo = new UserRepository(conn)
    let emailRepo = new EmailRepository(conn)
    let emailSender = new EmailSender(logger, emailRepo)
    let userManager = new UserManager(logger, userRepo)

    return new NewsletterSender(logger, emailSender, userManager)
}

Great ! You wrote the factory that creates the NewsletterSender service and your controller works. This is good if you have only one controller. Unfortunately it would become more complicated if you had more than one. And in practice you will have more than one controller.

In this case you would need to write a factory for each controller. For the services that are used in several controllers like the Logger, you would have to write the initialization code in different factories. But code replication is bad, especially in this part of the code that is a little messy and not really meant to be unit tested. We should probably consider refactoring.

The idea would be to create an object containing all the services. There would be one big factory that creates this object representing the context of the whole application.

Let’s say you have another controller in charge of updating a user in the database. It uses a service called UserUpdater that depends on the Logger and the UserRepository. You would have this application context:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Context {
    newsletterSender: NewsletterSender
    userUpdater: UserUpdater
    // other services that are not used directly by controllers can also be added here
    logger: Logger
    conn: DatabaseConnection
    userRepo: UserRepository
    emailRepo: EmailRepository
    emailSender: EmailSender
    userManager: UserManager
}

function buildContext(): Context {
    let c = new Context()

    c.logger = new Logger()
    c.conn = new DatabaseConnection()
    c.userRepo = new UserRepository(c.conn)
    c.emailRepo = new EmailRepository(c.conn)
    c.emailSender = new EmailSender(c.logger, c.emailRepo)
    c.userManager = new UserManager(c.logger, c.userRepo)

    c.newsletterSender = new NewsletterSender(c.logger, c.emailSender, c.userManager)
    c.userUpdater = new UserUpdater(c.logger, c.userRepo)

    return c
}

This context can be used in all your controllers. And the buildContext function is the only place where you have to think about service initialization.

This context is somehow a very basic dependency injection container.

Improvements

The context we just created has two majors flaws:

  • The first one is that if the application grows, the size of the buildContext function would rapidly become unmanageable. We need to be able to split this function into smaller parts.

  • The second flaw is even more troublesome. Sure the context can be used in all the containers. But that also means that each controller have to build the whole context even if they only use only a small part of it. Why would you create a database connection if you do not need it ? We want to create only the services we need, when we need them.

There is a solution to avoid this. Instead of only storing the services in the context, let’s also store the functions to build them, the factories.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Context {
    services: Map<string, any>
    factories: Map<string, () => any>
}

class A {}
class B {a: A}

function buildContext(): Context {
    let context = new Context()
    context.services = new Map()
    context.factories = new Map()

    // register service A
    context.factories.set("A", function() {
        return new A()
    })

    // register service B
    context.factories.set("B", function() {
        let b = new B()
        // We need to retrieve A from the context.
        // We can not use context.services directly because it may not be there yet.
        // So we need to use a retrieveService function that use the factories.
        b.a = retrieveService(context, "A")
        return b
    })

    return context
}

// retrieveService is a function that retrieves a service a the context.
// It uses the factory to build the service.
// The created services are registered in context.services.
// The next times, the service will be retrieved from context.services
// without instantiating the service again.
function retrieveService(context: Context, service: string) {
    if (context.services.has(service)) {
        return context.services.get(service)
    }
    if (!context.factories.has(service)) {
        return null
    }
    let factory = context.factories.get(service)
    let s = factory()
    context.services.set(service, s)
    return s
}

All the magic happens in the retrieveService function. It uses the singleton pattern. When the function is called, it checks if the service has already been built. If it is the case, it reuses the instance. If the instance does not exist, it uses the factory to create and save the service. It is the very core of a dependency injection container.

You now have lazy initialization for your services. This solves the second flaw.

But this pattern also solves the first flaw. That is because the factories can also use the retrieveService function. They do not need to know exactly how the dependencies are created. Therefore the factories can be declared in any order. That leaves room to reorganize the buildContext function, even when the number of factories increases.

The code could be rewritten in a cleaner way to create a real dependency injection container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
type Factory = (container: Container) => any;

class Container {
    services: Map<string, any>
    factories: Map<string, Factory>

    constructor() {
        this.services = new Map()
        this.factories = new Map()
    }
    
    set(name: string, factory: Factory) {
        this.factories.set(name, factory)
    }
    
    get(name: string): any {
        if (this.services.has(name)) {
            return this.services.get(name)
        }
        if (!this.factories.has(name)) {
            return null
        }
        let s = this.factories.get(name)(this)
        this.services.set(name, s)
        return s
    }
}

// And now we can use the Container !

let c = new Container()

class A {}
class B {a: A}

c.set("A", function(c: Container) {
    return new A()
})

c.set("B", function(c: Container) {
    let b = new B()
    b.a = c.get("A")
    return b
})

let b = c.get("B") // B has be created with its dependency A
let a = c.get("A") // A has already been created for B, the same instance is reused

console.log(b.a === a) // true

And you are done, you have built a dependency injection container !

It just around 25 lines of code. Sure it is minimalist, but the main ideas are here and it works. The code is valid typescript and can be converted to javascript on the typescript playground. Then the javascript can be executed in your browser.

Conclusion

Now I hope you understand the concept of dependency injection. You should also know how dependency injection containers work. It is up to you to see if they can improve your application. I think that they have helped a lot in the web applications I have built. But in the end it really depends on your use case.

The main advantage of dependency injection is decoupling. It can greatly improve the reusability and the testability of your code. The drawback is that in exchange, the creation of the services will be far from your business logic. That can make your code harder to understand.

If you use go (golang), have a look at my library sarulabs/di. It uses the concepts presented in this article to bring dependency injection containers in go.

Side notes

Singletons

In the dependency injection container we created, objects are singletons. Some frameworks allows to register services with a factory that is called each time the get method is called. I think it is better to stick to singletons for all your services. This maintains a homogeneity in the use of the library. If you need to instantiate a service every time, do not register it in the container directly. Instead register a builder that can create the object you need.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MyServiceBuilder {
    build(): MyService {
        return new MyService()
    }
}

container.set("my-service-builder", function(c: Container) {
    return new MyServiceBuilder()
})

myService = container.get("my-service-builder").build()

Typed languages

The concept of dependency injection container is applicable in all object oriented languages. But it is far easier to implement it in languages that are not typed. The get method returns the object you need, but in typed languages it should be cast before it can be used.

1
container.get("my-service-builder").(MyServiceBuilder).build()

Container as a dependency

I have seen people using the container as a dependency for some services:

1
2
3
4
5
6
7
8
9
class MyService {
    container: Container
}

container.set("my-service", function(c: Container)) {
    let s = new MyService()
    s.container = c
    return s
})

While it is technically correct, it is really a bad idea. Using a dependency injection container, you have separated the services creation and your business logic. By using the container as a dependency you link the two parts again and you lose most of the benefits.

Moreover the real dependencies of your service are not clear anymore, and the service has become a lot more difficult to test.

There are very few cases where having the container as a dependency would be useful. It is probably something you should never do.