Parte III · NestJS

10. El ciclo de petición: middleware, guards, interceptores, pipes y filtros

Entre el instante en que un byte entra por el socket y el instante en que tu método del controlador ejecuta su primera línea, NestJS ha hecho mucho trabajo: ha dejado pasar middleware, ha preguntado a los guards si el llamante tiene derecho a estar ahí, ha envuelto la ejecución en interceptores, ha convertido y validado cada argumento con pipes, y ha preparado una red de filtros para el caso de que algo estalle. Ese recorrido es el ciclo de petición, y entenderlo con precisión quirúrgica es la diferencia entre escribir controladores que repiten treinta líneas de comprobaciones y escribir una aplicación donde la autorización, la validación, el registro y el formato de los errores viven exactamente en un sitio cada uno.

Este capítulo desmonta las cinco piezas una a una: qué es cada una, qué puede hacer, qué no debe hacer nunca, en qué momento exacto se ejecuta y por qué el orden importa. Es también el capítulo cuyas preguntas aparecen con más frecuencia en las entrevistas técnicas de NestJS, casi siempre en la forma «¿en qué orden se ejecutan?» y «¿por qué esto no puede ir en un middleware?».

CORE NEST Tiempo de lectura: ~115 min Prerrequisitos: capítulos 1, 6 y 9

10.1 Qué vas a poder hacer al terminar

10.2 Visión completa del ciclo

NestJS no inventa el ciclo de petición: lo ordena. Debajo hay un servidor HTTP (Express por defecto, Fastify como alternativa) que ya tiene su propio concepto de middleware. Lo que Nest añade es una cadena de responsabilidades explícita, con nombres distintos para cada tipo de responsabilidad, de manera que al leer un proyecto ajeno puedas saber dónde buscar sin abrir treinta ficheros. Las cinco piezas son middleware, guards, interceptores, pipes y filtros de excepción; las cuatro últimas se conocen en la documentación como enhancers y comparten ámbitos de registro, acceso al ExecutionContext y participación en el contenedor de inyección de dependencias.

10.2.1 El recorrido completo, paso a paso

Este es el diagrama que conviene tener memorizado. Cada bloque indica qué hace la pieza, qué información tiene disponible en ese instante y qué le está vedado precisamente porque llega demasiado pronto o demasiado tarde:

  PETICIÓN ENTRANTE
  PATCH /projects/9/tasks/42        Authorization: Bearer eyJ…
  Content-Type: application/json    {"estado":"HECHA","horas":"3"}
       │
  ═════▼═══════════════════════════════════════════════════════════════════
   1 · MIDDLEWARE              firma nativa (req, res, next)
       para qué        · helmet, cors, compresión, parseo del cuerpo
                       · RequestContext de MikroORM, requestId, logging
                       · cuerpo sin procesar para webhooks firmados
       ya tiene        · req crudo, cabeceras, cookies, IP, ruta como texto
       NO tiene        · el manejador que se ejecutará (ni su clase)
                       · los metadatos @Roles() / @Public()
                       · el DTO validado: todavía no existe
       si no llama a next(), la petición termina aquí
  ─────▼───────────────────────────────────────────────────────────────────
   2 · GUARDS                  canActivate(ctx) → boolean | Promise | Observable
       para qué        · ¿PUEDE este llamante ejecutar ESTE manejador?
                       · autenticación, roles, permisos, propiedad, feature flags
       ya tiene        · ExecutionContext completo: getClass() y getHandler()
                       · Reflector para leer metadatos del decorador
       NO tiene        · el cuerpo validado ni transformado (req.body es crudo)
       orden           global → controlador → método, secuencial, corta al primer no
       false → ForbiddenException (403) generada por Nest
  ─────▼───────────────────────────────────────────────────────────────────
   3 · INTERCEPTORES (antes de next.handle())
       para qué        · arrancar el cronómetro, abrir transacción, mirar la caché
       ya tiene        · ExecutionContext + CallHandler (la ejecución diferida)
       orden           global → controlador → método
       si no llama a next.handle(), el manejador NUNCA se ejecuta
  ─────▼───────────────────────────────────────────────────────────────────
   4 · PIPES                   transform(valor, metadata) por cada argumento
       para qué        · "3" → 3, texto → instancia de DTO, validar y rechazar
       ya tiene        · el valor crudo del argumento y su ArgumentMetadata
                         (type: body | query | param | custom, metatype, data)
       NO tiene        · la respuesta; ni debe consultar permisos
       orden           global → controlador → método → pipe del parámetro
       lanza BadRequestException (400) si la validación falla
  ─────▼───────────────────────────────────────────────────────────────────
   5 · MANEJADOR DEL CONTROLADOR
       recibe los argumentos ya convertidos y validados
       una sola responsabilidad: traducir HTTP a una llamada de caso de uso
  ─────▼───────────────────────────────────────────────────────────────────
   6 · SERVICIO / CASO DE USO / REPOSITORIO
       reglas de negocio, EntityManager de MikroORM, flush, eventos
       lanza excepciones de DOMINIO, no HttpException
  ─────▼───────────────────────────────────────────────────────────────────
   7 · INTERCEPTORES (después, sobre el flujo devuelto)
       para qué        · envolver en {data, meta}, serializar, medir, cachear
       orden           INVERTIDO: método → controlador → global
       modelo de cebolla: el primero que entra es el último que sale
  ─────▼───────────────────────────────────────────────────────────────────
   8 · RESPUESTA            200 OK   {"data":{…},"meta":{"requestId":"…"}}
  ═════════════════════════════════════════════════════════════════════════
Un detalle que se olvida siempre Los pipes se ejecutan después de los guards y después de la fase inicial de los interceptores. La consecuencia práctica es inmediata: un guard no puede confiar en el cuerpo de la petición, porque en ese momento req.body es lo que envió el cliente sin filtrar, sin convertir y sin validar. Si un guard toma decisiones de autorización leyendo req.body.rol, el sistema es vulnerable por diseño.

10.2.2 El camino de error

La segunda mitad del modelo mental es qué ocurre cuando algo falla. Nest envuelve la ejecución de cada manejador en una zona de excepciones: cualquier error lanzado en las fases 2 a 7 abandona el flujo normal y entra en la capa de filtros. Esta es la parte que más gente desconoce, y explica por qué a veces un filtro «no captura» lo que debería:

                     ¿DÓNDE SE LANZÓ LA EXCEPCIÓN?
  ─────────────────────────────────────────────────────────────────────────
   en un middleware de main.ts (app.use)
        → NO entra en la capa de filtros de Nest
        → responde el gestor de errores de Express/Fastify
        → síntoma típico: HTML de error en una API JSON
  ─────────────────────────────────────────────────────────────────────────
   en un guard          ┐
   en un pipe           │
   en el manejador      ├──→  CAPA DE FILTROS DE EXCEPCIÓN
   en un servicio       │
   en un interceptor    ┘     (antes o después de next.handle(): ambos entran)
  ─────────────────────────────────────────────────────────────────────────
                                  │
                                  ▼
   Se busca el PRIMER filtro cuyo @Catch() encaje con la excepción.
   Resolución INVERTIDA respecto a los guards:
        método  →  controlador  →  global
   El primero que encaja gana; SOLO se ejecuta uno. No hay cadena.
                                  │
              ┌───────────────────┴────────────────────┐
              ▼                                        ▼
   HAY filtro que encaja                     NO hay ninguno
   catch(exception, host)                     ExceptionsHandler por defecto
   tú decides código, cuerpo                  HttpException → su status
   y cabeceras                                cualquier otra cosa → 500
              │                                        │
              └───────────────────┬────────────────────┘
                                  ▼
   RESPUESTA DE ERROR       4xx / 5xx  application/problem+json
   Los interceptores posteriores NO se ejecutan sobre este camino:
   el flujo terminó en error, no en valor. Un interceptor que solo usa
   map() no verá nunca el error; necesita catchError() o tap({error}).
  ─────────────────────────────────────────────────────────────────────────
   Y si la respuesta YA se envió (streaming, @Res() manual), el filtro
   no puede reescribirla: "Cannot set headers after they are sent".
Analogía: el control de un aeropuerto

La puerta de la terminal es el middleware: mira si llevas mascarilla, te da un número de seguimiento y te deja pasar; no sabe a qué vuelo vas, porque eso lo decide un mostrador que está más adentro.

El control de pasaportes es el guard: comprueba tu identidad y si tienes derecho a entrar en esta zona concreta. Su respuesta es binaria, y si es negativa no llegas a facturar.

El escáner de equipaje es el pipe: no discute quién eres, examina lo que traes, convierte lo convertible (te obliga a sacar el portátil) y rechaza lo prohibido con un motivo concreto.

El personal de la puerta de embarque es el interceptor: te acompaña de ida y de vuelta, cronometra el proceso, y a la salida envuelve tu equipaje con una etiqueta con el número de vuelo. Está en los dos extremos, y por eso el último en recibirte es el primero que te vio.

La oficina de incidencias es el filtro de excepción: cuando algo va mal en cualquier punto, allí se traduce «error interno del sistema de facturación 0x8004» a «su vuelo se ha cancelado, aquí tiene un número de reclamación». Es la única que habla con el pasajero en caso de fallo, y nunca le enseña el volcado de memoria.

10.2.3 Tabla resumen de las cinco piezas

PiezaQué esPara qué sirveQué puede hacerQué no debe hacerEjemplo típico
Middleware Función (req, res, next) del framework HTTP subyacente, anterior al enrutado de Nest Infraestructura transversal ajena al dominio, y adaptación de librerías del ecosistema Express/Fastify Mutar req y res, añadir cabeceras, terminar la respuesta, envolver la continuación en un contexto asíncrono Autorizar (no conoce el manejador), validar DTOs, contener lógica de negocio, hacer consultas costosas por petición helmet, RequestContext.create() de MikroORM, identificador de correlación
Guard Clase con canActivate(context) que devuelve verdadero, falso o una excepción Responder a una única pregunta: ¿puede este llamante ejecutar este manejador? Leer metadatos del manejador con Reflector, consultar la base de datos, adjuntar el usuario a la petición Transformar el cuerpo, escribir en la respuesta, ejecutar la lógica del caso de uso, devolver true «por si acaso» JwtAuthGuard, RolesGuard, guard de propiedad del recurso
Interceptor Clase con intercept(context, next) que devuelve un Observable Envolver la ejecución del manejador: hacer algo antes, algo después, o ambas cosas Transformar la respuesta, medir, cachear, aplicar timeout, abrir una transacción, capturar errores con RxJS Decidir permisos (para eso está el guard), asumir que la respuesta es un objeto plano, romper el streaming Envoltorio {data, meta}, ClassSerializerInterceptor, LoggingInterceptor
Pipe Clase con transform(valor, metadata) aplicada a un argumento del manejador Convertir y validar la entrada antes de que llegue al controlador Devolver un valor distinto del recibido, instanciar el DTO, rechazar con BadRequestException Consultar permisos, escribir en la base de datos, tener efectos secundarios, depender del usuario autenticado ValidationPipe, ParseIntPipe, ParseUUIDPipe, pipe propio de recorte de cadenas
Filtro de excepción Clase con catch(exception, host) decorada con @Catch(...) Traducir cualquier error a una respuesta HTTP coherente y registrar lo que haga falta Elegir código de estado, cuerpo y cabeceras; registrar con traza; convertir errores de dominio y de MikroORM Contener lógica de negocio, filtrar mensajes internos al cliente, tragarse errores en silencio, lanzar a su vez ProblemDetailsFilter global, filtro específico de errores de MikroORM

10.2.4 Por qué este orden y no otro

El orden no es arbitrario: se deduce de la información disponible en cada momento y del coste de cada operación.

Cuidado con lo que Nest no controla Antes del primer middleware de Nest hay más capas de las que parece: el balanceador, el proxy inverso, TLS, el keep-alive del servidor HTTP y el parser de cabeceras de Node. Los límites de tamaño del cuerpo, los timeouts de socket y la confianza en X-Forwarded-For se configuran ahí, no en un guard. Si esperas resolver en Nest un problema que ocurre antes de Nest, vas a perder mucho tiempo.

10.3 Ámbitos de registro

Las cuatro piezas «enhancer» se registran exactamente en los mismos cuatro ámbitos. Aprenderlo una vez sirve para las cuatro.

10.3.1 Los cuatro ámbitos de un vistazo

  ÁMBITOS DE REGISTRO · de más amplio a más específico
  ═════════════════════════════════════════════════════════════════════════

  ┌─ A · GLOBAL desde main.ts ────────────────────────────────────────────┐
  │  app.useGlobalGuards(new ApiKeyGuard())                              │
  │  alcance   toda la aplicación, incluidas rutas de otros módulos      │
  │  ventaja   una línea, imposible olvidarlo en un endpoint nuevo       │
  │  LÍMITE    lo instancias tú con new → NO hay inyección de            │
  │            dependencias. No puedes pedir ConfigService ni el EM.     │
  └───────────────────────────────────────────────────────────────────────┘
  ┌─ B · GLOBAL con token APP_* ──────────────────────────────────────────┐
  │  { provide: APP_GUARD, useClass: JwtAuthGuard }                      │
  │  alcance   idéntico al anterior                                      │
  │  VENTAJA   lo instancia el contenedor → SÍ hay inyección completa    │
  │  matiz     se declara en un módulo (por convención, AppModule) pero  │
  │            su alcance es global, no del módulo                       │
  │  ESTE ES EL QUE DEBES USAR casi siempre                              │
  └───────────────────────────────────────────────────────────────────────┘
  ┌─ C · POR CONTROLADOR ─────────────────────────────────────────────────┐
  │  @UseGuards(RolesGuard) @Controller('tasks')                         │
  │  alcance   todos los métodos de esa clase                            │
  │  uso       política común de un recurso: "aquí manda el admin"       │
  └───────────────────────────────────────────────────────────────────────┘
  ┌─ D · POR MÉTODO ──────────────────────────────────────────────────────┐
  │  @UseGuards(OwnerGuard) @Patch(':id')                                │
  │  alcance   un único manejador                                        │
  │  uso       excepciones y reglas específicas de una operación          │
  └───────────────────────────────────────────────────────────────────────┘
  ┌─ E · POR PARÁMETRO (solo pipes) ──────────────────────────────────────┐
  │  @Param('id', ParseUUIDPipe) id: string                              │
  │  alcance   un único argumento. Es el ámbito más fino que existe.     │
  └───────────────────────────────────────────────────────────────────────┘

  EJECUCIÓN   guards, interceptores y pipes:  A/B → C → D → (E)
              filtros de excepción:           D → C → B/A   ¡INVERTIDO!

10.3.2 Global desde main.ts

src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Registro global sin inyección de dependencias: Nest no crea estas
  // instancias, las creas tú. Válido para piezas SIN dependencias.
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  // Se pueden pasar varios; se ejecutan en el orden en que aparecen.
  // app.useGlobalInterceptors(new RequestIdInterceptor(), new TimeoutInterceptor());

  await app.listen(3000);
}
bootstrap();

Los cuatro métodos son useGlobalPipes, useGlobalGuards, useGlobalInterceptors y useGlobalFilters. Su virtud es la simplicidad; su defecto, que se ejecutan fuera del contenedor de inyección de dependencias. Esa limitación tiene una consecuencia práctica muy concreta: en cuanto tu guard necesite leer una variable de configuración, consultar la base de datos o escribir en el logger inyectado, este ámbito deja de servirte.

10.3.3 Global con los tokens APP_GUARD, APP_PIPE, APP_INTERCEPTOR y APP_FILTER

Nest expone cuatro tokens especiales en @nestjs/core. Al proveer una clase con uno de estos tokens, Nest la instancia a través del contenedor y la aplica globalmente. Es la diferencia clave respecto al apartado anterior y el motivo por el que este ámbito es el recomendado por defecto.

src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';

