Skip to content

The request pipeline: middleware, guards, interceptors, pipes, handler, filters

Order is behaviour: what a stage can see, and whether it runs at all, depends on what ran before it.

Predict first

Before you read: predict

ExamHall's API saves a candidate's answer. In every file below, trace(step) appends step to the request's log:

A client with no Authorization header sends POST /attempts/a-7/answers with {"choice":"Z"}.

What is the reply's status, and what is the request's log?

Code for this question
// app.module.tsimport { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';import { AnswersController } from './answers.controller.js';import { AuditMiddleware } from './stages.js'; @Module({ controllers: [AnswersController] })export class AppModule implements NestModule {  configure(consumer: MiddlewareConsumer) {    consumer.apply(AuditMiddleware).forRoutes(AnswersController);  }} // answers.controller.tsimport { Body, Controller, Param, Post, UseGuards, UseInterceptors } from '@nestjs/common';import { AnswerPipe, JwtGuard, TimingInterceptor } from './stages.js';import { trace } from './trace.js'; @Controller('attempts')@UseGuards(JwtGuard)@UseInterceptors(TimingInterceptor)export class AnswersController {  @Post(':id/answers')  save(@Param('id') id: string, @Body(AnswerPipe) body: { choice: string }) {    trace('handler');    return { id, choice: body.choice };  }} // stages.tsimport { BadRequestException, CallHandler, CanActivate, ExecutionContext, Injectable,  NestInterceptor, NestMiddleware, PipeTransform, UnauthorizedException } from '@nestjs/common';import { tap } from 'rxjs';import { trace } from './trace.js'; @Injectable()export class AuditMiddleware implements NestMiddleware {  use(req: unknown, res: unknown, next: () => void) {    trace('middleware');    next();  }} @Injectable()export class JwtGuard implements CanActivate {  canActivate(ctx: ExecutionContext) {    trace('guard');    const req = ctx.switchToHttp().getRequest<{ headers: Record<string, string> }>();    if (!req.headers.authorization) throw new UnauthorizedException();    return true;  }} @Injectable()export class TimingInterceptor implements NestInterceptor {  intercept(ctx: ExecutionContext, next: CallHandler) {    trace('interceptor in');    return next.handle().pipe(tap(() => trace('interceptor out')));  }} @Injectable()export class AnswerPipe implements PipeTransform {  transform(value: { choice: string }) {    trace('pipe');    if (!['A', 'B', 'C', 'D'].includes(value.choice)) throw new BadRequestException();    return value;  }}
Pick one

The 30-second version

A Nest request passes middleware, then guards, then interceptors, then pipes, then the handler, and goes back out through the interceptors. Each stage sees only what earlier stages produced: middleware knows no route, and guards see unvalidated input. An error thrown anywhere skips every later stage and goes to the exception filters, which write the reply.

Order is behaviour: what a stage can see, and whether it runs at all, depends on what ran before it.

How it works

Step 1 of 12

One order. Middleware, guards, interceptors (before the handler), pipes, the handler, interceptors (after it); an error goes to the exception filters.

Analogy: A planetarium's evening show (astronomy outreach)

At the planetarium In NestJS
the building's door staff greet every visitor without knowing which show each one came for middleware: no route, no metadata (step 2)
the dome door holds tonight's show list and turns a visitor away before anyone further in sees them a guard (steps 3–4)
the presenter opens and closes the show; if she plays last night's recording instead, the projector never starts and no seat usher is needed an interceptor, and skipping handle() (steps 5–6)
the seat usher reads each ticket just before the lights go down, and turns a torn one away a pipe (step 6)
any problem, wherever it happens, goes to a visitor desk: the dome's own desk if it has one, else the main desk the exception filters (steps 8–9)

In real terms: each stage stands at one fixed post, and can check only what has reached that post.

Breaks down when: the door staff wait in the foyer and hear how the show ended. Nest middleware calls next() and is done; it never sees the handler's result or its error.

Contrasting case: a guard that only reads a token and attaches the user decides nothing about the route; that job fits middleware as well (step 12).

Examples, run for real

4.1 ExamHall EXH-3750: the answer route, and a closed attempt

POST /attempts/:id/answers binds @UseGuards(ThrottlerGuard, JwtGuard, RolesGuard) and a timing interceptor on the controller, and an AnswerPipe on the body; the service throws AttemptClosedError when the attempt is closed. Every stage calls trace:

4.1 EXH-3750 POST /attempts/a-7/answers: reply 201 · log throttler guard > JWT guard > roles guard > timing in > answer pipe > handler > timing out
4.1 EXH-3750 POST /attempts/a-3/answers (closed): reply 500 · body {"statusCode":500,"message":"Internal server error"}
4.1 MR !4050 same request, ProblemDetailsFilter via APP_FILTER: reply 409 · application/problem+json · title "Attempt closed"

The three guards ran in the order bound, the interceptor wrapped the pipe and the handler, and the pipe ran last before the handler (steps 1, 6, 7). The closed attempt's error matched no filter, so the built-in layer sent a 500 (step 10). MR !4050 registers ProblemDetailsFilter, @Catch(AttemptClosedError), with APP_FILTER; the same request now gets a 409 problem (step 11).

4.2 Chat: a public lobby, middleware against a guard

A chat API marks GET /rooms/lobby with @Public(), a SetMetadata decorator, and keeps GET /rooms/mine private. The same token check runs once as middleware for the controller and once as a global guard that reads @Public() from the handler:

4.2 AuthMiddleware for RoomsController, no token: GET /rooms/lobby (@Public()) 401 · GET /rooms/mine 401
4.2 AuthGuard via APP_GUARD reading @Public(), no token: GET /rooms/lobby 200 · GET /rooms/mine 401

The middleware cannot tell which handler comes next, so it refuses the public lobby too; the guard reads the handler's metadata (steps 2–3, 12).

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A streaming service lists titles. In every file below, trace(step) appends step to the request's log:

A viewer sends GET /titles?page=2 and gets a 200.

In what order is the request's log written?

Code for this question
// app.module.tsimport { Module } from '@nestjs/common';import { APP_INTERCEPTOR } from '@nestjs/core';import { TitlesController } from './titles.controller.js';import { LoggingInterceptor } from './stages.js'; @Module({  controllers: [TitlesController],  providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],})export class AppModule {} // titles.controller.tsimport { Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common';import { CacheHeaderInterceptor, PagePipe, PlanGuard } from './stages.js';import { trace } from './trace.js'; @Controller('titles')@UseGuards(PlanGuard)export class TitlesController {  @Get()  @UseInterceptors(CacheHeaderInterceptor)  list(@Query('page', PagePipe) page: number) {    trace('handler');    return { page, titles: [] };  }} // stages.tsimport { CallHandler, CanActivate, ExecutionContext, Injectable, NestInterceptor,  PipeTransform } from '@nestjs/common';import { tap } from 'rxjs';import { trace } from './trace.js'; @Injectable()export class LoggingInterceptor implements NestInterceptor {  intercept(ctx: ExecutionContext, next: CallHandler) {    trace('logging in');    return next.handle().pipe(tap(() => trace('logging out')));  }} @Injectable()export class CacheHeaderInterceptor implements NestInterceptor {  intercept(ctx: ExecutionContext, next: CallHandler) {    trace('cache in');    return next.handle().pipe(tap(() => trace('cache out')));  }} @Injectable()export class PlanGuard implements CanActivate {  canActivate() {    trace('plan guard');    return true;  }} @Injectable()export class PagePipe implements PipeTransform {  transform(value: string) {    trace('page pipe');    return Number(value);  }}

Your order

  1. 1empty
  2. 2empty
  3. 3empty
  4. 4empty
  5. 5empty
  6. 6empty
  7. 7empty

Tap the lines in the order you expect.

Question 2 of 6

A box office's API used a middleware to read the session token and set req.user. A merge request moves that code into a global interceptor, so that it can read the route's metadata later. After the MR, the files are:

The organiser sends DELETE /events/e-4 with Authorization: Bearer org-1.

What does the merged code reply?

