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?
// 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; }}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
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

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?
// 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
- 1empty
- 2empty
- 3empty
- 4empty
- 5empty
- 6empty
- 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?
// 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'; }}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?
// 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; }}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":"sam@x.io"}, then POST /rooms/r-1/invites with Authorization: Bearer m-1 and {"email":"sam"}.
Which of them come back as application/problem+json?
// 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 }; }}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?
// 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; }}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?
// 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']; }}Go deeper
Pro tip
- Time and log in an interceptor with
finalize(), ortap({ error }), not a plaintap(), 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.
- NestJS documentation, Request lifecycle (FAQ), the curriculum row's first source: https://docs.nestjs.com/faq/request-lifecycle, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/2c57865218fb97fc3beeb26c98001fe19cb3767a/content/faq/request-lifecycle.md (
nestjs-request-lifecycle.txt). Quoted: "In general, a request flows through middleware, then guards, then interceptors, then pipes, and finally back through interceptors on the return path". Teaching for steps 1, 7, 9. - NestJS documentation, Exception filters, the row's second source: https://docs.nestjs.com/exception-filters, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/exception-filters.md (
nestjs-exception-filters.txt). Teaching for steps 8, 10, 11. - NestJS documentation, Interceptors, the row's third source: https://docs.nestjs.com/interceptors, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/interceptors.md (
nestjs-interceptors.txt). Quoted: "If you don't callhandle()in your implementation of theintercept()method, the route handler method won't be executed at all." Teaching for steps 5, 6 and the tip. - NestJS documentation, Guards (as of 2026-10-13): https://docs.nestjs.com/guards, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/guards.md (
nestjs-guards.txt). Quoted: "Middleware, however, is context-blind by nature: it doesn't know which handler will be executed after it callsnext()." and "when a guard returnsfalse, the framework throws aForbiddenException". Teaching for steps 2, 3, 12. - NestJS documentation, Middleware (as of 2026-10-13): https://docs.nestjs.com/middleware, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/dcd64f725e8059a0473c4241a9ca94a8140441e3/content/middlewares.md (
nestjs-middleware.txt). Background for step 2.
Related lessons
- Read first: NestJS modules, custom providers and why DI makes testing cheap
- Related: Dependency inversion and NestJS dependency injection
- Related: Validation at the boundary: ValidationPipe, whitelisting, mass assignment
- Related: Security lens in review
- Related: Status codes and the error contract (RFC 9457 Problem Details)
- Related: A chain of handlers, one next() at a time
Checked against the tools and docs as of 2026-10-13.