What is Dependency Injection?

Classes mutual dependencies

In all object languages, objects need to call methods of other objects to do their job. This is called object cooperation.

Object cooperation must be carefully designed since complex patterns of object cooperation can undermine the whole application’s modularity. In fact, a class that cooperates with too many other classes is likely to be changed each time one of the classes it “knows of” changes.

The problematic acquaintances are not hardwired classes like the fundamental types (string, decimals, etc.) and classes that are part of the programming framework. This is because they are very unlikely to change, but all other application classes are likely to change as the application evolves and is maintained.

Mitigating mutual dependencies with interfaces

A way to mitigate dependency on other classes is to hide acquaintances behind interfaces. One might object that this way, we move from the problem of dependency on other classes to an equivalent problem of dependency on other interfaces. However, this is not the case.

In fact, well-designed interfaces are specific to a single purpose, while classes are specific to roles and responsibilities that are evolving concepts with a wider scope. Roles, and the way they are implemented, changes with the application evolution since they are tied to the application needs. The methods and properties, on the other hand, are needed by the specific operation encoded into an interface are very unlikely to change as they are tied to the logical definition of the operation itself, and not to the application needs.

If interfaces are designed according to the interface segregation principle, then each interface is responsible for a single pattern of interaction. This means classes will evolve by changing the way they implement their methods, and by implementing several more interfaces, but the interfaces should not change.

We may conclude that each class should not depend on other classes but just on other interfaces. This means that each class should use just the framework/fundamental types and interface types.

This would be an exaggeration since we previously discussed data-communication objects designed specifically to convey the data that must be processed to a module.

Let’s reformulate: classes may contain references just to framework/fundamental types, interfaces, and pure data communication objects.

Getting the needed dependencies

Communication objects move according to the well-defined pattern of data communication among layers, so tracking their changes is not difficult during application maintenance. Their data content is defined by the needs of the target layer, and the way they are filled is confined next to the boundary between the two communicating layers.

What about interfaces? How can each object get all the interfaces it needs to work properly? It cannot create them since they are just interfaces, not classes. Therefore, these interfaces must be placed inside the object by other classes. But how can other classes inject interfaces into an object without knowing its type, and without depending on the object they must fill?

What we threw out of the main door is coming back from the backdoor! Filling objects with interfaces when they are created is a very dangerous task because it may create a complex network of mutual dependencies. That is worse than the original one we were trying to avoid!

The only solution is to create another type of entity that takes care of doing this job, in a way that doesn’t depend on the application logic and on the way all classes will evolve.

This is the injector. So we just discovered dependency injection.

How dependency injection works

According to the dependency injection pattern, each class specifies all interfaces/types it needs in its constructor and/or in properties tagged for injection. Since objects are created by classes that manipulate more than just interfaces, they must be created by the dependency injection engine itself. This engine can take care of injecting all types they need in their constructors and in their tagged properties.

Where do the injected objects come from? Obviously, it is a developer’s responsibility to define how to create all types to be injected.

For this purpose, we fill a kind of dictionary called dependency injection container with key-value pairs. The key is the type/interfaces that might be injected in a class, while the value is the concrete type that implements or is a subtype of that key.

Thus, for instance, an ILogger interface might be the key, and a concrete class that is able to log data is the value. A class just specifies the ILogger type in its constructor, then the dependency injection chooses the actual implementation.

Below is the definition of a class that needs a logging service:

Get hands-on with 1200+ tech skills courses.