El ciclo de vida del request

Por: Artiko
nestjsbuenas-practicasciclo-de-vidainterceptoresguardsexception-filtersrxjs

El ciclo de vida del request

Un request en NestJS no entra al controlador y ya. Antes de llegar al handler atraviesa una tubería de capas, y después de que el handler devuelve algo, atraviesa otra vez parte de esa tubería en sentido inverso. Si no tenés claro el orden exacto, terminás poniendo lógica en la capa equivocada: autorización en el service, transformación de la respuesta en el controlador, try/catch regados por todos lados en vez de un filtro central.

Este capítulo ataca un problema de calidad muy concreto: cada preocupación transversal tiene una capa canónica donde vive. Poner la lógica en la capa correcta te da código más testeable (cada capa se testea aislada), menos acoplado (el handler no sabe de autenticación ni de formato de errores) y sin duplicación (un filtro de excepciones global reemplaza cien try/catch).

El orden exacto del pipeline

Este es el orden documentado en el que NestJS ejecuta las capas de un request, de principio a fin:

  1. Middleware (globales primero, luego los ligados a módulos)
  2. Guards (global → controlador → handler)
  3. Interceptores (parte pre) (global → controlador → handler)
  4. Pipes (global → controlador → handler → parámetro)
  5. Handler del controlador (y el/los service que invoque)
  6. Interceptores (parte post) (en orden inverso: handler → controlador → global)
  7. Exception filters (handler → controlador → global) si se lanzó una excepción en cualquier punto
  8. Respuesta al cliente

Dos detalles que cambian cómo escribís el código:

  • Los interceptores envuelven al handler. El mismo interceptor corre lógica antes (cuando llega el request) y después (cuando el Observable del handler emite). Por eso pueden medir tiempos, cachear y transformar la respuesta: tienen acceso a los dos lados.
  • Los exception filters capturan cualquier excepción lanzada en cualquier capa anterior (un guard que lanza ForbiddenException, un pipe que lanza BadRequestException, el service que lanza un error de dominio). No es un catch alrededor del handler: es el sumidero de todo el pipeline.

Otro punto sobre el orden dentro de cada tipo: cuando hay varios enhancers del mismo tipo, corren global → controlador → handler. La excepción son los interceptores en su fase post, que se desenrollan al revés (handler → controlador → global), como una pila.

sequenceDiagram
    autonumber
    participant C as Cliente
    participant MW as Middleware
    participant G as Guards
    participant I as Interceptores
    participant P as Pipes
    participant H as Handler + Service
    participant F as Exception Filters

    C->>MW: HTTP request
    MW->>G: next()
    Note over G: canActivate()<br/>¿autorizado?
    G->>I: true
    Note over I: fase PRE<br/>(logging, start timer)
    I->>P: next.handle()
    Note over P: transform()<br/>valida y transforma DTO
    P->>H: args validados
    Note over H: lógica de negocio
    H-->>I: valor de retorno
    Note over I: fase POST<br/>map()/tap()<br/>envuelve la respuesta
    I-->>C: respuesta final

    Note over G,H: Si CUALQUIER capa lanza una excepción...
    H--xF: throw HttpException / error de dominio
    F-->>C: respuesta de error con formato consistente

Middleware: lo de bajo nivel, antes de que Nest sepa quién sos

El middleware es lo más cercano al servidor HTTP crudo (Express o Fastify). Corre antes que cualquier guard, y en ese punto Nest todavía no resolvió qué handler va a atender el request. Trabajás con los objetos Request/Response de la plataforma, no con el ExecutionContext de Nest.

Qué va en middleware:

  • Cosas de infraestructura HTTP de bajo nivel: CORS, helmet, compresión, cookie-parser, body raw para webhooks.
  • Logging crudo de acceso (método, path, IP, user-agent) sin conocer la ruta resuelta.
  • Asignar un correlation-id al request para trazabilidad.
  • Terminar el request temprano por razones puramente de transporte (por ejemplo, rechazar un Content-Type no soportado).

