Dependency Injection (DI) can often feel opaque and daunting, especially to developers who are new to architecting codebases. It’s got a fancy name and a lot of the tools you might use to accomplish it have either an extremely steep learning curve, or one that’s so simple an inexperienced developer doesn’t realize the magic that’s actually happenning.

Trying to understand how DI is useful, flexible, and executed are things that I felt underprepared for coming out of college. Now that I’ve been in the workforce for about 2 years, I feel qualified to talk about it.

For a long time I was mainly a backend engineer. DI is mostly nonexistent in Django, and it’s so simple on Spring that I never bothered to understand it. It had been my goal for a while to move into Android development. My hobby apps were plagued with cyclic dependencies, and any questions I aksed online were usually answered with a nonchalant “you should use Dagger”.

Back then, I had no idea what dependency injection was, let alone how I could leverage Dagger to help solve my architecture problems. In this post, I hope to unpack DI and Dagger in order to show you that the concepts aren’t all that difficult.

The Basics

To talk about DI, we must first talk about the SOLID principles. For those who don’t know, the SOLID principles are general rules and guidelines software engineers should abide by to structure their codebases.

The acronym stands for the following:

Single responsibility - A class should have exactly one functionality. It should not have to know about implementation details other than its own. Open closed principle - Classes should be open for extension, but closed for modification. You can alter behavior by extending a class, but in no way should you be able to change behavior of a class in place. Liskov substitution - replacing an instance of a class for a subclass in your codebase should not alter the programs behavior. Subclasses should abide by the contract that their parent defines. Interface segregation - The same as single responsibility, but for interfaces. An interface should define how something completes exactly one task. Dependency inversion - Classes that have external dependencies should depend on the most abstract version of that dependency possible.

Too Many Queues

To showcase how SOLID influences your architecture, consider the following simple example.

You are implementing a Queue. Queues are typically backed by some kind of List . Because of this, you reach for ArrayList to implement this, solely because it’s the one you’re most familiar with.

class Queue < T > { private val list = ArrayList < T > ( ) fun enqueue ( element : T ) { list . add ( element ) } fun dequeue ( element : T ) : T { return list . removeAt ( 0 ) } }

That’s not so difficult. You’ve now got a simple functioning queue backed by an array list. You do however notice that constantly removing from index 0 on an arraylist does not perform well because it needs to shift the later elements in the array down to index 0. You know that a doubly linked list would do better in this situation for adding to the end and removing from the front. So you code that up.

class Queue < T > { private val list = MutableLinkedList < T > ( ) fun enqueue ( element : T ) { list . add ( element ) } fun dequeue ( element : T ) : T { return list . removeAt ( 0 ) } }

You however notice that sometimes when the queue you’re adding elements to grows extremely large, your program crashes with an OutOfMemoryException . Because of this, you decide that you need both a linked queue, and an array queue. You task your fellow engineers to intelligently swap between the two when a list gets to a certain size — running out of memory is unacceptable.

You now run into the problem that you have two separate implementations of a queue though — ArrayQueue and LinkedQueue . For whatever reason, you require additional functionality to your queue to prioritize which elements get removed when you call dequeue . You now have to implement that in two places, test for two implementations, and ship. This is roughly twice the amount of effort.

There is a solution

Your classes that have external dependencies should depend on the most abstract version of that dependency possible.

As shown above, Queues have an external dependency on some sort of list. Since Dependency Inversion specifies that we should always depend on the most abstract version of something, we can invert the dependency on linked list and array list upwards, to List (in this instance, MutableList ). If our implementations of ArrayList and LinkedList follow Liskov Substitution, we can rest assured that each implementation ultimately provides a different means to the same end state.

class Queue < T > { private val list = MutableList < T > ( ) fun enqueue ( element : T ) { list . add ( element ) } fun dequeue ( element : T ) : T { return list . removeAt ( 0 ) } }

We have a problem though. List is an interface, which cannot be instantiated. Instead it can only be implemented. How do we supply an instance of list so that our program can compile?

Why of course, we use Dependency Injection! DI simply means that we supply the dependencies of a class to that class externally. What does it look like?

class Queue < T > ( private val list : MutableList < T > ) { fun enqueue ( element : T ) { list . add ( element ) } fun dequeue ( element : T ) : T { return list . removeAt ( 0 ) } }

Simple, right?

Because our Queue now takes a constructor parameter of type List, we can supply it any class instance that implements List when we create our Queue.

