DependenciesGENERALLANGUAGE-SPECIFICFRAMEWORK-SPECIFIC

Constructor Injection

Take collaborators as constructor parameters and the type system enforces that a constructed object is a usable one. No framework required, and the parameter count is a design signal.

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.

The question

Of all the ways to hand an object its collaborators, which should be the default and why?

The requirement

The team has agreed to inject dependencies. Now there is an argument in review about whether to use constructor parameters, settable properties, or the framework's field-annotation feature.

The obvious build

Use the framework's field injection: annotate the fields, delete the constructor, and the container fills them in. It is the shortest code, it is what the documentation shows, and it removes the tedium of writing constructors and updating call sites. In a codebase that is entirely inside one framework and always will be, this genuinely costs less to write.

Why it breaks

The class can no longer be constructed in a plain unit test without the container, so tests get slower and heavier, and gradually people stop writing the fast ones.

How it breaks as requirements change
  • The class can no longer be constructed in a plain unit test without the container, so tests get slower and heavier, and gradually people stop writing the fast ones.
  • There is a window where the object exists with null fields. Any code that runs during construction — or a second container that resolves in a different order — meets a half-built object.
  • Fields can be added indefinitely without anyone noticing, because there is no signature getting longer. The class that quietly acquired nine dependencies never triggered a review comment (God Object).
  • The fields cannot be final/readonly, so an object that should be immutable after construction is not, and nothing stops a later method from reassigning one (Immutability).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The codebase already uses a framework that supports annotation-based field injection, so the "just use what the framework does" option is live and free.
  • Several objects genuinely have an optional collaborator — a metrics recorder that is absent in the CLI.
  • One legacy class is constructed reflectively by a serialization library and cannot take constructor arguments.
  • Mixed seniority team; whatever is chosen has to be obvious to someone in their first week.
Invariants
  • An object that has been constructed is fully usable. There must be no window in which it exists but will throw on first use because something was not set yet.
  • Every dependency an object uses is discoverable by reading that object's own declaration, without knowing the framework.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The constructor owns the object's definition of "ready to use": everything required for any method to work.
  • The caller owns supplying those, which in practice means the composition root owns it (Wiring and the Composition Root).
  • A method parameter owns anything that varies per call rather than per object — the request, the clock reading, the current user.
  • Nothing owns "fill this in later". That responsibility should not exist, and where it does it is usually a construction-order problem in disguise.
Boundaries
  • The line between constructor and method parameter is the line between what this object *is* and what this call is *about*. A gateway is what the service is; an order is what the call is about.
  • The line between required and optional is the line between a dependency and a feature toggle. An optional metrics recorder is better modelled as a required dependency with a no-op implementation, which removes a null check from every use site (Optional Values and Absence).
  • The boundary of the class is its constructor signature. That is the public statement of what it needs, and keeping it honest is what makes the parameter count a usable signal (Designing a Module Interface).

Start with plain construction and no framework at all

The reason to write the class as though no container exists is not purity. It is that a class with an ordinary public constructor works in both worlds — with a container, without one, in a test, in a script, in a different application — while a class that depends on annotations to be built only works inside the framework that understands them.

The code below is the whole technique. There is no library involved, the object is immutable after construction, and a test constructs it in one line.

No decorators, no container, no registration
1export class RefundService {
2 constructor(
3 private readonly gateway: PaymentGateway,
4 private readonly orders: OrderRepository,
5 private readonly audit: AuditLog, // never null: NoopAudit in the CLI
6 ) {}
7
8 async refund(orderId: OrderId, reason: Reason): Promise<Refund> {
9 const order = await this.orders.require(orderId)
10 const refund = await this.gateway.refund(order.charge, order.total)
11 this.audit.record(orderId, reason) // no `if (this.audit)`
12 return refund
13 }
14}
15
16// production wiring, in one place
17const refunds = new RefundService(stripe, pgOrders, new DbAuditLog(pool))
18
19// a test, in one line
20const refunds = new RefundService(fakeGateway, fakeOrders, new NoopAudit())

Two details are doing the work and both are easy to skip. readonly means nothing can swap a collaborator after construction, so the object's behaviour cannot change under you. And audit being a required parameter with a no-op implementation removes a null check from every use site and makes "auditing is off" an explicit, testable configuration rather than an absence.

The other three styles, and the narrow cases they are for

Setter, field and method injection all exist and all have a legitimate use. The reason constructor injection is the default is that it is the only one where the compiler enforces the "constructed means usable" invariant; the others trade that away for something, and it is worth being able to say what.

