A request comes into your NestJS API, a RolesGuard checks request.body.age >= 18, and it denies every single request — even the ones sending { "age": 25 }. The DTO has @Type(() => Number). The validation pipe is registered. The code compiles. It still fails.
The bug isn't in the guard's logic. It's in an assumption about when the guard runs relative to the pipe that was supposed to convert that string into a number.
What you'll learn
By the end of this article you'll be able to:
- State the exact order a request travels through in Nest — middleware, guards, interceptors, pipes, the handler, and filters — and explain why it's that order
- Predict what a guard, interceptor, or pipe can and cannot see at the moment it runs
- Trace what happens to the rest of the pipeline when a guard denies or a pipe throws
- Put authorization, validation, and cross-cutting logic in the component actually built for each job
- Reason about global vs. controller vs. route-level ordering when several guards or interceptors are stacked
Who this is for
You've built at least one NestJS controller with a @Controller(), a @Get() handler, and maybe a ValidationPipe. You don't need to have written a custom guard or interceptor yet — we'll build both from nothing.
This article is written against NestJS 11.x (verified against the @nestjs/core release history in August 2026, latest patch 11.2.3). The lifecycle order below is core framework behavior and has been stable across the 10.x → 11.x line; nothing here is major-version-specific. Where the underlying HTTP adapter (Express vs. Fastify) changes the answer, it's called out.
Table of contents
- The problem: a guard that can never win
- The mental model: a one-way pipeline with two loop-backs
- Stage 1: middleware
- Stage 2: guards
- Stage 3: interceptors, the "before" half
- Stage 4: pipes
- Stage 5: the handler
- Stage 6: interceptors, the "after" half
- Stage 7: exception filters
- Edge cases and gotchas
- Best practices: which component, for what
- FAQ
- Cheat sheet
The problem: a guard that can never win
Here's the controller and the guard that's supposedly protecting it:
// create-user.dto.ts
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
export class CreateUserDto {
@Type(() => Number)
@IsInt()
@Min(0)
age: number;
}
// adult-only.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class AdultOnlyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
// "age is a number by now, the DTO says so" — is it, though?
return req.body.age >= 18;
}
}
@Controller('users')
export class UsersController {
@UseGuards(AdultOnlyGuard)
@Post()
create(@Body() dto: CreateUserDto) {
return { ok: true, age: dto.age };
}
}
Send { "age": 25 } as JSON. req.body.age at this point is the string "25", because the body parser only produces JSON-shaped values — strings, numbers, booleans, objects, arrays — from the wire, and "25" was serialized as a number, so this particular field actually does arrive as a JS number here. Change the client to send it as a query param, or send { "age": "25" } from a form post, and req.body.age is a string. "25" >= 18 still happens to be true because of JS coercion — but "abc" >= 18 is false and so is "17" >= 18 reversed to "-5" >= 18... the guard is doing string/number comparisons on data nobody has validated or transformed yet, and it will eventually compare the wrong thing and let an invalid request through, or block a valid one.
The DTO's @Type(() => Number) transform, and the @IsInt()/@Min(0) checks, run inside the ValidationPipe — and pipes run after guards. The guard the developer wrote reads req.body raw, off the request object, before any pipe has touched it. It was never protected by the DTO at all; it just happened to work for the one payload shape someone tested.
The mental model: a one-way pipeline with two loop-backs
The mental model: a request moves through Nest's components in one fixed, linear order — but two of those components, guards and pipes, are gatekeepers that can end the trip early, and one component, the interceptor, wraps the rest of the pipeline rather than sitting at one point in it.
Middleware → Guards → Interceptors (before) → Pipes → Handler
│
Interceptors (after) ← ┘
│
Response
(any exception, from any stage) → Exception filters → Response
Every stage after the current one is conditional on the current one succeeding. A guard returning false, or any component throwing, skips straight to exception filters — nothing downstream of that point runs. That single rule explains every "why didn't my interceptor's logging line fire" question you'll ever have about Nest.
Key concept: don't memorize "guards before pipes" as trivia. Memorize why: guards answer "should this request happen at all," which has to be decided before Nest spends any effort parsing or transforming the payload for a request that might get rejected outright.
Stage 1: middleware
Middleware is the same Express/Connect-style middleware you'd write for any Node HTTP server: (req, res, next) => void. It runs first, before Nest's own routing has resolved anything, and it has no idea what controller or handler is about to run.
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
req['requestId'] = req.headers['x-request-id'] ?? randomUUID();
next();
}
}
Wire it in a module:
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
Use middleware for things that don't need Nest's execution context at all: request IDs, raw body logging, helmet, compression, cookie parsing. With the Fastify adapter, plain Express-style middleware isn't a drop-in — Fastify has its own plugin/hook system, and @nestjs/platform-fastify bridges some middleware but not arbitrary Express middleware written against req/res/next. If you're on Fastify, check a middleware package's Fastify compatibility before assuming it works unchanged.
Stage 2: guards
A guard implements canActivate() and returns (or resolves/emits) a boolean. true lets the request continue; false — or a thrown exception — stops it immediately, before anything else in the pipeline runs.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<string[]>('roles', context.getHandler());
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role));
}
}
Guards are where authorization belongs — "is this caller allowed to do this" — because that question should be answered before Nest does any further work, and because a guard is exactly the layer that has access to the identity a prior guard (or middleware) attached to the request, via constructor-injected providers.
Stage 3: interceptors, the "before" half
An interceptor implements intercept(context, next), where next.handle() returns an RxJS Observable representing "the rest of the pipeline" — pipes, the handler, and everything after. Code written before the next.handle() call runs on the way in; code chained onto the returned observable (via .pipe(tap(...)), .pipe(map(...)), etc.) runs on the way out.
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const start = Date.now(); // before: runs now, pre-pipes
return next.handle().pipe(
tap(() => console.log(`took ${Date.now() - start}ms`)), // after: post-handler
);
}
}
This is why interceptors are drawn twice in the pipeline diagram: they're a single component whose code straddles both sides of everything from pipes through the handler.
Stage 4: pipes
Pipes implement transform(value, metadata) and run against individual arguments — @Body(), @Param(), @Query() — immediately before Nest calls the handler with them. This is where ValidationPipe lives, and it's the only stage in the whole lifecycle where CreateUserDto's decorators actually execute.
@Injectable()
export class ParseAgePipe implements PipeTransform {
transform(value: unknown): number {
const n = Number(value);
if (Number.isNaN(n)) throw new BadRequestException('age must be a number');
return n;
}
}
This is the fix for the opening bug: the age check belongs in a pipe (or, more precisely, validation belongs in a pipe; the 18-or-over rule is a business/authorization decision and belongs in a guard — but a guard that reads the already-validated DTO, which pipes running earlier in a different request never happened for this guard's read of raw req.body. Since pipes run after guards, an "is this person old enough" check can't be a guard reading the DTO shape at all — it has to either be middleware/guard logic that does its own parsing, or, more idiomatically, be enforced as validation (@Min(18) on the DTO) plus a normal 403 path, not a guard.
Stage 5: the handler
The controller method itself. By the time it runs, every guard passed, every "before" interceptor ran, and every argument has been through its pipes — dto in create(@Body() dto: CreateUserDto) is a real CreateUserDto instance with age as an actual number. This is the one stage that's always exactly what it looks like.
Stage 6: interceptors, the "after" half
Once the handler returns (or its promise/observable resolves), control passes back through every interceptor that wrapped the call, in reverse order, via the operators chained onto next.handle(). This is where response transformation, caching the result, and after-the-fact logging belong — a ClassSerializerInterceptor stripping @Exclude() fields, a cache interceptor storing the result, the tap() above logging duration.
Stage 7: exception filters
If anything in the pipeline throws — a guard, an interceptor, a pipe, or the handler — Nest stops walking forward and looks for an exception filter instead. Filters resolve bottom-up: a filter bound to the specific route handler runs before one bound to the controller, which runs before a global filter.
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
res.status(exception.getStatus()).json({
statusCode: exception.getStatus(),
message: exception.message,
path: ctx.getRequest().url,
});
}
}
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Edge cases and gotchas
-
A denied guard skips interceptors entirely — both halves. If
canActivate()returnsfalse, Nest throws aForbiddenExceptionbefore any interceptor's "before" code runs. Timing and logging interceptors that assume they always fire will silently miss every rejected request. -
A pipe that throws skips the "after" interceptor logic, not the "before" half. The interceptor's pre-
next.handle()code already ran; the exception happens inside the observablenext.handle()returns, so a.pipe(tap(...))chained for success won't fire — you needcatchErrorif you want interceptor code to run on the error path too. -
Multiple guards/interceptors run in registration order, and at three possible scopes — global (
app.useGlobalGuards()), controller (@UseGuards()on the class), and route (@UseGuards()on the method) — with global running first, then controller, then route. The same ordering applies to interceptors and to pipes. - A guard cannot see a validated DTO, ever, for the current request — pipes haven't run. If a guard needs shape-checked data, it must parse defensively itself; it can't rely on a pipe that hasn't executed yet.
- WebSocket gateways and microservice transports reuse guards, interceptors, pipes, and filters, but there is no middleware stage — middleware is an HTTP-adapter concept (Express/Fastify), and gateways don't go through it.
-
app.useGlobalPipes()registers after module-level guards conceptually apply per-request — the stage order (guards, then pipes) holds regardless of whether a given guard/pipe is global, controller-scoped, or route-scoped.
Best practices: which component, for what
- Middleware — request-level, framework-agnostic concerns with no need for Nest's execution context: IDs, raw logging, compression, security headers.
-
Guards — authorization: "can this identity do this," using data already attached by middleware or a prior guard (like
request.userfrom a passport strategy). - Interceptors — cross-cutting concerns that wrap a call: timing, logging both sides, response shaping, caching, retry.
-
Pipes — per-argument validation and transformation: turning a raw
body/param/queryinto a typed, checked value. - Exception filters — turning any thrown error, from any stage, into a consistent response shape.
- Never put validation logic in a guard, and never put authorization logic in a pipe — each answers a question the other one can't see the data for yet.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
FAQ
Do guards run before or after middleware in NestJS?
After. The order is middleware, then guards, then interceptors, then pipes, then the handler.
Can a guard read a validated, transformed DTO?
No. Pipes — including ValidationPipe, which applies a DTO's class-validator/class-transformer decorators — run after guards. A guard only ever sees the raw request.
What happens to interceptors if a guard denies the request?
Nothing runs. A denied guard (canActivate() returning false, or throwing) skips both the "before" and "after" halves of every interceptor and goes straight to exception filters.
Does an interceptor's "after" logic run if the handler throws?
Only if the interceptor explicitly handles the error, e.g. with RxJS's catchError. A plain tap() chained for the success path is skipped when the observable errors instead of emitting a value.
Which exception filter runs when several are registered?
The most specific one: a filter bound to the route handler runs before a controller-level filter, which runs before a global filter — filters resolve from the bottom up, unlike guards, interceptors, and pipes, which resolve global-first.
Does the request lifecycle differ between Express and Fastify?
The guard/interceptor/pipe/filter order is identical — that's Nest's own execution model, independent of the HTTP adapter. Middleware is where they diverge: arbitrary Express-style middleware isn't automatically Fastify-compatible, since Fastify has its own hook and plugin system.
Cheat sheet
| Stage | Runs | Can stop the request | Typical use |
|---|---|---|---|
| Middleware | First, before routing |
next() not called |
request IDs, compression, headers |
| Guards | After middleware |
canActivate() returns false/throws |
authorization |
| Interceptors (before) | After guards, before pipes | only by throwing | start a timer, attach context |
| Pipes | After interceptors (before), before the handler | throws on invalid input | validation, transformation |
| Handler | After every argument is piped | throws | your actual business logic |
| Interceptors (after) | After the handler resolves | only affects success path unless using catchError
|
logging, response shaping, caching |
| Exception filters | On any thrown error, from any stage | — | consistent error responses |
// The order, as code you'd actually register:
app.use(RequestIdMiddleware); // 1. middleware
app.useGlobalGuards(new RolesGuard(reflector)); // 2. guards
app.useGlobalInterceptors(new TimingInterceptor()); // 3 & 6. before + after
app.useGlobalPipes(new ValidationPipe()); // 4. pipes
// 5. your handler runs here
app.useGlobalFilters(new HttpExceptionFilter()); // 7. on any throw, from any stage
Key takeaways
- The order is fixed: middleware → guards → interceptors (before) → pipes → handler → interceptors (after) → exception filters.
- Guards decide if a request proceeds; they never see data a pipe would have validated or transformed, because pipes run later.
- Interceptors wrap the rest of the pipeline — their "before" and "after" halves are one component, not two, and the "after" half only fires on success unless you handle errors explicitly.
- Any exception, from any stage, exits straight to exception filters, which resolve most-specific first.
- Put each concern in the component built for it — authorization in guards, validation in pipes — and lifecycle bugs like the opening one stop happening.
Back to that guard
The AdultOnlyGuard wasn't wrong about the rule — eighteen-or-over is a real requirement. It was wrong about when it was running: before anything had touched, checked, or converted the value it was reading. Move that rule into the DTO (@Min(18)) or into logic that reads the same raw shape a guard actually sees, and it stops being a guard that occasionally agrees with the data by accident.
Next Friday's episode goes one level deeper: what actually happens inside the DI container when two of your providers depend on each other.
Where has the lifecycle order bitten you — a guard, an interceptor, or a filter that fired (or didn't) when you least expected?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)