val arrayQueue = Queue ( ArrayList < Int > ( ) ) val linkedQueue = Queue ( LinkedList < Int > ( ) )

Therefore, we can now dynamically specify which type of queue we need by supplying it the dependency that it needs — which is simply a list. It doesn’t care which kind. This method of dependency injection is the simplest kind, and it’s called constructor injection.

If it’s impossible for your class to take constructor parameters, it’s also possible to perform member variable injection.

class Queue < T > { lateinit var list : MutableList < T > fun enqueue ( element : T ) { list . add ( element ) } fun dequeue ( element : T ) : T { return list . removeAt ( 0 ) } } val arrayQueue = Queue < Int > ( ) . apply { this . list = ArrayList < Int > ( ) } val linkedQueue = Queue < Int > ( ) . apply { this . list = LinkedList < Int > ( ) }

With member variable injection, your class exposes a public mutable variable (or setter) that can be used to inject your dependency.

In summary, dependency injection is a mechanism for achieving dependency inversion. In order to abide by dependency inversion, you may have to declare your dependency as an interface or abstract class. In order to get a valid implementation supplied to your class, you use dependency injection.

Added Benefits

Generally, the more tests you can write as unit tests instead of integration tests, the better. However, unit testing in complex systems with a large amount of dependencies per class can be daunting and often impossible. Instead, teams often fall back to slow integration and end-to-end tests. Worse yet, teams may forgo testing altogether.

Dependency injection in conjunction with proper dependency inversion can really ease this pain. Consider for example a class which at runtime would require making a network request.

interface NetworkClient { fun makeRequest ( ) : List < Int > } class NetworkManager ( private val client : NetworkClient ) { fun processRequest ( ) : List < Int > { return client . makeRequest ( ) . map { .. . } } }

Because our client dependency is supplied externally, at normal program runtime we can supply a normal implementation of this class which performs the network call you’d expect.

However, when testing you can create a dummy implementation of this interface which doesn’t actually make a network request. Instead it returns some fake data which is much quicker than making a network call in your tests.

Furthermore, you control the output of NetworkClient.makeRequest in this dummy implementation. You can make it error, return an empty list, a regular list, etc. Control your destiny in these tests so that you can test all branches of code within NetworkManager.processRequest .

Note About Mocking

The above can also be achieved using mocking. This can be a really powerful tool at your disposal and is effectively what you’re doing above. In my experience, using mocking tools can quickly make your tests so tighly coupled to implementation details that they actually test that something is implemented a certain way, not that it functions a certain way.

The balance between the two is somewhere in the middle.

Finally, Dagger

The remainder of this post is going to pertain to Dagger and Android specifically, but the concepts of DI being explained here are very similar with other tools.

The first Dagger tutorial I remember finding guided me through my DI setup, but didn’t really explain any of the concepts. Not only did I not understand DI itself or the motivation for it, but now I also had all of these other concepts rattling around in my head like Component , Module and Singleton .

In order to decompose those concepts, consider the following example that you’ve likely seen in the other tutorials.

class MyApplication : Application ( ) { val appComponent : AppComponent by lazy { DaggerAppComponent . builder ( ) . appModule ( AppModule ( this ) ) . build ( ) } } class MainActivity : AppCompatActivity ( ) { @Inject lateinit var viewModel : MyViewModel override fun onCreate ( .. . ) { activity . appComponent . inject ( this ) } } class MyViewModel @Inject constructor ( private val repository : MyRepository ) : ViewModel ( ) { .. . } class MyRepository @Inject constructor ( private val client : NetworkClient , private val database : MyDatabase ) { .. . } @Component ( modules = [ NetworkModule :: class , DatabaseModule :: class ] ) interface AppComponent { fun inject ( activity : MainActivity ) } @Module class AppModule ( private val app : MyApplication ) { @Singleton @Provides fun providesApp ( ) = app } @Module class DatabaseModule { @Provides @Singleton fun providesDatabase ( app : MyApplication ) : MyDatabase { return MyDatabase . create ( app . context ) } } @Module class NetworkModule { @Provides fun providesRetrofit ( ) : NetworkClient = Retrofit . Builder ( ) . baseUrl ( .. . ) . create ( ) }

That’s quite complicated for such a small amount of code! It’s no wonder people new to DI get so confused when they jump right into Dagger.

Let’s work bottom up to explain this.

Modules

Modules define how dependencies are created. We can define how dependencies are created, but how do they get where they need to go?