The genuinely hard case is the one at the bottom: a class the framework constructs reflectively. It is not a design preference, it is a constraint, and the honest response is to isolate it rather than to change the whole codebase's style around it (Constraints Are Part of the Design).

How should this object receive this collaborator?

Is the collaborator required for the object to function, and does it stay the same for the object's whole life?

Constructor parameter

when Required, and fixed for the object's lifetime. This is almost everything.

cost More typing, and every construction site changes when the list changes — which is the signal, not the cost.

Method parameter

when The value varies per call: the current request, the acting user, the timestamp for this operation.

cost Callers must supply it every time, which is correct but does mean it appears in every signature along the path (Request Context Propagation is the backend version of this problem).

Setter / property injection

when The collaborator genuinely changes during the object's life, or there is a construction cycle you cannot break today.

cost There is now a window where the object is unusable, and nothing enforces that the setter was ever called. Every method has to tolerate the un-set state or document that it does not.

Field / annotation injection

when A framework-constructed class you do not control the instantiation of, and the team is entirely inside that framework.

cost Untestable without the container, fields cannot be immutable, and the dependency count becomes invisible so it grows unnoticed.

Reflective construction (ORM entity, deserialized DTO)

when A library requires a no-argument constructor and you cannot change it.

cost Accept it, and keep those classes free of behavioural dependencies entirely — they are data. Injecting collaborators into an entity the ORM materialises is where this genuinely goes wrong (The Anemic Domain Model discusses the other half of that argument).

When the parameter list gets long

SCALE-SPECIFICAt five engineers and forty classes, threading constructor parameters by hand is a non-issue and a container is overhead nobody needs. Somewhere past a few hundred wired objects the threading genuinely does dominate, and teams that refuse a container on principle at that size end up hand-maintaining a two-thousand-line composition root — which is a defensible choice but should be a chosen one, not a default inherited from advice written for a smaller codebase.

A constructor with nine collaborators is the most useful design feedback constructor injection produces, and the standard reactions to it — hide it with field injection, or bundle the parameters into a context object — both delete the message and keep the problem.

The message is nearly always about responsibilities. An object that needs nine collaborators is doing nine things, and the fix is on the responsibility side of the design, not the injection side.

smellConstructor with too many collaborators

looks like A constructor taking eight or more dependencies — typically a repository, a gateway, a mailer, a cache, a metrics recorder, a feature-flag client, a clock and an event publisher — in a class named SomethingService.

suggests The class has accumulated a use case at a time and now owns several unrelated reasons to change. The dependency count is a proxy for the responsibility count, and it is a good one because it is mechanical and cannot be argued with (Divergent Change).

fix Group by reason to change, not by count. Look for a subset of the dependencies used by a subset of the methods — that subset is a class. If no such split exists, check whether some parameters are configuration rather than collaborators and collapse those into one typed value (Introduce Parameter Object). Do not introduce a context object holding unrelated services; that converts a visible problem into an invisible one and adds a dependency magnet to the codebase (The Common Module).

when this is fine It is genuinely correct in a composition root, where having every dependency is the entire job. It is also fine in a deliberate facade or edge handler whose stated responsibility is orchestration and which contains no logic of its own — an object that does nothing but call five collaborators in order is coherent, even though it is wide (Facade). And in a small codebase with a genuinely rich single concept, seven collaborators can be the honest shape rather than a smell.

How to build it

Most important first.

  • Default to plain constructor parameters, assigned to immutable fields, with no framework involved. Write it as if no container existed — because a class written this way works with or without one, and a class written the other way only works with one.
  • Make every required dependency a required parameter, so a missing one is a compile error rather than a null at runtime.
  • Replace optional dependencies with a null-object implementation supplied by the wiring, rather than a nullable field (Polymorphism).
  • Keep per-call values out of the constructor. If a parameter would be different for two consecutive calls, it belongs in the method.
  • Treat a growing parameter list as information about responsibilities, not as an inconvenience to be routed around with a container (Single Responsibility, Carefully).

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.

Cost of the next change
  • Adding a dependency: one parameter, plus every construction site. In a codebase with one composition root that is two files; in one with construction scattered, it is however many places call new, and that pain is a measurement of how concentrated your wiring is.
  • Removing a dependency: the compiler lists every site that passes it. Under field injection nothing lists them, so unused dependencies accumulate and are never removed.
  • Writing a test for a new behaviour: construct the object with fakes in one line, no container, no lifecycle. This is the change that happens most often and it is the one constructor injection makes cheapest.
  • Introducing a container later: nearly free, because a class with a public constructor is exactly what every container knows how to build. Going the other way — removing a container from annotation-injected classes — touches every class.
