Dependency Injection in Python
debt(d7/e5/b5/t5)
Closest to 'only careful code review or runtime testing' (d7), since detection_hints lists pylint with automated:no and a regex code_pattern that flags inline construction in __init__, but no tool reliably proves a dependency should have been injected — it surfaces in code review or when tests prove hard to write.
Closest to 'touches multiple files / significant refactor in one component' (e5), because while quick_fix is 'move construction out of __init__ and accept a Protocol parameter', every call site that instantiated the class must now supply the collaborator, so fixing one hard-bound class ripples to its construction points across the composition root.
Closest to 'persistent productivity tax' (b5), since applies_to spans web, cli, queue-worker and library; a chosen wiring approach (manual composition root vs container) shapes how every collaborator is constructed and tested across many work streams without fully defining the system's shape.
Closest to 'notable trap (a documented gotcha most devs eventually learn)' (t5), per the misconception that DI 'requires a special framework or container' — competent Python devs commonly over-engineer with a container or reach for global service locators, a documented gotcha rather than an always-wrong inversion.
Also Known As
TL;DR
Explanation
Dependency injection (DI) is a concrete application of the inversion-of-control principle: instead of a class reaching out to build or locate the things it needs, those things are handed to it - usually through its constructor, a method parameter, or an attribute. The caller (or a container, factory, or composition root) becomes responsible for wiring objects together, so the dependent class only states what it needs via parameters and type hints. In Python this is often simpler than in statically typed languages because duck typing and Protocols let you swap implementations without a heavyweight framework: passing a different object that satisfies the same interface is all it takes. The three common forms are constructor injection (dependencies passed to __init__, the default and clearest choice), setter/attribute injection (assigned after construction, useful for optional collaborators), and method injection (passed per-call when a dependency varies by operation). The payoff is testability and flexibility: a class that receives a database client can be tested with an in-memory fake; a service that receives an HTTP session can be reconfigured for retries or mocked entirely. Without DI, code instantiates concrete classes inline (self.db = PostgresClient(...)), hard-binding behavior to a specific implementation and making isolated unit tests impossible without monkeypatching. Python supports DI with zero dependencies - plain function and constructor arguments plus typing.Protocol cover most needs. For larger applications, containers such as dependency-injector, or framework-native systems like FastAPI's Depends, manage object lifetimes, singletons, and graphs of wiring at a single composition root. The key discipline is to push object creation up to the edges of the system (main, app factory, container) and keep business logic ignorant of how its collaborators are built. Avoid the related anti-pattern of the service locator, where code asks a global registry for dependencies - that hides requirements and reintroduces hidden coupling. Inject explicitly so dependencies are visible in signatures.
Common Misconception
Why It Matters
Common Mistakes
- Instantiating concrete dependencies inside __init__ (self.db = PostgresClient()), hard-binding the class to one implementation and blocking isolated tests.
- Using a global service locator or module-level singleton that classes reach into, hiding real dependencies from their signatures.
- Over-engineering small scripts with a full DI container when plain constructor arguments would be clearer.
- Importing and constructing dependencies deep in business logic instead of wiring them at a single composition root.
- Relying on monkeypatching in tests to replace inline-created objects rather than injecting a fake collaborator.
Avoid When
- A small standalone script with one concrete implementation that will never be swapped or tested in isolation.
- The dependency is a pure stdlib utility (e.g. json, math) with no behavioral variance worth abstracting.
- Introducing a full DI container would add indirection that outweighs the wiring it removes.
When To Use
- A class depends on external systems (databases, HTTP clients, queues) that must be faked or mocked in tests.
- Multiple implementations of a collaborator exist or are anticipated (prod vs test, different vendors).
- You want a single composition root controlling object lifetimes and wiring instead of scattered instantiation.
- Business logic should remain ignorant of how its collaborators are constructed or configured.
Code Examples
class PostgresClient:
def query(self, sql): ...
class UserService:
def __init__(self):
# hard-coded dependency - caller has no control,
# cannot test without a real Postgres instance
self.db = PostgresClient()
def get_user(self, uid):
return self.db.query(f"SELECT * FROM users WHERE id={uid}")
# Testing requires monkeypatching the import:
service = UserService() # always talks to real DB
Use parameterized query placeholder (e.g. self.db.query with a parameterized statement) rather than f-string interpolation in get_user.