Engineering Notes
Home/Backend/Frameworks/Dependency Injection
Backend · Frameworks

Dependency Injection

A DI container builds your object graph so classes only declare what they need. Watch Nest resolve a dependency tree from the leaves up, then compare singleton, request and transient scopes and untangle circular dependencies.

⚙️ Backend● Intermediate⏱ 10 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Why manual wiring doesn't scale
  • What a DI container actually does
  • Constructor injection and testability
  • Scopes, circular dependencies and custom tokens
Part 1The Container Mechanics)

This topic assumes familiarity with backend/frameworks/05-controllers (thin controllers that delegate to services) and previews concepts architecture/architecture/05-dependency_inversion_and_injection covers as a general OOP design principle. That architecture topic is about why depending on abstractions instead of concrete implementations is good design; this topic is about how a concrete framework — NestJS, as the running example — actually wires that dependency graph together at runtime, mechanically.

1The Problem: Manual Wiring Doesn't Scale

Without a DI container, every class that needs a collaborator has to construct it itself, or receive it from whoever constructs it:

ts
class OrderRepository {
  constructor(private db: Database) {}
}

class OrderService {
  constructor(private repo: OrderRepository) {}
}

class OrderController {
  constructor(private service: OrderService) {}
}

// Someone, somewhere, has to do this by hand:
const db = new Database(config);
const repo = new OrderRepository(db);
const service = new OrderService(repo);
const controller = new OrderController(service);

This "someone" is called the composition root, and in a real application with dozens of services, each depending on several others, hand-writing this wiring becomes its own maintenance burden — every new dependency means updating every constructor call up the chain, by hand, everywhere that class is instantiated.

2What a DI Container Actually Does

A DI container automates exactly this wiring. You declare what a class needs (via its constructor signature) and mark classes as injectable; the container inspects the declared dependencies, builds the graph, and instantiates everything in the right order:

ts
import { Injectable, Module, Controller, Get } from "@nestjs/common";

@Injectable()
class OrderRepository {
  findAll() { return []; }
}

@Injectable()
class OrderService {
  constructor(private repo: OrderRepository) {} // declared, not constructed
  listOrders() { return this.repo.findAll(); }
}

@Controller("orders")
class OrderController {
  constructor(private service: OrderService) {} // also declared, not constructed
  @Get()
  list() { return this.service.listOrders(); }
}

@Module({
  controllers: [OrderController],
  providers: [OrderService, OrderRepository],
})
class OrderModule {}

Nothing in this code calls new OrderService(...) or new OrderRepository(...) anywhere. @Injectable() marks a class as something the container can construct; listing it in a module's providers array tells the container it should be available for injection within that module. When NestJS bootstraps OrderModule, it reads OrderController's constructor via TypeScript's emitted type metadata, sees it needs an OrderService, resolves that class's constructor, sees it needs an OrderRepository, resolves that (no further dependencies), and only then constructs the whole chain bottom-up: OrderRepository first, then OrderService(repo), then OrderController(service).

3Constructor Injection Is the Whole Mechanism

There's no separate "registration" step beyond the providers array and no manual container.get(Foo) calls scattered through the code (that pattern — a global lookup by type — is called service location, and is a different, more error-prone approach the constructor-injection style deliberately avoids). Everything a class needs is declared once, in its constructor parameter list, and the container guarantees every instance it hands out already has its dependencies satisfied:

ts
@Injectable()
class NotificationService {
  constructor(
    private orderService: OrderService,
    private mailer: MailerService,
  ) {}
}

By the time application code receives a NotificationService instance — however deep in the graph it sits — orderService and mailer are already real, fully-constructed objects. No consumer of NotificationService needs to know or care how those got there.

4Why This Beats Manual Wiring: Testability

The single biggest practical payoff is substitutability in tests. Because OrderService only ever declares "I need something shaped like OrderRepository," a test can hand it anything satisfying that shape — including a fake that never touches a real database:

ts
class FakeOrderRepository {
  findAll() { return [{ id: 1, total: 42 }]; } // no real DB involved
}

const service = new OrderService(new FakeOrderRepository() as any);
console.log(service.listOrders()); // deterministic, no I/O, no test database needed

In production, the container supplies the real OrderRepository; in a test, the test supplies a fake directly via the constructor — OrderService itself never changes and never even knows the difference. This is the direct payoff of the inversion principle covered in architecture/architecture/05-dependency_inversion_and_injection: the high-level OrderService depends on the shape of a repository, not a concrete database-backed implementation, so anything matching that shape is interchangeable.

