Ultimate Guide to Dependency Injection in Android — Part 1. DI and Its Benefits
Story time. Why understanding the basics of DI is important.
My introduction to Dagger was terrifying, to say the least. I was a junior developer and I was assigned to a project. I remember the very first time I saw the codebase. It was a rather complex code, filled with advanced usage of the Dagger library. In my opinion, I should have been assigned to this project in the first place. But it is what it is. The company was short on devs, the deadlines were coming and the project needed help. I remember feeling absolutely terrified and overwhelmed by it all. The development was fast-paced, and most of the time, I found myself just copying and pasting code. I knew that the code worked, but I didn’t understand it. Eventually, my lack of knowledge came back to bite me when it was time for me to develop new features. I found myself unable to make even the simplest changes and I had to use hacks instead. This experience taught me the importance of really understanding how Dependency Injection works.
That’s why I’ve decided to write this series of articles. I will take us through the very basics of Dependency Injection, all the way up to advanced usage of Dagger and Hilt. I want to help other developers avoid the same stressful situation I found myself in.
In this first article, we’ll take a look at what DI is and what are its benefits.
What is Dependency Injection?
Dependency Injection (DI) is a software design pattern that can improve code quality and maintainability. The pattern involves separating the creation of objects from their usage. Such approach makes it easier to modify and test code. It is used to reduce coupling and increase flexibility in software development.
Let’s look at an example to help us understand better:
class RigidComputer() {
private val processor = Processor()
fun compute() {
processor.compute()
}
}
fun main(args: Array<String>) {
val computer = Computer()
computer.compute()
}
What’s the problem with this code? Well, the most obvious thing here is that a RigidComputer
class is dependent on a Processor
class. In other words, the RigidComputer
class is tightly coupled to the Processor
. You might have heard that a lot of times when people speak about DI. Essentially, tight coupling makes code:
Hard to reuse
Tough to refactor
Almost impossible to test properly
We’ll go into why that’s the case in a minute. Meanwhile, let’s see how we can apply the DI pattern here:
class FlexibleComputer(private val processor: Processor) {
fun compute() {
processor.compute()
}
}
fun main(args: Array<String>) {
val processor = Processor()
val computer = Computer(processor)
computer.compute()
}
As you can see, in this FlexibleComputer
we’ve moved the creation of the Processor
class out of our previous RigidComputer
class. There is no tight coupling anymore. We can now pass any implementation of the Processor
which gives us lots of benefits. More specifically, our code is now:
Easy to reuse
Effortless to refactor
Painless to test
Now let’s look at each of these points in detail.
Benefits of DI
Reusability
One of the main benefits of DI is that it makes code easier to reuse. When dependencies are injected into a class, the class can be easily reused in different contexts. At the same, we don’t need to modify the class’s implementation.
Let’s imagine a scenario where we have a requirement to build MultiCoreComputer
and a SingleCoreComputer
from our RigidComputer
class. The difference between them is in their processor types. How can we do that with our RigidComputer
class? The short answer is we wouldn’t be able to. Not easily at least.
We have no control over the creation of the Processor
in our RigidComputer
class. This forces us to create separate classes for the MultiCoreComputer
and the SingleCoreComputer
.
class MultiCoreComputer() {
private val processor = MultiCoreProcessor()
fun compute() {
processor.compute()
}
}
class SingleCoreComputer() {
private val processor = SingleCoreProcessor()
fun compute() {
processor.compute()
}
}
val multiCoreComputer = MultiCoreComputer()
val singleCoreComputer = SingleCoreComputer()
Now, let’s see how we would do that with our FlexibleComputer
:
val multiCoreProcessor = MultiCoreProcessor()
val singleCoreProcessor = SingleCoreProcessor()
val singleCoreComputer = FlexibleComputer(singleCoreProcessor)
val multiCoreComputer = FlexibleComputer(multiCoreProcessor)
And that’s it. If we needed a QuadCoreComputer
or anOctaCoreComputer
— it would be as easy as passing a different Processor
to our FlexibleComputer
.
Ease of Refactoring
Another benefit of DI is that it makes code easier to refactor. By injecting dependencies into a class, it becomes possible to replace them. We are able to swap out dependencies without touching the class’s implementation.
Consider the following example:
class RigidWashingMachine() {
private val processor = Processor()
fun prepareWashParams() {
processor.compute()
}
}
class RigidComputer() {
private val processor = Processor()
fun compute() {
processor.compute()
}
}
We have our good ol’ RigidComputer
and another non-flexible RigidWashingMachine
. Both of them are dependent on a Processor
. However, they are completely different in their functionality. RigidWashingMachine
washes and RigidComputer
computes. Changing the Processor
in such code is troublesome because we risk breaking RigidComputer
or RigidWashingMachine
. So what do we do? Dependency Injection to the rescue!
class FlexibleComputer(private val processor: IProcessor) {
fun compute() {
processor.compute()
}
}
class FlexibleWashingMachine(private val processor: IProcessor) {
fun prepareWashParams() {
processor.compute()
}
}
interface IProcessor {
fun compute()
}
First of all — let’s introduce an interface IProcessor
. This makes sure we’re not relying on any concrete implementation of the Processor
. Now we can implement our DI pattern. Our new FlexibleWashingMachine
and FlexibleComputer
do not care about the implementation of the IProcessor
. Whoever works with the FlexibleWashingMachine
and FlexibleComputer
just needs to pass in theProcessor
implementation they need. They don’t have to worry about breaking something.
The same cannot be said about our previous example. Messing with RigidComputer
and RigidWashingMachine
when they are relying on the same Processor
can be dangerous.
Ease of Testing
One of the challenges of testing code is dealing with dependencies. By using DI, it becomes easier to test code by injecting fake or mock dependencies into a class.
Let’s look at our usual RigidComputer
:
class RigidComputer() {
private val processor = Processor()
fun compute() {
processor.compute()
}
}
How can we test this? Well, in its current implementation — it’s impossible. We could make our Processor
a public lateinit var
and set it during the test. But this would be field dependency injection and it’s not needed here. We’ll cover field dependency injection in the next article.
In our case, it would be best to use our FlexibleComputer
as usual:
@Test
fun `testComputer_withValidParams_givesProperResult`() {
val fakeProcessor: IProcessor = FakeProcessor()
val computer = FlexibleComputer(fakeProcessor)
val result = computer.compute() //in case FlexibleComputer returns something
}
In this example, we’re testing the FlexibleComputer
class by injecting a FakeProcessor
object. Here we can configure the FakeProcessor
how we like. By providing FlexibleComputer
with a dependency that we can configure ourselves — we can simulate and test any behavior that we like.
Recap
Dependency injection is a powerful technique for managing dependencies in software applications. By using DI, we can improve code reusability, as well as make refactoring and testing easier. Hopefully, this helped you understand some of the core concepts of DI which will be necessary for my next articles. In the next article, we’ll look into DI types, manual dependency injection, and best practices for DI.
In the next article, we’ll cover manual dependency injection, field injection, and best practices when using DI.
Resources: