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):

An interceptor that adapts to the application type
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:

A typed metadata decorator and its low-level equivalent
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.

Metadata at class and handler level
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:

Reading metadata with get, getAll, getAllAndOverride and getAllAndMerge
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)

get(decorator, target)

Metadata of one target. context.getHandler() for the method, context.getClass() for the controller. Returns undefined when the target has none (handlerOnly is missing from the first response because JSON.stringify drops it).

getAllAndOverride(decorator, [targets])

The first defined value, checked in the order you pass the targets. With [handler, cls] the method overrides the class: ["admin"] on restricted, ["reader"] on open. Use it for "class sets a default, a method may replace it".

getAllAndMerge(decorator, [targets])

All defined values merged: arrays are concatenated in target order (["admin","reader"]), objects are merged. Use it when the method adds to what the class grants.

getAll(decorator, [targets])

One entry per target, in order, including undefined for targets without metadata (serialised as null above). The two methods above are built on it.

Key-based overloads

Each method also accepts a metadata key string instead of a decorator reference (reflector.get<string[]>('tags', handler)), the form that pairs with SetMetadata(). You pass the result type yourself, so nothing ties it to the writer.

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?) and getControllers(options?) return InstanceWrapper[]. A wrapper carries the instance, the token, the class (metatype), the name and scope information such as isDependencyTreeStatic().

  • 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’s KEY so metadataKey can 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.

A discoverable method decorator
import { DiscoveryService } from '@nestjs/core';

// Method decorator that both stores the event name and registers the class
export const OnBookEvent = DiscoveryService.createDecorator<string>();
Two providers that subscribe through the decorator
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:

The explorer
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 metadataKey filter 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.

  • instance is undefined for providers that are not yet instantiated (and request- or transient-scoped providers have no single instance), which is why the explorer checks isDependencyTreeStatic() first. Scopes are in Injection Scopes.

  • Do the scan in a lifecycle hook (onModuleInit here, or onApplicationBootstrap when you need every module initialised) so that all providers exist.

  • The same pattern applies to controllers: getControllers({ include: [ExecutionContextModule] }) returns ['ShelvesController'] (by wrapper.name), which is how route-listing and analytics tools enumerate the application. Module boundaries and what include filters 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 to getAllAndOverride() or getAllAndMerge().

  • 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 on getType() 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 instance and the scope before calling methods; use bind(instance) or the this of the handler is lost.

  • Decorators run at class definition, not at request time. They only store metadata; Reflector and DiscoveryService read 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 for nest generate decorator. Older material, the book among it, shows SetMetadata('roles', roles) with a string key read through reflector.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 the metadataKey filter are the current API for finding decorated classes. Hand-rolled explorers built on the ModulesContainer and MetadataScanner.scanFromPrototype() appear in older tutorials; in the Nest 12 typings scanFromPrototype() and getAllFilteredMethodNames() are marked deprecated in favour of getAllMethodNames(), which the explorer above uses.

  • Nest 12 core packages are ESM-only. Import paths in the examples therefore use .js extensions ('./book-events.js'); the CommonJS template (Jest) uses the same APIs without the extension.

  • GraphQL contexts are not an ExecutionContext view of their own: convert with GqlExecutionContext.create(context) to reach the resolver arguments.