Guide
Dependency injection explained
Dependency injection (DI) is a technique where a class receives the
objects it needs from the outside instead of creating them with new or
fetching them from global singletons. The pattern sits at the heart of modern backend
frameworks — NestJS, Spring, ASP.NET Core, Angular — and is the reason you can swap a
real payment gateway for a mock in a unit test without rewriting business logic. DI is
often discussed alongside inversion of control (IoC): your application
framework or composition root decides wiring; individual classes just declare what they
depend on. This guide explains why tight coupling hurts, the three injection styles,
how DI containers resolve object graphs, lifetime scopes, testing strategies, the service
locator anti-pattern, and when plain constructor arguments beat a container entirely.
The problem: hidden dependencies
Consider an OrderService that does this internally:
class OrderService {
private db = new PostgresClient(process.env.DATABASE_URL);
private mailer = new SendGridMailer(process.env.SENDGRID_KEY);
async placeOrder(cart) { /* ... */ }
}
Every caller gets Postgres and SendGrid whether the environment supports them or not. Unit tests cannot run without a live database. Staging cannot point at a fake mailer without environment hacks. Changing storage means editing the class itself, not configuration. These are hidden dependencies — collaborators buried inside the implementation.
Dependency injection flips the relationship: OrderService declares
Database and Mailer interfaces in its constructor; something
else — a framework container or a main() composition root — supplies
concrete implementations. The service stays focused on order logic; wiring lives in
one place. That separation is what makes
unit tests
fast and
microservice
boundaries swappable.
Inversion of control vs dependency injection
The terms overlap but are not identical:
- Inversion of control — control flow is inverted: a framework
calls your code (HTTP handler, event listener) and manages lifecycle.
You do not call
server.listen()inside every route handler; the framework owns the loop. - Dependency injection — a specific IoC technique for supplying collaborators. The "inversion" is that the class no longer controls which implementation it gets.
Most teams say "DI" when they mean both: a container that constructs the object graph and injects dependencies at creation time. Manual DI — passing dependencies through constructors in plain Node or Go without a library — is still DI; you just own the composition root yourself.
Three injection styles
Constructor injection (preferred)
Dependencies are required constructor parameters. The object cannot exist in an invalid
state — if it needs a PaymentGateway, you must provide one at construction.
Immutability is natural: mark fields readonly. This is the default in
NestJS (@Injectable() classes), Spring (@Autowired on
constructors), and modern C# (primary constructors).
class OrderService {
constructor(
private readonly db: Database,
private readonly mailer: Mailer,
) {}
async placeOrder(cart: Cart) { /* uses this.db, this.mailer */ }
}
Setter / property injection
Optional dependencies are assigned after construction via setters or public properties. Useful when a dependency is truly optional or circular references make constructor injection awkward — but optional dependencies often signal a design smell (split the class or use a facade). Setter injection allows partially initialized objects, which is harder to reason about.
Interface / method injection
A dependency is passed to a specific method rather than stored on the object.
Common for per-request context: an HTTP handler receives RequestContext
as a method argument. ASP.NET Core's [FromServices] on action parameters
is method injection. Use when the dependency's lifetime matches a single operation,
not the whole service instance.
DI containers and the composition root
A DI container (IoC container) automates object graph construction.
You register bindings — "when something asks for PaymentGateway, provide
StripeGateway" — and the container recursively resolves constructors.
Register once at startup; request handlers receive fully wired services.
Typical registration API (pseudocode across frameworks):
container.register(Database, PostgresDatabase)— concrete bindingcontainer.register(PaymentGateway, StripeGateway)— interface to implcontainer.registerSingleton(Cache, RedisCache)— one instance sharedcontainer.registerScoped(UserContext, RequestUserContext)— one per request
The composition root is the single place in your app where container
registration happens — usually main.ts, Program.cs, or
AppModule. Application code outside that file should not call
container.resolve() ad hoc; that drifts toward the service locator
anti-pattern (below).
Framework examples:
- NestJS —
@Module({ providers: [OrderService, ...] }); constructor types drive resolution; supports custom providers and factory functions. - Spring — component scanning with
@Component,@Configurationbeans; enormous ecosystem of starters auto-wire JDBC, Redis, security filters. - ASP.NET Core —
builder.Services.AddScoped<IOrderService, OrderService>(); built-in middleware resolves per-request scopes.
Small scripts and CLI tools often skip containers entirely: a 40-line main()
that constructs three objects manually is clearer than importing a DI library.
Object lifetimes: singleton, scoped, transient
Containers track how long each instance lives. Wrong lifetime choice causes subtle bugs — especially in web servers handling concurrent requests.
| Lifetime | Behavior | Typical use |
|---|---|---|
| Singleton | One instance for the entire process | Config readers, connection pool facades, stateless clients |
| Scoped | One instance per request / unit of work | Database contexts, per-user authorization state, unit-of-work repositories |
| Transient | New instance every time it is requested | Lightweight stateless helpers, strategy objects selected per call |
Captive dependency is a classic mistake: registering a scoped service
(e.g. DbContext) inside a singleton (e.g. BackgroundJobProcessor).
The scoped object never disposes and may hold stale connections across requests.
Fix: inject IServiceScopeFactory and create a scope per job, or redesign
lifetimes.
Stateful singletons holding user-specific data are a concurrency hazard. If a service caches "current user" on a singleton field, two simultaneous HTTP requests overwrite each other. Scope user state to the request or pass it explicitly as a method argument.
DI and testability
The primary payoff of DI is substitutable collaborators in tests. With constructor injection:
const fakeMailer = { send: jest.fn() };
const fakeDb = new InMemoryDatabase();
const service = new OrderService(fakeDb, fakeMailer);
await service.placeOrder(testCart);
expect(fakeMailer.send).toHaveBeenCalledWith(/* ... */);
No container required in unit tests — plain new with fakes is often
fastest. Integration tests may spin up a test container with test doubles registered
for external APIs while using a real in-memory database. See
software testing fundamentals
for the test pyramid and mock vs fake guidance.
Interface boundaries make mocking practical. If OrderService
depends on a concrete StripeClient with 40 methods, tests couple to Stripe's
surface. Depend on a narrow PaymentGateway interface with charge()
only — the
adapter pattern
wraps Stripe behind it.
Service locator: the anti-pattern cousin
A service locator is a global registry: Locator.get(Mailer)
anywhere in the codebase. It hides dependencies just like new SendGridMailer()
— readers cannot see what a class needs without opening every method. It also makes
parallel tests fight over global state.
DI containers become service locators when application code calls
container.resolve() outside the composition root. Rule of thumb:
resolve at the edges (HTTP entry, message consumer, CLI main);
inject through constructors everywhere else. Framework request pipelines
already do this — your controller constructor receives services; it should not pull
them from a static ServiceProvider.
DI beyond backend servers
Frontend and React
React Context is DI for UI trees: a AuthProvider supplies
useAuth() to descendants without prop drilling. It is not a full container
— you still compose providers manually at the app root — but the principle matches:
dependencies flow inward, components declare what they consume.
Configuration and feature flags
Inject configuration objects rather than reading process.env inside business
logic. A AppConfig registered at startup centralizes validation and makes
tests pass explicit config. Same for
feature flag clients —
inject an interface so tests always see flags as "on" or "off" deterministically.
Plugins and strategy selection
DI pairs naturally with the
strategy pattern:
register multiple PricingStrategy implementations, inject the collection,
select by market at runtime. Payment processors, storage backends, and notification
channels are everyday plugin slots.
Decision table: when and how to inject
| Situation | Recommendation |
|---|---|
| Small CLI or script (< 200 lines) | Manual constructor wiring in main(); no container |
| HTTP API with 10+ services | Framework DI container; constructor injection throughout |
| Per-request database context | Scoped lifetime; never singleton |
| Optional logging verbosity | Inject Logger interface; default to no-op in tests |
| Third-party SDK with huge surface | Adapter interface + single implementation registered in composition root |
| Need dependency mid-method only | Method injection or pass context parameter; avoid service locator |
Common mistakes
- God constructor — 12 dependencies signals the class does too much; split by responsibility or introduce a facade.
- Leaking framework attributes into domain — keep
@Injectableon infrastructure adapters; pure domain classes should not import NestJS decorators. - Interface explosion — wrapping every class in an
I*interface "for DI" adds noise when only one implementation exists and will ever exist. - Circular dependencies — A needs B, B needs A. Fix by extracting shared logic to a third class, using events, or lazy provider factories — not setter injection as a permanent bandage.
- Testing through the container — spinning up the full DI graph for every unit test slows suites; construct the class under test directly with fakes.
Production checklist
- All services use constructor injection for required dependencies.
- Composition root is a single module — no
resolve()in business logic. - Lifetimes documented: singleton vs scoped vs transient choices reviewed for captive dependencies.
- External integrations hidden behind narrow interfaces for test doubles.
- Configuration validated once at startup and injected — not read from env inside domain code.
- Unit tests construct services with fakes; integration tests use container with test registrations.
- CI pipeline runs tests without production secrets — DI makes that possible.
Key takeaways
- Dependency injection supplies collaborators from outside so classes stay focused and testable.
- Constructor injection is the default — required dependencies, immutable fields, clear contracts.
- DI containers automate wiring at the composition root; small apps can wire manually.
- Lifetime scopes (singleton, scoped, transient) prevent concurrency and disposal bugs.
- Avoid the service locator pattern — hidden globals undermine the benefits of DI.
Related reading
- Software design patterns explained — strategy, adapter, repository, and how DI enables them
- Software testing fundamentals explained — mocks, fakes, and the test pyramid
- Microservices architecture explained — service boundaries and adapter glue between services
- CI/CD pipelines explained — automated test gates that DI-friendly code makes practical