Motivation

Dependency Injection is one of the things that I wish had come up when I was first learning how to program. And to an extent, it did, I just didn’t realize its full potential until it was shown to me on a much larger scale in my first job as a software engineer. I intend to do this for you with this three-part series, although I have some ideas for some examples that might be based off of this series.

Introduction

Dependency Injection is also known as “Inversion of Control,” which is a little more illustrative of what’s actually going on. Instead of classes resolving what they need on their own, instead they ask for the minimum interface that they need.

The Problem

This is easiest explained with an example, so here goes. I’m using Python because it’s fairly simple to follow regardless of what your primary language is.

Here’s my original implementation, a program even simpler than FizzBuzz:

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class ConsoleLogger:
	def debug(message: str) -> void:
		print(f"[DEBUG]: {message}")
		
	def info(message: str) -> void:
		print(f"[ INFO]: {message}")
		
class Counter:
	def __init__(self):
		self.__logger = ConsoleLogger()
		
	def run(self):
		for i in range(100):
			if i % 3 == 0:
				self.__logger.debug(str(i))
			if i % 5 == 0:
				self.__logger.info(str(i))
		
if __name__ == "__main__":
	Counter().run()

This is neat code, as it has one class handling the logging, and another handling the main functionality which happens to be counting to 100 and logging based on what number we find.

Lets say that we want to change some functionality. Maybe we want to log to a file instead. That’s not too hard:

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class FileLogger(Logger):
	def __init__(self, filename = "log.txt"):
		self.__file = open(filename, "a")

	def debug(self, message: str) -> None:
		# Do nothing, we don't want debug messages clogging the file
		return

	def info(self, message: str) -> None:
		self.__file.write(message)

We even can change out the file we’re logging to easily, just by passing that as a parameter. Neat!

Now we need to change our implementation of Counter as well:

main.py
1
2
3
class Counter:
	def __init__(self):
		self.__logger = FileLogger()

Python’s duck typing takes care of a lot of this for us, so the only line we need to change is in the constructor. In other languages, that might have needed more changes to get the types right, but still a fairly straightforward change nonetheless. Let’s make another one.