@Module({
  imports: [ConfigModule.forRoot(), MikroOrmModule.forRoot(), TasksModule],
  providers: [
    // ── Guards ──────────────────────────────────────────────────────────
    // JwtAuthGuard puede inyectar JwtService, ConfigService y el EM.
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: RolesGuard },   // se ejecuta DESPUÉS

    // ── Interceptores ───────────────────────────────────────────────────
    { provide: APP_INTERCEPTOR, useClass: RequestLoggingInterceptor },
    { provide: APP_INTERCEPTOR, useClass: TransformResponseInterceptor },

    // ── Pipe de validación con opciones y con inyección disponible ──────
    {
      provide: APP_PIPE,
      useFactory: (config: ConfigService) => new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
        // En producción no revelamos qué campos esperábamos.
        disableErrorMessages: config.get('NODE_ENV') === 'production',
      }),
      inject: [ConfigService],
    },

    // ── Filtros: el orden de declaración importa, ver más abajo ─────────
    { provide: APP_FILTER, useClass: ProblemDetailsFilter },
    { provide: APP_FILTER, useClass: MikroOrmExceptionFilter },
  ],
})
export class AppModule {}
main.tsINCORRECTO
// El guard necesita ConfigService y el EntityManager…
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly config: ConfigService,
    private readonly em: EntityManager,
  ) {}
  async canActivate(ctx: ExecutionContext) { /* … */ return true; }
}

// …pero lo registramos con new, así que TENEMOS que inventarnos
// los argumentos. Y aquí empieza el desastre:
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new JwtAuthGuard(new ConfigService(), null as any));
// · ConfigService recién construido: no ve la configuración cargada
//   por ConfigModule.forRoot(), ni el .env, ni la validación de esquema.
// · em a null: revienta en la primera petición que consulte la BD.
// · Si el guard es de ámbito REQUEST, esto directamente no puede funcionar.
// · Y el error no aparece al arrancar, sino en producción a las 3 de la
//   madrugada, con un "Cannot read properties of null".
app.module.tsCORRECTO
// Mismo guard, sin tocar una sola línea de su código.
@Module({
  providers: [
    // Nest lo construye: resuelve ConfigService y EntityManager con las
    // instancias REALES del contenedor, respetando ámbitos y ciclo de vida.
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],
})
export class AppModule {}

// Ventajas concretas de este ámbito:
// · Inyección de dependencias completa, incluidos providers asíncronos.
// · Puede ser de ámbito REQUEST si lo necesita (con coste de rendimiento).
// · Se puede sustituir en los tests con overrideProvider(APP_GUARD).
// · Si falta una dependencia, Nest falla AL ARRANCAR, no en caliente.
// Nota: el token admite varios providers; se acumulan, no se sobrescriben.
Los filtros globales por token tienen una trampa de orden Nest resuelve los filtros del más específico al más general y, dentro del ámbito global, invierte el orden de registro. Es decir, el APP_FILTER declarado en último lugar es el primero que se consulta. Depender de ese detalle es frágil: haz que los @Catch() de tus filtros sean disjuntos (uno captura errores de MikroORM, otro captura el resto) en lugar de confiar en quién gana el desempate.

10.3.4 Por controlador y por método

src/tasks/tasks.controller.ts
@UseGuards(RolesGuard)                    // ámbito: toda la clase
@UseInterceptors(ClassSerializerInterceptor)
@UseFilters(TasksExceptionFilter)
@Controller('projects/:projectId/tasks')
export class TasksController {
  @Get()
  @Roles('MIEMBRO')                       // metadato leído por RolesGuard
  listar(@Query() filtros: ListarTareasDto) { /* … */ }

  @Patch(':id')
  @Roles('EDITOR')
  @UseGuards(TaskOwnerGuard)               // ámbito: solo este método
  @UsePipes(new ValidationPipe({ groups: ['actualizar'] }))
  actualizar(
    @Param('id', ParseUUIDPipe) id: string,        // ámbito: un parámetro
    @Body() dto: ActualizarTareaDto,
  ) { /* … */ }
}

// Se puede pasar la CLASE (Nest la instancia y la inyecta: preferible)
//   @UseGuards(TaskOwnerGuard)
// o una INSTANCIA (útil solo para configurar opciones, sin inyección)
//   @UsePipes(new ValidationPipe({ transform: true }))
Clase o instancia Pasa siempre la clase cuando puedas: Nest la construye con el contenedor y además reutiliza la misma instancia entre peticiones, lo que ahorra memoria. Pasa una instancia solo cuando necesites configurarla con opciones y esa pieza no tenga dependencias, como ocurre con new ParseIntPipe({ errorHttpStatusCode: 422 }). Si necesitas ambas cosas —opciones e inyección—, usa useFactory con el token APP_*, o crea una subclase que reciba sus opciones en el constructor.

10.3.5 Orden de ejecución cuando hay varios del mismo tipo

PiezaOrden de ejecución¿Se ejecutan todas?Notas importantes
Middleware Los de app.use() primero, en orden de llamada. Después los de configure(), en el orden de los apply() y en el orden de registro de los módulos. Sí, mientras cada uno llame a next() Es la única cadena donde el orden depende del orden de importación de módulos. Si el orden importa de verdad, decláralos en un solo módulo.
Guards global → controlador → método; dentro de cada ámbito, el orden del array o de los argumentos de @UseGuards() No: se evalúan secuencialmente y el primero que deniega corta Aprovéchalo: pon primero el barato (autenticación por token) y después el caro (consulta a la base de datos para comprobar la propiedad).
Interceptores Antes de handle(): global → controlador → método. Después: método → controlador → global Sí, salvo que uno no llame a next.handle() Modelo de cebolla. El interceptor global es el más externo: lo primero que ve la petición y lo último que toca la respuesta. Por eso el envoltorio {data} va en el global.
Pipes global → controlador → método → pipes del parámetro; cada uno recibe la salida del anterior Sí, todos, en cadena Es una composición de funciones. @Query('page', new DefaultValuePipe(1), ParseIntPipe) primero rellena y luego convierte: el orden es el que escribes.
Filtros Invertido: método → controlador → global No: solo uno. El primero cuyo @Catch() encaje No es una cadena de responsabilidad acumulativa. Si quieres que el filtro global también actúe, tienes que llamarlo explícitamente o extender BaseExceptionFilter.
El error de diseño más frecuente en esta sección Registrar un @Catch() vacío (que captura absolutamente todo) a nivel de método o de controlador y esperar que el filtro global siga normalizando el formato. No lo hará: el específico gana y el global ni se consulta. El resultado es una API con dos formatos de error distintos según el endpoint, que es exactamente el problema que el filtro global venía a resolver.

10.4 Middleware

10.4.1 Qué es y, sobre todo, qué no es

Un middleware en NestJS es lo mismo que un middleware en Express: una función que recibe la petición, la respuesta y una función next, y que se ejecuta antes de que el enrutador decida qué manejador corresponde. Esa frase contiene toda su potencia y toda su limitación. Puede hacer cualquier cosa con los objetos nativos: leerlos, mutarlos, añadir propiedades, escribir cabeceras, terminar la respuesta sin llamar a nadie más. Pero no sabe —no puede saber— qué clase y qué método van a atender la petición, así que cualquier decisión que dependa de metadatos del manejador le queda fuera de alcance.

Históricamente el middleware es la pieza más antigua: es el patrón que popularizó Connect en 2011 y que heredaron Express, Koa y prácticamente todo el ecosistema Node. Nest lo mantiene por compatibilidad y por pragmatismo: hay cientos de paquetes escritos como middleware de Express y sería absurdo no poder usarlos. La regla de decisión es esta: si la funcionalidad viene de una librería del ecosistema Express o Fastify, va en un middleware; si la escribes tú y depende de tu dominio, casi siempre pertenece a un guard o a un interceptor.

10.4.2 Clase con NestMiddleware y middleware funcional

src/common/middleware/logger.middleware.ts · clase
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()   // permite inyección de dependencias
export class LoggerMiddleware implements NestMiddleware {
  private readonly logger = new Logger('HTTP');

  use(req: Request, res: Response, next: NextFunction): void {
    const inicio = process.hrtime.bigint();
    const { method, originalUrl } = req;

    // 'finish' se dispara cuando la respuesta se ha enviado por completo,
    // así que aquí ya conocemos el código de estado y los bytes escritos.
    res.on('finish', () => {
      const ms = Number(process.hrtime.bigint() - inicio) / 1e6;
      const bytes = res.get('content-length') ?? '-';
      this.logger.log(
        `${method} ${originalUrl} ${res.statusCode} ${bytes}B ${ms.toFixed(1)}ms`,
      );
    });

    next();   // sin esta llamada la petición se queda colgada para siempre
  }
}
src/common/middleware/no-cache.middleware.ts · funcional
import { Request, Response, NextFunction } from 'express';

// Middleware funcional: una función suelta. Es la forma correcta cuando
// NO necesitas inyección de dependencias ni estado.
export function noCache(req: Request, res: Response, next: NextFunction) {
  res.setHeader('Cache-Control', 'no-store, max-age=0');
  res.setHeader('Pragma', 'no-cache');
  next();
}

// Se registra igual que una clase:
//   consumer.apply(noCache).forRoutes('auth');
//
// Ventaja: cero ceremonia y es trivial de testear.
// Límite: sin acceso al contenedor. En cuanto necesites ConfigService,
// convierte la función en clase con @Injectable().
//
// Ojo con la firma según la plataforma:
//   Express → (req, res, next)
//   Fastify → (req, reply, next)
// Un middleware que use API específica de Express (res.render, req.flash)
// deja de compilar el día que migres a Fastify. Es acoplamiento real.

10.4.3 configure(consumer): forRoutes, exclude y varios apply

El middleware no se declara en providers ni con un decorador: se conecta implementando NestModule y su método configure. Ese método recibe un MiddlewareConsumer, una pequeña API fluida cuyo orden de llamadas es significativo.

src/app.module.ts · configuración completa del middleware
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';

@Module({ imports: [TasksModule, AuthModule, WebhooksModule] })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    // ── 1 · Varios middleware en un solo apply: se ejecutan EN ESTE ORDEN
    consumer
      .apply(RequestIdMiddleware, LoggerMiddleware, MikroOrmContextMiddleware)
      // exclude SIEMPRE antes de forRoutes; si lo pones después, se ignora
      .exclude(
        { path: 'health', method: RequestMethod.GET },
        { path: 'metrics', method: RequestMethod.GET },
      )
      .forRoutes('*');   // todas las rutas

    // ── 2 · Otro apply: se ejecuta después del bloque anterior
    consumer
      .apply(RawBodyMiddleware)
      .forRoutes({ path: 'webhooks/stripe', method: RequestMethod.POST });

    // ── 3 · forRoutes acepta tres formas de referirse a las rutas:
    consumer
      .apply(SlowDownMiddleware)
      .forRoutes(
        'auth/login',                                      // cadena
        { path: 'auth/reset', method: RequestMethod.POST }, // objeto con método
        AuthController,                                     // clase controlador
      );
  }
}
Aviso de versión: los comodines cambiaron en Nest 11 Nest 11 pasó a Express 5, que usa path-to-regexp v8 y ya no admite comodines anónimos. Un forRoutes('*') o un @Get('files/*') que funcionaban en Nest 10 deben pasar a comodín con nombre, del estilo '{*path}' o 'files/*ruta'. Si al actualizar aparece un error de path-to-regexp al arrancar, esta es la causa. Consulta la guía de migración de tu versión concreta antes de copiar patrones de un tutorial antiguo.

Dos comportamientos de exclude que conviene tener claros: solo afecta a los middleware declarados en ese apply, y no funciona para los middleware registrados con app.use() en main.ts, porque esos ni pasan por el consumer. Si necesitas excluir rutas de un middleware global, o lo declaras en configure(), o metes la condición dentro del propio middleware.

10.4.4 Casos reales

Caso 1 · Identificador de correlación con AsyncLocalStorage. Es el middleware más rentable que puedes escribir: permite que cualquier log emitido durante la petición, a cualquier profundidad de la pila, lleve el mismo identificador sin pasarlo como parámetro por veinte funciones.

src/common/context/als.ts + request-id.middleware.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

export interface AlcanceDePeticion {
  requestId: string;
  usuarioId?: string;
  inicio: bigint;
}
export const almacen = new AsyncLocalStorage<AlcanceDePeticion>();
export const contextoActual = () => almacen.getStore();

@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    const entrante = req.header('x-request-id');
    // NUNCA aceptes el valor del cliente tal cual: iría a los logs y a la
    // respuesta. Un valor con saltos de línea permite falsificar entradas
    // de log (log forging); uno de 10 MB llena el disco.
    const valido = typeof entrante === 'string' && /^[\w-]{8,64}$/.test(entrante);
    const requestId = valido ? entrante : randomUUID();

    res.setHeader('x-request-id', requestId);
    // La clave está aquí: run() envuelve TODA la continuación asíncrona,
    // así que el store sigue disponible dentro de guards, servicios,
    // callbacks de MikroORM y filtros de excepción.
    almacen.run({ requestId, inicio: process.hrtime.bigint() }, () => next());
  }
}

Caso 2 · RequestContext de MikroORM. MikroORM necesita un EntityManager aislado por petición: el identity map y la unidad de trabajo son estado mutable, y compartirlos entre peticiones concurrentes provoca fugas de datos entre usuarios. El adaptador oficial de Nest lo registra por su cuenta, pero conviene saber qué está pasando debajo.

src/common/middleware/mikro-orm-context.middleware.ts
import { MikroORM, RequestContext } from '@mikro-orm/core';

@Injectable()
export class MikroOrmContextMiddleware implements NestMiddleware {
  constructor(private readonly orm: MikroORM) {}

  use(_req: Request, _res: Response, next: NextFunction): void {
    // Crea un fork del EntityManager y lo asocia al contexto asíncrono
    // de esta petición. Todo lo que ocurra dentro de next() usará ese fork.
    RequestContext.create(this.orm.em, next);
  }
}

// IMPORTANTE: MikroOrmModule.forRoot() ya registra este middleware por ti
// (opción registerRequestContext, activada por defecto). Solo lo escribirías
// a mano si necesitas controlar su posición respecto a otros middleware.
//
// Fuera del ciclo HTTP no hay petición y por tanto no hay contexto: en tareas
// programadas, consumidores de cola o comandos de CLI hay que abrirlo
// explícitamente con el decorador @CreateRequestContext() (MikroORM 6;
// se llamaba @UseRequestContext() en la versión 5). Si lo olvidas, verás
// "Using global EntityManager instance methods for context specific actions
// is disallowed", que es exactamente la protección funcionando.

Caso 3 · Cabeceras de seguridad con helmet y compresión. Aquí el middleware brilla porque el trabajo ya está hecho por otros. Se registra en main.ts porque debe aplicarse a absolutamente todo, incluidas las rutas que no gestiona ningún controlador.

src/main.ts
import helmet from 'helmet';
import compression from 'compression';
import { json, urlencoded } from 'express';

const app = await NestFactory.create(AppModule);

app.use(helmet());          // ~15 cabeceras de seguridad, coste despreciable
app.use(compression());     // gzip/brotli sobre respuestas grandes

// Límites de tamaño del cuerpo: el valor por defecto de Express es 100 kb.
// Subirlo "por si acaso" convierte cada endpoint en un vector de DoS.
app.use(json({ limit: '1mb' }));
app.use(urlencoded({ extended: true, limit: '1mb' }));

app.enableCors({ origin: ['https://app.ejemplo.com'], credentials: true });

Caso 4 · Cuerpo sin procesar para webhooks firmados. Stripe, GitHub o Shopify firman el cuerpo exacto que enviaron. Si el parser de JSON lo deserializa y luego tú lo vuelves a serializar para verificar la firma, el resultado no coincide: cambia el orden de las claves, los espacios o la representación de los números. Hay que conservar el buffer original.

src/main.ts + webhooks.controller.ts
// 1 · Nest guarda el buffer original si se lo pides al crear la app.
const app = await NestFactory.create(AppModule, { rawBody: true });

// 2 · En el manejador se recupera con el tipo RawBodyRequest.
import { RawBodyRequest, Controller, Post, Req, Headers } from '@nestjs/common';

@Controller('webhooks')
export class WebhooksController {
  @Post('stripe')
  recibir(
    @Req() req: RawBodyRequest<Request>,
    @Headers('stripe-signature') firma: string,
  ) {
    const crudo = req.rawBody;            // Buffer intacto, byte a byte
    if (!crudo) throw new BadRequestException('Falta el cuerpo');
    // La verificación de firma se hace SOBRE EL BUFFER, nunca sobre el JSON
    // reserializado, y con comparación en tiempo constante.
    const evento = this.stripe.webhooks.constructEvent(crudo, firma, this.secreto);
    return this.procesar(evento);
  }
}
// Alternativa para casos raros: crear la app con bodyParser: false y montar
// express.raw({ type: 'application/json' }) solo en la ruta del webhook.

