Dependency Injection
An object receives its collaborators instead of constructing them. That is the whole idea, it needs no library, and it is the third of three things people call DIP.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Should an object build the things it needs, or be handed them?
OrderService has to charge the customer. It needs a payment gateway. Today it constructs a StripeGateway inside its own constructor, and every test of order logic now needs a Stripe key.
OrderService constructs its own StripeGateway in its constructor. Callers write new OrderService() and get a working object with no ceremony, which is genuinely pleasant: there is no wiring, nothing to configure, and the dependency is discoverable by reading one file. For a script, a prototype, or code with no tests, this is the right amount of design.
The first test of order logic tries to new OrderService() and hits the network. The usual fix is a boolean or an environment check inside the service — a test-only branch in production code, which is a defect waiting for the day the environment is misread.
- The first test of order logic tries to
new OrderService()and hits the network. The usual fix is a boolean or an environment check inside the service — a test-only branch in production code, which is a defect waiting for the day the environment is misread. - The CLI backfill needs a gateway configured with a longer timeout. There is no way to say so, so a second constructor appears, then a third, and the configuration surface grows inside the service.
- Sandbox versus live is a Stripe key, and the key comes from the environment, so
OrderServicenow reads configuration. It has acquired a second reason to change: how config is loaded (Divergent Change). - You cannot tell from
OrderService's signature that it touches the network. Its dependencies are invisible to the caller and to the type checker, which means nothing warns you when a supposedly-pure code path acquires an I/O call (Hidden Global State).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The team writes tests and wants them to run offline in under a second; that constraint alone decides most of this.
- There is exactly one payment provider and no roadmap item for a second, so any argument resting on "swap the provider" is not available here.
- TypeScript with no DI framework. Introducing one would be a separate decision with its own cost (What a Framework Charges).
- The service is called from an HTTP handler, a background retry job and a CLI backfill, so whatever it depends on must be constructible in three different contexts.
- A charge must happen exactly once per order; nothing about how the gateway is obtained may make that easier to violate.
- No test may reach a real payment provider. This is a hard invariant, not a preference, and code that makes it depend on an environment variable has already broken it.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
OrderServiceowns order logic and the decision that a charge must be made.- The
PaymentGatewayimplementation owns talking to Stripe, including its keys, retries and error mapping. - The caller — HTTP handler, job, CLI — owns nothing about payment; it just passes along whatever the composition root gave it.
- The composition root owns construction: reading configuration, choosing implementations, and assembling the object graph exactly once (Wiring and the Composition Root).
- The seam is the constructor signature. It is the place where a dependency stops being a secret and becomes part of the type — which is why injection is a *documentation* device as much as a testing one.
- Injection alone draws no architectural boundary. Injecting a concrete
StripeGatewayis still injection, and it is often the right amount: you gain testability with a hand-written double and you have added no interface (Interface Versus Implementation). - Where injection stops is important: it is for collaborators with behaviour, not for data. Injecting a currency code or a tax rate is configuration passing, and treating configuration as a dependency to be resolved is how services end up with twenty constructor parameters (Long Parameter List).
Construct, or receive
The entire mechanical content of dependency injection fits in a diff of about four lines. What makes it worth a lesson is not the mechanism but the three properties it changes at once: the object becomes testable, the dependency becomes substitutable, and — most underrated — the dependency becomes *stated*.
That last one is why the injected version is better even in a codebase that will never have a second gateway and never write a test. A constructor is a declaration of what this object touches, and a reader gets it for free.
export class OrderService {
private gateway = new StripeGateway(process.env.STRIPE_KEY!)
async place(order: Order) {
validate(order)
await this.gateway.charge(order.total, order.card)
await this.repo.save(order)
}
}
// new OrderService() // works everywhere...
// ...including in tests, where it charges a real card
// if STRIPE_KEY happens to point at production.export class OrderService {
constructor(
private gateway: PaymentGateway,
private repo: OrderRepository,
) {}
async place(order: Order) {
validate(order)
await this.gateway.charge(order.total, order.card)
await this.repo.save(order)
}
}
// The signature now says: this object talks to money
// and to storage. Nothing else. No test can reach Stripe
// unless someone passes it a Stripe.Three separate things changed. The test can pass a recording fake, so order logic gets fast offline tests. The backfill can pass a differently-configured gateway, so configuration stops leaking into the service. And the constructor is now a truthful list of what this object touches, which makes "why does this need a gateway?" a question a reviewer can ask without reading the body. None of those required an interface, a container, or the word "inversion".
What it buys, what it charges
Injection is usually presented as free. It is not: the wiring has to live somewhere, and the object graph stops being readable from the code that uses it. Scoring the options makes the exchange explicit — but a matrix implies a precision this does not have, and the caveat matters more than the numbers.
The row worth staring at is "reads locally". Constructing your own dependency scores highest there, and that is a genuine advantage that the standard advice tends to pretend does not exist.
| Option | Simplicity | Flexibility | Testability | Operational | Migration cost | Note |
|---|---|---|---|---|---|---|
| Constructs its own (`new` inside) | Simplest to read and to write; nothing to wire and nothing to trace. Untestable without touching the network, and configuration inevitably leaks in. Correct for scripts, prototypes and code with a known short life (When Design Does Not Pay). | |||||
| Constructor injection, concrete type | The default. Testable with a hand-written fake, dependency visible in the signature, no interface and no framework. The right first move in the large majority of cases (Constructor Injection). | |||||
| Constructor injection behind an interface | Adds compile-time isolation and a real substitution point. Pays when the dependency is volatile or when keeping a vendor SDK off the policy classpath matters (Dependency Inversion). | |||||
| Container-resolved injection | Removes the threading burden at real scale. Moves graph errors from compile time to startup, makes the graph unreadable from source, and adds a framework the team must now know (Wiring and the Composition Root). | |||||
| Service locator lookup | Looks like the container option and is not: dependencies become invisible again, and every consumer can silently acquire any dependency at any time (Service Locator). |
caveat These scores are relative and situational, not measurements — the top row genuinely wins in a codebase with a six-week lifetime, and the container row genuinely wins at three hundred wired objects. Nothing here captures the two variables that actually decide it: how many people have to read this code, and how long it has to live. A team that agrees on one convention and applies it consistently beats a team that picks the theoretically better option per class, and no matrix can say that.
The failures are all about where the graph lives
Almost every way injection goes wrong is a version of the same mistake: the object graph ends up in more than one place, or in no place anyone can find. Injection does not reduce the total amount of construction; it concentrates it, and the value comes entirely from that concentration being real.
The last row is the one to watch, because it is the mitigation failing: a container adopted to fix the wiring burden reintroduces exactly the invisibility that injection was adopted to remove.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A new dependency is needed three levels down the call chain | Three unrelated classes gain a constructor parameter they only pass along | The graph is threaded by hand and the intermediate objects are structural, not meaningful | Construct the deep object at the root and inject the *finished* collaborator, rather than passing its ingredients down. If that is impossible, the layering is the problem, not the injection (Package by Layer). |
| Somebody adds a default value to a constructor parameter "for convenience" | A test that forgot to override it silently uses the production implementation and passes | The default makes the dependency optional, so omission stops being a compile error | No defaults for behavioural dependencies. Make the omission a type error; convenience factories can supply defaults where that is genuinely wanted. |
| Configuration values get injected alongside collaborators | A constructor with fourteen parameters, most of them strings and numbers | Configuration is being treated as a dependency to be resolved rather than data to be passed | Group configuration into one typed value object and inject that; keep behavioural dependencies separate from settings (Introduce Parameter Object). |
| Two entry points — the HTTP app and a worker — each build the graph | A bug reproduces in the worker only; the two graphs have drifted | There are two composition roots and nothing keeps them in agreement | One shared graph-building function, parameterised by the differences. The differences should be a short, explicit list (Wiring and the Composition Root). |
| A container is adopted to remove the threading burden | Graph errors move from the compiler to a stack trace at startup, or later, on first resolution | Resolution by type name at runtime is exactly the invisibility injection was adopted to remove | Adopt it consciously, keep registration in one file, and add a startup test that resolves the whole graph — the container has removed the compiler's proof, so you have to buy it back (Service Locator). |
How to build it
Most important first.
- Take the collaborator as a constructor parameter. That is the change, and in most languages it is a one-line diff plus fixing the call sites (Constructor Injection).
- Type the parameter as concretely as you can get away with. Start with the concrete class; introduce an interface only when a second implementation or a compile-time isolation argument actually shows up (Dependency Inversion).
- Push construction upward until it reaches a single place, and stop there. If two places construct the graph you have two graphs and they will drift.
- Inject the things that make the object hard to reason about — network, clock, randomness, filesystem — and leave the rest alone (Volatile Dependencies, Time as a Dependency).
- Do not adopt a container yet. Manual wiring works to a surprising size, and adopting a container to solve a wiring problem you do not have is the most common way this idea goes wrong.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Next change under the naive design — "the backfill needs a 30-second timeout": edit
OrderServiceto accept a timeout, thread it through, update every caller, and retest order logic even though no order logic changed. Half a day, and the service gets one more field it should not own. - Next change after injection: construct a differently-configured gateway in the backfill's composition root. One file, no change to
OrderService, no order-logic retest. - Next test for a new order rule: milliseconds, offline, with a five-line fake gateway. Under the naive design this test either does not exist or is an integration test, which is the compounding cost.
- What got more expensive: every new dependency now has to be threaded from the root to the object that uses it, so a dependency added three levels deep touches three files that do not care. That is the standing tax and it is what makes containers tempting later.
- Somebody has to wire the graph, and that somebody is now a file with a hundred
newcalls. The complexity did not vanish; it moved to a place where it is at least visible (Wiring and the Composition Root). - Reading the code no longer tells you what runs.
this.gateway.charge()could be anything, and finding out requires reading the composition root — a real loss of local reasoning traded for a real gain in testability (Local Reasoning). - Applied without judgement it produces constructors nobody can read, and the resulting parameter lists are frequently worse than the coupling they replaced.
What can go wrong
- Injection is applied to everything, including pure value types and stable utilities, and the constructor list becomes an inventory of the codebase (Over-Decomposition).
- The dependency is injected but the object still constructs a second one internally, so half the graph is visible and half is not — the worst of both, because the signature now lies by omission.
- Optional dependencies get default values in the constructor, so the production default is silently used in a test that meant to override it, and the test passes for the wrong reason.
- A container is introduced, and the mitigation itself fails: dependencies become invisible again — resolved by type name at runtime — which is the exact property injection was adopted to remove (Service Locator).
OrderServicedepends on the gateway type it names in its constructor — nothing more, and now visibly.- The composition root depends on every concrete implementation and on configuration. It becomes the one file that knows everything, which is acceptable precisely because it contains no logic.
- Every intermediate caller acquires the burden of passing the dependency along, unless the root constructs the whole chain. That burden is the real cost of injection and it is the thing containers exist to hide.
- "Dependency injection means using a DI framework." It means passing an argument. Every framework in this space is an optional convenience for the wiring problem, and adopting one is a separate decision with a separate cost (Library or Framework).
- "DI and DIP are the same." They are not. DIP is about which module declares the interface; DI is about how the object receives it. You can inject a concrete class and invert nothing, and you can invert perfectly and construct at the top of
mainwith no injection framework anywhere (Dependency Inversion, Critically). - "So every dependency should be injected." Injecting a pure function, a value type or a stable standard-library call adds parameters and buys nothing. The candidates are the volatile ones (Volatile Dependencies).
- "Injection decouples." It makes a dependency *visible and replaceable*.
OrderServiceis exactly as dependent on there being a payment gateway as it was; what changed is that the dependency is now declared rather than hidden (Kinds of Coupling).
- long-parameter-list
- hidden-global-state
Testing it, and how it ages
- A hand-written fake gateway that records charges. Five lines, no framework, and it doubles as the specification of what
OrderServiceexpects (Test Doubles, Precisely). - Assert on outcomes, not on call sequences. "One charge was recorded for this order" survives a refactor; "
chargewas called once with these three arguments" is a test of the implementation and will break on every rename (Mocking). - One integration test that the real gateway can charge a sandbox account. Injection makes this test rare rather than absent, which is the correct outcome (Where a Test Must Be Real).
- A test that constructing the production graph in the composition root actually succeeds — under manual wiring the compiler proves this, under a container it needs an explicit test (Wiring and the Composition Root).
- The constructor signature becomes the honest summary of what the object touches, and it gets read that way in review. Teams start noticing "why does this need a gateway?" as a design question, which is injection paying a dividend nobody planned for (Review as Design Feedback — and Why It Arrives Too Late).
- When the graph gets deep enough that threading dependencies is genuinely painful — commonly somewhere past a hundred wired objects — a container becomes a reasonable trade rather than a fashion.
- It stops working when configuration and dependencies get conflated. The fix is to keep injecting behaviour and to pass configuration as a single typed value object, not to inject each setting (Introduce Parameter Object).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat an object which constructs its own collaborator cannot be given a different one is true in every language; only the ceremony differs.
- PARADIGM-SPECIFICIn OO the unit of injection is an object handed to a constructor. In a functional codebase the same thing is a function passed as an argument or a value carried in a reader environment, and it needs no class, container or interface —
chargeOrder(order, charge)is dependency injection in full. Importing the OO ceremony into a functional codebase produces constructor-like factory functions that nobody needs. - CONTESTEDThe strongest case against, argued seriously by people who have maintained large injected codebases: injection makes the runtime object graph invisible in the source, so understanding what a call actually does requires reading wiring that lives far away, and the cumulative loss of local reasoning across thousands of call sites outweighs the testing benefit — especially in a language with good module-level test seams or with link-time substitution. The counter is that the alternative is not "visible dependencies" but "hidden ones", and the strongest form of that critique is really an argument against containers rather than against passing arguments.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — "can I construct this object in a test" is the cheapest design question there is, and what to do with the double once you have it is theirs.
- — Programming Languages & Runtime Internals — languages with link-time or module-level substitution (Go build tags, C++ link seams, Python import patching) can get the testability benefit without the constructor parameter, which genuinely weakens the argument for injection in those settings.