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 binding
  • container.register(PaymentGateway, StripeGateway) — interface to impl
  • container.registerSingleton(Cache, RedisCache) — one instance shared
  • container.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, @Configuration beans; enormous ecosystem of starters auto-wire JDBC, Redis, security filters.
  • ASP.NET Corebuilder.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.

LifetimeBehaviorTypical 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

SituationRecommendation
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 @Injectable on 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