Execution Context, Reflector & Discovery
|
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 @nestjs/core 12.1.2, published 2026-09-30), Node.js 22 and 24 LTS,
TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, all checked on 2026-10-02. Three small tools let a Nest component answer the questions that
guards, interceptors, filters and plugins keep asking: which kind of application am I running in (ArgumentsHost),
which controller and method am I about to run (ExecutionContext), what did the developer declare on that method
(Reflector) and which classes in the whole application carry a given marker (DiscoveryService). Together they are
what turns a decorator such as @Roles() into behaviour in the Bookshelf application that grows across this section.
ArgumentsHost
Nest handlers receive different arguments depending on the transport. An HTTP handler runs with Express’s
[request, response, next] (or Fastify’s request and reply), a microservice handler with a payload and a context, a
WebSocket handler with a client and its data, and a GraphQL resolver with [root, args, context, info].
ArgumentsHost is the abstraction over that array. Nest passes an instance wherever you may need it: the catch()
method of an exception filter receives one as host, and guards and interceptors receive its subclass, the
ExecutionContext (next section).
getArgs() returns the raw array and getArgByIndex(n) plucks one item, but indexing couples the code to one
transport. The portable way is to ask for the type and switch to the matching view:
host.getType(); // 'http' | 'rpc' | 'ws' out of the box, packages add their own ('graphql')
host.switchToHttp(); // HttpArgumentsHost: getRequest<T>(), getResponse<T>(), getNext<T>()
host.switchToRpc(); // RpcArgumentsHost: getData<T>(), getContext<T>()
host.switchToWs(); // WsArgumentsHost: getData<T>(), getClient<T>()
getType() is the guard rail for components that must be reusable. switchToHttp().getRequest() is meaningless in a
microservice handler, so shared code branches on the type first. The interceptor below records who called which handler
for the Bookshelf audit trail, and works unchanged under HTTP, microservices and WebSockets (GraphQL, which
@nestjs/graphql reports as 'graphql', falls into the default branch here; see
GraphQL: Other features - Execution context for GqlExecutionContext):
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
export const auditLog: string[] = [];
@Injectable()
export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const target = `${context.getClass().name}.${context.getHandler().name}`;
let caller: string;
switch (context.getType<string>()) {
case 'http': {
const req = context.switchToHttp().getRequest<{ method: string; url: string }>();
caller = `http ${req.method} ${req.url}`;
break;
}
case 'rpc': {
const rpc = context.switchToRpc();
caller = `rpc ${JSON.stringify(rpc.getData())}`;
break;
}
case 'ws': {
const ws = context.switchToWs();
caller = `ws ${JSON.stringify(ws.getData())}`;
break;
}
default:
caller = context.getType<string>(); // for example "graphql"
}
return next.handle().pipe(tap(() => auditLog.push(`${caller} -> ${target}`)));
}
}
The getType() type argument widens the return type to string so the switch can name types that are not in the
default union. The http branch can be exercised with supertest against a real controller; to exercise
the rpc, ws and graphql branches without starting a microservice or a gateway, a test can build an
ExecutionContextHost (the class Nest itself uses, from @nestjs/core/helpers/execution-context-host.js; an internal
helper that is handy in unit tests, not an application API) with a payload and a context type. The audit log after one
request of each kind is:
http GET /shelves/open -> ShelvesController.open
rpc {"isbn":"1"} -> ShelvesController.open
ws {"room":"b1"} -> ShelvesController.open
graphql -> ShelvesController.open
The WsArgumentsHost and RpcArgumentsHost interfaces are the whole story for those transports: getData() returns
the message payload, getClient() the socket (WebSocket) and getContext() the transport context such as the NATS
subject or the gRPC metadata (microservices). The same views are what exception filters use; see
Exception Filters & Error Handling.
ExecutionContext
ExecutionContext extends ArgumentsHost with two methods, available in guards (canActivate()) and interceptors
(intercept()):
-
getHandler()returns a reference to the method that will be invoked next. -
getClass()returns the controller class (the type, not an instance) that the handler belongs to.
For a GET /shelves/restricted request bound to ShelvesController.restricted(), getHandler().name is
"restricted" and getClass().name is "ShelvesController" (the interceptor above uses exactly this to build its log
line). The reason they matter is that they are the targets you hand to Reflector: a decorator attaches metadata to a
method or to a class, and these two references say which method and which class to read it from.
const methodKey = context.getHandler().name; // "restricted"
const className = context.getClass().name; // "ShelvesController"
Where each component receives its context, and in which order guards, interceptors and filters run, is described in Request Lifecycle; the components themselves are in Guards and Interceptors.
Reflector and metadata
A metadata decorator records a value on a method or class; it does nothing by itself. A guard or interceptor then reads
it back with Reflector, a helper class from @nestjs/core that is available for injection in any module without
importing anything. Reflector.createDecorator<T>() creates a typed decorator, and the SetMetadata() function from
@nestjs/common is the low-level equivalent that takes an explicit key:
import { SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
// Typed decorator, no key to manage
export const Roles = Reflector.createDecorator<string[]>();
// Low-level equivalent with an explicit key
export const Tags = (...tags: string[]) => SetMetadata('tags', tags);
@Roles(['admin']) is type-checked against string[], and reflector.get(Roles, target) returns string[] with no
cast, because the decorator reference carries the type. The key and transform options of createDecorator() and the
DiscoveryService sibling are covered in Custom Decorators, which also
compares the two styles; createDecorator() is what nest generate decorator produces in Nest 12 and what the docs now
use. The controller below puts metadata on the class and on one handler, and applies the interceptor from the previous
section.
import { Controller, Get, Req, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuditInterceptor } from './audit.interceptor.js';
import { InspectGuard } from './inspect.guard.js';
import { Roles, Tags } from './roles.decorator.js';
@Controller('shelves')
@UseGuards(InspectGuard)
@UseInterceptors(AuditInterceptor)
@Roles(['reader'])
export class ShelvesController {
@Get('open')
open(@Req() req: { inspection: unknown }) {
return req.inspection; // only class-level metadata
}
@Get('restricted')
@Roles(['admin'])
@Tags('internal')
restricted(@Req() req: { inspection: unknown }) {
return req.inspection; // handler and class metadata
}
}
The guard reads the same metadata with every Reflector method and stores the results on the request, so the handler can
return them and the differences are visible:
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.decorator.js';
@Injectable()
export class InspectGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const handler = context.getHandler();
const cls = context.getClass();
const req = context.switchToHttp().getRequest();
req.inspection = {
handlerOnly: this.reflector.get(Roles, handler),
classOnly: this.reflector.get(Roles, cls),
override: this.reflector.getAllAndOverride(Roles, [handler, cls]),
merged: this.reflector.getAllAndMerge(Roles, [handler, cls]),
all: this.reflector.getAll(Roles, [handler, cls]),
tagsByKey: this.reflector.get<string[]>('tags', handler),
};
return true;
}
}
Running the example gave these responses (the Reflector method names are the keys):
curl -s localhost:3000/shelves/open
curl -s localhost:3000/shelves/restricted
{"classOnly":["reader"],"override":["reader"],"merged":["reader"],"all":[null,["reader"]]}
{"handlerOnly":["admin"],"classOnly":["reader"],"override":["admin"],"merged":["admin","reader"],"all":[["admin"],["reader"]],"tagsByKey":["internal"]}
What each method does:
| Method | Behaviour (checked above) |
|---|---|
|
Metadata of one target. |
|
The first defined value, checked in the order you pass the targets. With |
|
All defined values merged: arrays are concatenated in target order ( |
|
One entry per target, in order, including |
Key-based overloads |
Each method also accepts a metadata key string instead of a decorator reference ( |
Argument order matters: getAllAndOverride(Roles, [cls, handler]) makes the class win. Because the metadata may sit
on either level, a guard that reads only context.getHandler() silently ignores class-level decorators, which is the
usual mistake in hand-written role guards. A complete role guard, and a global guard with a @Public() opt-out built on
getAllAndOverride(), are in Guards.
DiscoveryModule and DiscoveryService
Reflector answers "what metadata does this handler have?" while a request is being processed. DiscoveryService,
from @nestjs/core, answers the application-wide question at startup: "which providers and controllers exist, and which
of them carry my decorator?" It is the building block for plugin systems and for packages such as @nestjs/schedule
and @nestjs/event-emitter, which scan providers for @Cron() or @OnEvent() methods. Import DiscoveryModule into
the module that needs it (it provides and exports DiscoveryService and MetadataScanner), then inject the service:
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
@Module({
imports: [DiscoveryModule],
providers: [BookEventExplorer],
})
export class ExecutionContextModule {}
The service has these members (as declared in the @nestjs/core 12 typings):
-
getProviders(options?)andgetControllers(options?)returnInstanceWrapper[]. A wrapper carries theinstance, thetoken, the class (metatype), thenameand scope information such asisDependencyTreeStatic(). -
The options are
{ include: [SomeModule] }, which limits discovery to those modules, or{ metadataKey: Decorator.KEY }, which returns only classes registered by a discoverable decorator. -
DiscoveryService.createDecorator<T>()creates a discoverable decorator. It stores metadata and, when applied to a class, registers that class under the decorator’sKEYsometadataKeycan find it. -
getMetadataByDecorator(decorator, wrapper, methodKey?)reads the value back, from the class or, with the optional third argument, from a method.
Building an explorer
An explorer is a provider that scans the application once at startup and wires up whatever it finds. The Bookshelf
example is a small in-process event bus: any provider method decorated with @OnBookEvent('book.created') becomes a
subscriber, without the publisher knowing about it.
import { DiscoveryService } from '@nestjs/core';
// Method decorator that both stores the event name and registers the class
export const OnBookEvent = DiscoveryService.createDecorator<string>();
import { Injectable } from '@nestjs/common';
import { OnBookEvent } from './book-events.js';
@Injectable()
export class SearchIndexer {
@OnBookEvent('book.created')
index(payload: unknown) {
return `indexed ${JSON.stringify(payload)}`;
}
}
@Injectable()
export class Mailer {
@OnBookEvent('book.created')
notify(payload: unknown) {
return `mailed ${JSON.stringify(payload)}`;
}
@OnBookEvent('book.deleted')
farewell(payload: unknown) {
return `farewell ${JSON.stringify(payload)}`;
}
helper() {
return 'not a handler';
}
}
The explorer lists the providers, walks the methods of each instance with MetadataScanner (also exported by
DiscoveryModule) and reads the decorator metadata method by method:
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { OnBookEvent } from './book-events.js';
type Handler = (payload: unknown) => unknown;
@Injectable()
export class BookEventExplorer implements OnModuleInit {
private readonly logger = new Logger(BookEventExplorer.name);
private readonly handlers = new Map<string, Handler[]>();
constructor(
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner,
) {}
onModuleInit() {
// Method decorators do not register their class, so inspect every provider
const wrappers = this.discovery.getProviders();
for (const wrapper of wrappers) {
const { instance } = wrapper;
if (!instance || !wrapper.isDependencyTreeStatic()) continue; // skip request/transient scoped
const prototype = Object.getPrototypeOf(instance);
for (const methodName of this.scanner.getAllMethodNames(prototype)) {
const event = this.discovery.getMetadataByDecorator(OnBookEvent, wrapper, methodName);
if (!event) continue;
const list = this.handlers.get(event) ?? [];
list.push(instance[methodName].bind(instance));
this.handlers.set(event, list);
this.logger.log(`Registered ${wrapper.name}.${methodName} for "${event}"`);
}
}
}
emit(event: string, payload: unknown): unknown[] {
return (this.handlers.get(event) ?? []).map((handler) => handler(payload));
}
registeredEvents(): string[] {
return [...this.handlers.keys()].sort();
}
}
With DiscoveryModule imported, calling emit() gives:
registeredEvents(): [ 'book.created', 'book.deleted' ]
emit('book.created', { id: 1 }): [ 'indexed {"id":1}', 'mailed {"id":1}' ]
emit('book.deleted', { id: 1 }): [ 'farewell {"id":1}' ]
helper() was ignored because it carries no metadata. Points worth noting:
-
The
metadataKeyfilter only finds classes that were decorated at class level. The docs state that a discoverable decorator registers "the classes they decorate"; a decorator used only on methods does not register its class, so the explorer above scans every provider. With a class-level marker (@FeatureFlag('experimental')on the class) you can skip the scan:getProviders({ metadataKey: FeatureFlag.KEY })returns only those classes, as shown in Custom Decorators. -
instanceisundefinedfor providers that are not yet instantiated (and request- or transient-scoped providers have no single instance), which is why the explorer checksisDependencyTreeStatic()first. Scopes are in Injection Scopes. -
Do the scan in a lifecycle hook (
onModuleInithere, oronApplicationBootstrapwhen you need every module initialised) so that all providers exist. -
The same pattern applies to controllers:
getControllers({ include: [ExecutionContextModule] })returns['ShelvesController'](bywrapper.name), which is how route-listing and analytics tools enumerate the application. Module boundaries and whatincludefilters on are in Modules.
Pitfalls
-
Reading only the handler. Metadata declared on the controller class is invisible to
reflector.get(X, context.getHandler()). Pass both targets togetAllAndOverride()orgetAllAndMerge(). -
Wrong order. The order of the targets array is the order of precedence (override) or of concatenation (merge).
-
Assuming HTTP.
switchToHttp().getRequest()in a component that also runs on a gateway or a microservice returns the wrong arguments; branch ongetType()or keep the component HTTP-only. Guards for other transports are described at WebSockets: Guards and Microservices: Guards. -
Instances at startup. In an explorer, check
instanceand the scope before calling methods; usebind(instance)or thethisof the handler is lost. -
Decorators run at class definition, not at request time. They only store metadata;
ReflectorandDiscoveryServiceread it later.
What changed recently, and where the book is outdated
-
Reflector.createDecorator()is the documented way to attach typed metadata, and the Nest 12 CLI generates it fornest generate decorator. Older material, the book among it, showsSetMetadata('roles', roles)with a string key read throughreflector.get<string[]>('roles', …); it still works and remains the right tool for decorators that take several arguments or need a fixed key, but it gives up the type link between writer and reader. -
DiscoveryService.createDecorator(),getMetadataByDecorator()and themetadataKeyfilter are the current API for finding decorated classes. Hand-rolled explorers built on theModulesContainerandMetadataScanner.scanFromPrototype()appear in older tutorials; in the Nest 12 typingsscanFromPrototype()andgetAllFilteredMethodNames()are marked deprecated in favour ofgetAllMethodNames(), which the explorer above uses. -
Nest 12 core packages are ESM-only. Import paths in the examples therefore use
.jsextensions ('./book-events.js'); the CommonJS template (Jest) uses the same APIs without the extension. -
GraphQL contexts are not an
ExecutionContextview of their own: convert withGqlExecutionContext.create(context)to reach the resolver arguments.