⚠️ Common Pitfalls

  1. Constructing dependencies manually inside a class marked @Injectable(). constructor() { this.repo = new OrderRepository(db) } defeats the entire point — the container can no longer substitute a fake in tests, and the class silently owns a concrete dependency instead of an interchangeable one.
  2. Forgetting to list a provider in a module's providers array. The container will throw a "cannot resolve dependency" error at bootstrap — a class can be perfectly @Injectable()-decorated and still be unresolvable if the module wiring doesn't know about it.
  3. Confusing DI with service location. Reaching for a global container.get(SomeService) from deep inside unrelated code reintroduces the same tight coupling DI is meant to remove — the whole point is that dependencies arrive via the constructor, not via an ambient lookup.
  4. Assuming DI is NestJS-specific magic. The pattern (declare dependencies via constructor, let a container resolve and construct the graph) is a general technique implemented by many frameworks across many languages — NestJS is simply this topic's concrete running example.

📋 Quick Reference

text
Composition root  -> where the object graph gets built, by hand, without a container
@Injectable()      -> marks a class as constructible by the container
providers: [...]   -> tells a module's container which injectables it can resolve
Constructor injection -> dependencies declared as constructor params, never `new`'d internally
Container's job    -> read constructor signatures, build the graph, instantiate bottom-up
Payoff             -> swap a real dependency for a fake in tests with zero production code changes
Anti-pattern        -> service location (global `container.get(X)` lookups scattered through code)
Part 2Scopes, Cycles, and Custom Tokens)

Part 1 covered the mechanics of constructor injection and why a container beats hand-wiring. This part covers three things that only come up once an application grows past the trivial case: how long an instance lives, what happens when two classes need each other, and how to inject something that isn't a class at all.

1Provider Scope: Singleton by Default

Unless told otherwise, a DI container constructs each provider once and hands out the same instance to every consumer for the lifetime of the application — this is singleton scope, and it's the default precisely because most services (a repository, a mailer, a config reader) are stateless and safe to share:

ts
@Injectable()
class OrderService { /* constructed once, shared everywhere */ }

Two different controllers that both depend on OrderService receive the same object reference — not two separate instances. This matters if a provider accidentally holds mutable per-request state, since that state would then leak across unrelated requests handled by different controllers sharing the same singleton instance.

2Request Scope: One Instance Per Request

When a provider genuinely needs to hold state specific to a single incoming request (the authenticated user, a request-id for logging correlation), request scope creates a fresh instance for every request instead:

ts
import { Injectable, Scope } from "@nestjs/common";

@Injectable({ scope: Scope.REQUEST })
class RequestContextService {
  userId: number | null = null; // safe to mutate — this instance is never shared across requests
}

Request-scoped providers cost more (a new instance, and a new instance of everything that transitively depends on it, per request) so they're reserved for genuinely per-request state — reaching for Scope.REQUEST by default "just to be safe" defeats the performance benefit of the shared-singleton default for the vast majority of stateless services.

Singleton providers are shared by all requests, request-scoped providers are rebuilt per request and make their dependents request-scoped, transient providers are new per injection site
Singleton providers are shared by all requests, request-scoped providers are rebuilt per request and make their dependents request-scoped, transient providers are new per injection site

3Transient Scope: A Fresh Instance Per Injection Site

A third, rarer scope creates a brand-new instance every time the provider is injected anywhere, even multiple times within the same request:

ts
@Injectable({ scope: Scope.TRANSIENT })
class UniqueIdGenerator { /* a new instance per injection site, not shared at all */ }

This is uncommon in practice — most things needing "freshness" are better modeled as a plain method call (generator.next()) on a shared singleton rather than a new instance of the whole provider — but it exists for genuinely stateful, non-shareable helper objects.

4Circular Dependencies

Two providers can end up needing each other — UserService needs OrderService to look up a user's orders, and OrderService needs UserService to validate the order's owner exists:

ts
@Injectable()
class UserService {
  constructor(private orderService: OrderService) {}
}

@Injectable()
class OrderService {
  constructor(private userService: UserService) {} // circular!
}

Resolved naively, this is unconstructable: building UserService requires an OrderService, which requires a UserService, forever. Frameworks like NestJS provide an explicit escape hatch — a forward-reference wrapper that defers resolution of one side until both classes exist:

ts
import { forwardRef, Inject } from "@nestjs/common";

@Injectable()
class UserService {
  constructor(@Inject(forwardRef(() => OrderService)) private orderService: OrderService) {}
}

forwardRef is a workaround, not a fix — a genuine circular dependency between two services is usually a sign that a piece of shared logic belongs in a third, lower-level service both can depend on without depending on each other, which is worth reaching for before reaching for forwardRef as the default fix.

5Custom Tokens: Injecting Non-Class Dependencies