What the recommended approach costs
  • It is more typing than annotations, and in a large framework-native codebase that difference is real and felt on every class.
  • It makes construction order explicit, which means circular dependencies become compile errors rather than working code. That is a benefit in design terms and an obstruction on the afternoon you have a cycle and a deadline.
  • It pushes all construction to one place, so the composition root becomes a large, boring, high-fan-out file that violates every other guideline in this domain — deliberately, and that is fine (Wiring and the Composition Root).

What can go wrong

Failure modes
  • The parameter list grows to fourteen and the team's response is to introduce field injection to hide it, which removes the signal and keeps the problem (Long Parameter List).
  • A parameter object is introduced that bundles unrelated dependencies purely to shorten the list, so the class depends on a grab-bag whose contents nobody controls (The Common Module).
  • Circular construction: A needs B and B needs A. Field injection makes this compile; constructor injection makes it impossible, which is the constructor telling you about a cycle in the design (Dependency Cycles).
  • The mitigation fails when a serialization or ORM library requires a no-argument constructor, and one gets added "just for the framework" — after which nothing enforces the invariant any more.
Dependencies, and their direction
  • The class depends on the types named in its constructor and nothing else — that is the property being bought, and it is checkable by the compiler.
  • The class does *not* depend on the framework. No annotation, no import, no lifecycle interface, which means it can be tested, reused and eventually moved without the framework coming along (What a Framework Charges).
  • The caller depends on being able to construct all of the parameters, which is what pushes construction upward until it lands in one place.
Misreads
  • "Constructor injection requires a DI framework." It requires a constructor. Plain new in main() is constructor injection, and it is how a surprisingly large fraction of well-structured systems are wired (Dependency Injection).
  • "Many constructor parameters means constructor injection does not scale." It means the class has too many responsibilities. The parameter count is the measurement, and swapping to field injection deletes the measurement rather than the problem (Single Responsibility, Carefully).
  • "Optional dependency, so use a setter." Almost always better as a required parameter with a no-op implementation: one construction path, no nullable field, no null check at every use (Optional Values and Absence).
  • "Every collaborator should be a constructor parameter." Per-call values, and dependencies used by exactly one method out of twelve, are better as method parameters — and the second case is usually telling you the class should be split.
Smells this explains
  • long-parameter-list
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • The clearest test of the design is whether a unit test can construct the object in one statement. If it needs a container, a lifecycle callback or reflection, the class has a dependency on the framework that its signature does not admit (Testing as Design Feedback).
  • Assert that construction with fakes yields a fully usable object — not by writing a test for it, but by having no code path that could produce a half-built one.
  • Test the null-object implementation the same as any other, so "metrics disabled" is a tested configuration rather than an assumed one (Test Doubles, Precisely).
  • One test that the real composition root constructs successfully, which under plain constructors is mostly the compiler's job already (Wiring and the Composition Root).
How this design ages
  • Parameter lists grow, and that growth is the most reliable early warning of a class taking on responsibilities. Teams that watch it split classes a year earlier than teams that hide it (Divergent Change).
  • When an object legitimately needs many collaborators — a request handler at the edge, say — the honest answer is often a factory or a builder for it, not a change of injection style.
  • The style stops fitting where object lifecycles are genuinely dynamic: a plugin loaded at runtime, an actor whose collaborators change during its life. Those are the real cases for setter or method injection, and they are rarer than they are used (Plugin Architecture).

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.

  • GENERALThe core property — a constructed object is a usable object, enforced by the type system rather than by convention — holds in any language where construction is a distinct step with a checked signature.
  • LANGUAGE-SPECIFICIn Java, C#, Kotlin, Swift and TypeScript the constructor is the natural place and immutable fields make the guarantee stick. In Go there is no constructor: the idiom is an exported NewFoo(dep Dep) *Foo function, which gets the same property by convention rather than by the compiler, and a zero-value struct can still be created by anyone. In Python, keyword arguments with no defaults get most of it; in a dynamically-typed language nothing prevents a caller from passing anything at all, so the enforcement is a test rather than a compile step.
  • FRAMEWORK-SPECIFICSpring, Angular, NestJS and similar can inject into fields or properties, and in a codebase that is entirely inside one of them the ceremony difference is the main argument. Note that Spring itself has recommended constructor injection over field injection for years, for exactly the immutability and testability reasons here, so this is one place where framework guidance and design reasoning agree.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether a "constructed object is usable" guarantee is enforced by the compiler, by a convention, or not at all is a language property, and it decides how much of this argument survives translation.