Dependency injection, scopes, custom providers
Nest registers providers under a token and reads constructor types to build the graph once at boot. Custom providers swap how a token resolves; scopes decide how many instances live and how far REQUEST contagion spreads.
A junior swaps @Injectable() for @Injectable({ scope: Scope.REQUEST }) on a tiny RequestContext service so it can hold the current user per request. It compiles, tests pass. A week later p99 latency has crept up across half the API. The reason: every controller that injects RequestContext, and every service those controllers injected, is now built fresh on every single request. One decorator quietly turned a tree of cached singletons into per-request churn — REQUEST scope bubbles, and it took the whole subgraph with it.
How resolution actually works
Before you reach for a custom provider or a non-default scope, you need to know how Nest’s DI container actually works — because most of the gotchas in the hook come from misunderstanding the lookup order and what “singleton” really means.
Dependency injection in Nest is not magic — it is a lookup table built once. Every provider registers under a token, and by default that token is the class itself. When you write providers: [CatsService], that is shorthand for the full form { provide: CatsService, useClass: CatsService }: the token (provide) and the thing that satisfies it (useClass) happen to be the same class.
Resolution works backwards from a consumer’s constructor. When Nest needs to build a CatsController, it reads the types of its constructor parameters — constructor(private cats: CatsService) — and for each one looks up a provider registered under that token in the controller’s module (and its imports). It recursively resolves each dependency’s own dependencies, builds a directed graph, topologically sorts it, and instantiates from the leaves up. Crucially this happens once, at bootstrap, for default-scope providers; the resulting singletons are cached and handed to every consumer thereafter. The famous “Nest can’t resolve dependencies of X” error is simply a missing node: a token nobody registered, or one registered in a module that isn’t imported or doesn’t export it.
// providers: [CatsService] is sugar for the canonical long form:
{ provide: CatsService, useClass: CatsService }
@Injectable()
export class CatsController {
// The TYPE here (CatsService) is the token Nest looks up.
constructor(private readonly cats: CatsService) {}
}Custom providers: changing what a token resolves to
The long form exists so you can decouple the token from the implementation. There are three workhorses plus an alias:
useClass— resolve a token to a class, possibly a different one.{ provide: CatsService, useClass: MockCatsService }makes every injector ofCatsServicereceive a mock — the swap is invisible to consumers.useValue— resolve a token to a ready-made value: a constant, a config object, a connection, or a test double. No instantiation, Nest just hands back the value.useFactory— resolve a token by running a function at boot. The factory can declare its own dependencies viainject, and it may be async (return a Promise that Nest awaits before the graph is ready) — the standard way to do async config or open a connection.useExisting— alias one token to another so two tokens share one instance.
// useValue: a constant / config object / mock
{ provide: 'CONFIG', useValue: { apiKey: process.env.API_KEY } }
// useFactory: computed at boot, can inject others, can be async
{
provide: 'DB_CONNECTION',
useFactory: async (config: ConfigService) => {
const conn = await createConnection(config.get('DB_URL'));
return conn;
},
inject: [ConfigService], // resolved and passed as factory args, in order
}Injection tokens: when there is no class to point at
A class doubles as its own token because it still exists at runtime. A TypeScript interface does not — interfaces are erased during compilation, so constructor(private repo: CatsRepository) where CatsRepository is an interface gives Nest nothing to look up. The same goes for a plain config object. The fix is an explicit injection token: a string or, better, a Symbol, registered as the provide value and pulled in with the @Inject(TOKEN) decorator, since the type alone can’t carry it.
export const CATS_REPO = Symbol('CATS_REPO'); // a stable token, no name clashes
@Module({
providers: [{ provide: CATS_REPO, useClass: PostgresCatsRepository }],
})
export class CatsModule {}
@Injectable()
export class CatsService {
// @Inject is required: the interface type vanishes at runtime.
constructor(@Inject(CATS_REPO) private readonly repo: CatsRepository) {}
}▸Why this works
Why a Symbol over a string token? String tokens like 'CONFIG' live in a flat global namespace — two libraries that both pick 'CONFIG' will silently collide, and the second registration wins. A Symbol('CONFIG') is unique by identity even if two symbols share a description, so exporting export const CONFIG = Symbol('CONFIG') and importing that binding makes the token impossible to shadow by accident. Strings are fine for quick app-internal wiring; symbols are the safer choice for anything published or shared across modules.
The four resolution strategies trade off along a few axes — when you reach for each, whether it can pull in other providers, and whether it can do async work. Together they cover every wiring need: useClass for swapping implementations, useValue for constants and test doubles, useFactory for anything that must be computed or awaited at boot, and useExisting to avoid accidental duplication. Without useFactory’s async support, you’d have to open a database connection before the Nest graph is ready — which is why it’s the standard approach for connections and config.
| Strategy | Use when | Injects other providers? | Async? |
|---|---|---|---|
useClass | Swap implementation behind a token (real vs mock) | Yes (via the class constructor) | No |
useValue | Constants, config objects, mocks, ready-made connections | No (value is fixed) | No |
useFactory | Value must be computed at boot (env-driven, conditional) | Yes (via inject: array) | Yes (returns a Promise) |
useExisting | Alias one token to another, share one instance | N/A (points at an existing provider) | No |
Scopes: how many instances, and the contagion trap
Resolution decides what to inject; scope decides how many instances exist and how long they live. There are three:
Scope.DEFAULT(singleton) — one instance for the whole application, built once at boot and cached. This is the norm, and it is fast: no per-request allocation, the graph is already wired.Scope.REQUEST— a new instance per incoming request, garbage-collected when the request finishes. You need this only when a provider must hold genuine per-request state (the current user, a request-scoped transaction). The cost is real and, worse, it bubbles: the request scope propagates up the injection chain, so any provider or controller that injects a request-scoped provider becomes request-scoped too, and so on transitively. One REQUEST provider deep in a graph can quietly make a large subtree per-request — exactly the hook’s latency regression.Scope.TRANSIENT— a fresh instance for each consumer that injects the token; transient instances are not shared. Useful for stateful helpers (a per-consumer logger with its own context) where sharing would be wrong.
// Singleton (implicit default) — fastest, one instance app-wide
@Injectable()
export class CatsService {}
// Request-scoped — new instance per request; CONTAGIOUS up the chain
@Injectable({ scope: Scope.REQUEST })
export class RequestContext {}
// Transient on a custom provider — new instance per consumer
{ provide: 'LOGGER', useClass: Logger, scope: Scope.TRANSIENT }Failure modes you will actually hit
Three break real apps. Circular dependencies: module A needs B and B needs A, so Nest can’t decide which to build first and one side resolves as undefined. The escape hatch is forwardRef(() => OtherService) on both sides, but it’s a smell — the cleaner fix is usually to extract the shared logic into a third provider both depend on. “Nest can’t resolve dependencies of X”: a token wasn’t registered, or it lives in a module that isn’t imported, or is imported but not exported. Accidental request-scope contagion: a REQUEST (or TRANSIENT) provider sneaks into a hot path and silently makes its consumers request-scoped, costing latency for no benefit — audit scope before you ship it.
You inject an interface — constructor(private repo: CatsRepository) — and get 'Nest can't resolve dependencies'. Why?
A leaf service is marked @Injectable({ scope: Scope.REQUEST }). What happens to the controllers and services that inject it?
A service needs to know the current request's authenticated user inside deeply-nested business logic. Pick the approach.
- 01Walk through how Nest resolves a controller's dependencies, and what the 'Nest can't resolve dependencies of X' error actually means.
- 02Why does adding scope: Scope.REQUEST to one small service hurt latency across the API, and what's the singleton-safe alternative for per-request data?
Nest’s DI is a lookup table built once: every provider registers under a token (by default the class itself, since providers: [X] desugars to { provide: X, useClass: X }), and the container resolves a consumer by reading its constructor parameter types as tokens, recursively building and topologically sorting the dependency graph, then instantiating leaves-first at bootstrap and caching the singletons. Custom providers decouple the token from the implementation: useClass swaps the class behind a token (real vs mock), useValue hands back a constant or config object, useFactory computes the value at boot and can inject other providers via its inject array and even be async, and useExisting aliases one token to another. Because a TypeScript interface is erased at runtime you can’t inject it by type — register an explicit string or Symbol token and pull it in with @Inject(TOKEN). Scope governs instance lifetime: Scope.DEFAULT is a fast app-wide singleton, Scope.TRANSIENT gives each consumer its own instance, and Scope.REQUEST gives one per request but bubbles up the chain so every consumer becomes request-scoped — the silent latency trap. Watch for circular dependencies (break them with forwardRef, better by extracting a shared provider), missing-token errors (register, import, or export the token), and accidental request-scope contagion in hot paths. Now when you see p99 quietly creeping up after a small provider change, check scope first — one Scope.REQUEST annotation deep in the graph can silently rebuild a subtree of singletons on every request.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.