Writing future-proof code with dependency injection

Jordan Edmunds, PhD
4 min readApr 25, 2024

--

Backwards-incompatible changes are no more

Software engineer injecting a dependency — courtesy of GPT-4 ❤

This is part of my series on dependency

In the previous article on writing more testable code with dependency injection, we went over a simple application that sent an email notification to a user. Here, we will show how not only do we get better testability, but also future-proof our code.

Let’s take the e-mail application from the last article, without dependency injection, which sends a notification to the user:

from third_party_library import send_email

class MyApplication:
def send_notification(self, user, content) -> None:
send_email(user, content)


if __name__ == "__main__":
application = MyApplication()
application.send_notification()

Oops! Our provider made a breaking API change

Now, suppose the library we are using for our e-mail provider third_party_providermakes some backwards-incompatible change to their API, and the send_email function doesn’t work the same anymore — now they want us to use a new function called send_email2 .

Anyone who has dealt with third-party libraries or SDKs knows this happens all the time. Depending on the release cycle of the provider, you might need to make these types of changes every few weeks to every year or so.

To fix this, we need to change the send_notification implementation:

from third_party_library import send_email2

class MyApplication:
def send_notification(self, user, content) -> None:
send_email2(user, content)

Keep it backwards-compatible

But we don’t want to make these changes all at once. Our boss actually wants us to support both versions of the third-party library, at least until we are confident the new one works as expected and our customers are satisfied with the result.

So we make even more changes to the send_notification method:

import third_party_library

class MyApplication:
def send_notification(self, user, content) -> None:
if third_party_library.__version__ >= (1, 2)
third_party_library.send_email2(user, content)
else:
third_party_library.send_email(user, content)

Now, in this toy example, we only call a single third-party function: send_email , and we only call it in a single place: thesend_notification method.

In practice, we will likely be making dozens or hundreds of calls to the same third-party function throughout our codebase, and very likely calling into more than just a single function. This type of refactor to support multiple versions would likely require hundreds of changes to our codebase, very quickly resulting in a messy and tangled product.

But what if we could isolate that function call to just a single place, and ensure that only a single change was required for any breaking change in the third-party API?

Dependency Injection to the rescue

Instead of directly calling send_email , we define an internal interface for the third-party library called NotificationSender , which should have a send() method.

import abc
import third_party_library

class NotificationSender(abc.ABC):
def send(self, user, content) -> None:
pass


class MyApplication:
def __init__(self, sender: NotificationSender):
self.sender = sender

def send_notification(self, user, content) -> None:
self.sender.send(user, content)

Now, we can implement this interface for our third-party library. We could put the versioning logic inside the dependency, but personally I prefer to keep it outside (for why I prefer this, see the subsequent article on writing configurable code with dependency injection):

class ThirdPartySenderV1(NotificationSender):
def send(self, user, content) -> None:
third_party_library.send_email(user, content)

class ThirdPartySenderV2(NotificationSender):
def send(self, user, content) -> None:
third_party_library.send_email2(user, content)

And now we can incorporate this into our application:

DESIRED_VERSION = 2

if __name__ == "__main__":
if DESIRED_VERSION == 2:
sender = ThirdPartySenderV2()
else:
sender = ThirdPartySenderV1()

application = MyApplication(sender=sender)
application.send_notification("Bob", "Hello World")

Now, third_party_library can change as much as it wants, and we only need to make a single change in our code — passing in a different sender into the application. No logic inside the application needs to be changed whatsoever.

A different notification scheme, please!

Fast-forward 2 years, and e-mail has become an outdated way to send notifications to users. Now your company has a mobile app, and all notifications should be pushed to that app rather than sent via e-mail.

Thankfully, you used dependency injection to write your notification-sending logic, and so you only need to make the following changes to the code:

class MobileNotificationSender:
def send(user, content):
send_mobile_notification(user, content)

if __name__ == "__main__":
sender = MobileNotificationSender()
sender = MyApplication(sender=sender)
sender.send_notification("Bob", "Hello World")

You submit the code, and your manager’s jaw drops. What she expected would be a months-long slog you managed to do in 5 minutes.

Maybe go home early today?

This is part of my series on dependency injection. For the previous story in the series, see Writing more testable code with dependency injection.

For the next stosy in the series see Writing more modular code with dependency injection

--

--

Jordan Edmunds, PhD

Software Engineer who writes about #software, #diversity, #health, and random musings. Reformed academic, Berkeley EECS PhD. Autistic self-advocate. He/Him.