Code for this question
// app.module.tsimport { Module } from '@nestjs/common';import { APP_INTERCEPTOR } from '@nestjs/core';import { EventsController } from './events.controller.js';import { UserInterceptor } from './stages.js'; @Module({  controllers: [EventsController],  providers: [{ provide: APP_INTERCEPTOR, useClass: UserInterceptor }],})export class AppModule {} // events.controller.tsimport { Controller, Delete, Param, UseGuards } from '@nestjs/common';import { OrganiserGuard } from './stages.js';import { trace } from './trace.js'; @Controller('events')@UseGuards(OrganiserGuard)export class EventsController {  @Delete(':id')  remove(@Param('id') id: string) {    trace('handler');    return { deleted: id };  }} // stages.tsimport { CallHandler, CanActivate, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';import { trace } from './trace.js'; type Req = { headers: Record<string, string>; user?: { role: string } }; @Injectable()export class UserInterceptor implements NestInterceptor {  intercept(ctx: ExecutionContext, next: CallHandler) {    const req = ctx.switchToHttp().getRequest<Req>();    if (req.headers.authorization === 'Bearer org-1') req.user = { role: 'organiser' };    return next.handle();  }} @Injectable()export class OrganiserGuard implements CanActivate {  canActivate(ctx: ExecutionContext) {    return ctx.switchToHttp().getRequest<Req>().user?.role === 'organiser';  }}
Pick one

Question 3 of 6

A payments ledger caches account summaries. In every file below, trace(step) appends step to the request's log:

A client sends GET /ledgers/l-1/summary?currency=EUR and gets the EUR summary. It then sends GET /ledgers/l-1/summary?currency=XYZ.

What is the second reply, and what is the second request's log?

Code for this question
// app.module.tsimport { Module } from '@nestjs/common';import { LedgerController } from './ledger.controller.js'; @Module({ controllers: [LedgerController] })export class AppModule {} // ledger.controller.tsimport { Controller, Get, Param, Query, UseInterceptors } from '@nestjs/common';import { CurrencyPipe, SummaryCacheInterceptor } from './stages.js';import { trace } from './trace.js'; @Controller('ledgers')export class LedgerController {  @Get(':id/summary')  @UseInterceptors(SummaryCacheInterceptor)  summary(@Param('id') id: string, @Query('currency', CurrencyPipe) currency: string) {    trace('handler');    return { id, currency, balance: 1250 };  }} // stages.tsimport { BadRequestException, CallHandler, ExecutionContext, Injectable, NestInterceptor,  PipeTransform } from '@nestjs/common';import { of, tap } from 'rxjs';import { trace } from './trace.js'; const cache = new Map<string, unknown>(); @Injectable()export class SummaryCacheInterceptor implements NestInterceptor {  intercept(ctx: ExecutionContext, next: CallHandler) {    trace('cache');    const key = ctx.switchToHttp().getRequest<{ path: string }>().path;    if (cache.has(key)) return of(cache.get(key));    return next.handle().pipe(tap((body) => cache.set(key, body)));  }} @Injectable()export class CurrencyPipe implements PipeTransform {  transform(value: string) {    trace('pipe');    if (!['EUR', 'USD'].includes(value)) throw new BadRequestException();    return value;  }}
Pick one

Question 4 of 6

A chat service maps errors to RFC 9457 problem details:

Two requests arrive: POST /rooms/r-1/invites with no Authorization header and {"email":"[email protected]"}, then POST /rooms/r-1/invites with Authorization: Bearer m-1 and {"email":"sam"}.

Which of them come back as application/problem+json?

Code for this question
// app.module.tsimport { Module } from '@nestjs/common';import { APP_FILTER } from '@nestjs/core';import { InvitesController } from './invites.controller.js';import { ProblemDetailsFilter } from './problem-details.filter.js'; @Module({  controllers: [InvitesController],  providers: [{ provide: APP_FILTER, useClass: ProblemDetailsFilter }],})export class AppModule {} // problem-details.filter.tsimport { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; type Res = { status(code: number): Res; type(t: string): Res; json(body: unknown): void }; @Catch(HttpException)export class ProblemDetailsFilter implements ExceptionFilter {  catch(error: HttpException, host: ArgumentsHost) {    const status = error.getStatus();    host.switchToHttp().getResponse<Res>().status(status).type('application/problem+json')      .json({ type: 'about:blank', title: error.message, status });  }} // invites.controller.tsimport { BadRequestException, Body, CanActivate, Controller, ExecutionContext, Injectable, Param,  PipeTransform, Post, UnauthorizedException, UseGuards } from '@nestjs/common'; @Injectable()export class SignedInGuard implements CanActivate {  canActivate(ctx: ExecutionContext) {    const req = ctx.switchToHttp().getRequest<{ headers: Record<string, string> }>();    if (!req.headers.authorization) throw new UnauthorizedException();    return true;  }} @Injectable()export class InvitePipe implements PipeTransform {  transform(value: { email?: string }) {    if (!value.email?.includes('@')) throw new BadRequestException();    return value;  }} @Controller('rooms')@UseGuards(SignedInGuard)export class InvitesController {  @Post(':roomId/invites')  invite(@Param('roomId') roomId: string, @Body(InvitePipe) body: { email: string }) {    return { roomId, invited: body.email };  }}
Pick one

Question 5 of 6

A box office's checkout refuses expired seat holds:

A buyer sends POST /holds/h-9/checkout and gets a 500.

Which one change makes that request get a 409, while POST /holds/h-2/checkout still gets a 201?

Code for this question
// app.module.tsimport { Module } from '@nestjs/common';import { HoldsController } from './holds.controller.js'; @Module({ controllers: [HoldsController] })export class AppModule {} // holds.controller.tsimport { Controller, Param, Post, UseGuards } from '@nestjs/common';import { OpenHoldGuard } from './open-hold.guard.js'; @Controller('holds')@UseGuards(OpenHoldGuard)export class HoldsController {  @Post(':id/checkout')  checkout(@Param('id') id: string) {    return { id, paid: true };  }} // open-hold.guard.tsimport { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; export class HoldExpiredError extends Error {} const expired = new Set(['h-9']); @Injectable()export class OpenHoldGuard implements CanActivate {  canActivate(ctx: ExecutionContext) {    const id = ctx.switchToHttp().getRequest<{ params: { id: string } }>().params.id;    if (expired.has(id)) throw new HoldExpiredError();    return true;  }}
Pick one

Question 6 of 6

A payments API shows each account's balance to its owner only:

Account 42's owner sends GET /accounts/42/balance with x-user: u-7 and gets a 403.

Where is the defect?

Code for this question
// app.module.tsimport { Module } from '@nestjs/common';import { AccountsController } from './accounts.controller.js'; @Module({ controllers: [AccountsController] })export class AppModule {} // accounts.controller.tsimport { Controller, Get, Param, ParseIntPipe, UseGuards } from '@nestjs/common';import { OwnerGuard } from './owner.guard.js'; @Controller('accounts')@UseGuards(OwnerGuard)export class AccountsController {  @Get(':accountId/balance')  balance(@Param('accountId', ParseIntPipe) accountId: number) {    return { accountId, balanceCents: 1250 };  }} // owner.guard.tsimport { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; const owners = new Map<number, string>([[42, 'u-7']]); type Req = { params: { accountId: number }; headers: Record<string, string> }; @Injectable()export class OwnerGuard implements CanActivate {  canActivate(ctx: ExecutionContext) {    const req = ctx.switchToHttp().getRequest<Req>();    return owners.get(req.params.accountId) === req.headers['x-user'];  }}
Pick one

Go deeper

Pro tip
  1. Time and log in an interceptor with finalize(), or tap({ error }), not a plain tap(), so requests whose handler throws are counted too. When: adding a latency or audit interceptor to routes that can fail. Verify (RAN, tips.mjs, a payout route whose handler throws):
    tip 1 handler throws, timing in tap(): reply 500 · timed lines 0
    tip 1 handler throws, timing in finalize(): reply 500 · timed lines 1
Source

The pages are vendor documentation that changes with releases, so their claims are labelled as of 2026-10-13; every behaviour this lesson grades is also executed on the pinned versions.

Checked against the tools and docs as of 2026-10-13.