The world’s leading publication for data science, AI, and ML professionals.

A Fairly Short Explanation of the Dependency Injection Pattern with Python

Digging into this extremely useful though often neglected design pattern

A picture of a syringe, because this is about dependency INJECTIONS, do you get it? Ha ha ha. Ok, was trying to crack a little joke, but I'm afraid it was a VEIN attempt. Do you get it? Ha ha ha. (I'm sorry). Photo by Diana Polekhina on Unsplash
A picture of a syringe, because this is about dependency INJECTIONS, do you get it? Ha ha ha. Ok, was trying to crack a little joke, but I’m afraid it was a VEIN attempt. Do you get it? Ha ha ha. (I’m sorry). Photo by Diana Polekhina on Unsplash

Dependency Injection as a concept is neither sexy nor cool, as pretty much any design pattern. And despite that, when properly harnessed, it is extremely useful – again, as pretty much any design pattern. Hopefully, by the end of this article, you will have another tool for your belt.

Let’s go for it.


What does dependency injection mean?

Dependency injection is a fancy term for a simple concept: giving an object the things it needs, rather than having it create them itself. This is useful for a few reasons: it makes our code more modular and easier to test, and it can make our programs more flexible and easier to extend.

Here’s an example to illustrate the concept. Imagine we have a class that represents a superhero. This class has a name attribute and a power attribute, and it has a use_power() method that prints out a message saying how the superhero uses their power. Here’s what that class might look like:

Python">class Superhero:
    def __init__(self):
        self.name = 'Spider-Man'
        self.power = 'spider-sense'

    def use_power(self):
        print(f'{self.name} uses their {self.power} power!')

# create a superhero and use their power
spiderman = Superhero()
spiderman.use_power()  # prints "Spider-Man uses their spider-sense power!"

This works fine, but there’s a problem: our Superhero class is creating its own name and power attributes. This means that every superhero we create will have the same name and power unless we explicitly set them when we create the object. This isn’t very flexible or modular.

To fix this, we can use dependency injection. Instead of having the Superhero class create its own name and power attributes, we can pass them in as arguments to the __init__() method. Here’s what that might look like:

class Superhero:
    def __init__(self, name, power):
        self.name = name
        self.power = power

    def use_power(self):
        print(f"{self.name} uses their {self.power} power!")

# create a superhero with the name "Superman" and the power "flight"
superman = Superhero("Superman", "flight")

# use the superhero's power
superman.use_power()  # prints "Superman uses their flight power!"

# create a superhero with the name "Batman" and the power "money"
batman = Superhero("Batman", "money")

# use the superhero's power
batman.use_power()  # prints "Batman uses their money power!"

As you can see, using dependency injection makes our Superhero class more flexible and modular. We can create superheroes with any name and power we want, and we can easily swap out the name and power for different superheroes.

What are some real use cases for this?

Testing

Dependency injection makes it easier to write unit tests for our code. Since we can pass in the objects that our class depends on as arguments, we can easily swap out the real objects for mocked or stubbed objects that we can control in our tests. This allows us to test our class in isolation and make sure it behaves as expected.

Configuration

Dependency injection can make our code more configurable. For example, we might have a class that sends emails. This class might depend on an EmailClient object that actually sends the emails. Instead of hard-coding the EmailClient object into our class, we can use dependency injection to pass it in as an argument. This way, we can easily change the EmailClient object that our class uses without modifying the class itself.

This is how the code would look without dependency injection:

class EmailSender:
    def __init__(self):
        self.email_client = GoodMailClient()

    def send(self, email_text, recipient):
        return self.email_client.send(email_text, recipient)

    # other methods that use self.mail_client

And this is how it would look with dependency injection:

class EmailSender:
    def __init__(self, email_client):
        self.email_client = email_client

    def send(self, email_text, recipient):
        return self.email_client.send(email_text, recipient)

    # other methods that use self.mail_client

The second approach allows you to very easily go from this:

# Create an instance of a good email client
good_email_client = GoodMailClient()

# Create an instance of EmailSender, injecting the dependency
sender = EmailSender(email_client=good_email_client)

# Send the mail
sender.send('Hey', '[email protected]')

To this:

# Create an instance of a better email client
better_email_client = BetterMailClient()

# Create an instance of EmailSender, injecting the dependency
sender = EmailSender(email_client=better_email_client)

# Send the mail
sender.send('Hey', '[email protected]')

… without modifying at all the EmailSender class.

Extension

Dependency injection can make our code more extensible. For example, we might have a class that processes data. This class might depend on a DataProcessor object that does the actual data processing. Instead of having our class create its own DataProcessor object, we can use dependency injection to pass it in as an argument. This way, we can easily swap out the DataProcessor object for a different one if we want to extend our class’s functionality.

These are just a few examples, but there are many other use cases for dependency injection in the real world.

That sounds great, what’s the catch?

Well, there’s no catch per se, but there are some issues, of course. One of the main drawbacks is that it can make our code more verbose and harder to read, especially if we are injecting a large number of dependencies. This can make our code more difficult to understand and maintain.

Another potential drawback is that dependency injection can make our code more difficult to debug. Since the dependencies are passed in as arguments, it can be harder to track down the source of an error if something goes wrong.

Additionally, dependency injection can make it more difficult to understand the dependencies of a class or module. Since the dependencies are passed in from the outside, it can be harder to see at a glance what an object depends on, which can make it harder to understand how it works.

Overall, while dependency injection has many benefits, it can also have some drawbacks. As with any Software design pattern, it’s important to weigh the pros and cons and decide whether it’s the right approach for a given situation.

Last thoughts

From my experience, and I want to stress that this is just my humble opinion, it is usually a good idea to start using this pattern from the very beginning, even the MVP. It might sound more complex (and well – it is), but when you get the drill, it’s a no brainer, and flexibilizes any further modifications you might want to add later. However, it’s also a good idea to keep an eye on the number of dependencies injected – you don’t want it to grow too much.

The most common drawback I’ve found personally is that the code becomes harder to understand, especially when you have a new joiner in the team. But that’s easily solved with a proper onboarding: the benefits outweigh the costs.

References:

[1] M. Seeman, Dependency Injection is Loose Coupling (2010), Ploeh Blog

[2] Havard S, Downsides to Dependency Injection (2010), StackOverflow

[3] A. Culp, The Dependency Injection Design Pattern (2011), MSDN


If you enjoy reading stories like these, you can support my work on Medium directly and get unlimited access by becoming a member using my referral link here! 🙂


Related Articles