Introduction to Dependency Injection (DI)
Dependency Injection: Part one
Hey everyone! Welcome back to the site. Today's blog is a bit different from what we usually do. This one is a part of a series that I am planning to make on dependency injection or DI. The difference between this and the other blogs on the site (till date) is that this one will be more conceptual. So sit back but stay focused for we are not coding in this one xD.
Little background here. I personally struggled when I was learning dependency injection for Android using tools like Dagger and Hilt. Reasoning was simple. I had no idea why we were doing this! I had no experience working on a large Android application and therefore could not relate to the paradigm. DI is not something that strikes you immediately when working on small projects. It's not something you need for every app that you build and defintely not something you would want to use for a hackathon project (unless you have templates that include DI already :P) This thinking is evolved with time and scale of your project. When you truly focus on building a tested software and realize that testing is cumbersome with mocks and predefined variables, that is when you realize the true beauty of dependency injection.
So what am I trying to achieve here? Narrow down the vast stream of data (and not really information) into a series that can be used by everyone as a primer. I have seen numerous sources and experts on DI and I am no expert in this domain. But surely, being a learner myself, I know the pitfalls and can therefore present this information to you in a way that makes sense. I would try not to keep this conceptual blog generic. I'll dig deep into details wherever I need to. Please bear with me since DI has a notoriously steep learning curve. It will take time and patience to truly grasp the concept and thought process. This series exists to make sure that best programming practices and thinking is developed rather than just using a tool like Dagger.
With that in mind, let's get started! Side note: I'll be revising this series every now and then. Even this blog and will list the updates made in a section after this. If you see an update there that you have missed, please go through it and revamp what you've learnt.
What is a dependency?
In software world, dependency is a way establishing a relationship between 2 entities that rely/depend on each other. In mathematics, this can be seen in the form of this equation:
y = f(x)
From the above equation you can see that y depends on x. When x changes, y must change as well. Dependencies are inevitable and we need to learn how to manage them gracefully since dependencies are subject to change with time.
Consider a large codebase with numerous services, each service with it's own interaction with a DB. Initial approach was to use Postgres as the DB provider. Each service interacts with the DB from within, i.e each object has a reference to the DB internally. We can say that the DB provider is tightly coupled with our service. Now, for some reason the org handling this codebase decides to migrate from Postgres to MySQL. You can imagine the technical debt the org might face due to this decision given the code is not structured well. Each service would have to be individually migrated to the new DB provider and most of the tasks would be repetitive. The intertial weight of the codebase is too high to accommodate changes easily.
This is one of the aspects dependency injection resolves. Instead of referencing an object's dependency from within the object, the reference is taken out therefore making it easy for us to replace any part of the object. In relatable terms, you inject the dependency from outside. Let's understand this with a small code example:
// java like code, please bear with me
class PC {
private final Intel cpu;
public PC() {
cpu = new Intel();
}
public powerON() {
return cpu.powerON();
}
}
We have a PC
class that has Intel
as it's dependency. Obviously, PC
cannot function without a CPU and Intel is our CPU in this case. Assume the Intel
class contains the specs as its parameters. Not going to define that here. Just assume a simple class with a powerON
method that prints a hello world message. It seems ok at the first glance right? But here are a few problems with this approach:
Intel
is tightly coupled with ourPC
class. In the constructor if you notice, we are instantiating a new CPU for eachPC
object. This means that we cannot possible create aPC
with a new type of CPU like an AMD CPU or maybe even a Quantum chip. We have to create a newPC
class to do so if we proceed with this approach.- Suppose we need to test the implentation of this class. We cannot create a new
Intel
instance (with different specs for example) or even another dummy CPU instance to test ourPC
since ourPC
depends on theIntel
class entirely.
There are 2 steps to resolve this problem. One is to introduce abstraction. We can create an abstract class called CPU and extend Intel, AMD etc from it. Our PC can then depend on the abstract class and thus our problem is resolved. This by the way, is inversion of control. Instead of depending on concrete classes we depend upon abstractions that can be extended by classes that represent real objects. These type of abstractions establish an "IS A" type of relation between objects. Leading from our example, Intel IS A CPU, AMD IS A CPU etc. This level of abstraction is also necessary since it makes code easy to test and allows us to inject dependencies in general.
Second step is to de-couple the PC
instance from our Intel
instance entirely i.e our PC
should not just be allowed to have an Intel
CPU. It should be allowed to have whichever CPU we can think of and for that the user (or the program) should be able to decide which CPU is used by an instance of PC. If you haven't guessed it already, we are going to add CPU as a constructor value for our PC class.
abstract class CPU{
abstract void powerON();
}
class PC {
private final CPU cpu;
public PC(CPU cpu) {
this.cpu = cpu;
}
public powerON() {
return cpu.powerON();
}
}
As you can see, we have introduced a new abstract class called CPU
and we can now have any type of CPU
as the main CPU
of our PC instance. Condition remains that the CPU
must extend the abstract class CPU
. That's it. If we have a class like so,
class Intel extends CPU{
...
}
then we can use this class as an argument to our constructor. This approach has the following advantages:
- We can now pass whatever value as the cpu of our
pc
object. By introducing a leyer of abstraction and a constructor parameter, we have given enough power to our program to dictate how ourPC
works. Hell we can even have a Quantum Chip by IBM as part of ourPC
, the only condition remains that it must extend the abstract class CPU. 2.Testing now becomes easier since we can have dummy values for our cpu field in ourPC
object. We can even confgure aDummyCPU
to fail some tests and perform mutation testing on ourPC
class.
Please note that this is not the only way to introduce a dependency from the outside. The method we have discussed is called Constructor Injection
and it is pretty self explanatory why it is called so. The other kind of dependency injection is called Field Injection
. This happens when we use setters to set the value of our field instead of our constructor. From our previous example:
class PC {
private final CPU cpu;
public setCPU(CPU cpu){
this.cpu = cpu;
}
public powerON() {
return cpu.powerON();
}
}
It is clear from our thought experiment that dependency injection or DI is based on Inversion of Control
principle. Generic code is used to control the execution of specific code.
Dependencies in a big project
In our small example, we used a couple of steps to tackle the problems posed by our dependencies. Extrapolating this to real world applications at scale, this approach would pose problems simply because manual dependency injection would mean that we are in control of all the dependencies of all the classes that branch our from a base class. Each class would have numerous dependencies and we would have to ensure manually that all dependencies are met. This would also mean that in case of some dependencies that are loading lazily, or at the time they are called, we would have to maintain a system that handles their lifecycle. This sort of a system would have to be custom made with the help of a dependency graph that would allow us to locate all dependencies we have in memory at runtime.
This is where automated DI solutions come in. There are of 2 types:
- Reflection based : Connecting dependencies at runtime.
- Static solutions : Connecting dependencies at compile time.
Example of a reflection based solution is Guice and a static code generation solution is Dagger. Dagger addresses the issues with reflection based solutions and hence is a widely adopted solution.
Advantages of using DI
- Making our code testable If it was not already clear, de-coupling of classes allows us to test code easily by injecting dummy objects at the time of testing.
- De-couples our classes thereby reducing the dependency of one class on another This means that our classes can function independently without having strong co-relation with one another.
- Code is easier to maintain since there is a clear blueprint the app would follow
- Adding/replacing components is easier. We can switch the implementation of an existing class with a new one and expect the behaviour to remain the same since classes are not tighlty coupled.
Apart from the obvious advantages that are stated above, DI is cool. Simple. It is in accordance with the SOLID principles of programming and therefore make you more of an engineer and less of a programmer.
Conclusion
I hope I was able to give you a brief overview of what dependencies are and how to manage them best in a project. In the upcoming parts we will dive deep into Dagger
and how it helps with DI in Android.