Passing dependencies in: dependency injection
Open in the interactive app: practice, quiz, mock interview →In one minute
- Dependency injection means an object receives the things it needs (a repository, a clock, an HTTP client) from outside instead of creating or looking them up itself. The caller decides which implementation; the object only declares what it needs.
- Three forms: constructor injection (the default), setter or property injection (for optional or late-bound dependencies), and parameter injection (pass it to the one method that needs it). One place, the composition root at the program's entry point, builds the object graph.
- A DI container automates that wiring and adds lifetimes: singleton (one per container), scoped (one per request or unit of work) and transient (new every time). Lifetimes are where most container bugs live.
- The classic container bug is the captive dependency: a singleton holding a scoped object, such as a database session, which then outlives its request, leaks memory and mixes data between users. Validate the graph at startup.
- Pure DI (wiring by hand in
main) is often enough. Reach for a container when the graph has hundreds of nodes, per-request scopes, or framework integration; prefer containers that fail at build or startup time over ones that fail on the first request.
How it works
The problem
An invoice service sends reminder emails. The first version builds what it needs:
class InvoiceReminder:
def __init__(self):
self.db = PostgresInvoices(os.environ["DATABASE_URL"])
self.mail = SmtpMailer("smtp.internal", 587)
def run(self):
for inv in self.db.overdue(datetime.now()):
self.mail.send(inv.email, "Your invoice is overdue")Every test needs a real database and SMTP server, or a mock.patch on module paths that breaks when files move. The class reads the environment and the system clock, so nothing in its signature tells a reader what it depends on. Swapping SMTP for an email API means editing this class and every other class that builds its own mailer.
Hand the dependencies in
With constructor injection the class states its needs and builds nothing:
class InvoiceReminder:
def __init__(self, invoices, mailer, clock):
self.invoices, self.mailer, self.clock = invoices, mailer, clockTests pass an in-memory repository, a recording mailer and a fixed clock. Production code builds the real ones once, in main. This is the mechanism behind the Dependency Inversion Principle: the policy owns the interfaces, and the composition root decides which adapters plug in. It is also how you get a Singleton without a global, by creating one instance in main and passing it down.
The three forms
- Constructor injection makes dependencies mandatory and visible, and lets the object be immutable after construction. Use it by default.
- Setter or property injection suits optional collaborators with a sensible default (a logger that defaults to a no-op) and frameworks that must construct objects before wiring them. The cost is a window in which the object exists half-configured.
- Parameter injection passes the dependency to the method that uses it:
handle(request, clock). Good when only one method needs it or when the value changes per call, like a request-scoped transaction.
The composition root
Mark Seemann named the single place where the graph is assembled the composition root. It is main, the web framework's startup hook, or a Lambda handler's module scope. Only the composition root knows concrete types, reads configuration and decides lifetimes. Everything below it receives finished objects. When code deep in the domain calls Container.get(Mailer), the container has become a service locator: dependencies are hidden again, just behind a nicer API, and tests must configure a global container.
Containers and lifetimes
A container is a registry from a service key (a type, an interface or a name) to a recipe: a factory or constructor, its dependencies, and a lifetime. Resolving a key walks the dependency graph depth-first, building leaves first. Three lifetimes cover almost every case:
- Singleton: built once per container and shared. Connection pools, HTTP clients, configuration, caches.
- Scoped: built once per scope, usually one HTTP request or one message being processed. Database sessions or units of work, the current user, a per-request cache.
- Transient: built fresh on every resolution. Small stateless helpers.
Lifetimes impose a rule: a longer-lived object must not hold a shorter-lived one. A singleton constructed with a scoped DbSession keeps that first request's session forever; every later request uses a session whose transaction, identity map and user context belong to someone else. The same happens indirectly through a transient: singleton to transient to scoped is still captive. .NET's container checks this when ValidateScopes is on (the default in the Development environment) and throws when a scoped service is resolved from the root provider or captured by a singleton.
How containers find dependencies
- Reflection at runtime: Spring reads constructor signatures and annotations, .NET reads constructor parameter types, NestJS and Angular read TypeScript decorator metadata. Flexible, but missing bindings surface at startup or on first use.
- Code generation at build time: Dagger (Java and Android) generates factory classes with an annotation processor, and Google Wire generates plain Go constructors from provider functions. A missing binding is a compile error, and the generated code is ordinary calls you can read and step through.
- Signature inspection per request: FastAPI's
Dependsreads a handler's parameters, resolves each dependency once per request by default, and runs the teardown half ofyielddependencies after the response.
Reviewing agent-written code
Agents reproduce the code around them. In a codebase with a container they will often inject the container itself and call get inside methods, or register a new service as a singleton because the neighbour was a singleton, regardless of what it holds. In a codebase without one they write self.client = httpx.Client() inside a constructor. In review, check that new classes declare dependencies in their constructor, that only the composition root touches the container, that the lifetime of each new registration matches its state (anything holding a session, transaction or user is scoped), and that the change came with a test that injects fakes rather than patching modules.
The same shape elsewhere
DI is an instance of ownership and lifetime decided by whoever creates the context, not by the code that uses it. A Unix process does not open its own stdin and stdout: the shell opens files or pipes and passes the descriptors in, which is why grep works on a file, a pipe or a socket unchanged. systemd socket activation passes already-bound listening sockets to a service through LISTEN_FDS. Kubernetes injects configuration and credentials through mounted volumes and environment variables rather than having pods fetch their own. The bugs match too: a descriptor or credential that outlives the context it was meant for.
Down the stack
Layer 1The contract
For a valid graph, resolving a service returns a fully constructed object whose dependencies are all present; a container never returns a half-built object. A singleton factory runs at most once per container, even under concurrent first resolution. A scoped factory runs at most once per scope. Objects created within a scope are disposed when the scope ends, in reverse order of creation, so nothing is closed while something that depends on it is still open. A container that offers validation promises to report missing bindings and lifetime violations before serving traffic, naming the chain of services involved.
Layer 2The mechanism
Spring keeps BeanDefinitions in a DefaultListableBeanFactory, and its singleton registry has three maps: singletonObjects for finished beans, earlySingletonObjects for partially built ones exposed to break setter-injection cycles, and singletonFactories that produce those early references. Spring Boot has rejected circular references by default since 2.6, because the early-reference trick hands out objects before their initialisation completes. Request-scoped beans injected into singletons are handled with a scoped proxy: the singleton holds a CGLIB or JDK proxy, and every call looks up the current request's real object.
.NET describes each registration as a ServiceDescriptor (service type, implementation or factory, lifetime). The root ServiceProvider caches singletons; each IServiceScope caches its scoped instances and tracks every disposable it created, disposing them in reverse order when the scope ends. Dagger's annotation processor emits a Factory class per binding and a component that calls them in dependency order, so at runtime there is no reflection at all. Wire's generated wire_gen.go is the same idea in Go: a function that calls your providers in order and returns cleanup functions.
Layer 3The machine
Reflection-based containers pay at startup: scanning the classpath or assemblies, reading annotations, generating proxy classes. A large Spring Boot service commonly spends several seconds there, which matters for autoscaling and serverless cold starts. That cost is why Dagger and Wire exist, why Spring added ahead-of-time processing for GraalVM native images, and why Android apps avoid reflective DI. Generated wiring costs what hand-written new calls cost.
Per request, a scoped graph is a handful of allocations: ten objects of 100 bytes at 5,000 requests per second is 5 MB/s of short-lived garbage, which a generational collector reclaims cheaply. The expensive runtime failure is a captive dependency. An ORM session holds an identity map of every row it has loaded, so a session captured by a singleton grows without bound: at 2 KB per tracked row and 50 rows per request, it reaches 1 GB after about 10,000 requests. Injecting interfaces also means indirect calls (an itab lookup in Go, a virtual call in the JVM), about 1 to 2 ns each and a barrier to inlining unless the JIT or Go's profile-guided optimisation sees one dominant concrete type.
Why it's built this way
- Objects that build their own collaborators cannot be tested or reconfigured without editing them, so construction moves out to the caller.
- Moving construction out everywhere would scatter it, so it is collected in one composition root.
- Large graphs make hand-wiring tedious and objects need different lifetimes, so containers add registries and scopes.
- Lifetimes can be combined wrongly, and the damage (leaks, cross-user data) appears only under load, so containers validate graphs at startup or compile time.
- Reflection costs startup time and moves errors to runtime, so generated wiring wins where cold starts or compile-time safety matter.
Diagram
flowchart TD
MAIN["composition root: main()"] --> REG["register services and lifetimes"]
REG --> VAL{"validate graph"}
VAL -- "missing binding" --> E1["fail at startup"]
VAL -- "singleton -> scoped" --> E2["fail: captive dependency"]
VAL -- "ok" --> SRV["serve requests"]
SRV --> SC["create scope per request"]
SC --> H["resolve handler"]
H --> S1["DbSession (scoped, cached in scope)"]
H --> S2["HttpClient (singleton, cached in root)"]
H --> S3["Validator (transient, new)"]
H --> DONE["request done"]
DONE --> DSP["dispose scope in reverse creation order"]Worked example
A payments API on .NET registered FraudRules as a singleton because it loaded a rules file at startup. A later change gave it a constructor parameter IAccountRepository, which was scoped and wrapped a database context. Production did not validate scopes, so nothing failed. The first request's context was captured by the singleton for the life of the process.
Symptoms appeared in three ways. Memory grew by about 80 MB per hour, because the context's change tracker kept every account it ever loaded; pods were recycled by the out-of-memory killer every 9 hours. Concurrent requests used the same context from different threads, producing intermittent "a second operation was started on this context" exceptions at around 0.3% of requests. Worst, fraud checks read account balances cached since the pod started, so limits raised during the day were ignored until the next restart.
The fix took three lines: inject an IServiceScopeFactory into FraudRules and create a short scope per check, or make FraudRules scoped and move the rules file into a separate singleton RulesSnapshot. The team chose the second, turned on ValidateOnBuild and ValidateScopes in every environment, and added a test that builds the provider with validation on. Startup grew by 40 ms; the next captive dependency failed the build instead of production.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Build dependencies inside the class | Simplest to write | Untestable without patching; hidden dependencies | Throwaway scripts |
Pure DI (hand wiring in main) | Explicit; compile-time checked; no framework | Wiring code grows with the graph; lifetimes by hand | Small and medium services, libraries, Go |
| Reflection container (Spring, .NET, NestJS) | Little wiring code; scopes and framework integration | Startup cost; errors at startup or first use; magic | Large apps on frameworks built around one |
| Generated wiring (Dagger, Wire) | Compile-time errors; no reflection; readable output | Build step; less dynamic | Mobile, serverless, cold-start sensitive services |
| Service locator | Easy to retrofit into legacy code | Hides dependencies; global state in tests | Transitional only, while migrating to injection |
Pitfalls in production
- Captive dependencies: a singleton holding a session, transaction or user context. Fix: validate scopes in every environment and inject a scope factory where a long-lived object truly needs short-lived work.
- Injecting the container: classes call
container.getin their methods, turning DI back into a service locator. Fix: only the composition root touches the container. - Constructor bloat: a class with nine injected dependencies. The container makes it painless to keep adding them, which hides a class doing too much. Fix: treat more than four or five as a design smell and split responsibilities.
- Late failures: a missing binding found on the first request to a rarely used endpoint at 3 a.m. Fix: resolve or validate the whole graph at startup, or use compile-time DI.
- Disposal in the wrong order: a connection pool closed before the repository that flushes through it. Fix: let the scope or container dispose in reverse creation order, and never dispose injected objects you did not create.
Leadership lens
- Decide per codebase whether you want a container at all. A team of five on a Go service is usually better off with pure DI; a 300-module Spring monolith is not. Write the decision down.
- Make lifetime a reviewed property of every registration. "Why is this a singleton?" is one of the highest-value questions in a DI-heavy code review.
- Require startup validation in CI. It converts an entire class of production incidents into failed builds for the cost of milliseconds.
- Give agents a pattern to copy: constructor injection, registration in one module, a test with fakes. They follow the nearest example, so make the nearest example correct.
Check yourself
What is the difference between dependency injection and the Dependency Inversion Principle?
Show answer
Inversion is about the direction of source dependencies (policy owns the interface, details implement it). Injection is a mechanism for supplying the implementation at runtime; you can inject concrete classes without any inversion.
A singleton depends on a transient, which depends on a scoped service. Is that safe?
Show answer
No. The transient is built once, when the singleton is built, so it and the scoped service it holds live as long as the singleton. The scoped service is captive through the chain.
Why is calling
container.get(X)inside a business method worse than constructor injection, if both use the same container?Show answer
The dependency no longer appears in the signature, tests must configure a shared container, and missing bindings are found only when that line runs.
Why do Android and serverless teams favour Dagger or Wire over reflective containers?
Show answer
Generated wiring has no reflection or classpath scanning at startup, which shortens cold starts, and missing bindings become compile errors.
Watch
- Dependency Injection, The Best Pattern (CodeAesthetic): a clear visual walk from hard-coded dependencies to injected ones, with the testing payoff.
- Dependency INVERSION vs Dependency INJECTION in Python (ArjanCodes): separates the principle from the mechanism with a refactoring in Python.
Free resources
- Inversion of Control Containers and the Dependency Injection pattern (Martin Fowler): the article that named the pattern and compared it with service locators.
- Composition Root (Mark Seemann): where wiring belongs and why only one place should touch the container.
- Dependency injection guidelines (Microsoft Learn): lifetimes, scope validation and the captive dependency problem in a production container.
- Wire user guide (Google): compile-time DI for Go, with providers, injectors and cleanup functions.
- Dependencies with yield (FastAPI docs): per-request dependencies with setup and teardown in Python.
- Service Locator is an Anti-Pattern (Mark Seemann): the argument against calling the container from business code.