10.4.5 Qué no hacer en un middleware

El error conceptual más común es meter autorización en un middleware. Funciona en el caso trivial —«todas las rutas bajo /admin requieren rol de administrador»— y se rompe en cuanto la regla depende del manejador concreto, que es lo normal. La razón es estructural: el middleware corre antes del enrutado y por tanto no tiene ExecutionContext, no tiene getHandler() y no puede preguntar al Reflector por los metadatos que puso tu decorador.

auth.middleware.tsINCORRECTO
@Injectable()
export class AuthMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // PROBLEMA 1: la lista de rutas públicas se mantiene A MANO y se
    // compara con cadenas. El día que alguien añada /auth/registro,
    // quedará protegido sin querer… o al revés.
    const publicas = ['/auth/login', '/health'];
    if (publicas.includes(req.path)) return next();

    // PROBLEMA 2: no puede leer @Roles() ni @Public(). No existe el
    // manejador todavía. Toda la autorización fina es imposible aquí.
    const token = req.headers.authorization?.replace('Bearer ', '');
    if (!token) {
      // PROBLEMA 3: construye la respuesta a mano, así que se salta el
      // filtro global. Este endpoint devuelve un formato de error distinto
      // al de toda la API, y ningún test lo detecta.
      res.status(401).json({ error: 'No autorizado' });
      return;   // y si olvidas este return, la petición sigue adelante
    }
    // PROBLEMA 4: propiedad inventada sobre req, sin tipado. El resto del
    // código hará (req as any).usuario y nadie sabrá de dónde sale.
    (req as any).usuario = this.jwt.verify(token);

    // PROBLEMA 5: si verify() lanza, la excepción no la captura ningún
    // filtro de Nest: responde el gestor de errores de Express con HTML.
    next();
  }
}
jwt-auth.guard.tsCORRECTO
export const ES_PUBLICO = 'es_publico';
export const Public = () => SetMetadata(ES_PUBLICO, true);

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly jwt: JwtService,
    private readonly reflector: Reflector,
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    // SOLUCIÓN 1+2: lo público se declara junto al endpoint, con un
    // decorador. Nada de listas de rutas paralelas al código.
    const publico = this.reflector.getAllAndOverride<boolean>(ES_PUBLICO, [
      ctx.getHandler(),   // el método concreto
      ctx.getClass(),     // el controlador entero
    ]);
    if (publico) return true;

    const req = ctx.switchToHttp().getRequest<Request>();
    const token = this.extraerToken(req);
    // SOLUCIÓN 3+5: lanzamos la excepción de Nest. El filtro global le da
    // el formato de toda la API, y el 401 lleva su WWW-Authenticate.
    if (!token) throw new UnauthorizedException('Falta el token');

    try {
      // SOLUCIÓN 4: tipamos la extensión de Request en un .d.ts propio.
      req.user = await this.jwt.verifyAsync(token, { algorithms: ['RS256'] });
    } catch {
      throw new UnauthorizedException('Token inválido o caducado');
    }
    return true;
  }
}
// Y se activa en TODA la aplicación con una línea en AppModule:
//   { provide: APP_GUARD, useClass: JwtAuthGuard }
// Cerrado por defecto, abierto por excepción explícita con @Public().
Tres cosas más que no deben vivir en un middleware
  • Consultas costosas por petición. Un middleware global se ejecuta también en /health, en las peticiones OPTIONS de preflight y en las rutas que devuelven 404. Cargar el usuario de la base de datos ahí multiplica las consultas sin necesidad.
  • Lógica de negocio. Si el middleware sabe qué es un «proyecto» o una «tarea», está en el sitio equivocado: no es testeable de forma unitaria sin simular req y res, y no se puede reutilizar desde un consumidor de cola o un comando de CLI.
  • Mutaciones silenciosas del cuerpo. Un middleware que «limpia» req.body deja el ValidationPipe ciego ante lo que llegó de verdad y hace imposible depurar por qué un campo desapareció. La conversión de la entrada es trabajo de un pipe.

10.5 Guards

Un guard responde a una sola pregunta, y responderla es toda su responsabilidad: ¿tiene este llamante derecho a ejecutar este manejador en este momento? Nada más. No transforma datos, no formatea respuestas y no ejecuta casos de uso. Esta disciplina es lo que hace que la autorización de una aplicación se pueda auditar leyendo una carpeta de diez ficheros en lugar de rastreando if repartidos por cuarenta controladores.

10.5.1 CanActivate y ExecutionContext

interfaz que implementan todos los guards
export interface CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean>;
}

// ExecutionContext extiende ArgumentsHost y añade lo que hace posible la
// autorización declarativa:
interface ExecutionContext extends ArgumentsHost {
  getClass<T>(): Type<T>;          // el controlador: TasksController
  getHandler(): Function;          // el método: actualizar()
  // heredado de ArgumentsHost:
  getType<T extends string>(): T;  // 'http' | 'rpc' | 'ws' | 'graphql'
  switchToHttp(): HttpArgumentsHost;
  switchToRpc(): RpcArgumentsHost;
  switchToWs(): WsArgumentsHost;
  getArgs<T extends any[]>(): T;   // los argumentos crudos del handler
}

El mismo guard puede ejecutarse en contextos de transporte muy distintos, y ahí está el motivo de que exista switchToX() en lugar de un simple getRequest(): un guard reutilizado en un gateway de WebSockets o en un microservicio recibe objetos completamente diferentes.

TransportegetType()Cómo se accede al contextoDónde suele estar el token
HTTP (Express/Fastify)'http'ctx.switchToHttp().getRequest() y .getResponse()Cabecera Authorization o cookie
WebSockets'ws'ctx.switchToWs().getClient() y .getData()client.handshake.auth o el propio mensaje
Microservicios (RPC)'rpc'ctx.switchToRpc().getContext() y .getData()Metadatos del transporte o el payload
GraphQL'graphql'GqlExecutionContext.create(ctx).getContext()El objeto context de Apollo
Guards agnósticos del transporte Si escribes una librería interna de autorización que se usará en HTTP y en colas, empieza el guard comprobando ctx.getType() y extrae las credenciales con una función distinta por transporte. Si solo tienes HTTP —el caso del 95 % de los proyectos— no añadas esa indirección: switchToHttp() directo es más legible.

10.5.2 Valores de retorno: por qué lanzar es mejor que devolver false

Devolver false hace que Nest lance una ForbiddenException genérica, con mensaje «Forbidden resource» y código 403. Es correcto pero pobre: pierdes la distinción entre «no sé quién eres» (401) y «sé quién eres y no puedes» (403), y el cliente no recibe ninguna pista útil. Lanzar la excepción concreta es casi siempre la opción correcta.

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

  canActivate(ctx: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', ctx.getHandler());

    // FALLO GRAVE: si el endpoint no declara @Roles(), devolvemos true.
    // Parece razonable ("no pide roles, luego es libre") y es una puerta
    // abierta: cualquier endpoint nuevo al que se olvide poner el
    // decorador queda accesible para todo el mundo, en silencio.
    if (!roles) return true;

    const req = ctx.switchToHttp().getRequest();

    // FALLO 2: si no hay usuario devolvemos false → 403. Pero el problema
    // real es que NO ESTÁ AUTENTICADO: debería ser 401 y el cliente
    // debería reintentar refrescando el token. Con un 403 no lo hará.
    if (!req.user) return false;

    // FALLO 3: comparación case-sensitive contra datos externos y sin
    // normalizar; 'Admin' no coincide con 'admin' y nadie se entera.
    return roles.some((r) => req.user.roles.includes(r));
    // FALLO 4: si req.user.roles es undefined, esto lanza un TypeError
    // que acaba en un 500. Un fallo de autorización nunca debe ser un 500.
  }
}
roles.guard.tsCORRECTO
export const Roles = Reflector.createDecorator<Rol[]>();

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

  canActivate(ctx: ExecutionContext): boolean {
    // Lee el metadato en método Y clase; el método gana si ambos existen.
    const requeridos = this.reflector.getAllAndOverride(Roles, [
      ctx.getHandler(),
      ctx.getClass(),
    ]);
    // Sin @Roles() no hay restricción de rol, pero la autenticación ya la
    // impuso el guard anterior. La decisión "abierto por defecto" se toma
    // UNA vez y de forma consciente, no por accidente.
    if (!requeridos?.length) return true;

    const { user } = ctx.switchToHttp().getRequest<Request>();

    // 401 frente a 403: dos situaciones distintas, dos códigos distintos.
    if (!user) throw new UnauthorizedException();

    const propios = new Set(user.roles ?? []);   // nunca undefined
    const autorizado = requeridos.some((r) => propios.has(r));

    if (!autorizado) {
      // Mensaje útil sin filtrar información interna: decimos qué hace
      // falta, no quién eres ni qué otros roles existen en el sistema.
      throw new ForbiddenException(
        `Se requiere uno de estos roles: ${requeridos.join(', ')}`,
      );
    }
    return true;
  }
}

10.5.3 Reflector: leer metadatos del manejador

Reflector es la utilidad que convierte un decorador en una decisión. Se inyecta como cualquier servicio y ofrece cuatro métodos que resuelven cuatro necesidades distintas.

MétodoQué haceCuándo usarlo
get(clave, objetivo)Lee el metadato de un solo objetivoCuando la política solo puede declararse en el método
getAll(clave, [objetivos])Devuelve un array con el valor de cada objetivo, sin combinarCasos raros en los que necesitas saber qué declaró cada nivel
getAllAndOverride(clave, [objetivos])Devuelve el primer valor definido: el orden del array decide la precedenciaLo más habitual: [getHandler(), getClass()] para que el método anule al controlador
getAllAndMerge(clave, [objetivos])Une los valores: concatena arrays y mezcla objetosPermisos acumulativos: los del controlador más los del método
src/auth/decorators · SetMetadata frente a Reflector.createDecorator
// ── FORMA CLÁSICA: SetMetadata con una clave de tipo cadena ─────────────
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Rol[]) => SetMetadata(ROLES_KEY, roles);
// Lectura: hay que repetir la clave y anotar el genérico a mano. Si te
// equivocas en la cadena, el guard lee undefined y no falla: solo deja
// de aplicar la regla, que es el peor tipo de error posible.
const roles = this.reflector.getAllAndOverride<Rol[]>(ROLES_KEY, [
  ctx.getHandler(), ctx.getClass(),
]);

// ── FORMA MODERNA: Reflector.createDecorator (Nest 10 y posteriores) ────
export const Roles = Reflector.createDecorator<Rol[]>();
// Uso idéntico en el controlador:  @Roles(['ADMIN'])
// Lectura con tipado automático y sin claves de texto:
const roles = this.reflector.getAllAndOverride(Roles, [
  ctx.getHandler(), ctx.getClass(),
]);   // roles: Rol[] | undefined, inferido

// Matiz de firma: createDecorator<T>() genera un decorador que recibe UN
// argumento de tipo T. Para @Roles('ADMIN', 'EDITOR') con parámetros
// variádicos sigue haciendo falta SetMetadata, o se declara como array.
// Con transform se puede normalizar el valor en el momento de decorar:
export const Roles = Reflector.createDecorator<string[], Rol[]>({
  transform: (valor) => valor.map((r) => r.toUpperCase() as Rol),
});

// ── Comparación de estrategias de combinación ───────────────────────────
// @Permisos(['tarea:leer'])  en la CLASE
// @Permisos(['tarea:editar']) en el MÉTODO
// getAllAndOverride → ['tarea:editar']                  (el método manda)
// getAllAndMerge    → ['tarea:editar', 'tarea:leer']    (se acumulan)
// Elegir mal aquí es un fallo de seguridad silencioso: con merge, un
// método NO PUEDE restringir lo que abrió el controlador.

10.5.4 Cuatro guards completos de producción

Guard 1 · API key para clientes máquina. El caso más simple y el que mejor ilustra la comparación en tiempo constante: comparar secretos con === filtra información por el tiempo de ejecución.

src/auth/guards/api-key.guard.ts
import { timingSafeEqual } from 'node:crypto';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  private readonly claves: Buffer[];

  constructor(config: ConfigService) {
    // Se leen UNA vez al construir el guard, no en cada petición.
    this.claves = config
      .getOrThrow<string>('API_KEYS')
      .split(',')
      .map((k) => Buffer.from(k.trim(), 'utf8'));
  }

  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest<Request>();
    const recibida = req.header('x-api-key');
    if (!recibida) throw new UnauthorizedException('Falta la cabecera x-api-key');

    const buf = Buffer.from(recibida, 'utf8');
    const valida = this.claves.some(
      // timingSafeEqual exige longitudes iguales o lanza; comprobarlas
      // antes filtra la longitud, que es información despreciable.
      (k) => k.length === buf.length && timingSafeEqual(k, buf),
    );
    if (!valida) throw new UnauthorizedException('Clave de API no válida');
    return true;
  }
}

Guard 2 · Guard global con excepción declarativa. El patrón «cerrado por defecto» es la única configuración defendible: se protege todo y se abren agujeros de uno en uno, de forma visible en el código del endpoint. El guard aparece en 10.4.5; lo que interesa aquí es cómo se combina con los demás y por qué el orden de los APP_GUARD es una decisión de rendimiento.

src/app.module.ts · cadena de guards ordenada por coste
providers: [
  // 1 · Barato: solo criptografía en memoria, sin base de datos.
  { provide: APP_GUARD, useClass: JwtAuthGuard },
  // 2 · Barato: lee metadatos y compara arrays en memoria.
  { provide: APP_GUARD, useClass: RolesGuard },
  // 3 · Caro: consulta la base de datos. Solo se llega aquí si 1 y 2 han
  //     pasado, así que un atacante sin token NUNCA provoca la consulta.
  { provide: APP_GUARD, useClass: PermisosGuard },
]
// Los guards se evalúan en secuencia y el primero que deniega corta la
// cadena. Ordenar de barato a caro no es micro-optimización: es la
// diferencia entre absorber una avalancha de peticiones sin token y
// tumbar la base de datos con ella.

Guard 3 · Propiedad del recurso. Es el guard que evita la vulnerabilidad más común de las APIs REST: el acceso a objetos de otro usuario cambiando el identificador de la URL. La clave es que la propiedad se comprueba dentro de la consulta, no con un if después de cargar la entidad.

src/tasks/guards/task-owner.guard.ts
@Injectable()
export class TaskOwnerGuard implements CanActivate {
  constructor(private readonly em: EntityManager) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest<Request>();
    if (!req.user) throw new UnauthorizedException();

    const { projectId, id } = req.params;

    // La pertenencia va EN el where. Si escribiéramos findOne(Task, { id })
    // y luego comprobásemos task.project.id === projectId, tendríamos que
    // acordarnos de hacerlo en cada endpoint. Aquí es imposible olvidarlo.
    const tarea = await this.em.findOne(
      Task,
      { id, project: { id: projectId, members: { user: req.user.id } } },
      { populate: ['project'] },
    );

    // 404 y no 403 de forma deliberada: un 403 confirmaría que la tarea
    // existe, lo que ya es una fuga de información en recursos privados.
    if (!tarea) throw new NotFoundException('Tarea no encontrada');

    // Se guarda en la petición para que el manejador no repita la consulta.
    // Esto es una optimización REAL: ahorra un viaje a la base de datos por
    // petición. El coste es acoplamiento: el servicio recibe la entidad ya
    // cargada y hay que documentarlo (ver 10.10).
    req.recurso = tarea;
    return true;
  }
}

Guard 4 · Genérico y parametrizable. Cuando varios recursos comparten la misma regla de propiedad, el paso siguiente es un único guard que reciba por metadatos la entidad, el parámetro de ruta y el campo propietario —@Propiedad({ entidad: Task, campoDueno: 'createdBy' })— y construya el findOne a partir de ellos. Gana en reutilización y hace la regla visible en la firma del endpoint, pero solo cubre condiciones del tipo «campo X igual al usuario»; en cuanto la regla sea «es suya, o es de su departamento, o tiene rol auditor», conviene una capa de políticas como CASL (capítulo 12) o mover la decisión al caso de uso.