Qué no va en middleware:

  • Autorización o autenticación de negocio: usá guards, que sí tienen acceso al ExecutionContext (saben la clase y el handler destino y sus metadatos con @SetMetadata/Reflector).
  • Transformar la respuesta: eso es de interceptores.
  • Validar el DTO: eso es de pipes.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';

@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    const id = req.headers['x-correlation-id'] ?? randomUUID();
    req.headers['x-correlation-id'] = id as string;
    res.setHeader('x-correlation-id', id);
    next();
  }
}

Se registra en el módulo con configure, no con un decorador:

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';

@Module({ /* ... */ })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(CorrelationIdMiddleware).forRoutes('*');
  }
}

Muchas piezas de infraestructura (CORS, helmet, compresión) se aplican mejor como middleware global en main.ts (app.use(...), app.enableCors(...)) que como clase. Reservá el middleware-clase para lo que necesite DI.

Guards: la autorización vive acá, no en el service

Un guard implementa CanActivate y su único trabajo es responder sí o no: ¿este request puede seguir hasta el handler? Devuelve boolean (o Promise/Observable de boolean). Si devuelve false, Nest corta y responde 403 Forbidden automáticamente.

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

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

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!required?.length) return true;

    const { user } = context.switchToHttp().getRequest();
    return required.some((role) => user?.roles?.includes(role));
  }
}

¿Por qué la autorización va en el guard y no en el service? Tres razones concretas:

  • Corta antes. El guard rechaza el request antes de que corran los pipes de validación y el handler. Si la autorización estuviera en el service, ya gastaste ciclos validando y entrando al handler para recién ahí darte cuenta de que no tenía permiso.
  • Es declarativa y reusable. Con @UseGuards(RolesGuard) + @Roles('admin') en el handler, la política es visible en la firma del endpoint. En el service, la regla queda enterrada y hay que replicarla en cada método.
  • Desacopla el negocio del permiso. El service hace lo que hace (crear un pedido, borrar un usuario); el guard decide si podés hacerlo. Mezclarlos rompe el principio de responsabilidad única: un mismo método de service tendría que conocer roles, tokens y contexto HTTP.

El guard tiene acceso al ExecutionContext, así que puede leer el usuario que un AuthGuard puso en el request, los metadatos del handler y el propio request. La autenticación (validar el JWT, poblar request.user) y la autorización (chequear roles/permisos) se tratan a fondo en el capítulo 10: Seguridad — incluyendo Passport, RBAC y CASL.

Ojo con la frontera: el guard responde una pregunta binaria de acceso. Reglas de negocio ricas (“un usuario premium puede tener hasta 5 proyectos activos”) no son autorización de guard, son invariantes de dominio y viven en el service/dominio. Si tu canActivate empieza a consultar tres repositorios y calcular totales, se te está filtrando negocio a la capa equivocada.

Interceptores: envolver, transformar y medir

El interceptor es la capa más poderosa y la más malinterpretada. Implementa NestInterceptor.intercept(context, next) y devuelve un Observable. Su superpoder es que corre a ambos lados del handler:

  • Antes de next.handle(): podés arrancar un timer, loguear la entrada, chequear un cache.
  • Después, operando sobre el Observable que devuelve next.handle() con operadores RxJS: map para transformar la respuesta, tap para efectos secundarios (logging, métricas), timeout para cortar requests lentos, catchError para mapear errores.

next.handle() devuelve un Observable que no emite hasta que te suscribís; Nest se suscribe por vos. Todo lo que encadenes con .pipe(...) después de next.handle() es la fase post.

Patrón 1: envolver toda respuesta en un sobre consistente

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

interface Envelope<T> {
  data: T;
  timestamp: string;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, Envelope<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler<T>,
  ): Observable<Envelope<T>> {
    return next.handle().pipe(
      map((data) => ({ data, timestamp: new Date().toISOString() })),
    );
  }
}

