DEV Community

Cover image for Understanding dependency injection once and for all.
Guilherme Ribeiro de Souza
Guilherme Ribeiro de Souza

Posted on

Understanding dependency injection once and for all.

This article explores and explains dependency injection with different approaches, from the worst way of doing it to using a library to handle the process.

Introduction

For a while now, I've been trying to explain the concept and usage of dependency injection to my teammates. I've tried a few approaches, but I realized I've never made myself clear. I noticed that using frameworks and libraries during these explanations made the challenge even greater. So, I've decided to explain using TypeScript and doing things more manually. Come along with me.

Our Context

Throughout the explanation, we will be using a simple Node.js project with Express and TypeScript. In this project, we will have three modules of examples. Think of these three modules separately, even though they are in the same project. Each module is responsible for an example scenario. This code will be available on my GitHub if you want to play around with it a bit.

Project Structure

In the project structure, we have the index.ts which is the application's entry point, the application's routes file, and the shared folder with components that would be shared among various modules, we have the "docs" folder with a JSON file for you to import into Insomnia and test the requests. Lastly, we have the "modules" folder where we have three subfolders: example1, example2, and example3, each representing a scenario. Within each "example" folder, there are DTOs, Entities, a repository, and a useCase. This structure will be repeated for the other modules with very specific changes.

Example 1

Starting with example 1, we have the class UserRepository, which is responsible for the methods to interact with the database. In this example, we have the method createUser simulating the creation of a record.

User repository code

In the sequence, we have the CreateUserUseCase class responsible for the business rule associated with user creation and for calling the repository to perform the registration of information.

Create user use case code

Note that the above class is dependent on the previously mentioned UserRepository class, as it receives it in its constructor and uses it during the execution of the execute() method.

Finally, we have the CreateUserController class that is responsible for receiving the request data, performing the necessary processing, and passing this information to the CreateUserUseCase class. It also provides the appropriate response to the user.

Create user controller code

Note that just like the previous class, this one directly depends on the CreateUserUseCase class.

After creating all these classes, we should create an instance of CreateUserController and export it so that we can access it in our routes file. Therefore, in the root of the "example1" folder, let's create a file called "index.ts" where we will create all the necessary instances.

Index file to create an instance of CreateUserController

The problem

Okay, we have our routes and endpoints working as expected, but there's a significant issue here. All of our concrete classes are dependent on other concrete classes, and aside from violating the SOLID principles, we might encounter significant difficulties in maintaining this code as the application grows. If we need to modify the implementation of the createUser method within the UserRepository class, we'll have to test all the other classes that depend on it, and we might need to make changes to those as well. Besides maintenance, we'll struggle to write unit tests since we'll have to mock the dependencies. In a scenario where we need to migrate our database service, the situation becomes even worse.

Example 2

In this example, we will use dependency injection to solve one of our problems. We will employ both dependency injection and dependency inversion to decouple our concrete classes from each other to some extent.

First, let's create an interface called IUserRepository with the signature of the createUser() method.

User repository interface code

Next, we will create the UserRepositoryImpl file that will extend the previously created interface, and this is where the actual implementation of the createUser() method will reside.

Implementation of UserRepository interface

The next step is to modify the CreateUserUseCase class to depend on the interface rather than the concrete class.

Change CreateUserUseCase dependency

Finally, we will modify our index.ts file in the root of the "example2" module to inject the implementation of the interface into the CreateUserUseCase class.

Injection of the implementation

Observations

Now you'll notice that the behavior of the application hasn't changed at all. However, our CreateUserUseCase class no longer depends on an implementation but rather on an interface. Therefore, if we need to modify the functionality of the createUser() method, this change will no longer affect the other classes. We'll be able to write unit tests more easily, and supporting a database change won't be as complex anymore, considering that the new implementations must adhere to the contract defined by the IUserRepository interface.

What we did in the index.ts file is precisely how dependency injection works. On one side, we have our CreateUserUseCase class depending on the IUserRepository interface, and on the other side, the actual implementation of the interface is being injected into its constructor.

In this case, we have also applied the concept of dependency inversion, as can be seen in the diagram below:

Dependency inversion diagram

As we can see in the diagram above, initially the CreateUserUseCase class was dependent on the UserRepository class. Now, we have the UserRepositoryImpl class depending on the interface.

Conclusion

We quickly saw how manual dependency injection is performed. Remember that it was only possible to inject the implementation into a class's constructor that depended on an interface because the implementation extends the interface. It may seem obvious, but that's precisely where the magic lies.

Bonus

We quickly saw how manual dependency injection is performed. Remember that it was only possible to inject the implementation into a class's constructor that depended on an interface because the implementation extends the interface. It may seem obvious, but that's precisely where the magic lies.

if (you like this.content) {
    Follow me on other social media ||
    Give me a suggestion for the next content
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)