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:

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:

Qué no va en middleware:

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:

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:

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:

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:

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:

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

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