Un guard no puede leer el DTO validado Los pipes se ejecutan después, así que req.body dentro de un guard es la entrada cruda del cliente: sin whitelist, sin conversión de tipos y sin validar. Si una regla de autorización depende del contenido del cuerpo —«solo el autor puede cambiar el campo estado a CERRADA»— esa comprobación pertenece al servicio o al caso de uso, donde el DTO ya es de fiar. Intentar hacerlo en un guard es un patrón inseguro con apariencia de buena práctica.

10.6 Interceptores

10.6.1 El modelo de programación orientada a aspectos

Un interceptor es la implementación en NestJS de un aspecto. La programación orientada a aspectos (AOP) nace en Xerox PARC a finales de los noventa —el trabajo de Gregor Kiczales que dio lugar a AspectJ— para resolver un problema muy concreto: hay responsabilidades que atraviesan la aplicación en perpendicular a su descomposición en módulos. El registro de actividad, la medición de tiempos, la gestión de transacciones o el formato de la respuesta afectan a cientos de métodos y no pertenecen a ninguno. Si se implementan por herencia o por composición, el código se duplica en todos ellos.

La AOP propone extraer esa responsabilidad a un aspecto y declarar dónde se aplica. En vocabulario clásico: el método del controlador es el join point, el interceptor es el advice de tipo around (envuelve la ejecución), y el decorador @UseInterceptors() junto con el ámbito de registro es el pointcut que decide a qué métodos afecta. Spring lo hace con proxies dinámicos; Nest lo hace con la clase InterceptorsConsumer, que compone los interceptores en una cadena de observables anidados.

interfaz y modelo mental
export interface NestInterceptor<T = any, R = any> {
  intercept(
    context: ExecutionContext,
    next: CallHandler<T>,
  ): Observable<R> | Promise<Observable>;
}
export interface CallHandler<T = any> {
  handle(): Observable<T>;   // ejecuta el resto de la cadena y el manejador
}

// handle() es EJECUCIÓN DIFERIDA. Nada ocurre hasta que alguien se
// suscribe al observable devuelto; Nest se suscribe al final. Por eso:
//   · el código ANTES de next.handle() corre antes del manejador
//   · los operadores encadenados a handle() corren DESPUÉS
//   · si no devuelves el observable, o no llamas a handle(), el manejador
//     NO se ejecuta nunca y la petición se queda colgada
  MODELO DE CEBOLLA · tres interceptores registrados
  ═════════════════════════════════════════════════════════════════════════
   global: TransformResponse    controlador: Logging    método: Timeout

   entrada                                                        salida
   ───────────────────────────────────────────────────────────────────────
   TransformResponse  ┐  antes                        después  ┌  (4)
     Logging          ┐  antes                      después  ┌ │  (3)
       Timeout        ┐  antes                    después  ┌ │ │  (2)
         PIPES → MANEJADOR → SERVICIO → valor devuelto  ┘ │ │ │  (1)
   ───────────────────────────────────────────────────────────────────────
   El primero en entrar es el ÚLTIMO en salir.
   Consecuencia práctica: el envoltorio {data, meta} debe ir en el
   interceptor MÁS EXTERNO (el global), porque envuelve el resultado ya
   procesado por todos los demás. Si lo pones en el método, el interceptor
   global recibiría {data:{data:…}} y lo envolvería otra vez.

10.6.2 Interceptor frente a middleware

CriterioMiddlewareInterceptor
MomentoAntes del enrutadoDespués de los guards, envolviendo el manejador
Conoce el manejadorNoSí: getClass() y getHandler(), más Reflector
Acceso a la respuestaSolo al objeto res nativo; no al valor devueltoAl valor devuelto por el manejador, y puede transformarlo
Modelo asíncrononext() estilo callbackObservable con todo RxJS disponible
TransporteSolo HTTP, y con la firma del framework concretoCualquier transporte: HTTP, WebSockets, RPC, GraphQL
Puede cortar el flujoSí, respondiendo directamenteSí, devolviendo un observable propio sin llamar a handle()
Inyección de dependenciasSí, si es clase con @Injectable()Sí, siempre
Usa esto paraLibrerías de terceros, parseo, contexto asíncrono, cabecerasTodo lo demás que sea transversal y dependa de tu dominio

10.6.3 Operadores de RxJS aplicables

OperadorPara qué en un interceptor
mapTransformar el valor devuelto: envolver en {data}, ocultar campos, adaptar formatos
tapEfectos secundarios sin modificar el valor: registrar, publicar métricas, auditar. Con la forma de objeto tap({ next, error }) se cubren ambos caminos
catchErrorConvertir un error de infraestructura en una HttpException, o reintentar de otra forma. Devuelve throwError(() => e) para dejarlo pasar
timeoutCortar la espera a un umbral y lanzar RequestTimeoutException
finalizeLiberar recursos siempre, en éxito, error o cancelación. Es el finally del flujo
of / fromDevolver un valor sin ejecutar el manejador (respuesta desde caché) o adaptar una promesa
defaultIfEmptyBlindarse ante manejadores que no devuelven nada; sin él, un flujo vacío puede dejar la respuesta sin cuerpo
retry / concatMapReintentos con criterio. Úsalo con extremo cuidado: reintentar un POST no idempotente duplica datos

10.6.4 Seis interceptores de producción

1 · Envoltorio de respuesta. Uniformar el formato de salida de toda la API en un solo sitio. Registrado como APP_INTERCEPTOR para que sea el más externo.

src/common/interceptors/transform-response.interceptor.ts
export interface RespuestaApi<T> {
  data: T;
  meta: { requestId?: string; timestamp: string; duracionMs?: number };
}

@Injectable()
export class TransformResponseInterceptor<T>
  implements NestInterceptor<T, RespuestaApi<T> | T>
{
  intercept(ctx: ExecutionContext, next: CallHandler<T>) {
    // Solo HTTP: en WebSockets o RPC el envoltorio no tiene sentido.
    if (ctx.getType() !== 'http') return next.handle();

    const res = ctx.switchToHttp().getResponse<Response>();
    const inicio = process.hrtime.bigint();

    return next.handle().pipe(
      map((data) => {
        // Casos que NO se deben envolver, y son más de los que parece:
        // descargas de fichero, respuestas ya envueltas y cuerpos vacíos.
        if (data instanceof StreamableFile) return data;
        if (res.statusCode === 204 || data === undefined) return data;

        return {
          data,
          meta: {
            requestId: contextoActual()?.requestId,
            timestamp: new Date().toISOString(),
            duracionMs: Number(process.hrtime.bigint() - inicio) / 1e6,
          },
        };
      }),
    );
  }
}
// Decisión de diseño: los ERRORES no pasan por aquí (el flujo termina en
// error, no en valor). Su formato lo define el filtro de excepción, y hay
// que cuidar que ambos sean coherentes entre sí. Ver 10.8.

2 · Medición de tiempos y registro estructurado. Nótese el uso de tap con las dos ramas y de finalize, que también cubre el caso de que el cliente cierre la conexión.

src/common/interceptors/logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(ctx: ExecutionContext, next: CallHandler) {
    const req = ctx.switchToHttp().getRequest<Request>();
    const inicio = process.hrtime.bigint();
    const ms = () => Number(process.hrtime.bigint() - inicio) / 1e6;

    // Aquí SÍ tenemos el manejador: se puede registrar el caso de uso
    // real, no solo la ruta. Es oro puro para depurar en producción.
    const etiqueta = `${ctx.getClass().name}.${ctx.getHandler().name}`;

    return next.handle().pipe(
      tap({
        next: () => this.logger.log({
          msg: 'ok', etiqueta, metodo: req.method, ruta: req.url,
          ms: +ms().toFixed(1), requestId: contextoActual()?.requestId,
        }),
        error: (e) => this.logger.warn({
          msg: 'error', etiqueta, ms: +ms().toFixed(1),
          error: e?.constructor?.name, requestId: contextoActual()?.requestId,
        }),
      }),
      // Se ejecuta también si el cliente aborta la petición, caso que
      // tap({next}) y tap({error}) no cubren.
      finalize(() => this.metricas.observarDuracion(etiqueta, ms())),
    );
  }
}

3 · Timeout. Un manejador que espera indefinidamente a un servicio externo agota el pool de conexiones y arrastra a toda la aplicación. El timeout es una de las piezas de resiliencia más rentables.

src/common/interceptors/timeout.interceptor.ts
export const TiempoLimite = Reflector.createDecorator<number>();

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(ctx: ExecutionContext, next: CallHandler) {
    // El umbral es configurable por endpoint: un informe pesado necesita
    // más margen que un GET por identificador.
    const ms = this.reflector.get(TiempoLimite, ctx.getHandler()) ?? 5_000;

    return next.handle().pipe(
      timeout(ms),
      catchError((e) =>
        e instanceof TimeoutError
          ? throwError(() => new RequestTimeoutException(
              `La operación superó ${ms} ms`))
          : throwError(() => e),   // cualquier otro error se deja pasar
      ),
    );
  }
}
// LÍMITE IMPORTANTE: timeout() deja de esperar la respuesta, pero NO
// cancela el trabajo que ya está en marcha. La consulta SQL sigue
// ejecutándose en el servidor de base de datos y el INSERT puede
// completarse igualmente. Para cancelar de verdad hace falta un
// AbortSignal propagado hasta el driver, o un statement_timeout en
// PostgreSQL. Un timeout de interceptor protege al CLIENTE, no al backend.

4 · Caché. Nest ofrece CacheInterceptor en el paquete @nestjs/cache-manager (se extrajo de @nestjs/common en la versión 10), que cachea las respuestas de los GET por URL. Escribir uno propio tiene sentido cuando la clave depende del usuario o del tenant.

src/common/interceptors/cache.interceptor.ts
@Injectable()
export class UserScopedCacheInterceptor implements NestInterceptor {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly reflector: Reflector,
  ) {}

  async intercept(ctx: ExecutionContext, next: CallHandler) {
    const ttl = this.reflector.get(CacheTtl, ctx.getHandler());
    const req = ctx.switchToHttp().getRequest<Request>();
    if (!ttl || req.method !== 'GET') return next.handle();

    // La clave INCLUYE al usuario. Olvidarlo es el bug de caché clásico:
    // el primer usuario que pide /me deja su respuesta para los demás.
    const clave = `resp:${req.user?.id ?? 'anon'}:${req.originalUrl}`;

    const guardada = await this.cache.get(clave);
    // of() devuelve un valor SIN llamar a next.handle(): el manejador y
    // el servicio no se ejecutan en absoluto. Ahí está todo el ahorro.
    if (guardada !== undefined) return of(guardada);

    return next.handle().pipe(tap((valor) => this.cache.set(clave, valor, ttl)));
  }
}

5 · ClassSerializerInterceptor. Viene incluido en @nestjs/common y aplica class-transformer al valor devuelto, respetando @Exclude() y @Expose(). Es la forma más económica de garantizar que un passwordHash no salga por el cable.

uso de ClassSerializerInterceptor
// Registro global (recomendado): en AppModule
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }

export class Usuario {
  id!: string;
  email!: string;
  @Exclude() passwordHash!: string;              // nunca se serializa
  @Expose() get nombreCompleto(): string {       // campo calculado
    return `${this.nombre} ${this.apellidos}`;
  }
  @Transform(({ value }) => value?.toISOString()) creadoEn!: Date;
}

// Opciones por endpoint:
@SerializeOptions({ groups: ['admin'], excludeExtraneousValues: true })
@Get(':id') ver(@Param('id') id: string): Promise<Usuario> { … }

// ── TRES TRAMPAS DOCUMENTADAS ──────────────────────────────────────────
// 1 · Solo funciona si devuelves una INSTANCIA de la clase. Si el servicio
//     hace un `select` con QueryBuilder y devuelve objetos planos, los
//     decoradores no se aplican y @Exclude() no protege nada.
// 2 · Si devuelves una entidad de MikroORM, la serialización puede tocar
//     relaciones no cargadas y provocar consultas inesperadas (N+1) o
//     errores de proxy no inicializado. Devuelve DTOs de salida.
// 3 · Con @Res() sin passthrough no se ejecuta: te has salido del ciclo.

6 · Transacción por petición. Es el interceptor más delicado del capítulo y merece una discusión honesta, porque resuelve un problema real a cambio de mover una decisión de negocio a la capa de transporte.

src/common/interceptors/transaction.interceptor.ts
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  private static readonly MUTACIONES = ['POST', 'PATCH', 'PUT', 'DELETE'];
  constructor(private readonly em: EntityManager) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest<Request>();
    if (!TransactionInterceptor.MUTACIONES.includes(req.method)) {
      return next.handle();
    }

    return from(
      // em.transactional() crea un fork y lo propaga por el contexto
      // asíncrono (TransactionContext). Los servicios que inyectan
      // EntityManager participan en ESTA transacción sin recibirla como
      // parámetro: no hay que ensuciar las firmas del dominio.
      this.em.transactional(async () => {
        // firstValueFrom convierte el observable en promesa. Si el
        // manejador lanza, la promesa rechaza y MikroORM hace ROLLBACK.
        return firstValueFrom(next.handle());
      }),
    );
  }
}
// ── LO QUE HAY QUE SABER ANTES DE USARLO ───────────────────────────────
// · La transacción se COMMITEA antes de que corran los interceptores
//   más externos. Un error en la serialización posterior NO hará rollback:
//   el cliente verá un 500 con los datos ya guardados.
// · firstValueFrom rompe el streaming: solo sirve para respuestas con un
//   único valor. No lo apliques a endpoints que devuelvan StreamableFile.
// · Mantiene una conexión del pool ocupada durante TODA la petición,
//   incluida cualquier llamada HTTP externa que haga el servicio. Una
//   transacción abierta esperando a una pasarela de pago es un desastre
//   de concurrencia y una fuente segura de deadlocks.
// · ALTERNATIVA PREFERIBLE en dominios complejos: abrir la transacción en
//   el caso de uso con em.transactional(), donde sí se sabe qué operaciones
//   forman una unidad atómica. El límite transaccional es una decisión de
//   negocio, no de transporte.
interceptores rotosINCORRECTO
@Injectable()
export class RotoInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    // FALLO 1: no devolvemos nada. La petición se queda colgada hasta que
    // el cliente agote su propio timeout. No hay error en los logs.
    next.handle().subscribe();
  }
}

@Injectable()
export class Roto2Interceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      // FALLO 2: tap NO transforma. Quien escribe esto espera envolver la
      // respuesta y en realidad no cambia nada; el bug se descubre en
      // integración, cuando el frontend no encuentra `data`.
      tap((datos) => ({ data: datos })),
      // FALLO 3: map sin return implícito ni explícito → undefined.
      // La respuesta se queda vacía con un 200. El peor error posible:
      // el cliente recibe éxito sin datos.
      map((datos) => { const envuelto = { data: datos }; }),
    );
  }
}

@Injectable()
export class Roto3Interceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const res = ctx.switchToHttp().getResponse();
    // FALLO 4: escribimos la respuesta a mano Y devolvemos el flujo.
    // "Cannot set headers after they are sent" o doble cuerpo.
    res.status(200).json({ ok: true });
    return next.handle();
  }
}
interceptores correctosCORRECTO
@Injectable()
export class CorrectoInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    // 1 · SIEMPRE devolver el observable. Nest se encarga de suscribirse
    //     una sola vez y de enviar la respuesta.
    return next.handle().pipe(
      // 2 · map para transformar, tap para efectos secundarios. La regla
      //     es literal: si cambias el valor, es map.
      map((datos) => ({ data: datos })),
      // 3 · protección ante manejadores que no devuelven nada
      defaultIfEmpty(null),
      // 4 · si necesitas tocar la respuesta, hazlo con la API de Nest y
      //     sin escribir el cuerpo: solo cabeceras o código de estado.
      tap(() => ctx.switchToHttp().getResponse()
        .setHeader('x-request-id', contextoActual()?.requestId ?? '')),
    );
  }
}

