NestJS Request Lifecycle Explained

The middleware to exception filter chain in order, with a rate-limiting guard and an audit-trail interceptor pulled from real production placement decisions.

It was a Saturday afternoon at the combat-sports tournament platform I CTO’d in London. A live broadcast was running, federations watching the standings page, and the standings-projector consumer was rebalancing every thirty seconds. The standings froze at 14:32 local. I’d hired most of that team.

We patched the Kafka side that day. What stuck with me afterward, and what shaped how I write NestJS now, is that almost every real production bug I’ve fixed in a Nest app lives at the seams between lifecycle stages. Auth in middleware where it should be a guard. Audit log in a controller where it should be an interceptor. Validation in a service where it should be a pipe. The lifecycle isn’t trivia. It’s where the decisions go.

So let’s walk it in order, and put a few real placement decisions next to each stage.

The chain, in order

Every HTTP request through a Nest app goes through this chain. The order matters and is not negotiable:

  1. Middleware
  2. Guards
  3. Interceptors (pre-controller half)
  4. Pipes
  5. Controller handler
  6. Interceptors (post-controller half)
  7. Exception filters (only on throw)

That’s it. The mental model I use: middleware is HTTP-shaped, guards make a yes/no call, interceptors wrap the call, pipes transform the input, and filters catch what escapes.

Middleware is HTTP plumbing

Middleware runs first. It still has the raw req and res. No Nest DI graph resolved for the route yet, no metadata about the handler. That’s the whole point. Middleware is for things that don’t care which controller answers.

