86.6 Dependency Injection in Python
Right, so you’ve heard about Dependency Injection (DI). You’ve probably been told it’s essential for “good architecture” and “testable code.” And you’re probably wondering if it’s just more Java-esque ceremony that Python doesn’t need. You’re not wrong to be suspicious. In Python, we often solve these problems more simply. But understanding DI isn’t about memorizing a framework; it’s about understanding a principle: that your classes shouldn’t be responsible for creating their own dependencies. It’s the art of handing things in instead of letting a class dig around to find them.
Think of it like this: a Chef class shouldn’t be built with a hard-coded, internal Oven(set_to=350). What if you want a different oven? Or need to test the chef by giving them a mock oven that records what temperature was asked for? If the chef constructs its own oven, you’re stuck. Instead, you should be able to inject the oven into the chef. The chef’s job is to cook, not to be an oven wholesaler.
The Absolutely Simplest Form: Just Pass It In
Let’s cut through the jargon. At its core, DI is just parameter passing. Seriously. Instead of a class creating its own dependencies, you pass them in via its __init__ method. That’s it. You’re already 80% of the way there.
# Without DI - The chef is stuck with one specific oven.
class BadChef:
def __init__(self):
self.oven = Oven() # Tight coupling. Yuck.
def bake_dinner(self):
self.oven.set_temperature(375)
self.oven.bake(for_minutes=45)
# With DI - We inject the dependency. Glorious flexibility!
class GoodChef:
def __init__(self, oven): # The oven is injected here.
self.oven = oven
def bake_dinner(self):
self.oven.set_temperature(375)
self.oven.bake(for_minutes=45)
# Now we can use different ovens!
regular_oven = Oven()
fancy_smart_oven = FancyOven()
test_oven = MockOven() # For testing
chef_for_weeknights = GoodChef(regular_oven)
chef_for_guests = GoodChef(fancy_smart_oven)
chef_for_tests = GoodChef(test_oven)
See? No magic. The GoodChef class is now decoupled from the specific type of Oven. It just expects something that has a set_temperature and bake method. This is also known as “Duck Typing” – if it quacks like an oven, it’s an oven.
Taking It Up a Notch: Dependency Injection Containers
Passing everything manually in __init__ is fine for small applications. But when you have a complex web of dependencies (e.g., a Controller needs a Service which needs a Repository which needs a DatabaseConnection), manually wiring this all up at the top of your program becomes a tedious, error-prone mess. This is where a Container comes in. It’s essentially a registry that knows how to build and manage your objects and their dependencies.
Don’t reach for a giant framework like Spring. The Python world has elegant solutions. Let’s look at punq, a wonderfully simple and powerful DI container.
# First, let's define some classes with dependencies.
class DatabaseConnection:
def __init__(self, connection_string: str):
self.conn_str = connection_string
class UserRepository:
def __init__(self, db: DatabaseConnection): # Depends on DatabaseConnection
self.db = db
def get_user(self, user_id):
# ... use self.db to fetch user
return f"User {user_id} from {self.db.conn_str}"
class UserService:
def __init__(self, repo: UserRepository): # Depends on UserRepository
self.repo = repo
def get_user_info(self, user_id):
return self.repo.get_user(user_id)
# Now, let's use punq to wire this mess together automatically.
import punq
# Create the container
container = punq.Container()
# Register our components. We tell the container how to build them.
container.register(DatabaseConnection, instance=DatabaseConnection("sqlite:///db.sqlite"))
container.register(UserRepository)
container.register(UserService)
# The container now knows how to resolve the entire chain!
service = container.resolve(UserService)
print(service.get_user_info(1))
# Output: User 1 from sqlite:///db.sqlite
The magic here is that punq sees that UserService needs a UserRepository. It then looks and sees that UserRepository needs a DatabaseConnection. It already has a DatabaseConnection instance registered, so it builds the UserRepository with that instance already injected, and then injects that into the UserService. It handled the dependency graph for you.
The Power of Interfaces and Abstractions
The examples above rely on duck typing, which is very Pythonic. However, for larger, more complex systems, being explicit about your interfaces is a huge win. It makes your code self-documenting and prevents you from accidentally injecting a object that only partially quacks like a duck. We use abc (Abstract Base Classes) for this.
from abc import ABC, abstractmethod
class IOven(ABC):
@abstractmethod
def set_temperature(self, temperature):
pass
@abstractmethod
def bake(self, for_minutes):
pass
# A real implementation
class StandardOven(IOven):
def set_temperature(self, temperature):
print(f"Real oven heating to {temperature}°F")
def bake(self, for_minutes):
print(f"Real oven baking for {for_minutes} minutes")
# A test implementation
class MockOven(IOven):
def __init__(self):
self.last_temperature = None
def set_temperature(self, temperature):
self.last_temperature = temperature
def bake(self, for_minutes):
pass
# The chef now explicitly depends on the abstraction, not a concrete class.
class AbstractedChef:
def __init__(self, oven: IOven): # <-- Look here! We depend on the interface.
self.oven = oven
def bake_dinner(self):
self.oven.set_temperature(375)
self.oven.bake(45)
# Registering with the container becomes more explicit.
container = punq.Container()
container.register(IOven, instance=MockOven()) # Or StandardOven()! Swap it in one place.
chef = container.resolve(AbstractedChef)
chef.bake_dinner()
print(f"Test oven was set to: {container.resolve(IOven).last_temperature}°F")
This is the final piece of the puzzle. By coding to an interface (IOven), your Chef class is not only decoupled from a specific oven implementation, but it’s also explicitly clear what methods it requires from its dependency. This makes testing, refactoring, and future extension dramatically easier. You’ve officially leveled up from writing scripts to designing systems. Welcome to the big leagues.