Request Lifecycle

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 20.19+ or 22.12+, TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, all checked on 2026-10-02. It pulls the order of execution of the request pipeline into one place. The order tables below are not copied from the documentation: they are what each component of the example application on this page logs (NestFactory with Express and with Fastify, Node.js 24.16, Vitest and supertest). The individual components have their own pages: Middleware, Guards, Interceptors, Pipes & Validation and Exception Filters & Error Handling.

The pipeline at a glance

A request is processed by a fixed sequence of stages. Each stage can be bound at several levels (global, controller, route and, for pipes, parameter), and the binding level decides the order inside the stage. In one sentence: the request goes through middleware, then guards, then the "before" half of the interceptors, then pipes, the handler, the "after" half of the interceptors, and finally, only if something threw, the exception filters.

sequenceDiagram autonumber participant C as Client participant M as Middleware participant G as Guards participant I as Interceptors participant P as Pipes participant H as Handler and service participant F as Exception filters C->>M: HTTP request Note over M: global (app.use), then module-bound M->>G: next() Note over G: global, controller, route G->>I: canActivate() returned true Note over I: before handle(): global, controller, route I->>P: next.handle() Note over P: global, controller, route, then each parameter P->>H: transformed arguments H-->>I: return value Note over I: after handle(): route, controller, global I-->>C: response H--xF: exception thrown anywhere after routing Note over F: route, controller, global (first match wins) F-->>C: error response
Figure 1. One request through the lifecycle (successful path, and the error branch)

Three details of this picture are worth remembering before reading the tables:

  • Everything but middleware belongs to the route. Guards, interceptors, pipes and filters are looked up after Nest has matched a controller method. A request that matches no route (a 404) therefore never reaches a guard, interceptor or pipe; only global middleware runs, and the not-found exception is handled by the exceptions layer.

  • Interceptors wrap everything inside them. They are entered before the pipes and left after the handler, and because they operate on an RxJS Observable they also see errors thrown by pipes, the handler and services.

  • Filters are the only stage that resolves from the most specific level up. Every other stage starts at the global level.

The example application

The application is a small lifecycle feature of the Bookshelf whose only job is to log every stage. A shared mark() function appends to an array and prints a line; it is a module-level function on purpose, because components created outside the dependency-injection container (app.use(), app.useGlobal*()) cannot inject a logger provider.

trace.ts
export const trace: string[] = [];
export const mark = (stage: string): void => {
  trace.push(stage);
  console.log(`[lifecycle] ${stage}`);
};

Each component logs its own name. Guards, interceptors and filters are created by small factory functions so that one class body can be reused at several binding levels; in real code you write plain named classes.

components.ts (middleware, guards and interceptors)
import {
  ArgumentMetadata, ArgumentsHost, BadRequestException, CallHandler, CanActivate, Catch,
  ExceptionFilter, ExecutionContext, HttpException, HttpStatus, Injectable, NestInterceptor,
  NestMiddleware, PipeTransform, Type,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
import { mark } from './trace.js';

@Injectable()
export class ModuleMiddleware implements NestMiddleware {
  use(req: any, _res: any, next: () => void): void {
    mark(`middleware:module ${req.method} ${req.url}`);
    next();
  }
}

export function globalMiddleware(req: any, _res: any, next: () => void): void {
  mark('middleware:global (app.use)');
  next();
}

export function makeGuard(label: string): Type<CanActivate> {
  @Injectable()
  class LogGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean {
      mark(`guard:${label}`);
      const request = context.switchToHttp().getRequest();
      // 403 when the header "x-deny" names this guard
      return request.headers['x-deny'] !== label;
    }
  }
  return LogGuard;
}

export function makeInterceptor(label: string): Type<NestInterceptor> {
  @Injectable()
  class LogInterceptor implements NestInterceptor {
    intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
      mark(`interceptor:${label}:before`);
      return next.handle().pipe(
        tap({
          next: () => mark(`interceptor:${label}:after`),
          error: (e: Error) => mark(`interceptor:${label}:error(${e.constructor.name})`),
        }),
      );
    }
  }
  return LogInterceptor;
}
components.ts (pipes and exception filters)
export class LogPipe implements PipeTransform {
  constructor(private readonly label: string) {}
  transform(value: unknown, metadata: ArgumentMetadata): unknown {
    mark(`pipe:${this.label}[${metadata.type}]`);
    return value;
  }
}

export class RejectPipe implements PipeTransform {
  transform(value: string): string {
    mark('pipe:reject');
    if (value === 'bad') throw new BadRequestException('rejected by pipe');
    return value;
  }
}

export class TeapotError extends HttpException {
  constructor() {
    super('I am a teapot', HttpStatus.I_AM_A_TEAPOT);
  }
}

export function makeFilter(label: string, exception?: Type<Error>): Type<ExceptionFilter> {
  @Catch(...(exception ? [exception] : []))
  class LogFilter implements ExceptionFilter {
    constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

    catch(error: Error, host: ArgumentsHost): void {
      mark(`filter:${label}(${error.constructor.name})`);
      const status = error instanceof HttpException ? error.getStatus() : 500;
      // Platform-agnostic reply: works for Express, Fastify and for errors thrown in
      // Fastify middleware, where the response is still the raw Node.js ServerResponse.
      this.httpAdapterHost.httpAdapter.reply(
        host.switchToHttp().getResponse(),
        { handledBy: label, message: error.message },
        status,
      );
    }
  }
  return LogFilter;
}

The controller binds one component of every kind at the controller level, a second one at the route level, and per-parameter pipes on the three arguments of the handler. The three routes below exercise different exits from the pipeline.

lifecycle.controller.ts
import {
  BadRequestException, Body, Controller, ForbiddenException, Get, Param, Post, Query,
  UseFilters, UseGuards, UseInterceptors, UsePipes,
} from '@nestjs/common';
import { LifecycleService } from './lifecycle.service.js';
import { LogPipe, RejectPipe, TeapotError, makeFilter, makeGuard, makeInterceptor } from './components.js';
import { mark } from './trace.js';

const ControllerGuard = makeGuard('controller');
const RouteGuard = makeGuard('route');
const ControllerInterceptor = makeInterceptor('controller');
const RouteInterceptor = makeInterceptor('route');
const ControllerFilter = makeFilter('controller', BadRequestException);
const RouteFilter = makeFilter('route', TeapotError);

@UseGuards(ControllerGuard)
@UseInterceptors(ControllerInterceptor)
@UsePipes(new LogPipe('controller'))
@UseFilters(ControllerFilter)
@Controller('lifecycle')
export class LifecycleController {
  constructor(private readonly service: LifecycleService) {}

  @Post(':id')
  @UseGuards(RouteGuard)
  @UseInterceptors(RouteInterceptor)
  @UsePipes(new LogPipe('route'))
  @UseFilters(RouteFilter)
  handler(
    @Body(new LogPipe('param:body')) body: { shout?: boolean },
    @Param('id', new LogPipe('param:id')) id: string,
    @Query('q', new LogPipe('param:query')) _q: string,
  ) {
    mark('handler');
    if (id === 'teapot') throw new TeapotError();
    if (id === 'boom') throw new Error('unexpected');
    return this.service.find(id, body?.shout ?? false);
  }

  @Get('strict/:id')
  strict(@Param('id', new RejectPipe()) id: string) {
    mark('handler');
    return { id };
  }

  @Get('forbidden')
  forbidden() {
    mark('handler');
    throw new ForbiddenException();
  }
}
lifecycle.service.ts
import { Injectable } from '@nestjs/common';
import { mark } from './trace.js';

@Injectable()
export class LifecycleService {
  find(id: string, shout: boolean): { id: string; shout: boolean } {
    mark('service');
    return { id, shout };
  }
}

The module binds the global guard, interceptor, pipe and filter through the APP_* tokens (so they take part in dependency injection) and the module-bound middleware through NestModule.configure(). The one global component that cannot go through a module, app.use() middleware, is registered in a setup() function that both entry points share.

lifecycle.module.ts
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { LifecycleController } from './lifecycle.controller.js';
import { LifecycleService } from './lifecycle.service.js';
import { LogPipe, ModuleMiddleware, makeFilter, makeGuard, makeInterceptor } from './components.js';

@Module({
  controllers: [LifecycleController],
  providers: [
    LifecycleService,
    // Globally bound through DI: these can inject providers (app.useGlobal* cannot).
    { provide: APP_GUARD, useClass: makeGuard('global') },
    { provide: APP_INTERCEPTOR, useClass: makeInterceptor('global') },
    { provide: APP_PIPE, useValue: new LogPipe('global') },
    { provide: APP_FILTER, useClass: makeFilter('global') },
  ],
})
export class LifecycleModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(ModuleMiddleware).forRoutes(LifecycleController);
  }
}
setup.ts, main-express.ts and main-fastify.ts
import { INestApplication } from '@nestjs/common';
import { globalMiddleware } from './components.js';

// Global middleware bound with app.use() runs before any module-bound middleware.
export function setup(app: INestApplication): void {
  app.use(globalMiddleware);
}

// main-express.ts
//   const app = await NestFactory.create(LifecycleModule);
//   setup(app);
//   await app.listen(3000);

// main-fastify.ts
//   const app = await NestFactory.create<NestFastifyApplication>(LifecycleModule, new FastifyAdapter());
//   setup(app);
//   await app.listen(3000, '0.0.0.0');

The order in practice

Start the Express variant and send one request; the application writes the stages to its console.

curl -X POST 'http://localhost:3000/lifecycle/42?q=x' -H 'content-type: application/json' -d '{"shout":true}'
{ "id": "42", "shout": true }

The [lifecycle] lines logged for that request, identical on Express and on Fastify, are:

Table 1. Order for a successful request (POST /lifecycle/42?q=x)
# Logged stage Binding

1

middleware:global (app.use)

app.use() in setup()

2

middleware:module

consumer.apply(…​).forRoutes(LifecycleController)

3

guard:global

APP_GUARD

4

guard:controller

@UseGuards() on the class

5

guard:route

@UseGuards() on the method

6

interceptor:global:before

APP_INTERCEPTOR

7

interceptor:controller:before

@UseInterceptors() on the class

8

interceptor:route:before

@UseInterceptors() on the method

9-11

pipe:global[query], [param], [body]

APP_PIPE, once per handler parameter

12-14

pipe:controller[query], [param], [body]

@UsePipes() on the class

15-17

pipe:route[query], [param], [body]

@UsePipes() on the method

18

pipe:param:query[query]

@Query('q', pipe)

19

pipe:param:id[param]

@Param('id', pipe)

20

pipe:param:body[body]

@Body(pipe)

21

handler

the controller method

22

service

the injected LifecycleService

23

interceptor:route:after

the tap() of the route interceptor

24

interceptor:controller:after

the tap() of the controller interceptor

25

interceptor:global:after

the tap() of the global interceptor

Reading the table:

  • The global, controller and route pipes each run once per handler parameter, not once per request, and they run on the parameters from the last to the first (query, param, body for a handler declared as body, id, q). The global pipe processes all three parameters before the controller pipe starts. This is why a ValidationPipe can validate the body, the parameters and the query string with a single global binding, and why a custom global pipe must check metadata.type if it should apply to only one of them.

  • Parameter-level pipes come last, again from the last parameter to the first.

  • The "after" interceptors run in reverse order because each interceptor wraps the next one: the route interceptor is the innermost, so its tap() fires first.

Failure paths

A thrown exception skips the rest of the lifecycle and jumps to the exception filters. The same application, driven through three failing requests, shows what still runs.

Table 2. Order when something throws
Request Stages that ran Handled by

POST /lifecycle/teapot?q=x (handler throws TeapotError)

middleware (2), guards (3), interceptors before (3), pipes (12 calls), handler, then interceptor:route:error, interceptor:controller:error, interceptor:global:error

filter:route (route filter matches, so the controller and global filters do not run)

POST /lifecycle/boom?q=x (handler throws a plain Error)

the same as above up to handler, then the three interceptor error callbacks

filter:global (neither the route filter, which catches TeapotError, nor the controller filter, which catches BadRequestException, matches)

GET /lifecycle/strict/bad (a parameter pipe throws BadRequestException)

middleware (2), guards (2), interceptors before (2), pipe:global[param], pipe:controller[param], pipe:reject, then two interceptor error callbacks

filter:controller (the handler and the service never run)

POST /lifecycle/1 with x-deny: controller (a guard returns false)

middleware (2), guard:global, guard:controller; no interceptor, pipe or handler stage

filter:global: the guard’s ForbiddenException is not a BadRequestException, so the controller filter does not match

GET /nope (no matching route)

middleware:global (app.use) only

filter:global (a NotFoundException)

Three conclusions follow, and each one is a frequent source of confusion:

  1. A guard failure is invisible to interceptors. Guards run before the interceptors are entered, so an interceptor that logs or times every request never sees a request rejected by a guard (or by middleware). If you need to observe those, use middleware or a global exception filter.

  2. A pipe failure is visible to interceptors. Pipes run after the interceptors were entered, so an interceptor’s catchError or tap({ error }) sees validation errors.

  3. Filters are chosen by exception type and level. The lookup starts at the route, then the controller, then the global level, and the first filter whose @Catch() matches the exception wins. An exception handled by a lower-level filter is not passed up to the next one; use class inheritance between filters to share behaviour.

Middleware order in detail

Middleware is the one stage that does not depend on a matched route, so its order is defined by when it was registered, not by where it is attached. The documented rule: first the middleware registered with app.use(), then the module-bound middleware matched by path; among modules, the @Global() modules first, then the root module, then the others ordered by their distance from the root module in the import graph. The following application confirms it:

ordering.module.ts (middleware part)
function middlewareFor(name: string) {
  @Injectable()
  class M implements NestMiddleware {
    use(_req: unknown, _res: unknown, next: () => void): void {
      mark(`middleware:${name}`);
      next();
    }
  }
  return M;
}

@Module({})
export class DeepModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(middlewareFor('DeepModule')).forRoutes('{*splat}');
  }
}

@Module({ imports: [DeepModule] })
export class FeatureModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(middlewareFor('FeatureModule')).forRoutes('{*splat}');
  }
}

@Global()
@Module({})
export class CoreModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(middlewareFor('CoreModule (@Global)')).forRoutes('{*splat}');
  }
}

@Module({
  imports: [CoreModule, FeatureModule],
  controllers: [PingController],
})
export class OrderingModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(middlewareFor('RootModule')).forRoutes('{*splat}');
  }
}

GET /ping logged, on both platforms:

middleware:CoreModule (@Global)
middleware:RootModule
middleware:FeatureModule
middleware:DeepModule

Two pitfalls hide here. The wildcard route is written '{splat}' (a named wildcard in braces), because Nest 11 and later run on Express 5 and Fastify 5, which no longer accept a bare ; the braces make the wildcard optional so it also matches the root path, whereas '*splat' needs at least one path segment. And the order between two middleware in the same module is simply the order of the apply() calls (or of the arguments of one apply()).

How each enhancer is bound, and which ones support DI

Guards, interceptors, pipes and exception filters are collectively called enhancers. Each can be bound at four levels and the level you pick also decides whether the component takes part in dependency injection.

Table 3. Binding levels and dependency injection
Binding How Runs DI?

Global, outside any module

app.use(fn), app.useGlobalGuards(), app.useGlobalInterceptors(), app.useGlobalPipes(), app.useGlobalFilters()

for every route

No. The binding happens outside the context of any module, so the constructor cannot receive providers.

Global, through a module

a provider with the token APP_GUARD, APP_INTERCEPTOR, APP_PIPE or APP_FILTER

for every route

Yes. Register it from any module; the module’s providers are available.

Controller

@UseGuards(), @UseInterceptors(), @UsePipes() or @UseFilters() on the class

for every route of that controller

Yes, when you pass the class (Nest instantiates it); No, when you pass new Something().

Route

the same decorators on a method

for that route only

Same as controller.

Parameter

a pipe passed to @Body(), @Param(), @Query() and similar, or to a custom parameter decorator

for that argument only

Same as controller (pass the class for DI, an instance for options).

Module-bound middleware

MiddlewareConsumer.apply(…​).forRoutes(…​) in NestModule.configure()

for the matched routes

Yes for class middleware (the constructor receives the module’s providers). Functional middleware has no constructor, so no.

Platform middleware

app.use(fn)

for every request, before Nest’s own routing

No.

The example module above already shows the DI-friendly global binding; here is the DI-less one next to it, to make the contrast concrete. Both bind a guard for the whole application, and only the first can inject a ConfigService.

Two ways to bind a global guard
// 1. Through a module: constructor injection works.
@Module({
  providers: [{ provide: APP_GUARD, useClass: ApiKeyGuard }],
})
export class SecurityModule {}

// 2. Outside any module: ApiKeyGuard would have to be built by hand, without DI.
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new ApiKeyGuard(/* nothing can be injected here */));

Notes that apply to every enhancer:

  • The APP_ tokens are pseudo-providers.* The framework consumes them during bootstrap; you cannot app.get() them or inject them into another provider.

  • You can register an APP_ token several times*, in the same or in different modules. Every registered component runs for every request, in registration order.

  • Order within a level is the order of the arguments: @UseGuards(Guard1, Guard2) runs Guard1 before Guard2. Decorators stacked on one target are all collected; the order between different kinds of enhancer (guards before pipes, and so on) is fixed by the lifecycle, not by the order of the decorator lines.

  • Binding on the class versus the method. A class-level decorator applies to every method, a method-level one to that method only, and both apply to a method that has both, in the order given in the tables above.

  • Providers injected into an enhancer come from the module that declares or imports it; see Providers & Dependency Injection and Modules.

Pitfalls

Registering a global component twice

An APP_INTERCEPTOR (or any other APP_*) provided from two modules is registered twice, and the component runs twice per request. This typically happens when a shared module is imported by several feature modules and also provides the token, or when an interceptor is registered both with APP_INTERCEPTOR and with app.useGlobalInterceptors(). The logging variant of the example application, providing the same interceptor from TwiceA and TwiceB, logged

interceptor:twice:before
interceptor:twice:before
interceptor:twice:after
interceptor:twice:after

for a single request. Double wrapping of a response-envelope interceptor yields { "data": { "data": …​ } }, and a double-counted metrics interceptor doubles your numbers. Provide each global enhancer from exactly one module.

The double registration
const Twice = makeInterceptor('twice');

@Module({ providers: [{ provide: APP_INTERCEPTOR, useClass: Twice }] })
export class TwiceA {}

@Module({ providers: [{ provide: APP_INTERCEPTOR, useClass: Twice }] })
export class TwiceB {}

Errors thrown from middleware

Middleware runs before a route handler is selected, so the exceptions layer treats an error thrown from it differently: only global exception filters can handle it, and a controller or route filter never sees it. The module with a failing middleware bound to GET /broken and a global filter logged, on both platforms:

middleware:CoreModule (@Global)
middleware:RootModule
middleware:FeatureModule
middleware:broken
filter:global(Error)

and the response was 500 {"handledBy":"global","message":"middleware failed"}. The code that triggers it:

A middleware that throws, bound to one route
@Module({})
export class BrokenMiddlewareModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer
      .apply((_req: unknown, _res: unknown, _next: () => void) => {
        mark('middleware:broken');
        throw new Error('middleware failed');
      })
      .forRoutes('broken');
  }
}

Middleware registered with app.use() is handled by the underlying HTTP platform, not by Nest’s middleware module, and the documentation recommends throwing (or calling next(err)) from middleware bound through MiddlewareConsumer instead. In the version tested here, an error thrown in an app.use() function was also handed to the global filter on both platforms, but treat that as an implementation detail rather than a contract.

Filters swallow the lifecycle, and do not chain

When an uncaught exception occurs the rest of the lifecycle is skipped and the request goes straight to the filters. An exception you catch yourself with try/catch never reaches a filter. A filter that handles an exception ends the search: if the route filter catches TeapotError, the controller and global filters do not see it (the teapot row in the failure table). To get a chain, make the controller filter extend the global one (extends BaseExceptionFilter) or rethrow explicitly.

Asking an interceptor to do a guard’s job (or the reverse)

Because guards run before interceptors, a guard cannot use anything an interceptor attaches to the request, and an interceptor cannot short-circuit authorization it did not set up. Put authentication and authorization in guards, cross-cutting behaviour that needs the response (mapping, caching, timing) in interceptors, and request-shaping that must happen before a guard (for example reading a session) in middleware. The next pitfall is a variant of the same ordering problem.

Reading data too early

