Introduction to Dependency Injection
How to understand fancy terminology around dependency injection
In this article, I want to give insight into dependency injection. The article will explain the dependency injection, inversion of control, composition and separation of concerns. Do not expect any extensive academic terms. This is a simplified version, which I would find helpful in the past.
Little heads up. It is expected, that you have basic knowledge of Java/Kotlin language or similar language to fully understand code snippets.
What is dependency injection?
The funny part is that dependency injection is only a technical term for a simple process. During any programming course, you are shown the creation of classes. Let's create one.
class Boat(private val model: String, private val id: String) {
fun getIdentity(): String {
return "$model: $identity"
}
}
In code, there is one class with two constructor variables. This is the simplest way to demonstrate the dependency injection. The act of passing objects to other classes is called dependency injection. The Boat
class is dependent on the model
type and id
strings — dependencies.
In reality, the program manipulates with much more complex dependencies. These dependencies contain programmed behaviours to our liking. Consequently, the program can grow into the required complexity.
Above we can see the simplest version of dependency injection. Let's go further for a more complex example of the Boat
, which should get us from point A to point B.
class Navigator{
fun navigateTo(double lat, double lon) {
Log.i("Autopilot", "Going to latitude: $lat, longitude: $lon")
}
}
class Engine {
fun start() {
Log.i("Engine", "Starting engine")
}
fun stop() {
Log.i("Engine", "Turning off engine")
}
}
class Boat(private val engine: Engine, private val navigator: Navigator) {
fun goToNewLocation(double lat, double lon){
engine.start()
navigator.navigateTo(lat, lon)
engine.stop()
}
}
In the example, the Boat
class depends on two other classes: Engine
and Navigator
. With these components, the Boat
can take us to different destinations. Now, our Boat
is equipped with only one specific Engine
, but what if we would like to change it or have multiple versions of the engine?
class Navigator{
fun navigateTo(double lat, double lon) {
Log.i("Autopilot", "Going to latitude: $lat, longitude: $lon")
}
}
interface Engine {
fun start()
fun stop()
}
class HightechEngine: Engine {
fun start() {
Log.i("Hightech Engine", "Starting engine")
}
fun stop() {
Log.i("Hightech Engine", "Turning off engine")
}
}
class Paddle: Engine {
fun start() {
Log.i("Paddle", "Paddling")
}
fun stop() {
Log.i("Paddle", "Taking pause")
}
}
class Boat(private val engine: Engine, private val navigator: Navigator) {
fun goToNewLocation(double lat, double lon){
engine.start()
navigator.navigateTo(lat, lon)
engine.stop()
}
}
In the code above, the Engine
acts as a generalisation for all engines. Every Engine
can be started or turned off. Moreover, thanks to this generalisation, the code can cover as many engines as we want. The code implements a new super high-tech engine class HightechEngine
and Paddle
(yes, the code can cover the paddle if we want to).
Technically said: These classes inherited the
Engine
class and they implement the properties of this class. TheBoat
class is dependent on theEngine
class, but it can be any class, which inherits theEngin
class.
To create multiple boats with different engines, it would be enough to:
val expensiveBoat = Boat(HighTechEngine(), Navigator())
val cheapBoat = Boat(Paddle(), Navigator())
From this point, these two boats have the same behaviour from a code structure perspective, but their behaviours during the execution can differ.
Inversion of control
The previous example shown us how to abstract the logic for multiple engines. In other words, the Boat
class is unaware of the Engine
subclasses. The Boat
can call the methods in the interface / abstract class and nothing more. However, the Engine
subclass can carry any logic inside of it. Some of them can make the boat fast, and some of them slow. It depends on the Engine
itself, not the Boat
as a whole.
Technically said: The
Boat
gives controlls to theEngine
and gives out the responsibilities for the logic encapsulated in theEngine
class. TheBoat
decides, when to call theEngine
, but what willEngine
do is up to the implementation of theEngine
itself. TheEngine
can provide return values or different callbacks, which canBoat
use to integrate other components.
The inversion was achieved via the interface of the Engine. Specific implementations implement the interface’s methods. The boat can be constructed with different engines via dependency injection.
All the plugins, frameworks and other extensions are built on top of this principle, where the extension gives the programmer publicly available methods. The logic inside of it cannot be changed from the programmer's perspective if he does not have access to the extension code directly.
Composition
Dependency injection is the process of passing dependencies for the new class construction. For example this:
val expensiveBoat = Boat(HighTechEngine(), Navigator())
val cheapBoat = Boat(Paddle(), Navigator())
In the code snippet above, you are the witness of composition. Now, this might be misleading. It could be justified that the dependency injection is composition. However, dependency injection is the process of passing dependencies. The composition is an architectural pattern as a whole. The class is composed of different components to achieve higher complexity.
It can be understood as an alternative to inheritance with its advantages and disadvantages.
Inheritance limits the subclasses by its template. It is beneficial for the generalisation of similar behaviour. In our case for the Engine
class, all the engines should have similar behaviour. However, the Boat
’s behaviour can differ because it contains multiple components with many options.
I would not compare the inheritance and composition, because both are needed to achieve inversion of control and are essential towards other principles.
Single responsibility principle/separation of concerns
Every module/class in the code should be responsible only for one task. The responsibilities should not be randomly thrown around. It will lead to confusion.
The Engine class should contain only logic about handling the engine's responsibilities. Nothing more. The navigator should contain logic about navigating the boat. Nothin more. If you program some task, ask yourself during the implementation, if the programmed code is the class’s responsibility. If not, move it somewhere else. The option is to move it to another class or create a new one. It depends on the situation.
The advantage is that you can usually fully unit test every created separated unit. The final class is much more robust. If some engine needed to be rewritten, it would not impact other classes because it is independent and self-contained.
Wrapping it together
Firstly, the programmer should learn how to make the code work. Later on, the code should start to shape into something more organized and consistent. It depends on the language, industry, and needs.
In general, these principles around dependency injection apply almost everywhere and usually is taken care of by some kind of framework, which takes on all the heavy lifting.
Plan what are you going to do. Scope down the problem by the single responsibility principle. Plan the classes with the help of composition and inheritance. Construct them with help of dependency injection.
You may ask yourself, why to do all these things and here are the reasons:
- testability of the code
- robustness
- clear architecture
- easier troubleshooting
- easier refactoring
class Navigator{
fun navigateTo(double lat, double lon) {
Log.i("Autopilot", "Going to latitude: $lat, longitude: $lon")
}
}
// generalises all the engines
// single responsibility item - it takes care of all the engine logic
interface Engine {
fun start()
fun stop()
}
// subvariant of the engine, which can be used to build a boat
class HightechEngine: Engine {
fun start() {
Log.i("Hightech Engine", "Starting engine")
}
fun stop() {
Log.i("Hightech Engine", "Turning off engine")
}
}
// subvariant of the engine, which can be used to build a boat
class Paddle: Engine {
fun start() {
Log.i("Paddle", "Paddling")
}
fun stop() {
Log.i("Paddle", "Taking pause")
}
}
// Boat is contructed with different components with composition
class Boat(private val engine: Engine, private val navigator: Navigator) {
fun goToNewLocation(double lat, double lon){
// the controlls are inversed
// the engine takes care of its tasks
engine.start()
// the navigator takes care of its tasks
navigator.navigateTo(lat, lon)
// the engine takes care of its tasks
engine.stop()
}
}
// with help of the dependency injection, we provide needed components to construct boat
val boat = Boat(HighTechEngine(), Navigator())
This is more of a theoretical overview. In the next couple of articles, I will show best practices from Android development with the Hilt dependency injection framework.
Thanks for reading, and stay tuned for more!
Do not forget to clap if you liked the article, and follow me for more.
More from Android development here:
More from multiplatform development, here: