The request lifecycle: guards, interceptors & filters
- Predict the order guards, interceptors, pipes and the handler run in, and what each one can see
- Choose a guard for admit/reject decisions and an interceptor for shaping or timing the response
- Write guards that fail closed and read @Roles metadata with Reflector; throw 401 vs return false (403); register via APP_GUARD
- Write interceptors that map the response, log failures with finalize, and return next.handle()
- Predict the status a thrown error becomes and which single filter handles it; register via APP_FILTER
Read
A request is a pipeline, and the order is fixed
FlagYour handler is the middle of a sandwich. For every request Nest runs:
middleware
→ guards (may I? -> 401/403)
→ interceptors (before)
→ pipes (is the input valid/typed? -> 400)
→ HANDLER
← interceptors (after) (map the response, log the duration)
← exception filters (turn any thrown error into an HTTP response)
Two facts that follow directly from that order, and that explain most confusion:
- Guards run before pipes. So inside a guard the body is still raw, untransformed input — a guard cannot rely on a DTO instance. Authorization decisions that depend on the payload either re-read the raw request or move into the handler/service.
- Interceptors wrap the handler on both sides. They're the only component that sees the request and the response, which is why timing, logging, caching and response envelopes live there.
Exception filters are last because they're the catch-all: anything thrown anywhere inside the pipeline (guard, pipe, handler, service) ends up there and becomes a status code.
Guards, interceptors, pipes and filters can each be registered at three scopes — global,
controller, handler — plus, for pipes, a fourth: a single parameter. (Middleware is the
exception: it isn't registered by scope at all — it's either app.use(...) in main.ts for
everything, or bound to route patterns in a module's configure.) Guards at different scopes
run in sequence, global first, then controller, then handler; interceptors nest, with
the global one outermost, wrapping all the others on the way in and on the way out.
Writing a guard and an interceptor
FlagA guard is a boolean, plus metadata
export const ROLES_KEY = "roles";
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Handler metadata first, class metadata as the fallback — that's what getAllAndOverride does.
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true; // no @Roles on this route -> open
const { user } = context.switchToHttp().getRequest();
if (!user) throw new UnauthorizedException(); // 401: we don't know who you are
return required.some((role) => user.roles.includes(role)); // false -> 403
}
}
Three things to take from that:
ExecutionContextis transport-agnostic.switchToHttp()is how you get the HTTP request; the same guard can handle a WebSocket or a microservice message viaswitchToWs()/switchToRpc(). Reaching straight forreqonly works for HTTP.Reflector+ a custom decorator is the idiomatic way to make a guard configurable per route.@Roles("admin")is data attached to the handler, not anifin the guard.return false→ 403. ThrowUnauthorizedExceptionwhen a credential is missing so the client knows to re-authenticate.
Registering it globally still lets you inject providers, as long as you register it as one:
{ provide: APP_GUARD, useClass: RolesGuard } // in a module's providers
app.useGlobalGuards(new RolesGuard(...)) also works, but you construct it outside any module,
so the injector supplies nothing: every dependency has to be fetched and passed by hand
(new RolesGuard(app.get(Reflector))), and kept in sync with the constructor. That's the whole
reason APP_GUARD / APP_INTERCEPTOR / APP_FILTER / APP_PIPE exist.
An interceptor wraps the call
@Injectable()
export class TimingInterceptor implements NestInterceptor {
private readonly logger = new Logger(TimingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const started = Date.now();
// Nothing runs until the returned Observable is subscribed — next.handle() IS the handler.
return next.handle().pipe(
tap({
next: () => this.logger.log(`${context.getHandler().name} ${Date.now() - started}ms`),
// A throw inside a tap handler becomes the stream's error, hiding the real one — so
// don't assume the rejected value is an Error.
error: (err: unknown) => {
const reason = err instanceof Error ? err.message : String(err);
this.logger.warn(`failed after ${Date.now() - started}ms: ${reason}`);
},
}),
);
}
}
next.handle() returns an Observable of the handler's eventual return value. Code before
it runs pre-handler; operators you pipe run post-handler. The classic bugs:
- Forgetting to return the stream — Nest subscribes to whatever
intercept()returns, soundefinedthrows ("You provided 'undefined' where a stream was expected") and the client gets a 500, while the handler never runs. Writingimplements NestInterceptorturns this into a compile error, because the interface requires anObservableback. tapvsmap—tapobserves,mapreplaces. A response envelope needsmap((data) => ({ data })).- Only handling the success path —
tap(() => …)(the single-callback form) never fires when the handler throws, so your duration metric silently ignores every failure. Use the observer form above, orfinalize.
Useful shapes this unlocks: catchError to translate errors, timeout(5000) to bound a slow
dependency, a cache interceptor returning of(cached) without ever calling next.handle().
Errors, and which filter gets them
FlagThe default error contract
Nest's built-in exception filter already turns HttpException and its subclasses into
responses:
throw new NotFoundException("order not found");
// 404 { statusCode: 404, message: "order not found", error: "Not Found" }
Anything not an HttpException becomes a generic 500 with the details hidden — which is
correct (never leak a stack trace or a driver error to a client) and is also why "my API
returns 500 for a duplicate email" happens: the driver error was never translated.
Translate at the boundary of the thing that produced it:
try {
return await this.repo.insert(dto);
} catch (e) {
if (isUniqueViolation(e)) throw new ConflictException("email already registered");
throw e; // unknown -> let it become a 500, and get logged
}
Rethrowing what you don't recognise is the whole discipline: map the expected condition, never swallow the rest.
A custom filter, and how selection works
@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
catch(error: DomainError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
res.status(error.status).json({ code: error.code, message: error.message });
}
}
@Catch(SomeError)narrows what it handles; bare@Catch()catches everything.- Nest picks one filter by position, not by how specific its
@Catchis: it checks route-level filters, then controller-level, then global, and the first whose@Catchmatches wins. So a route-level@Catch()beats a controller-level@Catch(DomainError), even for aDomainError. (Within one@UseFilters(A, B),Bis tried first, which is why the docs say to list a catch-all first.) - Filters don't chain: the chosen one owns the response. A catch-all must re-implement the
default response shape or delegate to
BaseExceptionFilter. - A global filter registered with
useGlobalFilters(new X())can't inject; use{ provide: APP_FILTER, useClass: X }when it needs a logger or config.
Filters own presentation of the error. They're not a substitute for handling errors where the context is: a filter can't retry, and it can't know which of your three DB calls failed.
Quiz
A guard needs to authorize based on dto.organizationId from the request body. Why is that awkward?
Every response must be wrapped as { data, requestId }. Which component, and why not the others?
Your global guard takes ConfigService in its constructor. Which registration lets Nest's injector build it?
An auth guard returns false when the Authorization header is missing. What does the client get, and what should it get?
A timing interceptor logs with next.handle().pipe(tap(() => log(Date.now() - start))). Which requests never get logged?
A controller has @UseFilters(DomainErrorFilter), which is @Catch(DomainError). One of its routes adds @UseFilters(AuditAllFilter), a bare @Catch(). That route throws a DomainError. What handles it?
A unique-constraint violation from the DB driver reaches the client as a 500. Where's the fix?
FlagWhy is Nest's rate limiter a guard rather than an interceptor?
FlagFill in the blank
An API-key guard that can't fail open: the interface it implements, and what it returns when no key is configured.
Flag@Injectable() export class ApiKeyGuard implements { canActivate(context: ExecutionContext): boolean { const expected = process.env.API_KEY; if (!expected) return ; const req = context.switchToHttp().getRequest(); return req.headers["x-api-key"] === expected; } }
Read route metadata set by a custom @Roles() decorator, letting the handler override the controller.
const required = this.reflector.<string[]>(ROLES_KEY, [ context.getHandler(), context.getClass(), ]);
Wrap every successful response in { data } — the operator that replaces the value, applied to the handler's stream.
intercept(context: ExecutionContext, next: CallHandler) { return next.().pipe(((data) => ({ data }))); }
Record the duration whether the handler succeeded or threw.
Flagreturn next.handle().pipe( (() => this.logger.log(`${Date.now() - started}ms`)), );
Register a global exception filter that needs an injected logger.
Flag@Module({ providers: [{ provide: , useClass: DomainErrorFilter }], }) export class AppModule {}
Handle only your domain error type in this filter, leaving everything else to Nest's default.
Flag@(DomainError) export class DomainErrorFilter implements ExceptionFilter {}
Predict the output
// Registered globally, in this order: LoggingInterceptor, AuthGuard, LogPipe.
// AuthGuard.canActivate: console.log("guard"); return true
// LoggingInterceptor.intercept: console.log("pre"); return next.handle().pipe(tap(() => console.log("post")))
// LogPipe.transform: console.log("pipe"); return value
@Post()
create(@Body() dto: CreateOrderDto) { // one argument, so the pipe runs once
console.log("handler");
}@Injectable()
export class AuditInterceptor { // no `implements NestInterceptor`, so this compiles
intercept(context: ExecutionContext, next: CallHandler) {
next.handle().pipe(tap(() => console.log("done"))); // no return
}
}
// Bound with @UseInterceptors(AuditInterceptor) on GET /orders; no custom filters.// Global CacheInterceptor:
intercept(context: ExecutionContext, next: CallHandler) {
const hit = this.store.get(keyOf(context));
if (hit) return of(hit); // never calls next.handle()
return next.handle().pipe(tap((body) => this.store.set(keyOf(context), body)));
}
// Inside it: a handler-level interceptor that logs "inner-pre", a ValidationPipe, the handler.class OrderClosedError extends Error {}
@Post(":id/items")
addItem(@Param("id") id: string) {
throw new OrderClosedError("order already closed");
}
// No custom exception filter is registered.