Con esto, cada handler devuelve su entidad limpia (return this.usersService.findOne(id)) y el interceptor se encarga del formato uniforme. El handler no conoce el sobre: esa es la ventaja.

Patrón 2: logging de tiempos con tap

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

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

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = Date.now();
    const { method, url } = context.switchToHttp().getRequest();

    return next.handle().pipe(
      tap({
        next: () => this.logger.log(`${method} ${url} — ${Date.now() - start}ms`),
        error: (err) =>
          this.logger.error(`${method} ${url} — falló: ${err.message}`),
      }),
    );
  }
}

tap no altera el valor emitido; solo observa. Es la herramienta correcta para métricas y logging: si usaras map para loguear estarías arriesgándote a mutar la respuesta por error.

Patrón 3: timeout

import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from '@nestjs/common';
import { catchError, Observable, throwError, timeout, TimeoutError } from 'rxjs';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      timeout(5000),
      catchError((err) =>
        err instanceof TimeoutError
          ? throwError(() => new RequestTimeoutException())
          : throwError(() => err),
      ),
    );
  }
}

Patrón 4: cache

El interceptor puede cortocircuitar el handler: si hay un hit de cache, devolvés un Observable con el valor guardado y next.handle() nunca se ejecuta. Es la base del CacheInterceptor de @nestjs/cache-manager. La estrategia de caching (qué cachear, TTL, invalidación) se cubre en el capítulo 7: Rendimiento.

Pipes: validar y transformar la entrada

El pipe implementa PipeTransform.transform(value, metadata) y opera sobre los argumentos del handler justo antes de que este los reciba. Tiene dos usos:

  • Transformación: convertir el value de entrada al tipo deseado (ParseIntPipe, ParseUUIDPipe, ParseBoolPipe).
  • Validación: chequear que el value cumple las reglas y, si no, lanzar (típicamente BadRequestException, que el filtro convierte en 400).

Como los pipes corren después de guards e interceptores-pre pero antes del handler, cuando tu método de controlador ejecuta ya recibe datos autorizados, validados y tipados. El ValidationPipe global con DTOs, class-validator/Zod, whitelisting y la dirección de Standard Schema en Nest 12 se desarrollan en el capítulo 5: Validación y transformación.

Exception filters: un solo lugar para los errores

El filtro implementa ExceptionFilter.catch(exception, host) y se decora con @Catch(...). Es el sumidero de excepciones de todo el pipeline: un guard que rechaza, un pipe que invalida, un service que lanza un error de dominio, todo cae acá. En vez de un try/catch en cada handler, un filtro centraliza el formato de la respuesta de error.

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';

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

  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    const { httpAdapter } = this.httpAdapterHost;
    const ctx = host.switchToHttp();
    const request = ctx.getRequest();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    // Nunca exponer stack traces ni el mensaje interno de un 500 al cliente.
    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal server error';

    // El detalle completo va al log, no a la respuesta.
    this.logger.error(exception instanceof Error ? exception.stack : exception);

    const body = {
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: httpAdapter.getRequestUrl(request),
      message,
    };

    httpAdapter.reply(ctx.getResponse(), body, status);
  }
}

Puntos de calidad clave:

  • Formato consistente. Todo error sale con la misma forma (statusCode, timestamp, path, message). El cliente parsea una sola estructura.
  • No filtrar detalles sensibles. En un 500 genérico devolvés un mensaje neutro y mandás el stack al logger. Nunca serialices el error crudo al cliente: expone rutas internas, versiones y a veces datos.
  • @Catch() sin argumentos captura todo; @Catch(HttpException) solo ese tipo; @Catch(PrismaClientKnownRequestError) te deja mapear errores de una librería específica a un HttpException con el status correcto.
  • Usá HttpAdapterHost en el filtro global en vez de tocar response directo, para que funcione igual con Express y con Fastify.