// Y si de verdad necesitas control total de la respuesta, sal del ciclo de
// forma explícita y consciente, sabiendo que pierdes los interceptores:
@Get('export')
async exportar(@Res() res: Response) {
  res.setHeader('Content-Type', 'text/csv');
  await this.servicio.volcarCsv(res);   // streaming directo al socket
}
// Con @Res({ passthrough: true }) conservas el ciclo (puedes devolver un
// valor y Nest lo serializa) y aun así puedes fijar cabeceras y cookies.
// Es la opción correcta el 90 % de las veces que crees necesitar @Res().

10.7 Pipes y validación

Un pipe es una función de conversión con dos posibles finales: devolver un valor —probablemente distinto del que recibió— o lanzar una excepción. Nada más. Esa simplicidad es la que permite que la validación de una API entera se resuelva con una línea en main.ts y unas cuantas anotaciones en clases planas.

10.7.1 PipeTransform, ArgumentMetadata y los dos tipos de pipe

interfaz, metadatos y un pipe propio de cada tipo
export interface PipeTransform<T = any, R = any> {
  transform(value: T, metadata: ArgumentMetadata): R;   // puede ser async
}
export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';  // de qué decorador viene
  metatype?: Type<unknown>;   // el tipo declarado en TypeScript, si existe
  data?: string;              // el argumento del decorador: @Param('id') → 'id'
}

// ── PIPE DE TRANSFORMACIÓN: cambia el valor, no juzga ──────────────────
@Injectable()
export class TrimPipe implements PipeTransform<unknown, unknown> {
  transform(valor: unknown, { type }: ArgumentMetadata): unknown {
    if (type !== 'body' || valor === null || typeof valor !== 'object') return valor;
    // Recortar espacios en la entrada evita el clásico "  ana@ejemplo.com "
    // que crea usuarios duplicados y no encuentra al iniciar sesión.
    return Object.fromEntries(
      Object.entries(valor).map(([k, v]) => [k, typeof v === 'string' ? v.trim() : v]),
    );
  }
}

// ── PIPE DE VALIDACIÓN: no cambia el valor, decide si es aceptable ─────
@Injectable()
export class ParseObjectIdPipe implements PipeTransform<string, string> {
  transform(valor: string, metadata: ArgumentMetadata): string {
    if (!/^[0-9a-f]{24}$/i.test(valor)) {
      // El mensaje incluye QUÉ parámetro falla usando metadata.data: es la
      // diferencia entre un error accionable y un "Bad Request" inútil.
      throw new BadRequestException(
        `El parámetro '${metadata.data}' no es un ObjectId válido`);
    }
    return valor;
  }
}
// Un pipe puede hacer las dos cosas (ValidationPipe transforma Y valida),
// pero conviene saber cuál es su intención principal: los de validación
// deben devolver el valor intacto para no sorprender a quien los combine.
Sin metatype no hay validación El ValidationPipe necesita metadata.metatype para saber qué clase instanciar y qué reglas aplicar. Si escribes @Body() dto: any, si el tipo es una interface o un type (que desaparecen al compilar), o si omites la anotación de tipo, el pipe recibe metatype vacío o inútil y devuelve el valor sin validar, sin avisar. Es el fallo número uno de esta sección: la validación no falla, simplemente no ocurre.

10.7.2 Los pipes integrados

PipeQué haceDetalle que importa
ValidationPipeInstancia el DTO con class-transformer y lo valida con class-validatorNecesita las dos librerías instaladas y un DTO que sea una clase
ParseIntPipeCadena a enteroRechaza '12.5' y '12abc'; acepta '+12' y espacios alrededor
ParseFloatPipeCadena a número decimalDepende de la coma o el punto según el cliente: no vale para importes localizados
ParseBoolPipeCadena a booleanoSolo acepta 'true', 'false', '1' y '0'; 'yes' lanza 400
ParseArrayPipeCadena separada o array a array tipadoOpciones items, separator, optional; valida cada elemento si items es un DTO
ParseUUIDPipeValida formato UUIDOpción version: '3' | '4' | '5' | '7' según la versión de Nest; imprescindible antes de tocar la base de datos
ParseEnumPipeValida que el valor pertenezca a un enumCompara contra los valores del enum, no contra sus claves
DefaultValuePipeSustituye undefined y null por un valor por defectoDebe ir antes del pipe de conversión en la lista de argumentos
ParseFilePipeValida ficheros subidos mediante validadores encadenadosSe usa con @UploadedFile() y validadores como MaxFileSizeValidator y FileTypeValidator
src/tasks/tasks.controller.ts · pipes integrados en su sitio
@Get()
listar(
  // El orden ES la cadena: primero rellena el hueco, luego convierte.
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
  // Sin optional:true, ausencia de parámetro provoca 400 en lugar de undefined.
  @Query('tags', new ParseArrayPipe({ items: String, separator: ',', optional: true }))
  tags?: string[],
  @Query('estado', new ParseEnumPipe(EstadoTarea, { optional: true }))
  estado?: EstadoTarea,
) { /* … */ }

@Get(':id')
// Validar el formato ANTES de consultar evita que un 'DROP TABLE' llegue al
// driver y, sobre todo, convierte un 500 por tipo inválido en un 400 limpio.
ver(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { /* … */ }

@Post(':id/adjunto')
@UseInterceptors(FileInterceptor('fichero'))
subir(
  @UploadedFile(new ParseFilePipe({
    validators: [
      new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
      new FileTypeValidator({ fileType: 'application/pdf' }),
    ],
    fileIsRequired: true,
  })) fichero: Express.Multer.File,
) { /* … */ }
// FileTypeValidator comprueba el MIME que DECLARA el cliente: es una
// comodidad, no una medida de seguridad. Verifica la firma real del fichero
// en el servicio si el contenido importa.

10.7.3 ValidationPipe a fondo

OpciónEfectoRecomendación
whitelistElimina del objeto toda propiedad sin decorador de validacióntrue siempre. Es la defensa contra asignación masiva
forbidNonWhitelistedEn lugar de eliminarlas, responde 400 indicando qué propiedad sobratrue. Convierte un fallo silencioso del cliente en un error explícito
transformDevuelve una instancia del DTO en lugar de un objeto planotrue. Sin esto, los métodos y getters del DTO no existen
transformOptions.enableImplicitConversionConvierte tipos primitivos según el tipo declarado, sin @Type()Evítalo. Ver el par de código siguiente
disableErrorMessagesOculta el detalle de los errores de validaciónActívalo solo en producción si el esquema es sensible; complica el soporte
exceptionFactoryFunción que recibe los ValidationError y devuelve la excepción a lanzarÚsala para emitir 422 con Problem Details y errores por campo
stopAtFirstErrorPara en el primer error de cada propiedadReduce el ruido; recuerda que no para en la primera propiedad, sino en el primer error de cada una
groupsAplica solo los decoradores del grupo indicadoPermite reutilizar un DTO en creación y actualización, a costa de legibilidad
errorHttpStatusCodeCambia el 400 por otro código422 si tu API distingue «peticion mal formada» de «semánticamente inválida»
skipMissingPropertiesNo valida propiedades ausentesPeligroso: convierte campos obligatorios en opcionales. Usa PartialType
forbidUnknownValuesRechaza objetos de los que no se conoce metadato algunoDéjalo activado (valor por defecto en class-validator 0.14): evita objetos que pasan la validación sin ser validados
validateCustomDecoratorsAplica la validación a los argumentos de decoradores propiosNecesario si validas datos extraídos con createParamDecorator
main.ts + dtoINCORRECTO
app.useGlobalPipes(new ValidationPipe({
  // Sin whitelist: todo lo que el cliente envíe llega al DTO.
  transform: true,
  transformOptions: {
    // El atajo "para no escribir @Type en cada campo".
    enableImplicitConversion: true,
  },
}));

export class ActualizarTareaDto {
  @IsString() titulo?: string;
  @IsInt() @Min(0) horas?: number;
  @IsBoolean() urgente?: boolean;
}

// LO QUE OCURRE DE VERDAD con enableImplicitConversion:
// · {"titulo": 12345}  → se convierte a "12345" y @IsString() PASA.
//   El tipo declarado gana a la validación: has desactivado @IsString().
// · {"horas": "tres"}  → Number('tres') es NaN; @IsInt() lo detecta, pero
//   {"horas": ""} pasa a 0 y @Min(0) lo acepta como válido.
// · {"urgente": "no"}  → Boolean('no') es true. Cualquier cadena no vacía
//   se convierte en true, incluida "false".
// · Sin whitelist, {"rol": "ADMIN"} sobrevive en el objeto y cualquier
//   em.assign(entidad, dto) posterior escala privilegios.
// Resultado: la API acepta basura, la guarda, y el error aparece tres
// capas más abajo o directamente en un informe a fin de mes.
main.ts + dtoCORRECTO
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,             // elimina lo no declarado
  forbidNonWhitelisted: true,  // y avisa de que sobraba
  transform: true,             // instancia real del DTO
  stopAtFirstError: true,
  // Conversión EXPLÍCITA campo a campo con @Type, nunca implícita.
  exceptionFactory: (errores: ValidationError[]) =>
    new UnprocessableEntityException({
      type: 'https://api.ejemplo.com/errores/validacion',
      title: 'Datos de entrada no válidos',
      status: 422,
      errors: errores.map((e) => ({
        campo: e.property,
        mensajes: Object.values(e.constraints ?? {}),
      })),
    }),
}));

export class ActualizarTareaDto {
  @IsOptional() @IsString() @Length(3, 120) titulo?: string;
  // El query string y los formularios siempre llegan como cadena: la
  // conversión se declara donde se necesita y solo donde se necesita.
  @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999) horas?: number;
  @IsOptional() @Transform(({ value }) => value === 'true' || value === true)
  @IsBoolean() urgente?: boolean;
}
// Ahora {"titulo": 12345} devuelve 422 con "titulo must be a string", que
// es exactamente lo que el cliente necesita saber para corregirlo.

10.7.4 class-validator: catálogo, anidamiento y validadores propios

FamiliaDecoradores más usados
Presencia@IsDefined, @IsOptional, @IsNotEmpty, @Allow, @ValidateIf
Tipos primitivos@IsString, @IsInt, @IsNumber, @IsBoolean, @IsDate, @IsEnum, @IsArray, @IsObject
Números@Min, @Max, @IsPositive, @IsNegative, @IsDivisibleBy, @IsNumber({ maxDecimalPlaces: 2 })
Cadenas@Length, @MinLength, @MaxLength, @Matches, @IsEmail, @IsUrl, @IsUUID, @IsJSON, @IsStrongPassword, @IsIn
Fechas@IsISO8601, @MinDate, @MaxDate (con @Type(() => Date) para que llegue como Date)
Colecciones@ArrayNotEmpty, @ArrayMinSize, @ArrayMaxSize, @ArrayUnique, y { each: true } en cualquier decorador
Objetos anidados@ValidateNested junto con @Type(() => Clase) de class-transformer
Comparación entre campos@Validate(ConstraintPropia) o un validador propio que reciba args.object
src/tasks/dto/crear-tarea.dto.ts · anidamiento y condicionales
class DireccionDto {
  @IsString() @Length(1, 200) calle!: string;
  @Matches(/^\d{5}$/, { message: 'El código postal debe tener 5 dígitos' })
  codigoPostal!: string;
}

export class CrearFacturaDto {
  @IsEnum(TipoCliente) tipo!: TipoCliente;

  // Condicional: el NIF solo se exige (y solo se valida) para empresas.
  @ValidateIf((o: CrearFacturaDto) => o.tipo === TipoCliente.EMPRESA)
  @IsString() @Matches(/^[A-Z]\d{8}$/) nif?: string;

  // Objeto anidado: hacen falta LAS DOS anotaciones.
  // · @Type indica a class-transformer qué clase instanciar.
  // · @ValidateNested le dice a class-validator que entre dentro.
  // Sin @Type, el hijo sigue siendo un objeto plano; class-validator no
  // encuentra metadatos y (con forbidUnknownValues activo) lanza un error
  // confuso del tipo "an unknown value was passed to the validate function".
  @ValidateNested() @Type(() => DireccionDto) direccion!: DireccionDto;

  // Array de objetos anidados: each: true en el ValidateNested.
  @IsArray() @ArrayNotEmpty() @ArrayMaxSize(100)
  @ValidateNested({ each: true }) @Type(() => LineaDto) lineas!: LineaDto[];

  // Array de primitivos: each: true en el decorador del tipo.
  @IsOptional() @IsUUID('4', { each: true }) etiquetas?: string[];
}
src/common/validators/es-unico.validator.ts · validador asíncrono con inyección
// 1 · La restricción es un provider de Nest, así que puede inyectar el EM.
@ValidatorConstraint({ name: 'EsUnico', async: true })
@Injectable()
export class EsUnicoConstraint implements ValidatorConstraintInterface {
  constructor(private readonly em: EntityManager) {}

  async validate(valor: unknown, args: ValidationArguments): Promise<boolean> {
    const [entidad, campo] = args.constraints as [EntityName<object>, string];
    if (valor === undefined || valor === null) return true;   // eso lo dice @IsOptional
    const existentes = await this.em.count(entidad, { [campo]: valor });
    return existentes === 0;
  }
  defaultMessage(args: ValidationArguments): string {
    return `Ya existe un registro con ${args.property} = ${args.value}`;
  }
}

export function EsUnico(entidad: EntityName<object>, campo: string, opts?: ValidationOptions) {
  return (objeto: object, propiedad: string) => registerDecorator({
    target: objeto.constructor, propertyName: propiedad,
    constraints: [entidad, campo], validator: EsUnicoConstraint,
    async: true, options: opts,
  });
}

// 2 · Sin esta línea en main.ts, class-validator instancia la restricción
//     con new y el EntityManager llega undefined. Es EL requisito olvidado.
useContainer(app.select(AppModule), { fallbackOnErrors: true });

// 3 · Uso:  @IsEmail() @EsUnico(Usuario, 'email') email!: string;
//
// ADVERTENCIA DE CONCURRENCIA: esto es una comprobación TOCTOU. Entre la
// validación y el INSERT hay una ventana en la que otra petición puede
// insertar el mismo valor. La validación mejora el mensaje de error; la
// GARANTÍA la da el índice único en la base de datos, y su violación se
// traduce a 409 en el filtro de excepción (ver 10.8.4). Hacen falta las dos.
// Coste añadido: una consulta por campo y por petición, también cuando el
// resto del DTO es inválido. Con muchos validadores asíncronos, considera
// mover la comprobación al caso de uso.

10.7.5 class-transformer y el problema de los query params

Todo lo que viaja en la URL o en un formulario es texto. No existe el número 42 en un query string: existe la cadena "42". Y como TypeScript borra los tipos al compilar, nadie convierte nada por ti.

src/common/dto/paginacion.dto.ts · DTO de paginación reutilizable
import { Expose, Transform, Type, plainToInstance } from 'class-transformer';

export class PaginacionDto {
  // @Type es la conversión declarativa correcta: solo este campo, solo a número.
  @IsOptional() @Type(() => Number) @IsInt() @Min(1) page: number = 1;

  @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit: number = 20;

  // @Transform permite lógica arbitraria: normalizar, partir, sanear.
  @IsOptional() @IsIn(['ASC', 'DESC'])
  @Transform(({ value }) => String(value ?? 'DESC').toUpperCase())
  orden: 'ASC' | 'DESC' = 'DESC';

  // 'a,b,c' → ['a','b','c'] y también acepta ?tag=a&tag=b (array real).
  @IsOptional() @IsString({ each: true })
  @Transform(({ value }) => Array.isArray(value) ? value : String(value).split(','))
  tags?: string[];

  // Getters que el servicio y el repositorio consumen directamente. Solo
  // existen si ValidationPipe se configuró con transform: true.
  @Exclude() get offset(): number { return (this.page - 1) * this.limit; }
}

// Uso en el controlador: @Get() listar(@Query() q: PaginacionDto) { … }
// Y la reutilización por composición, no por herencia múltiple:
export class ListarTareasDto extends IntersectionType(PaginacionDto, FiltroTareasDto) {}

