Safeguard
Application Security

Dependency injection in Python: a guide to testability and security boundaries

FastAPI shipped a built-in DI container in its very first release in December 2018 — but the same swappability that makes DI testable can quietly ship a mock into production.

Safeguard Research Team
Research
Updated 7 min read

Dependency injection (DI) has quietly become one of the most consequential architectural decisions in a Python codebase, and most teams make it without noticing. Python dependency injection looks less formal than Java's container-driven approach, but it carries the same testability payoff and the same security tradeoffs once a codebase grows past a few modules. When Sebastián Ramírez released FastAPI v0.1.0 on December 8, 2018, he built a DI mechanism — Depends() — directly into the framework's core, rather than bolting it on as a third-party add-on the way Django and Flask developers have had to for two decades. That single design choice turned FastAPI into arguably the most widely used dependency-injection pattern in the Python web ecosystem today, used to swap in database sessions, auth checks, and rate limiters on nearly every route. But DI is a double-edged design: the same mechanism that lets you swap a real PaymentGateway for a FakeGateway in a unit test is the mechanism that, misconfigured, can let a permissive FakeAuthorizer slip past code review and into a production container. This piece walks through the three canonical DI patterns available in Python — constructor injection, setter injection, and the service-locator anti-pattern — and examines where each one helps testability, and where each one can silently erode a security boundary that a reviewer assumed was solid.

What is dependency injection, and why did Python need it later than Java?

Dependency injection means a class receives its collaborators from the outside — usually via its constructor — rather than instantiating them itself. Java popularized this early because static typing and verbose class wiring made manual object graphs painful, giving rise to containers like Spring in 2003. Python delayed the trend because duck typing and first-class functions already made ad hoc substitution easy: you could monkey-patch a module attribute in a test without any container at all. That informality worked for small scripts, but it broke down as Python moved into large, long-lived services with request-scoped state — database sessions, authenticated user context, feature flags — that need to be constructed once per request and threaded through many layers. Django and Flask still have no built-in DI container; teams either hand-roll constructor injection or reach for a third-party library. FastAPI's decision to make Depends() a first-class citizen from release one reflects that shift, and several python dependency injection framework options — dependency-injector, injector, punq, wired, and svcs — exist precisely to fill the gap in frameworks that don't.

How does constructor injection improve testability?

Constructor injection passes every collaborator a class needs as an argument to __init__, making the full dependency graph visible at the call site instead of buried inside method bodies. Take a class class OrderService: def __init__(self, db: Database, notifier: Notifier): ... — a test can construct it with an in-memory FakeDatabase and a NullNotifier in three lines, with no monkeypatching, no unittest.mock.patch context managers, and no risk of a patched attribute leaking into an unrelated test. This is the pattern FastAPI's Depends() formalizes for HTTP handlers: a route function declares db: Session = Depends(get_db), and tests override that single callable via app.dependency_overrides rather than reaching into global state. The practical testability win is large — constructor-injected classes can be unit tested in isolation, with each collaborator independently mockable, which is why constructor injection is the pattern nearly every Python DI library defaults to. The cost is upfront: constructors grow longer as a class accumulates dependencies, which is often a useful signal that the class is doing too much.

Where does setter or property injection introduce security risk?

Setter injection assigns a dependency after construction, via a method or attribute (service.authorizer = RealAuthorizer()), rather than requiring it at creation time. This pattern is more flexible — it supports optional dependencies and circular wiring that constructor injection can't express cleanly — but it creates a window where an object exists in a partially-configured state. A class that defaults self.authorizer = None until something calls a setter can be used, accidentally, before that setter ever runs, meaning an authorization check silently no-ops instead of failing closed. This is a real-world instance of an incomplete mediation problem: the security boundary depends on an imperative sequence of calls happening in the right order, rather than being enforced by the type system at construction. Compare that to constructor injection, where a class simply cannot be instantiated without its Authorizer, and a missing dependency is a TypeError at startup rather than a silent bypass discovered in production logs. Teams that need optional wiring should prefer explicit Optional[T] = None constructor parameters with an internal null-object default over deferred setter calls.

How can DI containers accidentally ship a mock into production?

DI containers resolve concrete implementations based on configuration — often an environment variable, a config file key, or a string name — and that resolution step is where test doubles most often leak into production. A common pattern in libraries like dependency-injector is to define a container with providers keyed by environment: config.env set to "test" wires FakeAuthService, "prod" wires RealAuthService. If that environment variable is misread, unset, or defaults to the permissive branch — a classic leftover-debug-code failure mode conceptually related to CWE-489 (leftover debug code) and CWE-668 (exposure of resource to wrong sphere) — the application boots with a mock authorizer that approves every request, and nothing in the request path looks abnormal. This is not hypothetical carelessness unique to Python; it is a structural property of any container that resolves implementations dynamically rather than requiring them explicitly at every call site. The mitigation is boring but effective: fail loudly (raise, don't default) when the environment key is missing, and add a startup-time assertion that the resolved authorizer type matches an explicit allowlist in any environment flagged as production.

Why is the service-locator pattern considered a security anti-pattern?

The service locator pattern replaces explicit dependency declaration with a global registry that any code can query by name or type — container.get("authorizer") — at any point in a call stack, rather than only at construction. Martin Fowler's original 2004 writing on dependency injection already flagged the service locator as inferior to constructor injection because it hides a class's real dependencies from anyone reading its signature; you have to read the entire method body to know what it actually needs. In security terms, that opacity is the problem: a code reviewer checking whether a function respects a tenant-isolation boundary cannot tell, from the function signature alone, whether it pulled a scoped or an unscoped database client out of the locator. It also expands the blast radius of a single bad registration — because the locator is global and mutable at runtime, one incorrect container.register() call anywhere in a large codebase can silently affect every consumer that resolves that key afterward, a pattern of unrestricted dynamic resolution that maps to the general class described by CWE-914 (improper control of dynamic feature). Auditability is why most Python DI guidance, including the documentation for dependency-injector and injector, steers developers toward explicit constructor wiring and treats the locator as a pattern to migrate away from, not toward.

Does dynamic auto-wiring create the same risk as Java's classloader attacks?

Not to the same degree, but the underlying risk category is related. Java's Spring4Shell (CVE-2022-22965), disclosed in March 2022, exploited Spring's data-binding mechanism to reach the application's classloader through crafted HTTP parameters, ultimately enabling remote code execution — a vulnerability rooted in how Spring's reflection-based binding resolved attacker-influenced field names against live object graphs. No comparable CVE has been documented against Python's popular DI containers, and this should not be implied as an equivalent threat: Python's ecosystem doesn't have a directly analogous classloader-binding surface, and libraries like dependency-injector have no publicly attributed CVEs as of this writing. Still, the general principle carries over. Any DI setup that resolves a class dynamically from a string — by reading a type name out of a config file, a database row, or (worse) a request parameter — reintroduces the same category of risk that CWE-914 describes: attacker-influenced input steering which code actually executes. Python DI is safest when the set of resolvable types is fixed at deploy time and wiring is declared in code, not derived at runtime from untrusted input.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.