Decouple Your Code With Dependency Injection
Not many components live on their own, without any dependencies on others. Instead of creating tightly coupled components, we can improve the separation of concerns by utilizing dependency injection (DI).
Table of Contents
This article will introduce you to the core concept of dependency injection, without the need for third-party frameworks. All code examples will be in Java, but the general principles apply to any other language, too.
Example: DataProcessor
To better visualize how to use dependency injection, we start with a simple type:
The DataProcessor
has two dependencies: DbManager
and Calculator
.
Creating them directly in our type has several apparent disadvantages:
- The constructor calls can crash.
- Constructor signatures might change.
- Tightly bound to explicit implementation type.
It’s time to improve it!
Dependency Injection
James Shore, the author of The Art of Agile Development, put it quite nicely:
“Dependency injection is a 25-dollar term for a 5-cent concept.”
The concept is actually really simple: Giving a component all the things it needs to do its job.
In general, it means decoupling components by providing their dependencies from the outside, instead of creating them directly, which would create adhesion.
There are different ways how we can provide an instance with its necessary dependencies:
- Constructor injection
- Property injection
- Method injection
Constructor injection
Constructor, or initializer-based dependency injection, means providing all required dependencies during the initialization of an instance, as constructor arguments:
Thanks to this simple change, we can offset most of the initial disadvantages:
Easily replaceable:
DbManager
andCalculator
are no longer bound to the concrete implementations, and are now mockable for unit-testing.Already initialized and “ready-to-go”: We don’t need to worry about any sub-dependencies required by our dependencies (e.g., database filename, significant digits), or that they might crash during initialization.
Mandatory requirements: The caller knows exactly what’s needed to create a
DataProcessor
.Immutability: Dependencies are still final.
Even though constructor injection is the preferred way of many DI frameworks, it has its obvious disadvantages, too. The most significant one is that all dependencies must be provided at initialization.
Sometimes, we don’t initialize a component ourselves or we aren’t able to provide all dependencies at that point. Or we need to use another constructor. And once the dependencies are set, they can’t be changed.
But we can mitigate these problems by using one of the other injection types.
Property injection
Sometimes we don’t have access to the actual initialization of type, and only have an already initialized instance. Or the needed dependency is not explicitly known at initialization as it would be later on.
In these cases, instead of relying on a constructor, we can use property injection:
No constructor is needed anymore, we can provide the dependencies at any time after initialization. But this way of injection also comes with drawbacks: Mutability.
Our DataProcessor
is no longer guaranteed to be “ready-to-go” after initialization.
Being able to change the dependencies at will might give us more flexibility, but also the disadvantage of more runtime-checks.
We now have to deal with the possibility of a NullPointerException
when accessing the dependencies.
Method injection
Even though we decoupled the dependencies with constructor injection and/or property injection, by doing so, we still only have a single choice. What if we need another Calculator
in some situations?
We don’t want to add additional properties or constructor arguments for a second Calculator
, because there might be a third one needed in the future.
And changing the property every time before we call calc(...)
isn’t feasible either, and will most likely lead to bugs using the wrong one.
A better way is to parameterize the method call itself with its dependency:
Now the caller of calc(...)
is responsible for providing an appropriate Calculator
instance, and DataProcessor
is completely decoupled from it.
Even more flexibility can be gained by mixing different types of injection, and providing a default Calculator
:
The caller could provide a different kind of Calculator
, but it doesn’t have to.
We still have a decoupled, ready-to-go DataProcessor
, with the ability to adapt to specific scenarios.
Which Injection Type to Choose?
Every type of dependency injection has its own merits, and there isn’t a “right way”. It all depends on your actual requirements and the circumstances.
Constructor injection
Constructor injection is my favorite, and often preferred by DI frameworks.
It clearly tells us all the dependencies needed to create a specific component, and that they are not optional. Those dependencies should be required throughout the component.
Property injection
Property injection matches better for optional parameters, like listeners or delegates. Or if we can’t provide the dependencies at initialization time.
Some other languages, like Swift, make heavy use of the delegation pattern with properties. So, using it will make our code more familiar to other developers.
Method injection
Method injection is a perfect match if the dependency might not be the same for every call. It will decouple the component even more because now, just the method itself has a dependency, not the whole component.
Remember that it’s not either-or. We can freely mix the different types where it’s appropriate.
Inversion of Control Containers
We can cover a lot of use cases with these simple implementations of dependency injection. It’s an excellent tool for decoupling, but we actually still need to create the dependencies at some point.
But as our applications and codebases grow, we might need a more complete solution that simplifies the creation and assembling process, too.
Inversion of control (IoC) is an abstract principle of the flow of control. And dependency injection is one of its more concrete implementations.
An IoC container is a special kind of object that knows how to instantiate and configure other objects, including doing the dependency injection for you.
Some containers can detect relationships via reflection, others have to be configured manually. Some are runtime-based, others generate all the code needed at compile-time.
Comparing all the different options is beyond the scope of this article, but let’s check out a small example to get a better understanding of the concept.
Example: Dagger 2
Dagger is a lightweight, compile-time dependency injection framework.
We need to create a Module
that knows how to build our dependencies, that later can be injected by merely adding an @Inject
annotation:
The @Singleton
ensures that only one instance of a dependency will be created.
To get injected with a dependency, we simply add @Inject
to a constructor, field, or method:
These are just the absolute basics, and might not seem impressive at first. But IoC containers and frameworks allow us to not just decouple our component, but also to maximize the flexibility of dependency creation.
The creation process becomes more configurable and enables new ways of using the dependencies, thanks to the advanced features provided.
Advanced features
The features vary widely between the different kinds of IoC containers and the underlying languages, like:
- Proxy pattern and lazy-loading.
- Lifecycle scopes (e.g., singleton vs. one per thread).
- Auto-wiring.
- Multiple implementations for a single type.
- Circular dependencies.
These features are the real power of IoC containers. You might think features like “circular dependencies” are not a good idea. And you’re right.
But if we actually need such weird code constructs due to legacy code, or unchangeable bad design decisions in the past, we now have the power to do so.
Conclusion
We should design our code against abstractions, like interfaces, and not concrete implementations, to reduce adhesion.
The only information our code should need must be available in the interface, we can’t assume anything about the actual implementation.
“One should depend upon abstractions, [not] concretions.”
- Robert C. Martin (2000), Design Principles and Design Patterns
Dependency injection is a great way to do that by decoupling our components. It allows us to write cleaner and more concise code that’s easier to maintain and refactor.
Which of the three dependency injection types to choose depends much on the circumstances and requirements, but we can also mix the types to maximize the benefit.
IoC containers can provide another layout of convenience by simplifying the component creation process, sometimes in an almost magical way.
Should we use it everywhere? Of course not.
Just like other patterns and concepts, we should apply them if appropriate, not only because we can.
Never restrict yourself to a single way of doing things. Maybe the factory pattern or even the widely-loathed singleton pattern might be a better solution for your requirements.
Resources
- Inversion of Control Containers and the Dependency Injection pattern (Martin Fowler)
- Dependency Inversion Principle (Wikipedia)
- Inversion of Control (Wikipedia)