Let’s introduce another type of logger, one that logs to an array so we can verify that the correct things are logged:

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class ArrayLogger:
	def __init__(self):
		self.__debugs = []
		self.__infos = []

	def clear(self):
		self.__debugs = []
		self.__infos = []
		
	def info(self, message: str) -> None:
	    self.__infos.append(message)](<class ArrayLogger:
    def __init__(self):
        self.__debugs = []
        self.__infos = []

    def clear(self):
        self.__debugs = []
        self.__infos = []

    def debug(self, message: str) -> None:
        self.__debugs.append(message)
        
    def info(self, message: str) -> None:
        self.__infos.append(message)

    def get_debug_logs(self) -> list[str]:
        return self.__debugs

    def get_info_logs(self) -> list[str]:
        return self.__infos

Now we run into a problem: if we want to use the ArrayLogger to test that things run the right way, we can’t also use a different one when we actually run the program. Additionally, we can’t get to that array logger without exposing it on the class. To solve that, we might add a flag to the constructor, or maybe create a subclass that uses a different logger. Either way we have a new getter, and things aren’t looking as elegant as before.

Flag Approach Yikes, that’s a lot of special casing. Not so much single-responsibility anymore.

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
        
class Counter:
    def __init__(self, use_array_logger = False):
        self.__logger = ConsoleLogger() if not use_array_logger else ArrayLogger()
        
    def get_array_logger(self) -> None | ArrayLogger:
        if isinstance(self.__logger, ArrayLogger):
            return self.__logger
        return None

    def run(self):
        for i in range(100):
            if i % 3 == 0:
                self.__logger.debug(str(i))
            if i % 5 == 0:
                self.__logger.info(str(i))

Subclass Approach Look at all that repetition. We need to keep the methods in sync, too, to keep up with Python’s name mangling.

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Counter:
    def __init__(self):
        self.__logger = ConsoleLogger()
        
    def run(self):
        for i in range(100):
            if i % 3 == 0:
                self.__logger.debug(str(i))
            if i % 5 == 0:
                self.__logger.info(str(i))

class TestCounter:
    def __init__(self):
        super().__logger = ArrayLogger()
        
    def get_array_logger(self) -> ArrayLogger:
		return self.__logger
        
    def run(self):
        for i in range(100):
            if i % 3 == 0:
                self.__logger.debug(str(i))
            if i % 5 == 0:
                self.__logger.info(str(i))

Neither of those seem super elegant. Let’s try something else.

Enter Dependency Injection

To start, let’s pull the functionality of the different loggers into a base class that they all can extend from:

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from abc import ABC, abstractmethod

class Logger(ABC):
    @abstractmethod
    def debug(self, message: str) -> None:
		...

	@abstractmethod
	def info(self, message: str) -> None:
		...

class ConsoleLogger(Logger):
	...

class FileLogger(Logger):
	...

class ArrayLogger(Logger):
	...

Next up, let’s do the actual dependency injection. This line is creating a dependency:

main.py
1
2
3
class Counter:
	def __init__(self):
		self.__logger = ConsoleLogger()

Instead, let’s use the interface we just created to invert control over that dependency. Counter does not need to worry about which type of logger it gets – just that it gets one at all. I’m going to take this a step further and also ask for how many numbers we should log in the run function:

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Counter:
	def __init__(self, logger: Logger):
		self.__logger = logger
		
	def run(self, count = 100):
		for i in range(count):
			if i % 3 == 0:
				self.__logger.debug(str(i))
			if i % 5 == 0:
				self.__logger.info(str(i))

Now when we want one to test, we can give the class the type of logger we need:

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def test_counter():
	logger = ArrayLogger()
	counter = Counter(logger)
	counter.run(15)
	# Verify we have the right lengths
	assert len(logger.get_debug_logs()) == 5
	assert len(logger.get_info_logs()) == 3
	# Verify that we have the right numbers at the end
	assert logger.get_debug_logs()[-1] == '12'
	assert logger.get_info_logs()[-1] == '10'

If we’re running this as the main file, we can use the console logger:

main.py
1
2
3
if __name__ == "__main__":
	counter = Counter(ConsoleLogger())
	counter.run(50)

We can use loggers over again if we want to, as well:

main.py
1
2
3
4
5
6
7
8
if __name__ == "__main__":
	logger = ConsoleLogger()
	
	counter = Counter(logger)
	counter.run(50)
	
	otherCounter = Counter(logger)
	otherCounter.run(200)

Simple as That

That conversion, just asking for what we’re looking for instead of going and getting it or constructing it ourselves is Dependency Injection. This can fairly easily solve what’s called Static Abuse in other languages, and as I’ll discuss below, it can also help to make code easier to reuse.

Benefits of DI

Some of the benefits I tried to outline above fall into the below categories:

  1. Allows code to be more generalized
  2. Breaks coupling and explicit dependencies
  3. Makes it easier to test things by intercepting data or mocking it away

Generalized Code

Above, we had a counter that anytime we wanted to switch out its dependency on a Logger, we had to modify the implementation of the class. When we needed multiple at the same time, we had to duplicate the code. By cutting that out, we instead get code that uses what it’s told without needing to worry about what that other code is doing. If we wanted to write a logger that uploaded our logs to an API somewhere, that would be simple in the new code, but we’d still have to deal with making source code modifications in the older code because it’s tightly coupled, which brings me to my next point: coupling.

Breaks Coupling

Code is considered tightly coupled when its behavior or implementation can’t be fully executed or understood without understanding another piece. Code like that generally is better off as a single unit, but sometimes dependencies can be broken out and separated which also generally results in better reusability for both pieces.

Easier Testing

Testing gains two things from injected dependencies. First, parts of the code that consume data produced by the targeted part of code can be mocked out so that they record what is produced and allow it to be verified. That’s what ArrayLogger is doing up above. On the other hand, it also allows parts of the code that produce data needed for the targeted part of the code to be mocked out. In more complex software, this can be things like rendering backends, API calls, or database functionality, which makes it easier to run tests quickly and with a minimal set of required outside setup.

In Conclusion

Sometimes Dependency Injection gets conflated with Dependency Containers or DI Frameworks, which I’ll be covering in the next part, but the basic concept is what I’ve outlined here – ask for what you need, rather than trying to get it yourself.