// plainToInstance es lo que ValidationPipe usa por debajo. Conviene conocerlo
// para los casos en que el dato NO entra por HTTP (una cola, un fichero CSV):
const dto = plainToInstance(PaginacionDto, mensaje.payload, {
  excludeExtraneousValues: false,   // true exige @Expose() en cada campo
});
await validateOrReject(dto);        // misma validación, fuera del ciclo HTTP
@Exclude() y @Expose() son direccionales @Exclude() impide que un campo salga en la serialización (entidad → JSON) y @Expose() lo incluye o lo renombra con { name: 'created_at' }. Con excludeExtraneousValues: true se invierte la política por defecto: solo lo marcado con @Expose() sobrevive, lo que es la opción más segura para DTOs de salida. Ten cuidado al usar la misma clase para entrada y salida: acabarás con un objeto que ni valida bien ni serializa bien.

10.7.6 La alternativa: Zod

src/common/pipes/zod-validation.pipe.ts
import { z, ZodSchema } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private readonly esquema: ZodSchema) {}

  transform(valor: unknown) {
    const r = this.esquema.safeParse(valor);
    if (!r.success) {
      throw new UnprocessableEntityException({
        title: 'Datos de entrada no válidos',
        // El árbol de errores de Zod ya viene con rutas y mensajes.
        errors: r.error.issues.map((i) => ({ campo: i.path.join('.'), mensaje: i.message })),
      });
    }
    return r.data;   // devuelve el valor YA convertido y tipado
  }
}

export const crearTareaSchema = z.object({
  titulo: z.string().trim().min(3).max(120),
  // La conversión es parte del esquema, no un decorador aparte.
  horas: z.coerce.number().int().min(0).max(999).optional(),
  etiquetas: z.array(z.string().uuid()).max(10).default([]),
  // Reglas entre campos, que con class-validator exigen un validador propio:
}).refine((d) => !d.fin || !d.inicio || d.fin > d.inicio, {
  message: 'La fecha de fin debe ser posterior al inicio', path: ['fin'],
});

// El tipo se DERIVA del esquema: imposible que se desincronicen.
export type CrearTareaDto = z.infer<typeof crearTareaSchema>;

// Uso: @Post() crear(@Body(new ZodValidationPipe(crearTareaSchema)) dto: CrearTareaDto)
Criterioclass-validator + class-transformerZod
Integración con NestNativa: ValidationPipe viene de fábricaRequiere un pipe propio o el paquete nestjs-zod
Fuente de la verdadLa clase; el tipo y las reglas pueden desincronizarseEl esquema; el tipo se infiere con z.infer y no puede desviarse
Conversión de tiposDecoradores separados (@Type, @Transform) y la trampa de la conversión implícitaIntegrada y explícita con z.coerce
Reglas entre camposRequiere validadores propios o ValidationArgumentsrefine y superRefine, directo
Validación asíncrona con inyecciónNatural con useContainer y un @Injectable()Incómoda: hay que construir el esquema en una factoría que capture las dependencias
Swagger / OpenAPIAutomático con @nestjs/swagger y el plugin de CLINecesita nestjs-zod o un generador de esquemas aparte
Dependencias y arranquereflect-metadata, decoradores y emitDecoratorMetadata en tsconfigNinguna magia de metadatos; funciona igual fuera de Nest
Reutilización en el frontendPosible, pero arrastra decoradores al bundle de AngularExcelente: el mismo esquema valida en Angular y en Nest

Recomendación honesta: si empiezas un proyecto Nest convencional con Swagger, class-validator es el camino de menor resistencia y todo el ecosistema lo asume. Si compartes tipos entre Angular y Nest en un monorepo y valoras que el tipo y la validación sean el mismo artefacto, Zod es superior. Lo que no debes hacer es mezclar ambos en el mismo proyecto: dos formatos de error, dos modelos mentales y dos sitios donde buscar.

10.7.7 DTOs: qué son y por qué no son tus entidades

Un DTO (Data Transfer Object) es un objeto cuyo único propósito es describir la forma de un dato que cruza una frontera. No tiene comportamiento de negocio, no tiene identidad y no se persiste. Su valor está precisamente en ser un contrato explícito y estable, independiente de cómo estén organizadas las tablas por debajo.

tasks.controller.tsINCORRECTO
// La entidad hace de DTO de entrada Y de salida.
@Post()
async crear(@Body() tarea: Task) {          // (1)
  return this.em.persistAndFlush(tarea);    // (2)
}
@Patch(':id')
async actualizar(@Param('id') id: string, @Body() cambios: Task) {
  const t = await this.em.findOneOrFail(Task, id);
  wrap(t).assign(cambios);                  // (3)
  await this.em.flush();
  return t;                                 // (4)
}
// (1) La entidad no tiene decoradores de class-validator, así que el
//     ValidationPipe no valida NADA. Además whitelist no elimina nada
//     porque no hay lista blanca que aplicar.
// (2) El cliente puede enviar id, createdAt, version, owner… y campos
//     que ni existen en la API pública.
// (3) Asignación masiva: {"owner": "otro-usuario", "estado": "FACTURADA"}
//     escala privilegios y salta la máquina de estados del dominio.
// (4) La respuesta expone la forma interna de la base de datos: nombres de
//     columnas, claves foráneas, campos internos. El día que renombres una
//     columna, rompes a todos los clientes. Y si la entidad tiene una
//     relación no cargada, la serialización dispara consultas o revienta.
dto/ + tasks.controller.tsCORRECTO
// ── ENTRADA: contrato cerrado, solo lo que el cliente puede decidir ────
export class CrearTareaDto {
  @IsString() @Length(3, 120) titulo!: string;
  @IsOptional() @IsString() @MaxLength(2000) descripcion?: string;
  @IsOptional() @IsEnum(Prioridad) prioridad?: Prioridad;
  @IsOptional() @Type(() => Date) @IsDate() @MinDate(() => new Date()) vence?: Date;
}
// Derivados: una sola definición, cuatro variantes sin duplicar reglas.
export class ActualizarTareaDto extends PartialType(CrearTareaDto) {}
export class TareaRapidaDto extends PickType(CrearTareaDto, ['titulo']) {}
export class ImportarTareaDto extends OmitType(CrearTareaDto, ['vence']) {}
export class ListarTareasDto extends IntersectionType(PaginacionDto, FiltroDto) {}

// ── SALIDA: contrato estable, desacoplado del esquema de la base ───────
export class TareaRespuestaDto {
  @Expose() id!: string;
  @Expose() titulo!: string;
  @Expose() estado!: EstadoTarea;
  @Expose() @Type(() => UsuarioResumenDto) responsable?: UsuarioResumenDto;
  static desde(t: Task): TareaRespuestaDto {
    return plainToInstance(TareaRespuestaDto, t, { excludeExtraneousValues: true });
  }
}

@Post()
async crear(@Body() dto: CrearTareaDto): Promise<TareaRespuestaDto> {
  // El servicio recibe datos validados y decide qué campos se asignan.
  return TareaRespuestaDto.desde(await this.tareas.crear(dto, this.usuarioActual));
}
// Importa PartialType y compañía de @nestjs/swagger si usas OpenAPI
// (conserva los metadatos de @ApiProperty); si no, de @nestjs/mapped-types.

10.8 Filtros de excepción

10.8.1 HttpException y las excepciones integradas

Toda la gestión de errores de Nest se apoya en una clase base: HttpException, que empareja un cuerpo de respuesta con un código de estado. El manejador de excepciones por defecto aplica una regla simple: si el error es una HttpException, usa su código; si es cualquier otra cosa, responde 500 y registra la traza.

construcción y lectura de HttpException
// Firma: (response: string | object, status: number, options?: HttpExceptionOptions)
throw new HttpException('Servicio en mantenimiento', HttpStatus.SERVICE_UNAVAILABLE);

// Cuerpo estructurado en lugar de una simple cadena:
throw new BadRequestException({ code: 'TAREA_CERRADA', detalle: 'No admite cambios' });

// Nest 9 y posteriores admiten cause y description, muy útiles para no
// perder el error original al traducirlo:
throw new InternalServerErrorException('Fallo al contactar con el ERP', {
  cause: errorOriginal,          // se conserva para el log, NO se serializa
  description: 'ERP timeout',
});

// Dentro de un filtro:
exception.getStatus();     // number
exception.getResponse();   // string | object, tal cual se construyó
exception.cause;           // el error original, si se pasó
ExcepciónCódigoCuándo usarla
BadRequestException400Sintaxis o forma incorrecta de la petición
UnauthorizedException401No autenticado: falta el token, es inválido o ha caducado
ForbiddenException403Autenticado pero sin permiso para esta operación
NotFoundException404El recurso no existe (o no debe revelarse que existe)
MethodNotAllowedException405La ruta existe pero no admite ese verbo HTTP
NotAcceptableException406No se puede satisfacer el Accept del cliente
RequestTimeoutException408La operación excedió el tiempo límite del servidor
ConflictException409Conflicto de estado: duplicado, versión obsoleta, transición inválida
GoneException410Existió y se eliminó de forma permanente
PreconditionFailedException412Falló un If-Match o If-Unmodified-Since
PayloadTooLargeException413El cuerpo supera el límite configurado
UnsupportedMediaTypeException415Content-Type no soportado
ImATeapotException418Broma de la RFC 2324; útil como marcador en pruebas
UnprocessableEntityException422Bien formada pero semánticamente inválida: el 400 «de validación»
InternalServerErrorException500Fallo no previsto del servidor
NotImplementedException501Endpoint declarado pero sin implementar
BadGatewayException502Un servicio del que dependes devolvió algo inválido
ServiceUnavailableException503Sobrecarga o mantenimiento; acompáñalo de Retry-After
GatewayTimeoutException504Un servicio del que dependes no respondió a tiempo
HttpVersionNotSupportedException505Versión de HTTP no soportada

10.8.2 Excepciones de dominio: por qué el dominio no lanza HttpException

tasks.service.tsINCORRECTO
import { ConflictException, NotFoundException } from '@nestjs/common';

@Injectable()
export class TasksService {
  async cerrar(id: string): Promise<Task> {
    const t = await this.em.findOne(Task, id);
    // El servicio importa @nestjs/common y decide códigos HTTP.
    if (!t) throw new NotFoundException('Tarea no encontrada');
    if (t.estado === EstadoTarea.CERRADA) {
      throw new ConflictException('Ya estaba cerrada');
    }
    // Consecuencias:
    // · El dominio depende del framework web. Reutilizar este servicio
    //   desde un consumidor de BullMQ o un comando de CLI arrastra
    //   semántica HTTP donde no existe el concepto de "409".
    // · El test unitario del caso de uso comprueba códigos HTTP, que no
    //   son parte de la regla de negocio.
    // · La decisión "esto es un 409" queda repartida por cien servicios;
    //   cambiarla a 422 obliga a tocar cien ficheros.
    t.estado = EstadoTarea.CERRADA;
    await this.em.flush();
    return t;
  }
}
domain/errors.ts + service + filtroCORRECTO
// 1 · Errores de dominio: cero dependencias del framework.
export abstract class ErrorDeDominio extends Error {
  abstract readonly codigo: string;
  constructor(mensaje: string, readonly contexto?: Record<string, unknown>) {
    super(mensaje);
    this.name = new.target.name;   // conserva el nombre real en la traza
  }
}
export class RecursoNoEncontrado extends ErrorDeDominio {
  readonly codigo = 'RECURSO_NO_ENCONTRADO';
}
export class TransicionInvalida extends ErrorDeDominio {
  readonly codigo = 'TRANSICION_INVALIDA';
}

// 2 · El servicio habla el lenguaje del negocio.
if (!t) throw new RecursoNoEncontrado('Tarea no encontrada', { id });
if (t.estado === EstadoTarea.CERRADA) {
  throw new TransicionInvalida('La tarea ya está cerrada', { id, estado: t.estado });
}

// 3 · La traducción a HTTP vive en UN solo sitio: el filtro.
const MAPA_DOMINIO: Record<string, number> = {
  RECURSO_NO_ENCONTRADO: 404,
  TRANSICION_INVALIDA: 409,
  REGLA_DE_NEGOCIO: 422,
  PERMISO_INSUFICIENTE: 403,
};
// Y el mismo error, consumido desde una cola, se traduce a un reintento o
// a una cola de mensajes fallidos sin tocar el dominio.

10.8.3 Filtro global con Problem Details (RFC 9457)

La RFC 9457 —que sustituye a la 7807— define un formato estándar para los errores de una API HTTP: un objeto JSON con type, title, status, detail e instance, servido con Content-Type: application/problem+json y ampliable con campos propios. Adoptarlo cuesta una tarde y ahorra que cada cliente invente su propio manejo de errores.

src/common/filters/problem-details.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';

// @Catch() sin argumentos captura absolutamente todo, incluidos los errores
// que no heredan de Error. Es lo que se quiere en el filtro global.
@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
  private readonly logger = new Logger('Excepciones');
  constructor(
    // HttpAdapterHost hace el filtro independiente de Express o Fastify:
    // no usamos res.status().json(), sino la API del adaptador.
    private readonly adapterHost: HttpAdapterHost,
    private readonly config: ConfigService,
  ) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    // Un filtro global también recibe errores de contextos no HTTP.
    if (host.getType() !== 'http') throw exception;

    const { httpAdapter } = this.adapterHost;
    const ctx = host.switchToHttp();
    const req = ctx.getRequest<Request>();
    const produccion = this.config.get('NODE_ENV') === 'production';
    const requestId = contextoActual()?.requestId;

    const { status, cuerpo } = this.normalizar(exception, produccion);

    // Registro: los 5xx con traza completa, los 4xx en nivel bajo. Un 404
    // no es un incidente; un 500 sí, y sin traza no se puede diagnosticar.
    if (status >= 500) {
      this.logger.error(
        { requestId, ruta: req.url, metodo: req.method, ...cuerpo },
        exception instanceof Error ? exception.stack : String(exception),
      );
    } else {
      this.logger.debug({ requestId, ruta: req.url, status, code: cuerpo.code });
    }

    httpAdapter.setHeader?.(ctx.getResponse(), 'Content-Type', 'application/problem+json');
    httpAdapter.reply(
      ctx.getResponse(),
      { ...cuerpo, status, instance: req.url, requestId, timestamp: new Date().toISOString() },
      status,
    );
  }

  private normalizar(e: unknown, produccion: boolean) {
    if (e instanceof HttpException) {
      const r = e.getResponse();
      const base = typeof r === 'string' ? { title: r } : (r as Record<string, unknown>);
      return { status: e.getStatus(), cuerpo: { type: 'about:blank', ...base } };
    }
    if (e instanceof ErrorDeDominio) {
      return {
        status: MAPA_DOMINIO[e.codigo] ?? 422,
        cuerpo: { type: `https://api.ejemplo.com/errores/${e.codigo.toLowerCase()}`,
                  title: e.message, code: e.codigo },
      };
    }
    // Cualquier otra cosa es un fallo nuestro: mensaje genérico. NUNCA
    // e.message, que puede contener SQL, rutas del servidor o secretos.
    return {
      status: HttpStatus.INTERNAL_SERVER_ERROR,
      cuerpo: {
        type: 'https://api.ejemplo.com/errores/interno',
        title: 'Error interno del servidor',
        // El detalle solo en desarrollo, y jamás la traza al cliente.
        ...(produccion ? {} : { detail: e instanceof Error ? e.message : String(e) }),
      },
    };
  }
}
// Registro con inyección: { provide: APP_FILTER, useClass: ProblemDetailsFilter }
// Si prefieres reutilizar el comportamiento por defecto de Nest para lo que
// no sepas tratar, extiende BaseExceptionFilter y llama a super.catch().

10.8.4 Traducir los errores de MikroORM

Error de MikroORMCausaRespuesta correcta
NotFoundErrorfindOneOrFail sin resultado404 con mensaje genérico
UniqueConstraintViolationExceptionÍndice único violado (correo repetido)409, indicando el campo si es público
ForeignKeyConstraintViolationExceptionReferencia inexistente o borrado con hijos409 al borrar, 422 si el identificador enviado no existe
NotNullConstraintViolationExceptionCampo obligatorio a nulo422: es un fallo de validación que se escapó del DTO
CheckConstraintViolationExceptionRegla CHECK incumplida422
OptimisticLockErrorColisión de @Property({ version: true })409 con Retry-After o instrucción de recargar
DeadlockException / LockWaitTimeoutExceptionConcurrencia en la base de datos503 o 409 y reintento con retroceso exponencial
DriverException (resto)Fallo de conexión, sintaxis, permisos500 genérico y alerta: es un error nuestro
src/common/filters/mikro-orm-exception.filter.ts
import { NotFoundError, UniqueConstraintViolationException,
  ForeignKeyConstraintViolationException, NotNullConstraintViolationException,
  OptimisticLockError } from '@mikro-orm/core';

// @Catch con varias clases: solo captura estas, el resto va al filtro global.
@Catch(NotFoundError, UniqueConstraintViolationException,
       ForeignKeyConstraintViolationException, NotNullConstraintViolationException,
       OptimisticLockError)
export class MikroOrmExceptionFilter implements ExceptionFilter {
  catch(e: Error, host: ArgumentsHost): never {
    // Este filtro no responde: TRADUCE y relanza para que el filtro global
    // aplique el formato Problem Details. Un solo formato en toda la API.
    if (e instanceof NotFoundError) {
      throw new NotFoundException('Recurso no encontrado');
    }
    if (e instanceof UniqueConstraintViolationException) {
      // El detalle del driver contiene el nombre del índice y a veces el
      // valor: útil en el log, inaceptable en la respuesta.
      throw new ConflictException({
        code: 'DUPLICADO', title: 'Ya existe un registro con esos datos',
        campo: this.campoDelIndice(e),
      });
    }
    if (e instanceof ForeignKeyConstraintViolationException) {
      throw new ConflictException({ code: 'REFERENCIA_EN_USO',
        title: 'La operación viola una relación existente' });
    }
    if (e instanceof NotNullConstraintViolationException) {
      throw new UnprocessableEntityException({ code: 'CAMPO_OBLIGATORIO',
        title: 'Falta un campo obligatorio' });
    }
    throw new ConflictException({ code: 'CONFLICTO_DE_VERSION',
      title: 'El recurso ha cambiado; recárgalo e inténtalo de nuevo' });
  }
  private campoDelIndice(e: Error): string | undefined {
    return /\(([^)]+)\)/.exec((e as any).sqlMessage ?? e.message)?.[1];
  }
}
// Registro: { provide: APP_FILTER, useClass: MikroOrmExceptionFilter }
// junto con ProblemDetailsFilter. Los @Catch() son DISJUNTOS, así que no
// hay ambigüedad sobre quién atiende cada error.

10.9 Ejemplo integrado de producción

Todo lo anterior junto en un endpoint real: PATCH /projects/:projectId/tasks/:id. Sigue la petición de arriba abajo y fíjate en el momento exacto en que actúa cada pieza y en qué información tiene disponible.

  PATCH /projects/9/tasks/42        Authorization: Bearer eyJ…
  {"estado":"HECHA","horas":"3","borrado":true}
  ─────────────────────────────────────────────────────────────────────────
   t0   RequestIdMiddleware        genera req-a1b2 y abre el AsyncLocalStorage
   t1   MikroOrmContextMiddleware  fork del EntityManager para esta petición
   t2   JwtAuthGuard               verifica la firma → req.user = {id, roles}
   t3   RolesGuard                 @Roles(['EDITOR']) contra req.user.roles
   t4   TaskOwnerGuard             1 consulta: ¿la tarea 42 es del proyecto 9
                                   y el usuario es miembro? → req.recurso
   t5   LoggingInterceptor         arranca el cronómetro
   t6   TransactionInterceptor     BEGIN (es un PATCH)
   t7   TransformResponse          registra que habrá que envolver la salida
   t8   ParseUUIDPipe              valida projectId e id (2 argumentos)
   t9   ValidationPipe             "horas":"3" → 3 · "borrado" ELIMINADO por
                                   whitelist · instancia ActualizarTareaDto
   t10  Controlador                traduce HTTP → llamada al caso de uso
   t11  TasksService               regla de negocio; lanza TransicionInvalida
                                   si la tarea ya estaba cerrada
   t12  MikroORM flush             UPDATE dentro de la transacción
   t13  TransactionInterceptor     COMMIT (o ROLLBACK si t11/t12 lanzaron)
   t14  TransformResponse          {data:{…}, meta:{requestId:"req-a1b2"}}
   t15  LoggingInterceptor         registra 200 · 41,3 ms · req-a1b2
  ─────────────────────────────────────────────────────────────────────────
   Si algo lanza entre t2 y t13 → ROLLBACK y filtros: MikroOrmException
   primero (si el error es del ORM), ProblemDetails después. t14 y t15 no
   transforman la salida, pero t15 sí registra el error por su rama tap.
src/tasks/tasks.controller.ts · el endpoint completo
@Controller('projects/:projectId/tasks')
// Ámbito de clase: se aplica a todos los métodos del controlador.
@UseInterceptors(ClassSerializerInterceptor)
export class TasksController {
  constructor(private readonly tareas: TasksService) {}

  @Patch(':id')
  // ── Metadatos: los leen los guards vía Reflector, no se ejecutan ──────
  @Roles(['EDITOR', 'ADMIN'])
  @TiempoLimite(3_000)
  // ── Guards de método: se ejecutan DESPUÉS de los globales ─────────────
  @UseGuards(TaskOwnerGuard)
  // ── Interceptor de método: el más interno de los tres ─────────────────
  @UseInterceptors(TransactionInterceptor)
  @HttpCode(HttpStatus.OK)
  async actualizar(
    // Pipes de parámetro: el último eslabón antes del manejador.
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Param('id', ParseUUIDPipe) id: string,
    // ValidationPipe global: whitelist elimina 'borrado', transform
    // convierte '3' a 3 y devuelve una instancia real del DTO.
    @Body() dto: ActualizarTareaDto,
    // Decorador propio: extrae req.user, que puso JwtAuthGuard en t2.
    @CurrentUser() usuario: UsuarioAutenticado,
    // Decorador propio: extrae req.recurso, que puso TaskOwnerGuard en t4.
    // Así el servicio no repite la consulta de propiedad.
    @Recurso() tarea: Task,
  ): Promise<TareaRespuestaDto> {
    // El controlador NO tiene lógica: valida nada, autoriza nada, consulta
    // nada. Traduce y delega. Si crece, la lógica está mal colocada.
    const actualizada = await this.tareas.actualizar(tarea, dto, usuario);
    return TareaRespuestaDto.desde(actualizada);
  }
}

// ── El decorador de parámetro, por completitud ──────────────────────────
export const Recurso = createParamDecorator((_d: unknown, ctx: ExecutionContext) => {
  const req = ctx.switchToHttp().getRequest<Request>();
  if (!req.recurso) {
    // Falla ruidosamente en desarrollo: si falta el guard, el decorador
    // devolvería undefined y el servicio recibiría basura silenciosamente.
    throw new InternalServerErrorException('Falta @UseGuards(TaskOwnerGuard)');
  }
  return req.recurso;
});
src/tasks/tasks.service.ts · el caso de uso
@Injectable()
export class TasksService {
  constructor(private readonly em: EntityManager) {}

  async actualizar(tarea: Task, dto: ActualizarTareaDto, usuario: UsuarioAutenticado) {
    // Reglas de negocio con errores de DOMINIO, sin saber que existe HTTP.
    if (tarea.estado === EstadoTarea.CERRADA) {
      throw new TransicionInvalida('Una tarea cerrada no admite cambios',
        { id: tarea.id, estado: tarea.estado });
    }
    if (dto.estado === EstadoTarea.HECHA && !tarea.responsable) {
      throw new ReglaDeNegocio('No se puede completar una tarea sin responsable');
    }
    // Asignación EXPLÍCITA: el DTO ya está saneado, pero la lista de campos
    // asignables es una decisión del dominio, no del transporte.
    this.em.assign(tarea, {
      estado: dto.estado ?? tarea.estado,
      horas: dto.horas ?? tarea.horas,
      actualizadoPor: usuario.id,
    });
    // Sin flush explícito si el TransactionInterceptor envuelve la petición:
    // el commit hace flush. Con flush aquí también funciona y es más
    // explícito; elige una convención y respétala en todo el proyecto.
    await this.em.flush();
    return tarea;
  }
}

10.10 Rendimiento del ciclo

Las cifras concretas dependen del hardware, así que lo útil son los órdenes de magnitud y saber qué domina el coste. Mide siempre en tu entorno con una herramienta de carga (autocannon, k6) antes de optimizar nada.

CapaCoste típicoQué lo disparaQué hacer
Middleware sin E/S (helmet, requestId)MicrosegundosNada: es ruido estadísticoNo te preocupes por él
Parseo del cuerpoProporcional al tamañoPayloads grandes, JSON muy anidadoLimitar el tamaño; no subir el límite «por si acaso»
Guard sin consultaMicrosegundos; la verificación de un JWT firmado con RSA, decenas de microsegundosVerificaciones asimétricas repetidasCachear la clave pública (JWKS), no el token
Guard con consultaMilisegundos: domina todo lo demásUn guard de propiedad por peticiónOrdenar de barato a caro; reutilizar la entidad cargada
ValidationPipeDecenas de microsegundos en un DTO plano; milisegundos con arrays de cientos de objetos anidados@ValidateNested({each:true}) sobre colecciones grandes@ArrayMaxSize obligatorio; stopAtFirstError; validar por lotes
InterceptoresMicrosegundos por capa, más el coste de lo que haganSerializar objetos enormes; transacciones que abarcan llamadas externasNo apilar interceptores redundantes; acotar transacciones
Filtro de excepciónIrrelevante… salvo el stackRegistrar trazas completas de miles de 404 por segundoTraza solo en 5xx; muestrear el resto

10.11 Seguridad en el ciclo

10.12 Errores comunes y cómo solucionarlos

SíntomaCausa realSolución
El ValidationPipe no transforma: horas sigue siendo "3"Falta transform: true, o el campo no declara @Type(() => Number)Activar transform y anotar la conversión campo a campo; nunca recurrir a enableImplicitConversion
El DTO llega como objeto plano y sus getters no existentransform: false (valor por defecto)transform: true en el pipe global
La validación no se aplica y no hay ningún errorEl parámetro es any, una interface o un type: no hay metatype en tiempo de ejecuciónUsar siempre una clase como DTO y anotar el tipo del parámetro
Los decoradores de validación se ignoranFalta emitDecoratorMetadata/experimentalDecorators en tsconfig.json, o se importan de la librería equivocadaRevisar tsconfig; @Type viene de class-transformer y el resto de class-validator
Un objeto anidado no se validaFalta @Type(() => Clase) junto a @ValidateNested()Poner ambos decoradores; para arrays, { each: true }
«an unknown value was passed to the validate function»forbidUnknownValues activo y un objeto sin metadatos de validaciónDeclarar los decoradores en la clase anidada y usar @Type
@Body() llega vacío o undefinedContent-Type ausente o distinto de application/json; bodyParser: false; un middleware anterior consumió el streamComprobar la cabecera del cliente, no desactivar el parser y usar rawBody: true en lugar de leer el stream a mano
Un campo del cuerpo desaparece sin avisarwhitelist: true y el campo no tiene ningún decorador de validaciónAñadir el decorador que corresponda o @Allow(); activar forbidNonWhitelisted para que el fallo sea visible
El guard nunca se ejecutaEstá en providers pero no con el token APP_GUARD; o se usó useGlobalGuards con una instancia mal construida; o la ruta devuelve 404 y no hay manejadorRegistrar con APP_GUARD; verificar que la ruta existe con --verbose en el arranque
El guard global se ejecuta también en el login y devuelve 401Falta la excepción declarativa@Public() y lectura con getAllAndOverride sobre método y clase
El guard recibe ConfigService vacío o undefinedRegistrado con new desde main.ts: fuera del contenedorRegistrarlo con APP_GUARD o con useFactory
Mi filtro no captura el error@Catch(HttpException) no captura un Error normal; o hay un filtro más específico que gana; o el error se lanzó en un middleware de main.ts@Catch() vacío en el filtro global; @Catch() disjuntos; mover la lógica del middleware a un guard o interceptor
El filtro global se ignora en un controladorEse controlador tiene @UseFilters(): el más específico gana y solo se ejecuta unoEliminar el filtro local o hacer que relance para que el global normalice
El interceptor rompe la respuesta: cuerpo vacío con 200map sin return, o tap usado para transformarmap transforma, tap observa; añadir defaultIfEmpty
La petición se queda colgada sin errorUn middleware no llamó a next(), o un interceptor no devolvió el observable de handle()Revisar todos los caminos de retorno, incluidos los primeros return
«Cannot set headers after they are sent»Se escribió en res a mano y además se devolvió un valor, o el filtro intenta responder sobre una respuesta ya enviadaUsar @Res({ passthrough: true }); comprobar res.headersSent en el filtro
Una excepción lanzada dentro de un interceptor produce un 500 raroSe lanzó dentro de un catchError sin envolverla en throwError, o el filtro no la reconocereturn throwError(() => new BadGatewayException()) y mapear el error en el filtro global
Los decoradores de un guard funcionan en el controlador pero no en el métodoSe leyó con reflector.get(clave, ctx.getClass()) solamentegetAllAndOverride(clave, [ctx.getHandler(), ctx.getClass()])
«Using global EntityManager instance methods… is disallowed»Código fuera del ciclo HTTP sin RequestContext@CreateRequestContext() en el método del servicio (o @UseRequestContext() en MikroORM 5)

10.13 Buenas y malas prácticas

Haz esto

  • Una pieza, una responsabilidad. Autorización en guards, transformación de la respuesta en interceptores, conversión y validación en pipes, formato del error en filtros.
  • Registra con APP_* siempre que la pieza necesite dependencias: es el ámbito global con inyección completa.
  • ValidationPipe global con whitelist, forbidNonWhitelisted y transform desde la primera línea del proyecto.
  • Cerrado por defecto: guard global de autenticación y @Public() explícito donde haga falta.
  • Un único filtro que formatea y filtros específicos que traducen y relanzan. Un solo formato de error en toda la API.
  • Ordena los guards de barato a caro para que el tráfico no autenticado nunca llegue a la base de datos.
  • DTOs de entrada y de salida separados, derivados con PartialType y compañía en lugar de duplicados.
  • Identificador de correlación en el log, en la respuesta de error y en la cabecera x-request-id.
  • Prueba los enhancers de forma aislada con un ExecutionContext simulado, y el ciclo completo con supertest.
  • Documenta en el propio decorador lo que un guard deja en req, y protege su ausencia con un fallo ruidoso.

Evita esto

  • Autorizar en un middleware comparando rutas con cadenas: no conoce el manejador y la lista se desincroniza.
  • enableImplicitConversion: true como atajo: desactiva en silencio los decoradores de tipo.
  • Usar la entidad como DTO de entrada o de salida: abre asignación masiva y acopla la API al esquema.
  • Guards que devuelven true cuando no encuentran metadatos: el endpoint nuevo queda abierto y nadie se entera.
  • Devolver false en lugar de lanzar: pierdes la distinción entre 401 y 403 y el cliente no puede reaccionar.
  • tap para transformar o map sin return: respuestas vacías con código 200.
  • Filtros con @Catch() solapados repartidos por métodos y controladores: dos formatos de error según el endpoint.
  • Devolver error.message al cliente en los 5xx: filtras SQL, rutas y a veces credenciales.
  • Transacciones abiertas en un interceptor alrededor de llamadas HTTP externas: conexiones bloqueadas y deadlocks.
  • Lógica de negocio en cualquier enhancer. Si un guard sabe qué es una factura, la arquitectura ya está torcida.

10.14 Preguntas frecuentes

¿Cuál es el orden exacto de ejecución? Es la pregunta de entrevista.
Petición, middleware, guards, interceptores en su fase previa, pipes, manejador del controlador, servicio, interceptores en su fase posterior (en orden inverso), filtros de excepción si algo lanzó, y respuesta. Dentro de cada tipo, el ámbito global va primero, luego el del controlador y luego el del método; los pipes añaden un cuarto nivel con los del parámetro. La única excepción es la resolución de filtros, que va del más específico al más general: método, controlador, global.
¿Middleware o interceptor? Los dos parecen hacer lo mismo.
Usa middleware solo para dos cosas: integrar librerías del ecosistema Express o Fastify (helmet, compresión, parseo) y abrir contextos asíncronos que deben envolver toda la petición. Para todo lo demás, interceptor: conoce el manejador y sus metadatos, funciona igual en HTTP, WebSockets y RPC, tiene acceso al valor devuelto y sus errores entran en la capa de filtros. Como regla mnemotécnica: si tu código menciona req y res pero no tu dominio, es middleware.
¿Puedo leer el cuerpo validado desde un guard?
No, y esa imposibilidad es intencionada. Los pipes se ejecutan después de los guards, así que req.body en un guard es la entrada cruda del cliente. Si una regla de autorización depende del contenido del cuerpo, esa regla pertenece al caso de uso, donde el DTO ya está validado y donde además puedes lanzar errores de dominio con contexto. Intentar validar en un guard produce código duplicado y decisiones tomadas sobre datos no saneados.
¿Por qué mi guard global no puede inyectar ConfigService?
Porque lo registraste con app.useGlobalGuards(new MiGuard(...)), y ahí la instancia la construyes tú, fuera del contenedor. La solución es el token APP_GUARD: { provide: APP_GUARD, useClass: MiGuard } en AppModule. El alcance es idéntico —toda la aplicación— pero Nest resuelve las dependencias, respeta los ámbitos y, si falta alguna, falla al arrancar en lugar de en producción.
¿Se ejecutan varios filtros para el mismo error?
No. Solo se ejecuta el primero cuyo @Catch() encaje, buscando de lo más específico a lo más general. No es una cadena acumulativa. Si quieres que un filtro específico traduzca y el global formatee, haz que el específico relance una HttpException, como en 10.8.4, o extiende BaseExceptionFilter y llama a super.catch().
¿Los interceptores ven los errores?
Sí, pero solo si los buscas. Un interceptor que use únicamente map no se enterará: el flujo termina en error y map nunca se invoca. Para observar errores necesitas tap({ error }), para transformarlos catchError, y para código que deba ejecutarse en cualquier caso finalize. Ten en cuenta que si tu interceptor captura el error y no lo relanza, el filtro global nunca lo verá y la respuesta será lo que devuelvas tú.
¿Puedo usar el EntityManager dentro de un pipe?
Técnicamente sí, y de hecho un validador asíncrono que comprueba unicidad lo hace. Pero conviene medirlo dos veces: cada validador con consulta añade un viaje a la base de datos por petición, se ejecuta aunque el resto del DTO sea inválido, y su resultado no es una garantía porque otra petición puede insertar el mismo valor un milisegundo después. Úsalo para mejorar el mensaje de error, y deja la garantía real en el índice único, traduciendo su violación a un 409 en el filtro.
¿Por qué mi @Body() llega vacío?
Por orden de probabilidad: el cliente no envía Content-Type: application/json; la aplicación se creó con bodyParser: false; un middleware anterior leyó el stream de la petición y ya no queda nada que parsear; o estás en un GET o DELETE cuyo cuerpo algunos clientes eliminan. Comprueba primero la cabecera con las herramientas de red del navegador o con curl -v: acierta en la mayoría de los casos.
¿Cuándo un pipe propio y cuándo un interceptor?
Si transformas un argumento de entrada, es un pipe: recibe un valor y devuelve otro, es puro y es fácil de testear. Si transformas la respuesta o necesitas envolver la ejecución completa, es un interceptor. La señal de alarma es un pipe que necesita saber quién es el usuario autenticado o que escribe en la base de datos: eso indica que la responsabilidad está mal ubicada.
¿Debo poner el ValidationPipe global o por endpoint?
Global, sin dudarlo: la validación es una política de la aplicación, no una decisión de cada programador. Por endpoint solo se justifica cuando necesitas opciones distintas, típicamente groups o un exceptionFactory especial. Recuerda que un @UsePipes(new ValidationPipe()) en un método no sustituye al global: se ejecutan los dos, en cadena, y el segundo recibe la salida del primero.
¿Cómo pruebo un guard o un interceptor?
De forma unitaria, construyendo un ExecutionContext falso con switchToHttp, getHandler y getClass simulados, y comprobando el valor devuelto o la excepción lanzada. Para el ciclo completo, un test de integración con supertest sobre la aplicación real es imprescindible, porque los fallos de este capítulo casi siempre son de composición —un orden equivocado, un decorador que falta, un filtro que gana al global— y esos no se detectan con dobles de prueba. El capítulo 13 lo desarrolla.
¿Es normal tener seis o siete enhancers en una petición?
Sí, y suele ser buena señal: significa que la autenticación, la autorización, el registro, la validación, el formato y los errores están resueltos en un sitio cada uno en lugar de repetidos en cada controlador. El coste en tiempo de las capas sin entrada/salida es de microsegundos. Lo que hay que vigilar no es el número de capas, sino cuántas de ellas consultan la base de datos.
¿Los enhancers funcionan en WebSockets y microservicios?
Guards, interceptores, pipes y filtros sí, porque trabajan sobre ExecutionContext: solo tienes que usar switchToWs() o switchToRpc() en lugar de switchToHttp(), y comprobar getType() si la pieza es compartida. El middleware, en cambio, es exclusivo de HTTP: su firma es la del framework web. Los filtros de excepción de un gateway de WebSockets deben extender BaseWsExceptionFilter y usar WsException, porque no hay códigos de estado HTTP que devolver.
¿Y si necesito ejecutar código antes de los guards pero con acceso a la inyección de dependencias?
Un middleware declarado como clase con @Injectable() ya tiene inyección completa: se registra en configure() y Nest lo instancia con el contenedor. Lo que no tendrá nunca es el ExecutionContext. Si lo que necesitas es acceso a los metadatos del manejador antes que el resto de la lógica, la respuesta correcta es un guard registrado primero en la lista de APP_GUARD, no un middleware más listo.

10.15 Ejercicios

Nivel 1 · básico

10.1 Escribe un middleware que registre método, ruta, código de estado y duración de cada petición, excluyendo /health y /metrics. Comprueba con curl que las rutas excluidas no aparecen en el log.

10.2 Configura el ValidationPipe global con whitelist, forbidNonWhitelisted y transform. Envía un cuerpo con un campo inventado y otro con un tipo incorrecto, y anota la respuesta exacta de cada caso.

10.3 Crea un endpoint GET /tareas con paginación: page y limit con valores por defecto y conversión a número. Hazlo primero con DefaultValuePipe y ParseIntPipe, y después con un DTO y @Type. Compara ambas soluciones.

10.4 Implementa un guard de API key con comparación en tiempo constante y un decorador @Public() para excluir /health. Registra el guard globalmente con APP_GUARD.

Nivel 2 · intermedio

10.5 Escribe un interceptor que envuelva toda respuesta en {data, meta} con requestId y duración, y que no envuelva descargas de fichero ni respuestas 204. Añade un test que compruebe los tres casos.

10.6 Implementa un filtro global con formato Problem Details que registre los 5xx con traza y los 4xx sin ella, y que nunca revele error.message en producción. Verifica el comportamiento cambiando NODE_ENV.

10.7 Crea un validador asíncrono @EsUnico(Usuario, 'email') con inyección del EntityManager. Después demuestra la condición de carrera lanzando dos peticiones simultáneas con el mismo correo y añade el índice único más su traducción a 409.

10.8 Diseña un guard de propiedad genérico y parametrizable por metadatos que deje la entidad cargada en la petición, y un decorador de parámetro que la recupere. Mide con logs de SQL cuántas consultas ahorras frente a cargarla de nuevo en el servicio.

10.9 Añade un interceptor de timeout configurable por endpoint con un decorador propio. Comprueba con un endpoint lento que el cliente recibe 408 y observa en los logs de la base de datos que la consulta sigue ejecutándose. Explica por qué.

Nivel 3 · avanzado

10.10 Implementa el endpoint completo de 10.9 y escribe un test de integración que verifique el orden de ejecución real instrumentando cada pieza para que registre su nombre en un array compartido.

10.11 Construye un interceptor de transacción por petición y demuestra con un test que un error lanzado en el servicio provoca rollback. Después demuestra el límite: provoca un error en un interceptor más externo y comprueba que la transacción ya se había confirmado.

10.12 Migra la validación de un módulo de class-validator a Zod manteniendo idéntico el formato de error de la API. Documenta qué ganaste, qué perdiste y cuánto código desapareció.

10.13 Diseña un sistema de caché por interceptor con clave por usuario e invalidación por etiquetas al escribir. Discute qué endpoints no deben cachearse nunca y por qué.

Solución comentada · 10.5 · Interceptor de envoltorio con excepciones
@Injectable()
export class WrapResponseInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    if (ctx.getType() !== 'http') return next.handle();
    const res = ctx.switchToHttp().getResponse<Response>();

    return next.handle().pipe(
      map((data) => {
        // 1 · Streaming: envolverlo lo destruiría (el cliente espera bytes).
        if (data instanceof StreamableFile) return data;
        // 2 · 204 y 205 no pueden llevar cuerpo según la RFC 9110; añadirlo
        //     hace que algunos clientes HTTP fallen al parsear.
        if ([204, 205, 304].includes(res.statusCode)) return data;
        // 3 · Idempotencia: si ya está envuelto, no anidar. Ocurre cuando
        //     alguien registra el interceptor dos veces por descuido.
        if (data && typeof data === 'object' && 'data' in data && 'meta' in data) return data;
        return { data, meta: { requestId: contextoActual()?.requestId,
                               timestamp: new Date().toISOString() } };
      }),
      // 4 · Un manejador que no devuelve nada produce un flujo vacío; sin
      //     esto la respuesta se queda sin cuerpo con un 200 desconcertante.
      defaultIfEmpty({ data: null, meta: { requestId: contextoActual()?.requestId } }),
    );
  }
}
// Test de integración (extracto):
it('no envuelve las descargas', async () => {
  const r = await request(app.getHttpServer()).get('/informes/1.pdf');
  expect(r.headers['content-type']).toContain('application/pdf');
  expect(r.body.data).toBeUndefined();
});
it('respeta el 204', async () => {
  const r = await request(app.getHttpServer()).delete('/tareas/1').expect(204);
  expect(r.text).toBe('');
});

Lo importante de este ejercicio no es el map, es la lista de excepciones. Un envoltorio global escrito sin pensar en descargas, respuestas vacías y doble registro rompe endpoints que funcionaban, y lo hace de forma intermitente: los tests unitarios del controlador siguen pasando porque el interceptor no participa en ellos.

Solución comentada · 10.10 · Test que verifica el orden real de ejecución
// La técnica: un array compartido donde cada pieza anota su paso. Es la
// forma más fiable de convertir "creo que el orden es este" en una prueba.
export const traza: string[] = [];

@Injectable() class G1 implements CanActivate {
  canActivate() { traza.push('guard-global'); return true; } }
@Injectable() class I1 implements NestInterceptor {
  intercept(_c: ExecutionContext, n: CallHandler) {
    traza.push('interceptor-global-antes');
    return n.handle().pipe(tap(() => traza.push('interceptor-global-despues')));
  } }
@Injectable() class P1 implements PipeTransform {
  transform(v: unknown) { traza.push('pipe-global'); return v; } }

describe('orden del ciclo de petición', () => {
  it('sigue el orden documentado', async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [DemoController],
      providers: [
        { provide: APP_GUARD, useClass: G1 },
        { provide: APP_INTERCEPTOR, useClass: I1 },
        { provide: APP_PIPE, useClass: P1 },
      ],
    }).compile();
    const app = moduleRef.createNestApplication();
    // El middleware se registra aquí para incluirlo en la traza.
    app.use((_req, _res, next) => { traza.push('middleware'); next(); });
    await app.init();

    traza.length = 0;
    await request(app.getHttpServer()).post('/demo').send({ x: 1 }).expect(201);

    expect(traza).toEqual([
      'middleware',
      'guard-global',
      'interceptor-global-antes',
      'pipe-global',
      'handler',
      'interceptor-global-despues',
    ]);
    await app.close();
  });
});

Este test tiene un valor que va más allá del ejercicio: documenta el contrato del framework y avisa si una actualización cambia el comportamiento. Añadiendo piezas de ámbito de controlador y de método se verifica también la precedencia global → clase → método y la inversión en la fase posterior. Y si en tu proyecto el resultado no coincide con lo esperado, casi siempre es porque una pieza está registrada dos veces o porque un @UseFilters() local está capturando lo que creías que iba al filtro global.

Solución comentada · 10.11 · Transacción por petición y su límite
it('hace rollback si el servicio lanza', async () => {
  // El servicio crea la tarea y luego lanza un error de dominio.
  await request(app.getHttpServer())
    .post('/projects/9/tasks').send({ titulo: 'provoca fallo' }).expect(409);
  // La comprobación clave: nada quedó persistido.
  const em = orm.em.fork();
  expect(await em.count(Task, { titulo: 'provoca fallo' })).toBe(0);
});

it('DEMUESTRA EL LÍMITE: el commit ocurre antes de los interceptores externos',
  async () => {
  // Interceptor registrado como APP_INTERCEPTOR, es decir, MÁS EXTERNO que
  // el de transacción, que está a nivel de método.
  @Injectable() class RompeAlSalir implements NestInterceptor {
    intercept(_c: ExecutionContext, n: CallHandler) {
      return n.handle().pipe(map(() => { throw new Error('fallo al serializar'); }));
    }
  }
  await request(app.getHttpServer())
    .post('/projects/9/tasks').send({ titulo: 'se guarda igual' }).expect(500);

  // El cliente ha recibido un 500 y, sin embargo, el dato ESTÁ guardado:
  // la transacción se confirmó al terminar el interceptor interno, y el
  // error ocurrió después, ya fuera de su alcance.
  const em = orm.em.fork();
  expect(await em.count(Task, { titulo: 'se guarda igual' })).toBe(1);
});

La conclusión práctica es incómoda pero necesaria: una transacción por petición implementada en un interceptor no garantiza que un 500 implique ausencia de efectos. Solo garantiza atomicidad de lo que ocurra dentro de su envoltura. Si necesitas la garantía completa, abre la transacción en el caso de uso —donde sabes qué operaciones forman una unidad— y deja que el ciclo HTTP se ocupe solo del transporte. Es también la razón por la que las operaciones que disparan efectos externos (correos, webhooks, mensajes a una cola) deben publicarse después del commit, con el patrón outbox o con eventos posteriores a la transacción.

10.16 Resumen del capítulo

  • El orden es petición → middleware → guards → interceptores (antes) → pipes → manejador → servicio → interceptores (después) → filtros si hay error → respuesta. Dentro de cada tipo: global, controlador, método.
  • El middleware no conoce el manejador, y de ahí se deducen todas sus limitaciones: no puede autorizar con metadatos ni validar DTOs.
  • Un guard responde una sola pregunta y la responde lanzando la excepción correcta, no devolviendo false. Distinguir 401 de 403 no es cosmético.
  • Un interceptor es un aspecto: envuelve la ejecución y su fase posterior se ejecuta en orden inverso. El envoltorio de la respuesta va en el interceptor más externo.
  • Los pipes se ejecutan después de los guards, así que ningún guard puede confiar en el cuerpo de la petición.
  • whitelist: true no es una opción de estilo: es la defensa contra la asignación masiva. Y enableImplicitConversion puede desactivar en silencio los decoradores que creías que te protegían.
  • Los DTOs no son entidades. Separar entrada, salida y persistencia desacopla la API del esquema y cierra la puerta a exponer campos internos.
  • Solo se ejecuta un filtro por error, del más específico al más general. Haz que los @Catch() sean disjuntos y que los específicos traduzcan y relancen.
  • El dominio no lanza HttpException. Lanza errores propios y el filtro global los traduce a HTTP en un único sitio.
  • Problem Details (RFC 9457) con requestId convierte tus errores en algo que un cliente puede automatizar y tu equipo puede rastrear en los logs.
  • Lo caro del ciclo son las consultas a la base de datos, no las capas. Ordena los guards de barato a caro y no cargues dos veces la misma entidad.
  • La validación del cliente es usabilidad; la del servidor es seguridad. Toda regla que importe existe en el servidor.

10.17 Recursos adicionales

Siguiente paso Con el ciclo de petición dominado tienes el esqueleto sobre el que se apoya casi todo lo demás: la autenticación y la autorización del capítulo 12 son guards, la serialización y el registro son interceptores, y los contratos de entrada son pipes. El capítulo 11 sigue por el lado del contenedor —proveedores dinámicos, módulos configurables y ámbitos— y el 13 cierra el círculo enseñando a probar todas estas piezas por separado y en conjunto.