Explaining Dependency Injection ¶
As told to a coleague at work
DI libraries were created to fix this obvious, glaring deficiency of the platform.
Dependency injection frameworks have two goals:
- provide a shorter, more convenient, way of creating instances of classes that need other classes to run
- provide a way for swapping parts of the dependency graph without modifying the code, other than the DI-framework-specific code
Hm, I thought I have a nice example in tests somewhere, can't find it rn, so I'll need to make the examples up. Imagin you have these classes:
Now, you want to instantiate class A. You have to do it like this:
Or maybe like this:
Imagine you have not 4, but 14 classes like that.
You'd have to manually construct the classes, starting from the ones farthest from the class you want to instantiate, passing these to intermediary classes constructors, then passing those to further intermediate classes, and so on, until you finally have instances that you can pass as arguments to A.
With dependency injection frameworks, you don't have to do this manually.
Now, you have code like this:
To get an instance of A, you just need to:
Again, imagine these are not 4, but 14 classes - instantiating them manually
would require many lines of code, while with DI framework, no matter how large
the set of classes, you will be always able to get the instance you need with
this ...getA()
call.
So that's the first goal - you don't need to manually instantiate classes in a correct order, you can leave that to the framework.
Now, a valid question is: why would you design classes in this way? If a class A needs class B, it can just do val b = B() in the body and be done with it.
That's true, but that would fix/freeze the dependency in place, thus increasing coupling (and loose coupling is often what we want) - among other things, refactoring is harder in code written like that.
Another reason, that I honestly don't want to invoke given how bogus it sounds in our codebases - that have literally 0 tests... - is testing. If a class A takes class B as an argument to the constructor, you can swap class B for anything that's B-like. That, in most cases, means a mock or a stub.
The other reason is also purely academic in our code, but well... I'll tell you anyway, might be helpful when you encounter a well-written Kotlin codebase.
Let's make our example a little more complicated:
Here, A
depends on some B
, but it doesn't care if it gets B1
or B2
. In
a situation like this, with the direct approach, without dependency inversion,
you'd have something like this:
There's a conditional, and you decide which kind of B you want at the exact point you need a B.
However, class A
is not the best place for this conditional... Since it really
doesn't care which subclass it gets (visible from val b: B
), it shouldn't be
forced to decide which one to instantiate!
DI frameworks give you a place where you can write this conditional logic.
You do it like this:
Now, you can still instantiate A
in the same way as before -
DaggerSomeComponent.builder().build().getA()
- but you will get an A
with
either B1
or B2
. Neither A
nor B
needs to worry about choosing the
correct subclass, you don't need to put any special code in them, it'll be
handled by the DI framework.
This is the simplest case, but again - imagine there are 14 classes and multiple of them require such conditional logic.
With DI, all that conditional logic is in one place, instead of being spread all over the classes.
There's more - you can also swap/replace whole subsections of the graph, though it's kinda clunky in Dagger. So, if you have one "subsystem" (a collection of classes that collaborate to provide some functionality) that does something in a way X, and another that implements the same functionality but by doing Y, you can put them into Dagger Modules, and decide which module to include based on your condition. Note that, since Components get Modules via annotation at compile time and they're fixed in place, you need to create a separate component that uses your alternative module.
Hope that helped.
Now - go seek more enlightenment on the net!
In terms of pros, this helps code readability, scalability, decoupling, testing
Yup. Precisely.
There are cons, too: mostly more complicated build (for Dagger you need that compiler plugin) and the correct order of instantiating classes is obscured, meaning it's not visible in the code directly, you would need to go to the build/generated/ dir and look at the Factories.
And if DI is used, should it be used throughout the project, no matter scale?
I don't believe DI has to be used from the get go, nor do I believe that you need to DI everything throughout a project. That's my opinion, I'm going to defend it fiercely, but know that it's not the opinion shared by the majority of Java/Kotlin devs.
First of all, if you have a very localized dependency graph with few nodes, it's better to hide those dependencies completely. You do it with private classes in Kotlin and private inner/nested classes in Java.
An example of this is in TokenRepository
If the classes that a class (here: TokenRepository) depends on are all private, you can change them as you wish, without the overhead of setting up the DI for those classes. (edited)
That's basically the "Facade" design pattern in action.
I also don't think you need to use DI for everything, even if you do start using it.
Basically, ask yourself: am I spending too much time trying to figure out how to instantiate this class?
If yes - use a DI framework. If not - leave it alone.
I have a feeling that most Java/Kotlin programmers haven't ever heard of YAGNI
Or KISS for that matter.
Pick the simplest way that works, use it until it stops working well, then upgrade to the more complex thing as needed.
I sense actually implementing DI comes with a learning curve
Yeah.
That's because it's an add-on, not a feature of the language, at least in the context of Java and Kotlin.
There are multiple ways to do it, multiple frameworks, some disagreements in terminology between them, and so on. (edited)
Plus, when you start working on a project that already has a baroque DI, you just don't know where do these things you're reading right now in some class come from, and you can't find it out easily.
So I think starting from the minimal cases and building up from that is the best way to get into it.
(And as you can see, the minimal case is a single @Component
and a bunch of
@Injects
- you don't really need anything else in the beginning)
Implementation details
First, you should know what kapt is. kapt is a Kotlin Annotation Processor - which probably doesn't tell you much, but in short, it's an interface that Kotlin compiler plugins need to implement. These plugins are run just before the actual compilation from Kotlin source to JVM bytecode happens, and they can access the whole codebase the compiler is run in. Now, for one reason or another, these plugins/kapt processors cannot modify the codebase, but they can add new classes (in general, new Kotlin source code). Dagger has a com.google.dagger:dagger-compiler artifact that implements the kapt interface and can be used as an annotation processor. I'm using raw Dagger in SDK Demo, but Hilt, which is used in the other two apps, is built on top of Dagger and works in a very similar way, though it has its own annotation processor (com.google.dagger:hilt-android-compiler).
Ok, so Dagger is a plugin that is run by the Kotlin compiler over the whole
codebase. There are basically 2 things that Dagger searches for in the codebase:
interfaces annotated with @Component
and - this is important - everything else
that is annotated with @Inject
. Dagger examines all classes, and when it finds
one that is annotated with @Inject
, it does 2 things:
- it adds the class to the pool of classes that may be created using Dagger; and
- it examines the arguments that constructor takes, and sees if it can construct
them - that is, if all classes passed as arguments have a constructor
annotated with
@Inject
. Then, Dagger recurses, examining constructors of classes that the current class depends on.
Point 2 is what you usually work with when you use Dagger. However, the first
point is also important: all classes annotated with @Inject
are already
available for construction, no need to do anything else! When you define a
component, all @Inject
annotated classes are available in it. Classes and values
that are required for some of the to-be-constructed classes, like Context, need
to be provided to the component Builder, through @BindsInstance
. Another way to
supply the missing parts of a graph is to declare a module - it has to be a
separate abstract class - and use @Provide
-annotated methods to construct the
missing parts manually. Finally, you can declare a module as an interface, with
methods annotated with @Binds
- these are used to map interfaces to concrete
implementations.
Dagger traverses the dependency graph and creates factories for each class it found. These factories are used for instantiating classes in the correct order, ie. the factory of class X that depends on Y and Z takes care of finding and calling Y and Z factories before it instantiates class X. This doesn't have to be done in this way - there are other DI libraries that don't generate the factories during compile time; instead, they examine the classes in a codebase at runtime, creating the graph of dependencies and instantiating required classes via reflection. Koin is an example of such library.
Now, if Dagger automatically picks up @Inject
-annotated classes, why do we
need a component (not @Component.Builder
)? That's because Dagger has to know
which classes it needs to expose - which classes are the starting nodes of the
graph. There are two ways of giving Dagger this information:
- by declaring getter methods on the component interface, eg.
fun gimmeClassX(): X
- or by declaring a special method
fun inject(x: ClassThatWillHaveThingsInjected)
The former should be self-explanatory - for each such getter methods declared on
the component interface, there will be an implementation of that method on the
component implementation class. That method will simply pick a correct factory
and run it, returning the result. The latter case is a little more involved -
your ClassThatWillHaveThingsInjected
is examined in search of
@Inject
-annotated properties (most often lateinit var
s in Kotlin). Then, the
inject method is generated - it simply calls relevant factories and sets the
corresponding properties/attributes of a class passed to inject method.
TODO: clearer
That's basically it - one more advanced feature that's worth knowing is that you
can "scope" classes that are nodes in the dependency graph. Basically, normally,
each mention of class X
gets fresh instance of it from a factory. So if you have
classes Y
and Z
, both declared with class Y/Z @Inject constructor(val x: X){}
then Y
and Z
will have different instances of X
passed to their constructors.
You can control it in a few ways, you can cache an instance in the
@Provides
-annotated method for example, but it's better to declare, or use predefined
Scope
, which is an annotation you can slap on constructors' arguments and have
Dagger inject the same instance of a required class if the scope matches, and
will create a new instance if the scope is different in a particular class. You
can also inject multiple instances of the same class to a single constructor
thanks to this, if you want/need to.
In general, Dagger - while somewhat clunky - is not a bad piece of tech. The reason for its existence is the lack of first-class modules in Java. imports, which in Java express only compile-time dependencies, in other languages do both: you can analyze them to get the compile-time dependencies, but you can also execute them, during runtime. That's when the required/imported module gets created, along with all the modules it depends on, and so on, recursively. This is the case for Python, JavaScript, Ruby, and most other interpreted languages. In compiled languages, imports are strictly compile-time constructs, and they have no side-effects other than pulling a simplified name into current file's namespace. Well, that's not true for all compiled languages, for example OCaml includes both ways of working with modules, and now C++ got support for modules (I hear), but in Java and related compiled languages, imports in a package say nothing about the runtime behavior of classes defined there.