How Dependency Injection makes large Projects more fun to work with.

How Dependency Injection makes large Projects more fun to work with.
Because "Dependency Injection" is such a chunky word, I will refer to it as "DI" further in the article.

First of all, what the heck is DI?

Take the following example:
We have a web application which is accessible via a REST API. This application is connected to a database where user information are stored in. Also, there is a service for sending e-mails to users, for example, when they forget their password. Because we built our application on the concept of SoC (separation of concerns), we have a separate service implementation for all of these three services.
Now, when the user wants to reset their password by using a REST API endpoint to do so, the REST API service needs to access the database to look for the user by their e-mail address. After that, when a user was found to the specified e-mail address, an e-mail must be sent via the e-mail service.
Typically, constructing such services like a database or e-mail service, a lot of information is required like database or mail server credentials. These are mostly provided via some kind of configuration. Also, these services might need other services like caching middlewares or proxy services of any kind, which additionally must be constructed.

Now, DI comes into place. Instead of constructing the services in place where they are needed, we construct them somewhere in a higher level of the application (mostly in or near the main function / class) and then pass them down to the services where they are needed.

Here is a quick demonstration in Go.

Decoupling Services

In the example above, we directly couple together the MyAPIService with the MyDBService and MyEMailService.
You can do it like this, but you really should not. Why? What if you want to use a completely different database implementation? Or what if you want to use different database implementation depending on configuration, so you never know which database implementation is used at compile time? Therefore, interfaces come in handy.

Now, MyAPIService only references the DBService and EMailService interfaces, which describe the available functionalities of the services used. This allows us that we can swap the implementation instances as we desire, as long as they implement these interfaces.

Also, this allows easier testing of the single implementations because we can use  service mocks, for example.

Using DI Containers / Injectors

When you are working with large applications where some services might need to interact with dozens of other services, you will run into the - like I call it - "constructor hell". Just take a look at the following example from the old constructor implementation of the web server of my Discord bot shinpuru.

This is outrageously bad readable. And there is another problem: What if you need another service in the web server service? Then you need to add it to the constructor, then look where you initialized this constructor and also add the dependency there. And the same for every other service which needs access to the new service as well.

That's why a lot of developers are using various dependency injection libraries which introduce service injectors or containers. These are really just sophisticated key-value maps of service descriptors and service instances. Let's take a look at the following example. Here you can see the Setup function of a REST API Controller, which I am using in the new re-implementation of the web server of shinpuru.

As you can see, the only two objects I am passing to the Setup function are the Route where the Controller will register its endpoints to and a di.Container, which is the interface of the dependency injection containers of the DI library sarulabs/di, which I am using in shinpuru.

After that, I am just registering all the required services in my main function using the di.Builder. As you can see, I am passing functions there which describe how to build the services required. This pattern allows that services are only built once they are actually needed. This can improve the performance of your application because not all services are instantly created on startup. Also, you can specify teardown functions which will be called to gracefully shut down your services after they are no more needed.

Also, a lot of DI libraries allow to set lifetime specifications. In the example shown above, all services are initialized as singleton instances. That means, that the service is initialized on the first request to it and after that, all requests to the container with the same specifier are getting the same, before initialized instance. This is especially useful if you have stateful services like database connections, caching services or something like that.
On the other hand you can specify services as unshared, which means that a new service instance is created every time you request that specific service. This is handy for lightweight, stateless services.

Most DI libraries also allow working with scopes. This might come in handy, when you need separate instances for each request in a web application, for example. Here you can see how sarulabs/di implements and handles scopes.

Some DI containers even work using reflection. A great example of this is the built-in IServiceProvider implementation of .NET. Here you can find more how that works.

Resources

Videos