Injection Scopes
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. The examples assume a project generated by the Nest 12 CLI (ESM layout, Vitest, supertest); the microservice example runs over the TCP transporter, the gateway example over Socket.IO, and the resolver example over Apollo Server.
In most server frameworks every request gets its own thread or its own object graph, so per-request state is natural. Node.js is different: one process serves many requests on one event loop, so Nest creates each provider once at startup and shares it across all requests. That is why singletons are safe in Nest, and why the default is the cheapest option. Injection scopes are the escape hatch for the cases where a request-based or consumer-based lifetime is really what you want, for example per-request caching in GraphQL, request tracking or multi-tenancy. This page uses the Bookshelf scenario from the rest of the section and builds on Providers & Dependency Injection.
The three provider scopes
A provider has one of three scopes, set with the scope option of @Injectable() (or of the long-hand custom provider form) using the Scope enum from @nestjs/common.
| Scope | Lifetime |
|---|---|
|
One instance shared by the whole application. It lives as long as the application and is created while bootstrapping. This is the default; writing |
|
A new instance for each incoming request, garbage-collected when the request has been handled. |
|
Not shared between consumers: every class that injects the provider receives its own, dedicated instance. |
The example below declares one provider of each kind and shows the difference through two consumers and two requests. The created counters are a test helper that records how many instances the container built.
import { Injectable, Scope } from '@nestjs/common';
import { created } from './tracker.js';
@Injectable() // same as { scope: Scope.DEFAULT }
export class SingletonCounter {
readonly id = ++created.singleton;
}
@Injectable({ scope: Scope.REQUEST })
export class RequestCounter {
readonly id = ++created.request;
}
@Injectable({ scope: Scope.TRANSIENT })
export class TransientCounter {
readonly id = ++created.transient;
}
// Two singleton consumers: each one receives its own TransientCounter.
@Injectable()
export class FirstConsumer {
constructor(readonly counter: TransientCounter) {}
}
@Injectable()
export class SecondConsumer {
constructor(readonly counter: TransientCounter) {}
}
A controller that injects all of them, called twice, returns the ids of the instances it saw. The singleton keeps id 1, the request-scoped provider gets a new id per request, and the two singleton consumers each hold a different transient instance that never changes, because the consumers themselves are created only once.
curl http://localhost:3000/scopes
curl http://localhost:3000/scopes
{ "singleton": 1, "request": 1, "transientInFirst": 1, "transientInSecond": 2 }
{ "singleton": 1, "request": 2, "transientInFirst": 1, "transientInSecond": 2 }
Source: Injection scopes.
For a custom provider, set scope in the long-hand registration. The official page shows useClass and useFactory providers declared this way. Injecting the provider below into a request-scoped controller produces a fresh instance each time the controller was built.
@Module({
providers: [
// Every consumer of 'CACHE_MANAGER' gets its own TransientCounter.
{ provide: 'CACHE_MANAGER', useClass: TransientCounter, scope: Scope.TRANSIENT },
],
})
export class ScopesModule {}
Source: Injection scopes: usage.
The official page recommends the singleton scope for most use cases: an instance can be cached and initialised once, at startup. It also warns that some classes cannot be request-scoped, namely WebSocket gateways before Nest 12 (see Request scope in microservices, gateways and resolvers), Passport strategies (which have their own request-scoped form, described in the official Passport recipe) and cron controllers.
Controller scope
Controllers can be scoped too. The scope option of @Controller() applies to all of the controller’s route handlers: a request-scoped controller is instantiated for every request and discarded afterwards.
import { Controller, Get, Scope } from '@nestjs/common';
import { created } from './tracker.js';
@Controller({ path: 'scoped-controller', scope: Scope.REQUEST })
export class ScopedController {
readonly id = ++created.controller;
@Get()
whoAmI() {
return { controllerInstance: this.id };
}
}
Two requests return { "controllerInstance": 1 } and { "controllerInstance": 2 }.
Source: Injection scopes: controller scope.
Declaring the scope on the controller is rarely needed: a controller becomes request-scoped by itself as soon as it depends on something request-scoped, as the next section shows.
Scope bubbling
Request scope bubbles up the injection chain. Take BooksController → BooksService → BooksRepository. If only BooksService is request-scoped, BooksController becomes request-scoped too, because it depends on a provider that cannot exist before a request does. BooksRepository does not depend on BooksService, so it stays a singleton.
Transient scope does not bubble. A singleton that injects a transient provider receives a fresh instance of it, but the singleton itself stays a singleton, so only one instance of the transient provider is ever created for it. To get a new instance of the consumer per injection, mark the consumer TRANSIENT as well.
no scope declared
becomes REQUEST"] -->|injects| S["BooksService
scope: REQUEST"] S -->|injects| R["BooksRepository
DEFAULT singleton
built once"] end subgraph tr["Transient scope does not bubble"] direction LR RC["ReportsController
stays DEFAULT"] -->|injects| RS["ReportsService
stays DEFAULT"] RS -->|injects| L["AuditLogger
scope: TRANSIENT
one private instance"] end
@Injectable()
export class BooksRepository {
findAll() {
return ['Dune', 'Emma'];
}
}
// Request-scoped on purpose, e.g. because it caches per-request data.
@Injectable({ scope: Scope.REQUEST })
export class BooksService {
constructor(private readonly repository: BooksRepository) {}
findAll() {
return this.repository.findAll();
}
}
// Not declared with a scope, but it injects a request-scoped provider.
@Controller('books')
export class BooksController {
constructor(private readonly service: BooksService) {}
@Get()
findAll() {
return this.service.findAll();
}
}
@Injectable({ scope: Scope.TRANSIENT })
export class AuditLogger {}
// A singleton that injects a transient provider stays a singleton.
@Injectable()
export class ReportsService {
constructor(readonly logger: AuditLogger) {}
}
Adding a log line to every constructor shows what the container builds. After startup only singletons exist (BooksRepository, the single AuditLogger that belongs to ReportsService, ReportsService, ReportsController). Two calls to GET /books then build a new BooksService and a new BooksController each time, and never touch BooksRepository; two calls to GET /reports build nothing at all.
after startup: BooksRepository, AuditLogger, ReportsService, ReportsController
GET /books x2: BooksService, BooksController, BooksService, BooksController
GET /reports x2: (nothing constructed)
The same rule applies to enhancers. A guard that injects BooksService is built per request as well (the log reads BooksService, BooksGuard for each call), so one request-scoped provider can quietly make a large part of the pipeline request-scoped. Check the request lifecycle page for where guards, interceptors and pipes sit.
Source: Injection scopes: scope hierarchy.
Retrieving scoped providers
Because a request-scoped instance does not exist until a request creates it, moduleRef.get() (and app.get()) refuse to return it. In tests and in code that works with the container directly, use resolve(), optionally with a context identifier created by ContextIdFactory. Resolving twice with the same identifier yields the same instance, resolving without one yields a new instance each time.
import { ContextIdFactory } from '@nestjs/core';
// moduleRef.get(BooksService) throws:
// BooksService is marked as a scoped provider. Request and transient-scoped
// providers can't be used in combination with "get()" method.
// Please, use "resolve()" instead.
const contextId = ContextIdFactory.create();
const first = await moduleRef.resolve(BooksService, contextId);
const second = await moduleRef.resolve(BooksService, contextId);
const other = await moduleRef.resolve(BooksService);
// first === second, first !== other
Source: Module reference: resolving scoped providers. The full ModuleRef API, including ContextIdFactory and registerRequestByContextId, is covered in ModuleRef, Circular Dependencies & Lazy Loading.
The REQUEST token
When a request-scoped provider needs the original request object, inject the REQUEST token from @nestjs/core. REQUEST is inherently request-scoped, so any provider that injects it is request-scoped automatically (an explicit scope on that provider is optional and, for the token itself, ignored). Any provider that depends on such a provider becomes request-scoped, and this cannot be turned off.
import { Controller, Get, Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class RequestInfoService {
constructor(@Inject(REQUEST) private readonly request: Request) {}
describe() {
return {
method: this.request.method,
requestId: this.request.headers['x-request-id'] ?? null,
};
}
}
@Controller('whoami')
export class WhoAmIController {
constructor(private readonly info: RequestInfoService) {}
@Get()
get() {
return this.info.describe();
}
}
curl -H 'x-request-id: abc-123' http://localhost:3000/whoami
{ "method": "GET", "requestId": "abc-123" }
The token always yields the platform’s own request object, so the type to import depends on the adapter: Request from express for @nestjs/platform-express, FastifyRequest from fastify for @nestjs/platform-fastify. The headers property used above exists on both, and the same provider returns the same JSON when the application runs on Fastify; platform-specific members (originalUrl on Express, raw on Fastify) are not interchangeable.
Source: Injection scopes: request provider.
The object behind REQUEST is different in other transports, as the last section of this page shows. For authenticated users, a locale or a tenant, injecting the whole request into many providers is usually the wrong tool; see Performance and alternatives.
The INQUIRER token
INQUIRER (from @nestjs/core) resolves to the instance of the class that is asking for the provider. It is useful for loggers and metrics that want to tag their output with the consumer’s name. Use it in a transient provider: a transient provider is built once per consumer, so each consumer gets an instance that knows its own inquirer.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { INQUIRER } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
export class HelloService {
constructor(@Inject(INQUIRER) private readonly parentClass: object) {}
sayHello(message: string) {
console.log(`${this.parentClass?.constructor?.name}: ${message}`);
}
}
@Injectable()
export class AppService {
constructor(private readonly helloService: HelloService) {}
getRoot(): string {
this.helloService.sayHello('My name is getRoot');
return 'Hello world!';
}
}
Calling AppService#getRoot() logs AppService: My name is getRoot and returns Hello world!. The AppService above stays a singleton: INQUIRER does not make the injecting class request-scoped.
Source: Injection scopes: inquirer provider.
Performance and alternatives
Request-scoped providers are not free. Nest caches as much metadata as it can, but it still has to build the provider, and everything that depends on it up to the controller, for every request, and the garbage collector has to reclaim the instances afterwards. With 30,000 requests in flight there are 30,000 short-lived instances of the controller and of every request-scoped provider it depends on. The official docs state that a well-designed application with request-scoped providers should not see latency grow by more than about 5%, and recommend the default scope unless a provider must be request-scoped.
The cost spreads through bubbling. Making a widely injected provider (a database connection, a logger) request-scoped makes most of the application request-scoped. Before reaching for a scope, ask whether the provider needs request-unique state, or only a value that belongs to the current request such as the authenticated user, the tenant or a locale. For the second case Node’s AsyncLocalStorage keeps every provider a singleton and works in HTTP handlers, microservice message handlers and queue jobs alike. The pattern below stores the tenant in middleware and lets a plain singleton read it.
import { AsyncLocalStorage } from 'node:async_hooks';
import { Injectable, MiddlewareConsumer, Module, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
export interface RequestStore {
tenantId: string;
}
export const requestStorage = new AsyncLocalStorage<RequestStore>();
export class RequestStoreMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const tenantId = String(req.headers['x-tenant-id'] ?? 'public');
requestStorage.run({ tenantId }, next);
}
}
// A plain singleton: no scope bubbling, yet it sees the current request's tenant.
@Injectable()
export class CurrentTenantService {
get tenantId(): string {
return requestStorage.getStore()?.tenantId ?? 'public';
}
}
@Module({ providers: [CurrentTenantService] })
export class AlsModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestStoreMiddleware).forRoutes('{*splat}');
}
}
A request with x-tenant-id: acme receives { "tenant": "acme" }, and a request without the header { "tenant": "public" }, from the same singleton. See Middleware for how middleware is registered. The official recipe also covers the nestjs-cls package and typing the store. The first-party @nestjs/observe package (pre-1.0 at the time of writing) keeps such a store for every request, message and job it instruments, readable through TracerService.getAttribute(), so an application that already uses it needs no store of its own.
Source: Injection scopes: performance and Async local storage.
Durable providers
Bubbling hurts most in multi-tenant applications. Suppose ten customers each have their own database, and a request-scoped TenantConnection reads the tenant from the request and returns the matching connection. Most of the application depends on it, so most of it is rebuilt on every request, although two requests for the same tenant need identical objects. Request scope is too fine-grained: what is needed is one dependency-injection sub-tree per tenant.
Durable providers provide that. Instead of one tree per request, Nest keeps one tree per group of requests, where the grouping is decided by a context-id strategy that you write and register once.
The strategy
A ContextIdStrategy implements attach(contextId, request), called for each incoming request. It returns either a resolver function, or an object with a resolve function and an optional payload. The resolver receives a HostComponentInfo; for a host whose tree is durable, return the shared per-tenant context id, otherwise return the original per-request one. The payload is what @Inject(REQUEST) yields inside the durable tree.
import {
ContextId,
ContextIdFactory,
ContextIdResolver,
ContextIdResolverFn,
ContextIdStrategy,
HostComponentInfo,
} from '@nestjs/core';
import type { Request } from 'express';
const tenants = new Map<string, ContextId>();
export class AggregateByTenantContextIdStrategy implements ContextIdStrategy {
attach(contextId: ContextId, request: Request): ContextIdResolverFn | ContextIdResolver {
// Requests without the header share one "public" sub-tree.
const header = request.headers['x-tenant-id'];
const tenantId = typeof header === 'string' ? header : 'public';
let tenantSubTreeId = tenants.get(tenantId);
if (!tenantSubTreeId) {
tenantSubTreeId = ContextIdFactory.create();
tenants.set(tenantId, tenantSubTreeId);
}
return {
// Durable trees share the per-tenant id; everything else keeps the per-request id.
resolve: (info: HostComponentInfo) => (info.isTreeDurable ? tenantSubTreeId : contextId),
// What @Inject(REQUEST) yields inside the durable tree.
payload: { tenantId },
};
}
}
Register the strategy once, before the first request, for example in main.ts. It applies to the whole application:
import { ContextIdFactory } from '@nestjs/core';
ContextIdFactory.apply(new AggregateByTenantContextIdStrategy());
Source: Injection scopes: durable providers.
Two cautions. The official docs note that this strategy is not ideal for applications with a large number of tenants: it keeps every tenant it has ever seen in a map, so the number of cached sub-trees grows with it. And the payload is the only way a durable provider can see request data: the example falls back to a 'public' tenant when the header is absent because a variant that returns a plain per-request resolver for such requests leaves @Inject(REQUEST) undefined in the durable provider, which then fails with a TypeError on payload.tenantId. Every code path of attach that can reach a durable provider should return a payload.
Marking providers durable
A provider becomes durable when it is declared with scope: Scope.REQUEST and durable: true. Nest reads the flag only on providers explicitly declared with Scope.REQUEST; a provider that is request-scoped only implicitly gets its durability from its dependencies. Durability bubbles like request scope does: a provider (or controller) that depends on a durable provider is itself durable, as long as all of its other request-scoped dependencies are durable too, unless it declares Scope.REQUEST with durable: false. The durable flag is also accepted in the long-hand custom provider form.
import { Controller, Get, Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.REQUEST, durable: true })
export class TenantConnection {
readonly tenantId: string;
constructor(@Inject(REQUEST) payload: { tenantId: string }) {
this.tenantId = payload.tenantId;
}
}
// Durability bubbles up: this controller is durable because TenantConnection is.
@Controller('tenant-books')
export class TenantBooksController {
constructor(private readonly connection: TenantConnection) {}
@Get()
findAll() {
return { tenant: this.connection.tenantId };
}
}
Four requests, for acme, globex, acme and acme, return the expected tenant each time, but the container constructed TenantConnection only twice (once for acme, once for globex) instead of four times. A request with no header returned { "tenant": "public" } from a third sub-tree.
Source: Injection scopes: durable providers.
Request scope in microservices, gateways and resolvers
The request-scoped machinery is not limited to HTTP, but what plays the role of "the request" differs per transport, so the token differs too.
Microservices
In a microservice, every incoming message is a "request". Request-scoped handlers and providers inject a RequestContext with the CONTEXT token exported by @nestjs/microservices (not the HTTP REQUEST token). It exposes the message pattern, its data and the transporter-specific context (for example a NatsContext), the same object the @Ctx() decorator injects.
import { Controller, Inject, Injectable, Scope } from '@nestjs/common';
import { CONTEXT, MessagePattern, type RequestContext } from '@nestjs/microservices';
@Injectable({ scope: Scope.REQUEST })
export class MessageInfoService {
constructor(@Inject(CONTEXT) private readonly ctx: RequestContext<{ isbn: string }>) {}
describe() {
return { pattern: this.ctx.pattern, isbn: this.ctx.data.isbn };
}
}
@Controller()
export class BookMessagesController {
constructor(private readonly info: MessageInfoService) {}
@MessagePattern('books.find')
find() {
return this.info.describe();
}
}
A client sending books.find with the payload { isbn: '978-0' } received { "pattern": "books.find", "isbn": "978-0" }. Note type RequestContext: with isolatedModules and emitDecoratorMetadata enabled, TypeScript requires a type that appears in a decorated constructor signature to be imported with import type or an inline type modifier.
Source: Microservices: request-scoping.
WebSocket gateways
Gateways must behave as singletons, because each one owns real sockets. Before Nest 12, therefore, request-scoped providers could not be used with them. Starting with Nest 12, gateways support request-scoped providers: Nest creates a new instance of every request-scoped dependency per connected socket, and that instance lives as long as the connection. The socket itself is injectable with the REQUEST token. The scope is tied to the connection, not to a single message, so per-connection state survives across messages and is destroyed on disconnect.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import type { Socket } from 'socket.io';
@Injectable({ scope: Scope.REQUEST })
export class ConnectionStateService {
private sequence = 0;
constructor(@Inject(REQUEST) private readonly client: Socket) {}
next() {
return { clientId: this.client.id, sequence: ++this.sequence };
}
}
@WebSocketGateway()
export class ChatGateway {
constructor(private readonly connectionState: ConnectionStateService) {}
handleConnection(client: Socket) {
client.emit('connected', this.connectionState.next());
}
@SubscribeMessage('state')
onState() {
return { event: 'state', data: this.connectionState.next() };
}
}
Two Socket.IO clients were connected: the first received sequences 1 (on connect), 2 and 3 after two state messages, the second received 1 and 2, each with its own clientId. The official page repeats the warning that request-scoped providers add per-connection instantiation overhead, so use them only when you need per-connection state.
Source: Gateways: request-scoped gateways.
GraphQL resolvers
In a GraphQL application the "request" is the GraphQL context, so request-scoped providers inject CONTEXT from @nestjs/graphql instead of REQUEST. The value is whatever your context function in GraphQLModule returns, so configure it to include the HTTP request.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { CONTEXT, Query, Resolver } from '@nestjs/graphql';
@Injectable({ scope: Scope.REQUEST })
export class GraphqlRequestService {
constructor(
@Inject(CONTEXT) private readonly context: { request: { headers: Record<string, string> } },
) {}
locale(): string {
return this.context.request.headers['accept-language'] ?? 'en';
}
}
@Resolver()
export class LocaleResolver {
constructor(private readonly requestService: GraphqlRequestService) {}
@Query(() => String)
locale(): string {
return this.requestService.locale();
}
}
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
// Makes the HTTP request available as `context.request`.
context: ({ req }) => ({ request: req }),
}),
A query { locale } sent with the header accept-language: es-ES returns { "data": { "locale": "es-ES" } }. @nestjs/apollo with Express 5 also requires the @as-integrations/express5 package to be installed; without it the module aborts at startup with a "package is missing" error. For the GraphQL API itself, see GraphQL.
Source: Injection scopes: request provider (the GraphQL CONTEXT variant).
Pitfalls
-
Accidental bubbling. One request-scoped provider at the bottom of a deep chain rebuilds everything above it on each request. Log constructors in development to see what actually got request-scoped, as in the example above.
-
get()on scoped providers. It throws; useresolve()with a context id (see Retrieving scoped providers). -
Request scope in a tree that must stay static. Gateways before Nest 12, Passport strategies and cron controllers should not depend on request-scoped providers.
-
State leaking through a singleton. A singleton that stores per-request data in a field is a bug under concurrency, because many requests share it; either make the data request-scoped or keep it in
AsyncLocalStorage. -
Durable trees are shared between requests. Do not put anything unique to one request (a request id, a user session) into a durable provider, and bound the number of groups your strategy can create.
What changed recently and where the book is outdated
The book introduces Scope.REQUEST and Scope.TRANSIENT in its chapter on advanced concepts, and those still work as described. It predates, or does not cover in depth, several things that matter in a current application:
-
Durable providers and
ContextIdStrategy(the documented answer to multi-tenancy) and the exact semantics of thedurableflag, which is read only on providers declared withScope.REQUEST. -
Request-scoped WebSocket gateways, new in Nest 12 (the migration guide and the v12.0.0 release notes list them), with the socket injectable through
REQUEST. -
The
INQUIRERtoken for per-consumer transient providers. -
The current official page steers readers to
AsyncLocalStorage(and the pre-1.0@nestjs/observe) for the common "read the current user or tenant" case instead of request scope.
Source: Migration guide and nestjs/nest: release v12.0.0.