Preferí lanzar excepciones semánticas de Nest (NotFoundException, ConflictException, ForbiddenException) desde el service y dejar que el filtro las traduzca, en lugar de armar respuestas de error a mano en el controlador.

Enhancers: global, por controlador o por handler

Guards, interceptores, pipes y filtros son enhancers, y podés atarlos en tres alcances:

flowchart TD
    A[Enhancer] --> B{¿Qué alcance?}
    B -->|Toda la app| G["Global<br/>APP_GUARD / APP_INTERCEPTOR<br/>APP_FILTER / APP_PIPE<br/>o app.useGlobalX()"]
    B -->|Un controlador| C["@UseGuards() / @UseInterceptors()<br/>@UseFilters() / @UsePipes()<br/>a nivel de clase"]
    B -->|Un handler| H["Los mismos decoradores<br/>a nivel de método"]
    G --> R[Se ejecuta global → controlador → handler]
    C --> R
    H --> R

El binding con DI: por qué APP_GUARD y no useGlobalGuards

Hay dos formas de registrar un enhancer global y no son equivalentes:

// Opción A — sin DI: la instancia se crea a mano, fuera del contenedor.
// El guard NO puede inyectar providers (Reflector, repositorios, config).
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new RolesGuard(new Reflector())); // frágil, acoplado
}
// Opción B — con DI: lo registrás como provider con un token especial.
// Nest lo instancia dentro del contenedor y le inyecta todo lo que pida.
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_GUARD, useClass: RolesGuard },
  ],
})
export class AppModule {}

Los tokens son cuatro, uno por tipo de enhancer:

TokenEnhancerEquivalente manual en main.ts
APP_GUARDGuard globalapp.useGlobalGuards()
APP_INTERCEPTORInterceptor globalapp.useGlobalInterceptors()
APP_PIPEPipe globalapp.useGlobalPipes()
APP_FILTERException filter globalapp.useGlobalFilters()

La ventaja de los tokens APP_* es que el enhancer participa de la inyección de dependencias: puede inyectar el Reflector, tu ConfigService, un repositorio o cualquier provider del módulo. La variante app.useGlobalX(new ...) te obliga a construir las dependencias a mano y rompe el testeo aislado. Para todo enhancer global que necesite algo del contenedor, usá los tokens. (La DI avanzada y los custom providers son el tema del capítulo 4.)

Regla práctica de alcance:

  • Global: políticas que aplican a casi toda la app (filtro de errores, interceptor de logging, ValidationPipe, guard de autenticación base).
  • Controlador: cuando todos los endpoints de ese recurso comparten una regla (por ejemplo, un @UseGuards(AdminGuard) en un AdminController).
  • Handler: la excepción puntual (un endpoint con un interceptor de cache específico, o uno público con @Public() que saltea el guard global).

Antipatrones

Lógica de negocio en guards o interceptores

// ANTIPATRÓN: el guard calcula, consulta y decide reglas de negocio.
@Injectable()
export class CanCreateProjectGuard implements CanActivate {
  constructor(private readonly projects: ProjectsRepository) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const { user } = context.switchToHttp().getRequest();
    const count = await this.projects.countActiveByOwner(user.id); // negocio
    const limit = user.plan === 'premium' ? 20 : 5;                // negocio
    return count < limit;
  }
}

El guard terminó siendo un método de service disfrazado. Problemas: la regla no es testeable como negocio puro (necesitás un ExecutionContext falso), no se reusa desde un job o un comando CLI, y devolver 403 para “llegaste al límite de tu plan” es semánticamente incorrecto (debería ser un 409/422 de dominio).

// MEJOR: el guard solo autoriza acceso; la invariante vive en el service.
@Injectable()
export class ProjectsService {
  constructor(private readonly projects: ProjectsRepository) {}

