Dependency Injection Patterns
Decouple components using dependency injection for testability and flexibility. Covers constructor injection, service locators, DI containers, interface segregation, composition root, and the patterns that make code modular without overengineering.
Dependency injection (DI) is the most impactful design pattern for testable, maintainable code. Instead of components creating their own dependencies, they receive them from the outside. This single change — inverting the direction of dependency creation — makes code testable, swappable, and modular.
The Problem
# WITHOUT DI: Hard-coded dependencies (untestable)
class OrderService:
def __init__(self):
self.db = PostgresDatabase("prod-connection-string") # Hard-coded!
self.payment = StripeClient("sk_live_xxx") # Hard-coded!
self.email = SendGridClient("api-key-xxx") # Hard-coded!
def place_order(self, order):
self.db.save(order)
self.payment.charge(order.total)
self.email.send_confirmation(order.email)
# How do you test this without charging a credit card?
# How do you test this without a real database?
# Answer: You can't. Dependencies are hard-coded.
Constructor Injection
# WITH DI: Dependencies passed in (testable, flexible)
class OrderService:
def __init__(self, db, payment, email):
self.db = db # Could be Postgres, SQLite, or mock
self.payment = payment # Could be Stripe, PayPal, or mock
self.email = email # Could be SendGrid, SES, or mock
def place_order(self, order):
self.db.save(order)
self.payment.charge(order.total)
self.email.send_confirmation(order.email)
# Production:
service = OrderService(
db=PostgresDatabase("prod-connection-string"),
payment=StripeClient("sk_live_xxx"),
email=SendGridClient("api-key-xxx"),
)
# Test:
service = OrderService(
db=InMemoryDatabase(), # Fast, no external deps
payment=MockPaymentClient(), # No real charges
email=MockEmailClient(), # No real emails
)
# Switching providers:
service = OrderService(
db=PostgresDatabase("prod-connection-string"),
payment=PayPalClient("client-id"), # Swapped Stripe → PayPal
email=SESClient("region"), # Swapped SendGrid → SES
)
# Zero changes to OrderService code!
Interface Segregation
from abc import ABC, abstractmethod
# Define interfaces (contracts) for dependencies
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: float, currency: str) -> str:
"""Returns transaction ID."""
pass
@abstractmethod
def refund(self, transaction_id: str) -> bool:
pass
# Implementations
class StripeGateway(PaymentGateway):
def charge(self, amount, currency):
return stripe.Charge.create(amount=amount, currency=currency).id
def refund(self, transaction_id):
stripe.Refund.create(charge=transaction_id)
return True
class MockGateway(PaymentGateway):
def __init__(self):
self.charges = []
def charge(self, amount, currency):
txn_id = f"mock_{len(self.charges)}"
self.charges.append({"id": txn_id, "amount": amount})
return txn_id
def refund(self, transaction_id):
return True
# OrderService depends on interface, not implementation
class OrderService:
def __init__(self, payment: PaymentGateway):
self.payment = payment # Any PaymentGateway works
DI Container
# For larger applications: DI container manages wiring
from dependency_injector import containers, providers
class AppContainer(containers.DeclarativeContainer):
config = providers.Configuration()
# Infrastructure
database = providers.Singleton(
PostgresDatabase,
connection_string=config.db_url,
)
# Services
payment_gateway = providers.Factory(
StripeGateway,
api_key=config.stripe_key,
)
email_client = providers.Factory(
SendGridClient,
api_key=config.sendgrid_key,
)
order_service = providers.Factory(
OrderService,
db=database,
payment=payment_gateway,
email=email_client,
)
# Usage:
container = AppContainer()
container.config.from_env("APP_") # Load from environment
service = container.order_service()
# All dependencies automatically wired!
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
new inside constructors | Cannot test, cannot swap | Pass dependencies as constructor args |
| Service Locator everywhere | Hidden dependencies, test confusion | Prefer constructor injection |
| Inject everything | Over-engineered, 20 constructor params | Inject only things that vary (external deps) |
| No interfaces | Depend on concrete implementations | Interface for every external dependency |
| God container | Single container knows everything | Modular containers per feature |
Dependency injection is about one thing: making dependencies visible and swappable. If you can test your code without external services and swap implementations without changing business logic, you have good DI.