Backend with NestJS
Backend with NestJS

The request lifecycle: guards, interceptors & filters

Lesson · ~30 min
After this unit you can
  • 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

Simple
1 / 2

A request is a pipeline, and the order is fixed

Flag

Your 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.

Medium

Writing a guard and an interceptor

Flag

A 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:

  • ExecutionContext is transport-agnostic. switchToHttp() is how you get the HTTP request; the same guard can handle a WebSocket or a microservice message via switchToWs() / switchToRpc(). Reaching straight for req only 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 an if in the guard.
  • return false → 403. Throw UnauthorizedException when 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, so undefined throws ("You provided 'undefined' where a stream was expected") and the client gets a 500, while the handler never runs. Writing implements NestInterceptor turns this into a compile error, because the interface requires an Observable back.
  • tap vs map — tap observes, map replaces. A response envelope needs map((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, or finalize.

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().

Complex
1 / 2

Errors, and which filter gets them

Flag

The 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 @Catch is: it checks route-level filters, then controller-level, then global, and the first whose @Catch matches wins. So a route-level @Catch() beats a controller-level @Catch(DomainError), even for a DomainError. (Within one @UseFilters(A, B), B is 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?

Flag

Every response must be wrapped as { data, requestId }. Which component, and why not the others?

Flag

Your global guard takes ConfigService in its constructor. Which registration lets Nest's injector build it?

Flag

An auth guard returns false when the Authorization header is missing. What does the client get, and what should it get?

Flag

A timing interceptor logs with next.handle().pipe(tap(() => log(Date.now() - start))). Which requests never get logged?

Flag

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?

Flag

A unique-constraint violation from the DB driver reaches the client as a 500. Where's the fix?

Flag

Why is Nest's rate limiter a guard rather than an interceptor?

Flag

Fill 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.

Flag
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.

Flag
intercept(context: ExecutionContext, next: CallHandler) {
  return next.().pipe(((data) => ({ data })));
}

Record the duration whether the handler succeeded or threw.

Flag
return 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");
}
Flag
@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.
Flag
// 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.
Flag
class OrderClosedError extends Error {}

@Post(":id/items")
addItem(@Param("id") id: string) {
  throw new OrderClosedError("order already closed");
}
// No custom exception filter is registered.
Flag

Further reading