  async create(user: User, dto: CreateProjectDto): Promise<Project> {
    const count = await this.projects.countActiveByOwner(user.id);
    const limit = user.plan === 'premium' ? 20 : 5;
    if (count >= limit) {
      throw new ConflictException('Alcanzaste el límite de proyectos de tu plan');
    }
    return this.projects.create(user.id, dto);
  }
}

El guard queda solo con @Roles('user'). La regla de negocio es una invariante del dominio, testeable sin HTTP, y lanza una excepción semántica que el filtro traduce al status correcto.

try/catch regado en vez de un exception filter

// ANTIPATRÓN: cada handler arma su propia respuesta de error.
@Get(':id')
async findOne(@Param('id') id: string) {
  try {
    const user = await this.users.findOne(id);
    if (!user) return { error: 'not found', code: 404 };
    return user;
  } catch (e) {
    return { error: 'algo salió mal', code: 500, detail: e.message }; // filtra detalle
  }
}

Cada endpoint reinventa el formato del error (a veces code, a veces statusCode), a veces filtra e.message de un 500, y el ruido de try/catch ahoga la lógica. La respuesta correcta:

// MEJOR: el handler solo describe el camino feliz; los errores fluyen al filtro.
@Get(':id')
async findOne(@Param('id') id: string) {
  const user = await this.users.findOne(id);
  if (!user) throw new NotFoundException(`Usuario ${id} no existe`);
  return user;
}

El AllExceptionsFilter global da el formato uniforme y esconde el detalle sensible en un solo lugar. El handler se lee como pseudocódigo del caso de negocio.

Autorización en el service

Meter if (user.role !== 'admin') throw ... dentro de cada método del service dispersa la política de acceso, la duplica y la mezcla con negocio. La decisión de acceso pertenece al guard (declarativa, visible en el endpoint, cortando temprano). Reservá el service para invariantes de dominio, no para permisos.

Exponer stack traces

Devolver err.stack o el error crudo al cliente filtra rutas del filesystem, versiones de dependencias y a veces valores internos. El stack va al logger; al cliente le llega un mensaje neutro y un statusCode. Esto se conecta con el capítulo 10: Seguridad (fuga de información).

Transformar la respuesta en el controlador

Armar el sobre { data, timestamp } a mano en cada handler duplica el formato y acopla el controlador a la representación de salida. Ese envoltorio es trabajo de un interceptor global: el handler devuelve la entidad y nada más.

Checklist

  • Tenés claro el orden: middleware → guards → interceptores (pre) → pipes → handler → interceptores (post) → exception filters.
  • El middleware solo hace cosas de transporte HTTP de bajo nivel (CORS, helmet, compresión, correlation-id, logging crudo).
  • La autorización está en guards (canActivate), no en el service ni en middleware.
  • Las invariantes de negocio (límites de plan, reglas de dominio) están en el service, no en guards.
  • Los interceptores usan map para transformar la respuesta y tap para logging/métricas, sin mutar el valor con tap.
  • La respuesta uniforme (sobre { data, ... }) la arma un interceptor global, no cada handler.
  • Hay timeout en los interceptores para requests que pueden colgarse.
  • La validación y transformación de entrada está en pipes/DTOs, no en el handler.
  • Un AllExceptionsFilter global centraliza el formato de error; no hay try/catch decorativos en los handlers.
  • El filtro nunca expone stack ni el mensaje interno de un 500; ese detalle va al logger.
  • Los enhancers globales que necesitan DI se registran con APP_GUARD/APP_INTERCEPTOR/APP_PIPE/APP_FILTER, no con new en main.ts.
  • El alcance de cada enhancer (global/controlador/handler) es el mínimo que cubre el caso.

Con el pipeline ordenado y cada preocupación en su capa, el siguiente paso es exprimir el rendimiento de ese pipeline: adaptador Fastify, caching, compresión, complejidad algorítmica y perfilado del event loop. Seguí con el capítulo 7: Rendimiento →.