When talking with friends and family starting their software career I often hear the words dependency injection accompanied by Dagger, Guice or any other tool aiming to help out injecting your application’s dependencies. Unfortunately barely any of them actually understand or grasp why it’s needed and why do we need such tools to accomplish it. Some are even surprised when I tell them you don’t really need these tools to have dependency injection and that they’re just helpers. Really good helpers, but still just helpers.
Personally I believe the tools are great and help you develop faster and deliver better quality code. However, it’s important to understand the concepts behind. This often makes the tools easier to understand.
I’d like to provide at least one explanation with a step by step approach to what I learned and how I see dependency injection helping out in software development.
Starting with functions
Usually one explains dependency injection using object oriented paradigms. I’d like to first try to explain it with functional programming, because I think it’s simpler. Dependencies are everywhere in software. Hard dependencies or tight coupling as we mostly know them make it harder to extend, test and scale the code, while loose dependencies (or loose coupling) tend to be easier to deal with. So to start with we need to understand what really is a dependency.
In functional programming, functions are first class citizens. This means they can be passed as arguments, returned by other functions, assigned to variables, among other things. Yes you’ve guessed it, in functional programming dependencies are functions.
Let’s look at the map
example. I’ll be using Elixir because it’s the most purely functional programing language that I’m comfortable with. I’ll do my best to explain the syntax. Elixir already has this function in the standard library, but for the purpose of this post I’ll just implement it myself. We have a list of integers and at some point in our application we need to square these integers:
def map_to_squares([]) do
[]
end
def map_to_squares([head | tail]) do
[head * head | map_to_squares(tail)]
end
Using Pattern Matching, Elixir decides which function to call. When the list is not empty it will call the second function – map_to_squares([head | tail])
– and recursively build another list until eventually it’s empty. At this point it will call the first function – map_to_squares([])
.
Calling map_to_squares([1, 2, 3, 4])
yields the list [1, 4, 9, 16]
.
Can we call this function with another list and obtain its squared elements? Absolutely!
Can we call this function with a list and apply another operation to the list’s elements? Say we want to return a list of 1
s and 0
s depending if the number is odd or even respectively? No!
Why can’t we do this? Because the function can only square items. It has the squaring function coupled with its implementation. you can see it in head * head
. In other words, map_to_squares
is tightly coupled with the squaring function.
How can we make this work for any function? We need to invert the control. Simply put – we no longer say what function we’re going to apply to the list, but we let the calling code specify this function:
def map([], _) do
[]
end
def map([head | tail], fun) do
[fun.(head) | map(tail, fun)]
end
So now we have a function map
that receives another function. This function can be anything as long as it receives 1 parameter and this parameter is an element of the list.
Calling map([1, 2, 3, 4], fn x -> x * x end)
will still yield [1, 4, 9, 16]
, but now we can also call it like so:
map([1, 2, 3, 4], fn x ->
if Integer.is_even(x) do
0
else
1
end
end)
and get the list [1, 0, 1, 0]
. We no longer care what function we need to apply to the list members as long as it respects the rules already defined.
We’ve successfully given control to whom calls this function. The dependency to the mapping function is still there, but now it’s no longer tightly coupled. This makes it much more flexible and reusable. We don’t have to write several functions that apply an operation to each element in the list. We can use just this one and inject the operation as a function.
Notice also that we have decoupled the logic of the mapping function from the logic that iterates through the list. This is really good for testing. We can now make sure that our function iterates all elements in the list and calls the given mapping function for each element.
It’s worth noting that in functional programming there are others ways of addressing these dependencies which might be better than the one explained here – i.e. Partial Application
In the OO world
Things are not that different in the Object Oriented world (OO). If you are no stranger to this paradigm you know that often your objects have a lot of dependencies. These dependencies are other objects. I’ll be using Kotlin for the next examples mainly because I’m an Android developer. I know one can use a lot more functional constructs in Kotlin than in other OO languages, but for the sake of the argument I’ll try to stay away from these.
Let’s imagine the following scenario. We’re building an application for some transportation company which has several transport mediums and drivers. Each driver can drive one or more transports – bus, plane, car, etc. One could start thinking of having the following:
class Bus {
fun drive() = println("Driving on the road")
}
class Driver {
lateinit val bus: Bus
fun drive() = bus.drive()
}
As you can see we have a class Bus
that can be driven and a class Driver
that for now can only drive a bus. There are quite some issues with this approach, but let’s start with the bus dependency from the driver class.
With this approach the driver can only drive buses. However we were told that drivers could drive more than one transportation medium. So first thing we need to do is generalize the Bus
class. Let’s build an interface for a generic transport.
interface Transport {
fun drive()
}
class Bus : Transport {
override fun drive() = println("Driving on the road")
}
This step is very important because it will enable us to make sure the Driver
class depends on a generic transport and not only on a bus enabling the driver to effectively drive more than one transport. If you recall the example with functional programming this is similar in that the mapping function specifies a signature, but not how it should behave. Likewise we should specify interfaces and not the behavior. As a rule of thumb always depend on abstractions rather than concrete implementations.
We can now write our Driver
class as follows:
class Driver {
lateinit var transport: Transport
fun drive() = transport.drive()
}
Now the dependency to Bus
is no longer present. The Driver
class depends on an interface that describes the contract for transports. We can now have a single driver driving multiple transports:
interface Transport {
fun drive()
}
class Bus : Transport {
override fun drive() = println("Driving on the road")
}
class Airplane : Transport {
override fun drive() = println("Flying over the road")
}
class Driver {
lateinit var transport: Transport
fun drive() = transport.drive()
}
fun main(args: Array<String>) {
val driver = Driver()
val plane = Airplane()
val bus = Bus()
driver.apply {
transport = plane
drive()
}
driver.apply {
transport = bus
drive()
}
}
As you can see the created driver now can drive both a bus and a plane as well as any other class complying to the Transport
interface. Notice also how we’ve created another class – Airplane
– that can be used with the class Driver
and yet we didn’t have to touch the Driver
class.
We’ve given control to the code using Driver
. This is why we say dependency injection is a form of inversion of control.
What we’ve seen here is a form of dependency injection where the dependency is injected using a setter. This was done to keep the example simple. Other forms of injection include field injection (which in Kotlin looks pretty much the same) and constructor injection.
I personally prefer the approach of using constructor injection simply because it becomes impossible to call the methods without initializing all dependencies. As an example:
class Driver(private val transport: Transport) {
fun drive() = transport.drive()
}
Here the dependency must be passed when the instances of Driver
are created and therefore it’s impossible to forget about it before invoking the method Driver.drive()
. Also I take advantage of Kotlin’s type system to avoid null
s here. However, this would not work for our application because it requires one driver to drive multiple transport mediums.
One last thing that is very important to notice. We can now test the Driver
class without worrying about which transport medium it’s using under the hood. In fact, because we inverted the control to the calling code, during the tests we can call this class with a mock object and avoid creating a real transport making it way easier to test.
So why are tools like Dagger and Guice needed?
As we’ve seen now dependency injection is a concept that essentially lets the calling code have control of what’s being used. In the given example the dependencies are quite simple and trivial. So much that we actually manually created all the objects and injected every dependency.
However in a real world scenario things are not this simple. Usually your dependencies have dependencies which will have dependencies of themselves and so on. Usually all of these dependencies have a certain life time that needs to be managed. Things get complicated pretty fast and we end up with a graph of dependencies that is too hard to create and maintain.
Here’s where tools like Dagger and Guice help us out. They make it a lot easier to manage these dependencies. They create the dependency graph by themselves and ensure that when you request a given object, all its dependencies are fulfilled. This removes a lot of boilerplate code and boosts productivity.
Wrap up
In this post I’ve tried to give at least one reason why dependency injection is important in software development. We’ve started with a functional approach and moved to the Object Oriented world.
Here we know that there are a lot of tools to help us out with building and maintaining our dependency graph. Dependencies are present everywhere and they should be taken into account. Hard coded dependencies will make it almost impossible to extend the class’ behavior without touching it. Harnessing these tools helps us in the short and long term.
Photo by Sneaky Elbow on Unsplash