In order to do that, we need to talk about graphs.

The Dependency Graph

Supplying every class in your program with the right dependency turns out to be a graph problem — in particular a directed acyclic graph. Traversing every path that leads to your desired object defines how to construct that object.

The dependencies in your Modules can either depend on nothing at all or they themselves might need dependencies to be constructed. Dependencies are indicated in dagger with the @Provides annotation.

@Module class AppModule ( private val app : MyApplication ) { @Singleton @Provides fun providesApp ( ) = app } @Module class NetworkModule { @Provides fun providesRetrofit ( ) : NetworkClient = Retrofit . Builder ( ) . baseUrl ( .. . ) . create ( ) }

Consider for example the NetworkModule and AppModule above. Neither function annotated with Provides takes any parameters. To provide those objects to other classes requires no external objects. We will refer to these as root level dependencies.

Root level dependencies are the objects which your dependency graph begins to branch out from. Given only these dependencies, we have the following graph.

The following dependencies require dependencies themselves in order to be constructed.

@Module class DatabaseModule { @Provides @Singleton fun providesDatabase ( app : MyApplication ) : MyDatabase { return MyDatabase . create ( app . context ) } } class MyViewModel @Inject constructor ( private val repository : MyRepository ) : ViewModel ( ) { .. . } class MyRepository @Inject constructor ( private val client : NetworkClient , private val database : MyDatabase ) { .. . }

There are two methods of injection displayed above. One probably looks very familiar to you. MyRepository and MyViewModel are both just using normal constructor injection. In order for Dagger to pick them up, we annotate the constructor of the object with @Inject .

Behind the scenes at compile time, Dagger will generate Modules for classes that have constructors annotated with Inject. Therefore, when you can, you should prefer normal constructor injection in Dagger.

If your object doesn’t have a suitable constructor exposed, like MyDatabase , you should fall back to manually creating a Module to include that object in your dependency graph.

With the above classes, our dependency graph looks like the following.

Above you can see that in order to supply MyViewModel to a class which requests it, we must traverse every path of the graph.

Components

Components are the glue between what you want to inject and where you want to inject.

@Component ( modules = [ NetworkModule :: class , DatabaseModule :: class ] ) interface AppComponent { fun inject ( activity : MainActivity ) }

In the above component definition, we are defining the bridge between MainActivity and our Modules. The modules that this component has knowledge of are defined in @Component(modules = [...]) as well as the modules which are generated from constructor injection.

At compile time, Dagger will generate an implementation of AppComponent.inject(MainActivity) that fulfills all of the requested dependencies.

Injection

Finally, in order to fulfill the dependencies a class is requesting, Dagger needs to know what dependencies components are being requested. Thus, the last use of @Inject we will cover is member variable injection.

Objects which only have dependencies, and are not dependencies themselves, should expect to do variable injection. This allows objects to hook into the Dagger dependency graph by leveraging the injection points defined in your components.

class MainActivity : AppCompatActivity ( ) { @Inject lateinit var viewModel : MyViewModel override fun onCreate ( .. . ) { activity . appComponent . inject ( this ) } }

Above, we define a public lateinit var that is annotated with @Inject . During compile time, Dagger will inspect what member variables are requesting a dependency with @Inject and generate an implementation of AppComponent.inject accordingly.

The following is a simplification of the code generation that Dagger does for you.

private final class AppComponentImpl implements AppComponent { . . . private void inject ( MainActivity instance ) { MainActivity_MemberInjector . injectViewModel ( instance , DaggerAppComponent . this . createViewModel . get ( ) ) } } private final class MainActivity_MemberInjector implements MembersInjector < MainActivity > { . . . public static void injectViewModel ( MainActivity instance , MyViewModel viewModel ) { instance . viewModel = viewModel } }

Above, the implemented inject function hooks into the dependency graph and delegates the actual injection to the MemberInjector. It’s there that the instance of MyViewModel we need actually gets set on our activity.

Therefore, it is necessary for dependencies injected with this form of injection be public, mutable, and either nullable or lateinit properties.

Conclusion

Dependency injection itself really isn’t a complicated topic. However, when adding Dagger to the mix things complicate quickly. I found it helpful to try and compartmentalize the concepts in this article — understand DI first and Dagger last.

We haven’t even really begun to scratch the surface with the tools that Dagger provides, however we have shown how regular old constructor injection is really powerful when aided just a bit.

Changelog