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:

Copy
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:

Copy
// 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:

  1. Intel is tightly coupled with our PC class. In the constructor if you notice, we are instantiating a new CPU for each PC object. This means that we cannot possible create a PC with a new type of CPU like an AMD CPU or maybe even a Quantum chip. We have to create a new PC class to do so if we proceed with this approach.
  2. 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 our PC since our PC depends on the Intel 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.

Copy
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,

Copy
class Intel extends CPU{ ... }

then we can use this class as an argument to our constructor. This approach has the following advantages:

  1. 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 our PC works. Hell we can even have a Quantum Chip by IBM as part of our PC, 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 our PC object. We can even confgure a DummyCPU to fail some tests and perform mutation testing on our PC 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:

Copy
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:

  1. Reflection based : Connecting dependencies at runtime.
  2. 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

  1. 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.
  2. 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.
  3. Code is easier to maintain since there is a clear blueprint the app would follow
  4. 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.

© 2019-2022 • Copied with ❤️ by Aayush Malhotra