open atlas
↑ Back to track
NestJS, zero to senior NEST · 02 · 02

Guards, interceptors and exception filters

Nest's request lifecycle is fixed — middleware, guard, interceptor (pre), pipe, handler, interceptor (post), filter. Put auth in a guard, validation in a pipe, logging/transform in an interceptor, error shape in a filter.

NEST Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

A reviewer asks one question on your PR: “why is the role check inside the controller?” The handler opens with if (req.user.role !== 'admin') throw new ForbiddenException(), then validates the body by hand, then logs the duration with a Date.now() at the top and bottom, then formats every error into the same JSON shape. It works. It is also four cross-cutting concerns smeared into business logic, copy-pasted across nineteen handlers — and the day someone forgets the role line on handler twenty, it ships. Nest already has a slot for each of these. The reviewer is not asking you to move code; they are asking whether you know the request lifecycle.

The lifecycle is the whole lesson

Every HTTP request in Nest walks the same ordered gauntlet before and after your handler runs. Knowing the order is not trivia — it tells you where each concern can sit and, just as importantly, what it can see when it runs.

The sequence: middleware → guards → interceptors (pre) → pipes → handler → interceptors (post) → exception filters (on a throw). A guard runs after middleware but before any interceptor or pipe. Pipes run after guards have already decided the request may proceed — so by the time a pipe transforms the body, authorization is settled. Interceptors are the only stage that runs twice: once on the way in (before the handler) and once on the way out (wrapping the response). And exception filters sit off to the side, catching anything thrown anywhere downstream.

Two subtleties separate seniors from the docs-skimmers. First, interceptors resolve outbound first-in-last-out: inbound they run global → controller → route, but the response unwinds route → controller → global, like nested function returns. Second, the filter is a catch-all sink — a pipe that throws, a guard that throws, a handler that throws, even an interceptor’s stream that errors all land in the same filter. That is why error formatting belongs there and nowhere else.

Guards: a yes/no decision, read from metadata

A guard implements CanActivate. It returns a boolean (or a Promise/Observable of one) — true lets the request through, false (or a thrown exception) stops it. This is the home of authentication and authorization. The guard receives an ExecutionContext, a superset of ArgumentsHost that also knows which handler and controller are about to run — which is exactly what you need to read route-level metadata.

The canonical pattern is a @Roles() decorator that stamps metadata onto a handler, plus a RolesGuard that reads it back via the Reflector:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!required) return true; // no @Roles() on this route -> open
    const { user } = context.switchToHttp().getRequest();
    return required.some((role) => user?.roles?.includes(role));
  }
}

getAllAndOverride reads the metadata from the handler first and falls back to the controller, so a route-level @Roles('admin') overrides a controller default. Throw ForbiddenException instead of returning false if you want a 403 with a message rather than a bare denial.

Interceptors: wrap the handler, on both sides

An interceptor’s intercept(context, next) runs code before the handler, then calls next.handle() — which returns an RxJS Observable of the eventual response — and can run code after by piping operators onto that stream. This dual position makes interceptors the right tool for anything that brackets the call: logging and timing, caching, response shaping, and timeouts.

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = Date.now();
    return next.handle().pipe(
      tap(() => console.log(`took ${Date.now() - start}ms`)), // side-effect, no change to data
      map((data) => ({ data })),                              // reshape every response
    );
  }
}

tap is for side-effects that do not touch the payload (logging, metrics); map transforms the response body — here wrapping every handler’s return in { data: ... } so the whole API speaks one envelope. Because next.handle() is lazy, the pre-handler code runs synchronously when intercept is called, but nothing after next.handle() runs until the handler resolves. An interceptor can also short-circuit (return its own Observable without calling next.handle(), e.g. serving a cache hit) or catch downstream errors with the catchError operator.

Exception filters: shape the error, once

When anything in the chain throws, Nest’s built-in exception layer catches it and produces a response — HttpException and its subclasses map to their status code, anything else becomes a 500. A custom @Catch() filter lets you own that response shape globally instead of formatting errors by hand in every handler.

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';

@Catch(HttpException)
export class HttpErrorFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const status = exception.getStatus();
    res.status(status).json({
      statusCode: status,
      message: exception.getResponse(),
      timestamp: new Date().toISOString(),
    });
  }
}

@Catch(HttpException) narrows the filter to a type; @Catch() with no argument catches everything. Because the filter is the single sink for every throw in the pipeline, error formatting lives here and only here — a guard, pipe, or handler just throws the right exception and trusts the filter to render it.

Where does each concern go?

This is the senior judgment the whole lesson builds to. The mistake in the hook was not bad code — it was four concerns living in the wrong slot. Match the concern to the lifecycle stage:

StageJobWhen it runsCan block the request?Sees the response?
GuardAuthn / authz (yes/no)After middleware, before interceptor + pipeYes — false / throw stops itNo
PipeValidate + transform inputAfter guard, just before handlerYes — throws on invalid inputNo
InterceptorLogging, caching, transform, timeoutBefore AND after the handlerYes — can skip next.handle()Yes — maps the stream
FilterShape the error responseOnly when something throwsN/A — handles a throwSees the error, not the success body

Each of the four can be bound at three scopes: method (@UseGuards() on a handler), controller (the same decorator on the class), or global (app.useGlobalGuards() in bootstrap, or a provider token for DI-friendly globals). The rule of thumb: bind broad and override narrow — a global filter for the default error shape, a global ValidationPipe, an auth guard at controller level, and a @Roles() guard plus per-route metadata where the granular decision lives.

Why this works

Why a guard and not middleware for auth? Middleware runs first and is framework-agnostic Express/Fastify code — it has no access to Nest’s ExecutionContext, so it cannot see which handler or controller is the target and cannot read route metadata like @Roles(). A guard runs one step later, with the full execution context, which is exactly why authorization that depends on the route belongs in a guard. Middleware is for concerns that do not need to know the destination: raw body parsing, request IDs, CORS.

Pick the best fit

You need to enforce that only users with the 'admin' role can call an endpoint, and return a clean 403 otherwise. Which lifecycle component owns this?

Quiz

In the Nest request lifecycle, when does a guard run relative to pipes and interceptors?

Quiz

You want to wrap every response in { data: ... } and log how long each handler took. Which component, and how?

Recall before you leave
  1. 01
    State the Nest request lifecycle order, and explain what is special about where interceptors and exception filters sit.
  2. 02
    Map each of authentication/authorization, input validation, request logging, and error formatting to its correct lifecycle component, and name the binding scopes.
Recap

Guards, interceptors, pipes, and exception filters are Nest’s cross-cutting-concern layer, and the request lifecycle pins each one to a fixed slot: middleware, then a guard, then the interceptor’s pre-handler code, then a pipe, then the handler, then the interceptor’s post-handler code, with an exception filter catching any throw along the way. A guard implements CanActivate, returns a boolean or throws, and reads route metadata via the ExecutionContext and Reflector — making it the home of authentication and authorization, settled before any input is validated. An interceptor wraps the handler on both sides: it pipes the Observable from next.handle() through RxJS operators like tap (side-effects such as logging or timing) and map (response shaping), and can short-circuit or catch downstream errors. A pipe validates and transforms input just before the handler. An exception filter, declared with @Catch(), is the single sink for every throw, so error formatting lives there alone. The senior judgment is the placement: authz is a guard, validation is a pipe, logging/transform/timeout is an interceptor, error shape is a filter — each bindable at method, controller, or global scope, where you bind broad and override narrow. Now when you see a role check inside a handler or a try/catch that formats errors inside the service, you’ll know immediately which slot it belongs in — and you’ll move it there.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.