Dependency Injection¶
Intent¶
Supply an object's collaborators from outside instead of letting it construct or look them up. OrderService declares what it needs (a store, a payment gateway, a notifier, a clock, an id generator) as constructor parameters typed as Protocols; whoever builds the service decides which implementations those are. Production wires real objects once, tests wire fakes, and the service code is identical in both.
When to use and when not to¶
Use it when
- A class touches anything a test cannot control: the wall clock, random ids, a network, a database, stdout. Inject it and the test owns it.
- You want to assert on side effects (what was charged, who was notified) without
unittest.mock.patch; a hand-written fake records them and never lies about a method name. - Two deployments need different implementations (SQLite locally, Postgres in production); the choice belongs in one composition root, not in every class.
Leave it out when
- The dependency is a pure function or a value object (
math.ceil,Money); injecting something with no side effects and no variants adds a parameter and buys nothing. - The object is a leaf utility with one implementation and no test that needs to replace it.
- You are injecting twelve things. That is a class with too many jobs; split it instead of hiding the parameter list behind a container.
Structure¶
The service, the three Protocols it declares, and the implementations a composition root or a test chooses; Clock and IdGenerator come from common and are injected the same way.
classDiagram
class OrderService {
-_orders: OrderRepository
-_payments: PaymentGateway
-_notifier: Notifier
-_clock: Clock
-_ids: IdGenerator
+place_order(customer_id: str, amount: Money) Order
}
class OrderRepository {
<<interface>>
+add(order: Order)
+get(order_id: str) Order
}
class PaymentGateway {
<<interface>>
+charge(customer_id: str, amount: Money, idempotency_key: str) str
}
class Notifier {
<<interface>>
+send(customer_id: str, message: str)
}
class InMemoryOrderRepository {
-_orders: Dict~str,Order~
-_lock: Lock
}
class FakePaymentGateway {
+decline: bool
+charges: List~tuple~
}
class LoggingNotifier {
-_logger: Logger
}
class RecordingNotifier {
+sent: List~tuple~
}
class Order {
+id: str
+customer_id: str
+amount: Money
+placed_at: float
+payment_ref: str
}
OrderService o-- OrderRepository : injected
OrderService o-- PaymentGateway : injected
OrderService o-- Notifier : injected
OrderRepository <|.. InMemoryOrderRepository
PaymentGateway <|.. FakePaymentGateway
Notifier <|.. LoggingNotifier
Notifier <|.. RecordingNotifier
OrderService ..> Order : creates
OrderService knows the three interfaces and nothing below the dotted lines. The fakes are not second-class: FakePaymentGateway and RecordingNotifier live next to the contract, are typed against it, and are the reason the tests need no database and no patching.
Canonical example in Python¶
The contracts come first (code/patterns/dependency_injection.py, tested by code/patterns/tests/test_dependency_injection.py):
class PaymentDeclinedError(HandbookError):
"""The gateway refused the charge. Nothing is stored and nobody is notified."""
@dataclass(frozen=True, slots=True)
class Order:
id: str
customer_id: str
amount: Money
placed_at: float
payment_ref: str
@runtime_checkable
class OrderRepository(Protocol):
def add(self, order: Order) -> None: ... # ConflictError on a reused id
def get(self, order_id: str) -> Order | None: ...
@runtime_checkable
class PaymentGateway(Protocol):
def charge(self, customer_id: str, amount: Money, *, idempotency_key: str) -> str: ...
@runtime_checkable
class Notifier(Protocol):
def send(self, customer_id: str, message: str) -> None: ...
The service declares its needs in __init__ and constructs none of them:
class OrderService:
"""The consumer: every collaborator is a constructor parameter typed as a Protocol.
The service builds nothing it depends on and reads no global. Time and identity
arrive as ``Clock`` and ``IdGenerator`` from ``common`` instead of ``time.time()``
and ``uuid4()``, so a test controls both; the store, the gateway and the notifier
arrive the same way, so a test can observe every side effect.
"""
def __init__(
self,
orders: OrderRepository,
payments: PaymentGateway,
notifier: Notifier,
clock: Clock,
ids: IdGenerator,
) -> None:
self._orders = orders
self._payments = payments
self._notifier = notifier
self._clock = clock
self._ids = ids
def place_order(self, customer_id: str, amount: Money) -> Order:
if amount.cents <= 0:
raise ValidationError("amount must be positive")
order_id = self._ids.next_id()
reference = self._payments.charge(customer_id, amount, idempotency_key=order_id)
order = Order(order_id, customer_id, amount, self._clock.now(), reference)
self._orders.add(order)
self._notifier.send(customer_id, f"order {order_id} confirmed: {amount}")
return order
Three decisions to say out loud:
- Constructor injection, nothing else. Setter injection leaves a half-built object; a lookup inside the method (
registry.get("clock")) hides the dependency from the signature. With the constructor, theOrderService(...)call in a test is the documentation of what the service touches. - Protocols, not base classes.
FakePaymentGatewaynever inherits fromPaymentGateway; it qualifies by shape, and so does a double defined inside one test.@runtime_checkablelets a test assert that withisinstance. - Time and identity are collaborators.
time.time()anduuid4()inside a service make every assertion approximate;ClockandIdGeneratorfromcommonmakeplaced_at == EPOCHandid == "order-1"exact, andFakeClock.advance(60)tests time-dependent rules without sleeping.
The implementations, real and fake, sit next to the contract:
class InMemoryOrderRepository:
"""A dict behind the contract: the store for tests, demos and small deployments.
``_lock`` protects ``_orders`` so the duplicate check and the insert are one step.
"""
def __init__(self) -> None:
self._orders: dict[str, Order] = {}
self._lock = threading.Lock()
def add(self, order: Order) -> None:
with self._lock:
if order.id in self._orders:
raise ConflictError(f"order {order.id} already exists")
self._orders[order.id] = order
def get(self, order_id: str) -> Order | None:
with self._lock:
return self._orders.get(order_id)
def __len__(self) -> int:
with self._lock:
return len(self._orders)
class LoggingNotifier:
"""A production notifier for a handbook with no SMTP server: it logs, via an injected logger."""
def __init__(self, logger: logging.Logger) -> None:
self._logger = logger
def send(self, customer_id: str, message: str) -> None:
self._logger.info("to %s: %s", customer_id, message)
class FakePaymentGateway:
"""A test double you own: a stub for the outcome, a spy for the calls.
No ``unittest.mock``: a misspelt method on a hand-written fake fails loudly, while
``Mock()`` auto-creates it and the test passes for the wrong reason.
"""
def __init__(self, *, decline: bool = False) -> None:
self.decline = decline
self.charges: list[tuple[str, Money, str]] = []
def charge(self, customer_id: str, amount: Money, *, idempotency_key: str) -> str:
if self.decline:
raise PaymentDeclinedError(f"card declined for {customer_id}")
self.charges.append((customer_id, amount, idempotency_key))
return f"pay-{idempotency_key}"
class RecordingNotifier:
"""A spy: keeps every message so a test can assert on what was said, and to whom."""
def __init__(self) -> None:
self.sent: list[tuple[str, str]] = []
def send(self, customer_id: str, message: str) -> None:
self.sent.append((customer_id, message))
FakePaymentGateway is a stub for the outcome (decline=True) and a spy for the calls (charges). Unlike Mock(), it fails loudly on a misspelt method and survives a signature change only if you update it, which is the point.
main() is the composition root, the one function that names concrete classes together; nothing else in the module mentions SystemClock or UuidIdGenerator. Running python -m patterns.dependency_injection prints:
--- production wiring: the composition root alone names the concrete classes ---
placed an order with a 32-char uuid at wall-clock time; nothing below depends on either
--- test wiring: the same class, a fake in every slot ---
order-1 for ada: 25.00 USD at t=1700000000, paid as pay-order-1
notifier recorded: ('ada', 'order order-1 confirmed: 25.00 USD')
order-2 placed 60 s later: the clock is a collaborator
--- functional variant: partial() binds the collaborators once ---
order-3 for cy: 5.00 USD; orders stored: 3
--- a declined payment stores nothing and notifies nobody ---
rejected: card declined for dee; orders stored: 3; messages sent: 3
Pythonic variant¶
A class with one public method and five injected collaborators is a function with five bound arguments:
type PlaceOrder = Callable[[str, Money], Order]
def place_order(
orders: OrderRepository,
payments: PaymentGateway,
notifier: Notifier,
clock: Clock,
ids: IdGenerator,
customer_id: str,
amount: Money,
) -> Order:
"""The service as a function: collaborators first, then the request. Same body, no ``self``."""
if amount.cents <= 0:
raise ValidationError("amount must be positive")
order_id = ids.next_id()
reference = payments.charge(customer_id, amount, idempotency_key=order_id)
order = Order(order_id, customer_id, amount, clock.now(), reference)
orders.add(order)
notifier.send(customer_id, f"order {order_id} confirmed: {amount}")
return order
def bind(
orders: OrderRepository,
payments: PaymentGateway,
notifier: Notifier,
clock: Clock,
ids: IdGenerator,
) -> PlaceOrder:
"""``partial`` is constructor injection for functions: bind the collaborators once."""
return partial(place_order, orders, payments, notifier, clock, ids)
functools.partialis constructor injection for functions.bind(...)fixes the collaborators and returns aPlaceOrdercallable; the request arrives per call.- Keyword defaults are a seam with a cost.
clock: Clock | None = Noneplusself._clock = clock or SystemClock()keeps call sites short, but the dependency vanishes from the signature, andclock: Clock = SystemClock()is evaluated once at import. Use it for leaf utilities, not services. - pytest fixtures are injection by parameter name. A test that takes
wiringreceives the composed graph; the fixture is the test suite's composition root. - FastAPI's
Dependsdoes the same per request:service: OrderService = Depends(get_service)in a path function resolves the graph when the request arrives.
Why no container? Spring, Guice and dependency-injector solve auto-wiring across hundreds of components with lifecycle scopes. An LLD design has five to ten objects built once in main(); keyword arguments already give you named, typed, explicit wiring, and a container would hide the one thing the interviewer wants to see, the graph. Say "I wire by hand in the composition root and reach for a container only when the graph outgrows a screen."
| Reach for | When |
|---|---|
| Constructor parameters typed as Protocols | Services and anything with side effects (the default) |
functools.partial |
A single-function service, or a callback that needs collaborators |
| A keyword default | A leaf utility where the real implementation is almost always right |
A framework's injector (Depends, fixtures) |
Per-request or per-test lifetimes the framework already manages |
Real-world usage¶
- pytest fixtures are the most used injector in Python: parameters resolved by name, with
function,moduleandsessionscopes for lifetimes. - FastAPI
Dependsinjects per-request collaborators (database sessions, the current user) into path functions and composes them. - The stdlib injects by parameter.
json.dumps(default=...),socketserver.TCPServer(address, handler_class),logging.Handler.setFormatter(...): nothing is looked up, the caller passes it in. - This handbook: every LLD package takes
ClockandIdGeneratorin its constructors, which is why every demo prints the same numbers on every machine.
Related patterns and confusions¶
| Looks like Dependency Injection | How to tell them apart |
|---|---|
| Singleton | The object fetches a global (PaymentClient.instance()); with DI the collaborator is handed in. You can still build exactly one instance in the composition root; you lose only the global access. |
| Service Locator | registry.get("clock") inside the method: resolved at call time, invisible in the signature. It injects the locator, not the collaborator. |
| Strategy | Strategy is what you inject, a swappable algorithm; DI is how it arrives. Every strategy here is injected; not every injected object is a strategy. |
| Factory Method | A factory creates on demand inside the class; DI receives from outside. Inject a factory when the service must create many instances at runtime. |
| Dependency Inversion Principle | The D in SOLID says depend on abstractions. DI is the usual way to satisfy it, but you can inject concrete classes (no inversion) or instantiate a Protocol's implementation yourself (no injection). |
Where it appears in LLD problems¶
- Every problem in this handbook:
Clock,IdGeneratorand a repository are constructor parameters of each service, so the demos are deterministic and the tests need no patching. - Design a parking lot — the gate takes a
PricingStrategyand aClock; the tests drive the clock to price a stay. - Design a notification service (LLD) — channel senders are injected, so the tests record instead of sending.
- Design a payment gateway and digital wallet — the PSP adapter is injected; the tests use a declining fake exactly like
FakePaymentGateway.
Interview tips¶
Interview tip
Say it while drawing the constructor: "the service takes its store, gateway, notifier, clock and id generator as Protocols; main wires the real ones, tests wire fakes." Then the two sentences that mark an SDE2: name the test that becomes possible (a declined payment stores nothing, asserted on the fake) and name what you are not doing (no container, no patch, no global).
Common mistake
Injecting the abstraction and then defeating it: self._clock = clock or SystemClock() followed by time.time() three lines later, or a service that accepts a repository and also imports sqlite3. Runner-up: patch("module.time.time") in tests when a Clock parameter would have made the seam explicit and the test readable.
Related¶
- Repository — the most common thing to inject
- Clean code and testing — fakes versus mocks, and what a good test asserts
- Singleton — the global access DI replaces
- SOLID in Python — the Dependency Inversion Principle DI serves
- Strategy — the pattern most often delivered through injection
- Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern (2004)
- PEP 544 — Protocols: Structural subtyping
- pytest documentation: fixtures