import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  private readonly logger = new Logger('HTTP');

  use(req: Request, res: Response, next: NextFunction): void {
    const requestId = (req.headers['x-request-id'] as string) ?? randomUUID();
    res.setHeader('x-request-id', requestId);
    (req as Request & { requestId: string }).requestId = requestId;

    const startedAt = process.hrtime.bigint();
    res.on('finish', () => {
      const ms = Number(process.hrtime.bigint() - startedAt) / 1e6;
      this.logger.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms.toFixed(1)}ms rid=${requestId}`);
    });

    next();
  }
}

If you find yourself reading JWT claims, checking ownership, or rejecting based on user roles inside middleware, stop. That’s a guard. Middleware doesn’t know which handler is about to run, so it has no business deciding whether the caller is allowed to run it.

Guards make the yes or no call

Guards run after middleware and have full access to the ExecutionContext. They know which handler is about to fire and which controller class owns it. That metadata is what makes them the right home for rate limits, role checks, and feature flags.

At the federation platform we had some tournament-operations endpoints being called from automation in tight loops. Putting the rate limit in middleware would have been useless because we needed per-handler limits, not per-route-string limits. A guard with metadata was the obvious fit.

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  SetMetadata,
  TooManyRequestsException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import Redis from 'ioredis';

export const RateLimit = (perMinute: number) =>
  SetMetadata('rate-limit-per-minute', perMinute);

@Injectable()
export class RateLimitGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly redis: Redis,
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const limit = this.reflector.getAllAndOverride<number>(
      'rate-limit-per-minute',
      [ctx.getHandler(), ctx.getClass()],
    );
    if (!limit) return true;

    const req = ctx.switchToHttp().getRequest();
    const userId = req.user?.id ?? req.ip;
    const handler = `${ctx.getClass().name}.${ctx.getHandler().name}`;
    const key = `rl:${handler}:${userId}:${Math.floor(Date.now() / 60000)}`;

    const count = await this.redis.incr(key);
    if (count === 1) await this.redis.expire(key, 65);
    if (count > limit) {
      throw new TooManyRequestsException(`Limit ${limit}/min for ${handler}`);
    }
    return true;
  }
}

The thing I want to call out, because it’s the part that bites people, is that throwing from a guard does the right thing. It hits the exception filter chain at the end. You don’t need to manually return a response object. If you do, you’ve stepped out of the framework’s hands and now you own the failure modes yourself.

Interceptors wrap the handler

Interceptors are the only stage that runs code both before and after the controller. They get the same ExecutionContext and they get an RxJS Observable for the response. That’s an awkward API the first time. It’s also exactly what you want for audit trails, response shaping, and timeouts.

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { AuditEvent, AuditWriter } from './audit-writer';

@Injectable()
export class AuditTrailInterceptor implements NestInterceptor {
  private readonly logger = new Logger(AuditTrailInterceptor.name);

  constructor(private readonly audit: AuditWriter) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest();
    const actor = req.user?.id ?? 'anonymous';
    const action = `${ctx.getClass().name}.${ctx.getHandler().name}`;
    const startedAt = Date.now();

    return next.handle().pipe(
      tap({
        next: (body) => {
          this.write({ actor, action, status: 'ok', durationMs: Date.now() - startedAt, payload: redact(body) });
        },
        error: (err) => {
          this.write({ actor, action, status: 'error', durationMs: Date.now() - startedAt, error: err.message });
        },
      }),
    );
  }

  private write(event: AuditEvent): void {
    this.audit.enqueue(event).catch((err) => this.logger.error(`audit dropped: ${err.message}`));
  }
}

function redact(body: unknown): unknown {
  // strip tokens, password, raw card numbers before persisting
  return body;
}

The reason this is an interceptor and not a controller-level concern is durability. If the audit write lived in the controller, every controller would have to remember to call it, and someone would forget. With an interceptor bound at the module level on the protected resource, you can’t ship a handler that bypasses audit by accident.

Pipes shape the input

Pipes run after guards and interceptors’ pre-half, right before the handler. They take the raw arg and either transform it or reject it. Validation belongs here. Not in the service. Not in the controller body. Here.

import { IsString, IsInt, Min, Max } from 'class-validator';
import { Body, Controller, Post, UsePipes, ValidationPipe } from '@nestjs/common';

export class CreateBoutDto {
  @IsString() athleteA!: string;
  @IsString() athleteB!: string;
  @IsInt() @Min(1) @Max(20) round!: number;
}

@Controller('bouts')
export class BoutsController {
  @Post()
  @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
  create(@Body() dto: CreateBoutDto) {
    return { id: 'b_123', ...dto };
  }
}

The whitelist and forbidNonWhitelisted flags are not optional in my book. Without them, extra fields slide through your validation untouched, and three months later you’re hunting a bug where the client sent a typo’d field name and your handler quietly ignored it.

Exception filters are the last line

Once anything in the chain throws, the framework walks up to the closest exception filter and lets it format the response. The default global filter handles HttpException subclasses fine. The filters I add are for the cases the default doesn’t cover well, mostly database constraint errors and external-API timeouts.

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { QueryFailedError } from 'typeorm';

@Catch(QueryFailedError)
export class DatabaseExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(DatabaseExceptionFilter.name);

  catch(error: QueryFailedError & { code?: string; detail?: string }, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const req = host.switchToHttp().getRequest();

    if (error.code === '23505') {
      return res.status(HttpStatus.CONFLICT).json({
        error: 'duplicate',
        message: 'Resource already exists',
        requestId: req.requestId,
      });
    }

    this.logger.error(`DB error ${error.code}: ${error.detail ?? error.message}`);
    return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
      error: 'database_error',
      requestId: req.requestId,
    });
  }
}

Takeaways

  • Middleware is HTTP plumbing. If it needs to know which handler runs, it’s a guard.
  • Guards throw. Don’t return response objects from inside a guard.
  • Interceptors are where audit, response shaping, and timeouts belong. Bind them at the module level for the resources you care about.
  • Validation lives in pipes. Always set whitelist and forbidNonWhitelisted.
  • Exception filters are for the cases the default doesn’t cover well, mostly DB constraints and upstream timeouts.
  • The placement decision is the architecture. A yes-no call before the work runs belongs in a guard. A wrap around the handler belongs in an interceptor. Most lifecycle bugs are concerns living one stage too early or too late.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.