A guard sees the raw request: the body has been parsed by the platform but not yet validated or transformed by a pipe, so a guard that reads request.body.price gets whatever the client sent. Transformed values (class instances, parsed numbers) exist only from the pipes onward. Conversely, a pipe that needs the authenticated user should read it from a custom parameter decorator (see Custom Decorators) that the guard filled in, not from a global variable.

Enhancers and non-HTTP contexts

Global enhancers apply to HTTP routes. A connected microservice in a hybrid application does not inherit them by default, neither the useGlobal*() ones nor the APP_* ones, unless you opt in with the inheritAppConfig option of connectMicroservice() and call the useGlobal*() methods before it. NestFactory.createApplicationContext() does not wrap controller methods with enhancers at all, and global enhancers in lazy-loaded modules do not work properly. Guards, interceptors and filters see the transport through ExecutionContext and ArgumentsHost, so write them with context.getType() when they must serve HTTP and another transport. Source: NestJS: Hybrid application, NestJS: Execution context

Express and Fastify differences

The order of the stages is the same on both platforms: the traces above were identical for the Express and the Fastify application, apart from the points below. The differences are in what the components receive.

Aspect Express Fastify

Middleware arguments

Express req, res, next

the raw Node.js IncomingMessage and ServerResponse (Fastify middleware support comes from @fastify/middie), not Fastify’s request and reply wrappers

req.url inside module middleware

the full path, e.g. /lifecycle/42?q=x

relative to the mount point, e.g. /?q=x; read req.originalUrl when you need the full path

Response object in a filter or guard

Express Response: response.status(code).json(body)

FastifyReply: reply.status(code).send(body), but the raw ServerResponse (no .status()) when the error came from middleware

Portable reply from a filter

httpAdapter.reply(response, body, status)

the same call, and the only one that works for errors thrown in middleware

Global middleware on a 404

runs

runs

Wildcard routes (every route)

'{*splat}'

'{*splat}'

A filter written with response.status(status).send(…​) breaks on Fastify: that call throws TypeError: response.status is not a function for the middleware error and the request hangs until the client gives up. Writing the response through HttpAdapterHost (httpAdapter.reply()), as components.ts does, works on both platforms, and is the pattern the documentation uses in its "catch everything" filter.

The platform-agnostic reply
import { HttpAdapterHost } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    const { httpAdapter } = this.httpAdapterHost;
    const ctx = host.switchToHttp();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;
    httpAdapter.reply(ctx.getResponse(), { statusCode: status }, status);
  }
}

Where does my code belong?

Use the lifecycle to choose the stage, not the other way round.

I need to…​ Use

Add a correlation id, parse a cookie, or set a security header for every request, even a 404

Middleware (app.use() or a module-bound class)

Allow or deny a request based on who the caller is, a role, an API key or a feature flag

A guard, which receives the ExecutionContext and can read handler metadata with Reflector

Time a request, map the response, cache it, retry or time out the handler, translate exceptions

An interceptor

Validate or transform an argument (a body, id or query value)

A pipe

Turn an exception into a specific HTTP response

An exception filter

What changed recently

  • Express 5 and Fastify 5 are the platforms in Nest 11 and 12. Route patterns such as the middleware path need named wildcards ('{*splat}' for every route); the lifecycle itself is unchanged.

  • The request-lifecycle page now documents the exceptions layer for middleware. Errors thrown from middleware are handled by the exceptions layer, but only global exception filters apply to them.

  • Hybrid applications can opt in to the global enhancers of the main application with inheritAppConfig in connectMicroservice(); without it a connected microservice does not inherit them.

  • Standard Schema validation (the schema option on @Body(), @Query() and @Param()) runs at the pipe stage, so its position in the lifecycle is the same as ValidationPipe. See Pipes & Validation.

Where the book is outdated

The reference book (see the section bibliography) covers the request lifecycle in its chapter on the advanced building blocks. Its order of execution is still right; what it predates (January 2025 publication, before Nest 12):

  • Express 5 and Fastify 5 require named wildcards in route patterns, so a middleware bound with forRoutes('*') in older material is written forRoutes('{*splat}') today.

  • Global enhancers registered with app.useGlobal*() cannot use dependency injection; the APP_* tokens are the right default for anything that needs a provider.

  • The v12 packages are ESM-only, so examples use import with .js extensions in the current template.

  • Errors thrown from middleware and the filters that can handle them are spelled out in today’s lifecycle page.