Constructor injection as shown so far relies on a class's own type as the lookup key — but not every dependency is a class. A raw config value, a third-party client instance, or an interface (which, unlike a class, doesn't exist at runtime in TypeScript — see languages/typescript/01-type_system_fundamentals on type erasure) all need a different mechanism: an explicit string or symbol token.

ts
import { Module, Inject } from "@nestjs/common";

const DATABASE_CONNECTION = "DATABASE_CONNECTION"; // a token — just a unique key

@Module({
  providers: [
    {
      provide: DATABASE_CONNECTION,
      useFactory: () => createConnection(process.env.DATABASE_URL!), // arbitrary construction logic
    },
  ],
})
class DatabaseModule {}

@Injectable()
class OrderRepository {
  constructor(@Inject(DATABASE_CONNECTION) private db: Connection) {} // injected by token, not by class
}

This is also the mechanism that lets an interface be "injected" in spirit: define a token for IOrderRepository, provide a concrete PostgresOrderRepository against that token in production and an InMemoryOrderRepository against the same token in tests — consuming code depends only on the token and the interface shape, never on which concrete class actually satisfies it, which is the request-time realization of the dependency-inversion principle from part 1.

⚠️ Common Pitfalls

  1. Assuming every provider is automatically request-scoped. The default is singleton — a provider holding mutable state without an explicit Scope.REQUEST will leak that state across unrelated requests.
  2. Reaching for Scope.REQUEST everywhere "to be safe." Most services are genuinely stateless; blanket request-scoping adds real construction overhead per request for no benefit on services that don't need it.
  3. Treating forwardRef as a normal pattern rather than a smell. A circular dependency usually means shared logic should be extracted into a third, lower-level provider both sides depend on.
  4. Trying to inject an interface directly by type. TypeScript interfaces are erased at compile time and don't exist as values at runtime — a container needs a class or an explicit token to resolve against, never a bare interface.

📋 Quick Reference

text
Singleton (default) -> one instance, shared by every consumer, for the app's lifetime
Scope.REQUEST        -> one fresh instance per incoming request — for genuinely per-request state
Scope.TRANSIENT       -> a fresh instance per injection site — rare, usually a method call suffices instead
Circular dependency   -> forwardRef(() => X) defers resolution; usually a sign to extract shared logic
Custom token          -> a string/symbol key for injecting non-class values (config, clients, interfaces)
useFactory             -> arbitrary construction logic behind a token, run once by the container

🧠 Key takeaways

  • A container reads dependencies, builds the graph leaves-first, caches instances and injects them.
  • Constructor injection keeps dependencies explicit; tests swap providers with fakes.
  • Scopes: singleton (default, stateless), request (bubbles up, costly), transient (per injection site).
  • Break cycles by extracting a shared service or using events; forwardRef is a last resort. Use tokens for non-class values.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Why do backend frameworks use dependency injection?core▾

To manage object lifetimes and wiring centrally, decouple classes from concrete implementations, enable configuration per environment, and make testing easy by swapping implementations.

Follow-ups interviewers ask
  • Which frameworks have built-in DI? ASP.NET Core, NestJS, Spring; FastAPI has Depends.
Q2Explain DI lifetimes and a common mistake.core▾

Singleton (one per app), scoped (one per request), transient (new each time). A captive dependency, e.g. a singleton holding a scoped DbContext, makes the scoped object live forever and be shared across requests and threads.

Follow-ups interviewers ask
  • How do you use a scoped service from a singleton? Create a scope (IServiceScopeFactory) when needed.
Q3How does FastAPI's dependency injection differ?core▾

Dependencies are functions declared with Depends() in endpoint parameters, resolved per request, cached within a request, and can yield for setup/teardown (like DB sessions).

Follow-ups interviewers ask
  • How do you override in tests? app.dependency_overrides[get_db] = fake_db.
Q4Constructor injection vs method injection?senior▾

Constructor injection for dependencies the class always needs; method/parameter injection for values that vary per call (request context, cancellation tokens).

Follow-ups interviewers ask
  • What signals a design problem? Constructors with many dependencies (too many responsibilities).
Q5How do you register multiple implementations of an interface?core▾

Register them all and inject a collection (IEnumerable<IHandler>), use keyed/named services (.NET 8 keyed services, NestJS custom tokens), or a factory that picks by runtime value.

Follow-ups interviewers ask
  • Use case? Strategy pattern: choose a payment provider per country.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. In what order does the container create instances?
Topological order.
2. Making a provider request-scoped also affects…
Performance cost.
3. How do you inject a config object that isn't a class?
Custom providers.
4. Best fix for UsersService ↔ AuthService cycle?
Fix the design.