Ultimate Guide to Dependency Injection in Android — Part 1. DI and Its Benefits

Ultimate Guide to Dependency Injection in Android — Part 1. DI and Its Benefits

Photo by Manuel Chinchilla on Unsplash

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.

Photo by Rhys Kentish on Unsplash

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

Photo by Nareeta Martin on Unsplash

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 SingleCoreComputerfrom 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.

Photo by Todd Quackenbush on Unsplash

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.

Photo by National Cancer Institute on Unsplash

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:

Thanks for reading! If you found this post valuable, please recommend it (the little handclap) so it can reach others.

Did you find this article valuable?

Support Dashwave for Mobile Devs by becoming a sponsor. Any amount is appreciated!