Engineering Atlas › Low-Level Design

Passing dependencies in: dependency injection

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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, clock

Tests 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

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:

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

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

  1. Objects that build their own collaborators cannot be tested or reconfigured without editing them, so construction moves out to the caller.
  2. Moving construction out everywhere would scatter it, so it is collected in one composition root.
  3. Large graphs make hand-wiring tedious and objects need different lifetimes, so containers add registries and scopes.
  4. 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.
  5. 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

OptionStrengthsWeaknessesChoose when
Build dependencies inside the classSimplest to writeUntestable without patching; hidden dependenciesThrowaway scripts
Pure DI (hand wiring in main)Explicit; compile-time checked; no frameworkWiring code grows with the graph; lifetimes by handSmall and medium services, libraries, Go
Reflection container (Spring, .NET, NestJS)Little wiring code; scopes and framework integrationStartup cost; errors at startup or first use; magicLarge apps on frameworks built around one
Generated wiring (Dagger, Wire)Compile-time errors; no reflection; readable outputBuild step; less dynamicMobile, serverless, cold-start sensitive services
Service locatorEasy to retrofit into legacy codeHides dependencies; global state in testsTransitional only, while migrating to injection

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →