· software-engineers Editorial · Career · 6 min read
Dependency Injection Patterns Practical Guide
Constructor, setter, and interface injection compared, plus DI container tradeoffs across Spring, NestJS, and manual wiring in 2026.
What Dependency Injection Actually Solves
Dependency injection (DI) is not a framework feature — it’s a technique for inverting control over object creation so that a class receives its collaborators from the outside rather than constructing them itself. The payoff: classes become testable in isolation, swappable at runtime, and decoupled from concrete implementations.
Without DI, a class like OrderService might do new PostgresOrderRepository() inside its constructor. That hardcodes a dependency, making the service impossible to unit test without a real database, and impossible to swap for a different implementation without editing the class itself. DI flips this: OrderService receives an OrderRepository (interface) via constructor argument, and something else — a DI container, a factory, or a manually written composition root — decides which concrete implementation to hand over.
This single inversion is why DI shows up constantly in both take-home assignments and live coding interviews in 2026: it’s the fastest way to demonstrate you understand testability and coupling without writing a 10-page design doc.
The Three Core Injection Patterns
Constructor injection: dependencies passed as constructor arguments. This is the dominant pattern in 2026 across TypeScript (NestJS), Java (Spring), and C# (ASP.NET Core) codebases because it makes dependencies explicit and required — you cannot instantiate the object in an invalid state, and dependencies are immutable (readonly/final) after construction.
class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly notifier: NotificationPort
) {}
}
Setter/property injection: dependencies assigned via public setters or properties after construction. Useful for optional dependencies or circular dependency resolution, but it allows an object to exist in a partially-configured, invalid state before setters are called — a common source of null-reference bugs.
Interface injection: the dependency itself defines an injector method that the receiving class implements, and the injecting framework calls it. Rare in modern codebases outside older Java EE-style frameworks; mostly of historical/interview-trivia interest today.
Method injection: dependencies passed as parameters to individual methods rather than stored as fields at all — appropriate when a dependency is needed for exactly one operation and shouldn’t be part of the object’s persistent state (common in functional-leaning codebases).
DI Containers vs. Manual Wiring
A DI container (Spring’s ApplicationContext, NestJS’s module system, .NET’s IServiceCollection) automates the wiring: you register interfaces to implementations once, and the container resolves the full dependency graph, including transitive dependencies, at object creation time.
Manual wiring means writing the composition root by hand — literally calling constructors in the right order in one main.ts or bootstrap.ts file. This sounds primitive but many teams in 2026 (especially in Go, and increasingly in TypeScript projects influenced by hexagonal architecture advocates) explicitly prefer it: no reflection magic, no runtime surprises from misconfigured bindings, and the entire object graph is visible in one readable file.
The tradeoff is graph size. Manual wiring for 5-10 services is trivially readable. Past 50-100 services with complex lifetimes (singleton, request-scoped, transient), hand-wiring becomes its own maintenance burden, and a container’s declarative registration wins.
Comparison: DI Approaches Across Ecosystems
| Approach | Explicitness | Runtime overhead | Compile-time safety | Typical scale fit |
|---|---|---|---|---|
| Constructor injection, manual wiring | Highest — full graph in one file | None | Full (TS/Java compiler catches mismatches) | Small-to-mid codebases, <50 services |
| Constructor injection, DI container (Spring, NestJS) | Medium — registrations scattered across modules | Low-moderate (reflection/decorators at startup) | Partial (misconfigured bindings often fail at runtime, not compile time) | Mid-to-large codebases, microservices |
| Setter injection | Low — object can exist unconfigured | None | Weak — no guarantee setter was called | Legacy codebases, optional dependencies only |
| Service locator (anti-pattern) | Lowest — dependencies hidden inside method bodies | Low | None — failures surface only at call time | Avoid; common in interviews as a “spot the anti-pattern” question |
The Service Locator Anti-Pattern (and Why Interviewers Test for It)
A service locator is a global registry that classes query at runtime (ServiceLocator.get(OrderRepository)) instead of receiving dependencies through their constructor. It looks like DI but isn’t — it hides dependencies inside method bodies instead of declaring them in the public constructor signature, making it impossible to tell what a class needs just by reading its interface. Unit tests must set up global registry state before every test, creating hidden coupling between test files.
Interviewers routinely present a service-locator-based snippet and ask candidates to spot what’s wrong. The tell: any place code calls a static/global .get() or .resolve() method mid-method rather than receiving the value as a parameter. The 0-to-1 SWE Interview Playbook (https://www.amazon.com/dp/B0H256Z1MF?tag=sirjohnnymai-20) includes a dedicated “spot the anti-pattern” drill set covering service locator, along with singleton overuse and circular dependency traps.
Handling Circular Dependencies
Circular dependencies (Service A needs Service B, Service B needs Service A) are a design smell more often than a DI limitation — they usually indicate the two services should be merged, or a shared responsibility should be extracted into a third service. When genuinely unavoidable (rare, but happens with bidirectional event listeners), setter injection or a lazy Provider<T>/factory wrapper breaks the cycle by deferring resolution until first use rather than at construction time.
Testing With DI: The Real Payoff
The entire reason DI earns its complexity budget is test isolation:
const fakeRepo: OrderRepository = { findById: jest.fn(), save: jest.fn() };
const fakeNotifier: NotificationPort = { send: jest.fn() };
const service = new OrderService(fakeRepo, fakeNotifier);
No test framework magic, no database spin-up, no network calls — because the class only knows about interfaces, substituting fakes is a constructor call. This is why DI and hexagonal/ports-and-adapters architecture are frequently taught together: DI is the mechanism, ports-and-adapters is the architectural philosophy that decides where the interface boundaries go.
FAQ
Q: Is a DI container required to “do” dependency injection? A: No. DI is a technique (pass dependencies in rather than construct them internally); a container is an optional tool that automates the wiring for large graphs. Small services benefit more from manual wiring’s transparency than from container magic.
Q: Why do interviewers prefer constructor injection over setter injection? A: Constructor injection makes required dependencies impossible to omit — the object cannot be instantiated in an invalid, half-configured state. Setter injection allows exactly that, which is a common source of null-pointer/undefined bugs in production.
Q: How is dependency injection different from the dependency inversion principle? A: Dependency inversion is the design principle: high-level modules should depend on abstractions, not concrete low-level modules. Dependency injection is one concrete technique for achieving that principle in code — the constructor/setter mechanics of handing over the abstraction’s implementation at runtime.
Understanding this distinction — principle versus mechanism — is exactly the kind of precision that separates a strong system design answer from a mediocre one in 2026 interview loops.