El ciclo de vida del request
El ciclo de vida del request
Un request en NestJS no entra al controlador y ya. Antes de llegar al handler atraviesa una tubería de capas, y después de que el handler devuelve algo, atraviesa otra vez parte de esa tubería en sentido inverso. Si no tenés claro el orden exacto, terminás poniendo lógica en la capa equivocada: autorización en el service, transformación de la respuesta en el controlador, try/catch regados por todos lados en vez de un filtro central.
Este capítulo ataca un problema de calidad muy concreto: cada preocupación transversal tiene una capa canónica donde vive. Poner la lógica en la capa correcta te da código más testeable (cada capa se testea aislada), menos acoplado (el handler no sabe de autenticación ni de formato de errores) y sin duplicación (un filtro de excepciones global reemplaza cien try/catch).
El orden exacto del pipeline
Este es el orden documentado en el que NestJS ejecuta las capas de un request, de principio a fin:
- Middleware (globales primero, luego los ligados a módulos)
- Guards (global → controlador → handler)
- Interceptores (parte pre) (global → controlador → handler)
- Pipes (global → controlador → handler → parámetro)
- Handler del controlador (y el/los service que invoque)
- Interceptores (parte post) (en orden inverso: handler → controlador → global)
- Exception filters (handler → controlador → global) si se lanzó una excepción en cualquier punto
- Respuesta al cliente
Dos detalles que cambian cómo escribís el código:
- Los interceptores envuelven al handler. El mismo interceptor corre lógica antes (cuando llega el request) y después (cuando el
Observabledel handler emite). Por eso pueden medir tiempos, cachear y transformar la respuesta: tienen acceso a los dos lados. - Los exception filters capturan cualquier excepción lanzada en cualquier capa anterior (un guard que lanza
ForbiddenException, un pipe que lanzaBadRequestException, el service que lanza un error de dominio). No es uncatchalrededor del handler: es el sumidero de todo el pipeline.
Otro punto sobre el orden dentro de cada tipo: cuando hay varios enhancers del mismo tipo, corren global → controlador → handler. La excepción son los interceptores en su fase post, que se desenrollan al revés (handler → controlador → global), como una pila.
sequenceDiagram
autonumber
participant C as Cliente
participant MW as Middleware
participant G as Guards
participant I as Interceptores
participant P as Pipes
participant H as Handler + Service
participant F as Exception Filters
C->>MW: HTTP request
MW->>G: next()
Note over G: canActivate()<br/>¿autorizado?
G->>I: true
Note over I: fase PRE<br/>(logging, start timer)
I->>P: next.handle()
Note over P: transform()<br/>valida y transforma DTO
P->>H: args validados
Note over H: lógica de negocio
H-->>I: valor de retorno
Note over I: fase POST<br/>map()/tap()<br/>envuelve la respuesta
I-->>C: respuesta final
Note over G,H: Si CUALQUIER capa lanza una excepción...
H--xF: throw HttpException / error de dominio
F-->>C: respuesta de error con formato consistente
Middleware: lo de bajo nivel, antes de que Nest sepa quién sos
El middleware es lo más cercano al servidor HTTP crudo (Express o Fastify). Corre antes que cualquier guard, y en ese punto Nest todavía no resolvió qué handler va a atender el request. Trabajás con los objetos Request/Response de la plataforma, no con el ExecutionContext de Nest.
Qué sí va en middleware:
- Cosas de infraestructura HTTP de bajo nivel: CORS,
helmet, compresión,cookie-parser,bodyraw para webhooks. - Logging crudo de acceso (método, path, IP,
user-agent) sin conocer la ruta resuelta. - Asignar un
correlation-idal request para trazabilidad. - Terminar el request temprano por razones puramente de transporte (por ejemplo, rechazar un
Content-Typeno soportado).
Qué no va en middleware:
- Autorización o autenticación de negocio: usá guards, que sí tienen acceso al
ExecutionContext(saben la clase y el handler destino y sus metadatos con@SetMetadata/Reflector). - Transformar la respuesta: eso es de interceptores.
- Validar el DTO: eso es de pipes.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const id = req.headers['x-correlation-id'] ?? randomUUID();
req.headers['x-correlation-id'] = id as string;
res.setHeader('x-correlation-id', id);
next();
}
}
Se registra en el módulo con configure, no con un decorador:
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
@Module({ /* ... */ })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
}
}
Muchas piezas de infraestructura (CORS, helmet, compresión) se aplican mejor como middleware global en
main.ts(app.use(...),app.enableCors(...)) que como clase. Reservá el middleware-clase para lo que necesite DI.
Guards: la autorización vive acá, no en el service
Un guard implementa CanActivate y su único trabajo es responder sí o no: ¿este request puede seguir hasta el handler? Devuelve boolean (o Promise/Observable de boolean). Si devuelve false, Nest corta y responde 403 Forbidden automáticamente.
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role));
}
}
¿Por qué la autorización va en el guard y no en el service? Tres razones concretas:
- Corta antes. El guard rechaza el request antes de que corran los pipes de validación y el handler. Si la autorización estuviera en el service, ya gastaste ciclos validando y entrando al handler para recién ahí darte cuenta de que no tenía permiso.
- Es declarativa y reusable. Con
@UseGuards(RolesGuard)+@Roles('admin')en el handler, la política es visible en la firma del endpoint. En el service, la regla queda enterrada y hay que replicarla en cada método. - Desacopla el negocio del permiso. El service hace lo que hace (crear un pedido, borrar un usuario); el guard decide si podés hacerlo. Mezclarlos rompe el principio de responsabilidad única: un mismo método de service tendría que conocer roles, tokens y contexto HTTP.
El guard tiene acceso al ExecutionContext, así que puede leer el usuario que un AuthGuard puso en el request, los metadatos del handler y el propio request. La autenticación (validar el JWT, poblar request.user) y la autorización (chequear roles/permisos) se tratan a fondo en el capítulo 10: Seguridad — incluyendo Passport, RBAC y CASL.
Ojo con la frontera: el guard responde una pregunta binaria de acceso. Reglas de negocio ricas (“un usuario premium puede tener hasta 5 proyectos activos”) no son autorización de guard, son invariantes de dominio y viven en el service/dominio. Si tu
canActivateempieza a consultar tres repositorios y calcular totales, se te está filtrando negocio a la capa equivocada.
Interceptores: envolver, transformar y medir
El interceptor es la capa más poderosa y la más malinterpretada. Implementa NestInterceptor.intercept(context, next) y devuelve un Observable. Su superpoder es que corre a ambos lados del handler:
- Antes de
next.handle(): podés arrancar un timer, loguear la entrada, chequear un cache. - Después, operando sobre el
Observableque devuelvenext.handle()con operadores RxJS:mappara transformar la respuesta,tappara efectos secundarios (logging, métricas),timeoutpara cortar requests lentos,catchErrorpara mapear errores.
next.handle() devuelve un Observable que no emite hasta que te suscribís; Nest se suscribe por vos. Todo lo que encadenes con .pipe(...) después de next.handle() es la fase post.
Patrón 1: envolver toda respuesta en un sobre consistente
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';
interface Envelope<T> {
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Envelope<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<Envelope<T>> {
return next.handle().pipe(
map((data) => ({ data, timestamp: new Date().toISOString() })),
);
}
}
Con esto, cada handler devuelve su entidad limpia (return this.usersService.findOne(id)) y el interceptor se encarga del formato uniforme. El handler no conoce el sobre: esa es la ventaja.
Patrón 2: logging de tiempos con tap
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now();
const { method, url } = context.switchToHttp().getRequest();
return next.handle().pipe(
tap({
next: () => this.logger.log(`${method} ${url} — ${Date.now() - start}ms`),
error: (err) =>
this.logger.error(`${method} ${url} — falló: ${err.message}`),
}),
);
}
}
tap no altera el valor emitido; solo observa. Es la herramienta correcta para métricas y logging: si usaras map para loguear estarías arriesgándote a mutar la respuesta por error.
Patrón 3: timeout
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from '@nestjs/common';
import { catchError, Observable, throwError, timeout, TimeoutError } from 'rxjs';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
timeout(5000),
catchError((err) =>
err instanceof TimeoutError
? throwError(() => new RequestTimeoutException())
: throwError(() => err),
),
);
}
}
Patrón 4: cache
El interceptor puede cortocircuitar el handler: si hay un hit de cache, devolvés un Observable con el valor guardado y next.handle() nunca se ejecuta. Es la base del CacheInterceptor de @nestjs/cache-manager. La estrategia de caching (qué cachear, TTL, invalidación) se cubre en el capítulo 7: Rendimiento.
Pipes: validar y transformar la entrada
El pipe implementa PipeTransform.transform(value, metadata) y opera sobre los argumentos del handler justo antes de que este los reciba. Tiene dos usos:
- Transformación: convertir el
valuede entrada al tipo deseado (ParseIntPipe,ParseUUIDPipe,ParseBoolPipe). - Validación: chequear que el
valuecumple las reglas y, si no, lanzar (típicamenteBadRequestException, que el filtro convierte en400).
Como los pipes corren después de guards e interceptores-pre pero antes del handler, cuando tu método de controlador ejecuta ya recibe datos autorizados, validados y tipados. El ValidationPipe global con DTOs, class-validator/Zod, whitelisting y la dirección de Standard Schema en Nest 12 se desarrollan en el capítulo 5: Validación y transformación.
Exception filters: un solo lugar para los errores
El filtro implementa ExceptionFilter.catch(exception, host) y se decora con @Catch(...). Es el sumidero de excepciones de todo el pipeline: un guard que rechaza, un pipe que invalida, un service que lanza un error de dominio, todo cae acá. En vez de un try/catch en cada handler, un filtro centraliza el formato de la respuesta de error.
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// Nunca exponer stack traces ni el mensaje interno de un 500 al cliente.
const message =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
// El detalle completo va al log, no a la respuesta.
this.logger.error(exception instanceof Error ? exception.stack : exception);
const body = {
statusCode: status,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(request),
message,
};
httpAdapter.reply(ctx.getResponse(), body, status);
}
}
Puntos de calidad clave:
- Formato consistente. Todo error sale con la misma forma (
statusCode,timestamp,path,message). El cliente parsea una sola estructura. - No filtrar detalles sensibles. En un
500genérico devolvés un mensaje neutro y mandás elstackal logger. Nunca serialices el error crudo al cliente: expone rutas internas, versiones y a veces datos. @Catch()sin argumentos captura todo;@Catch(HttpException)solo ese tipo;@Catch(PrismaClientKnownRequestError)te deja mapear errores de una librería específica a unHttpExceptioncon el status correcto.- Usá
HttpAdapterHosten el filtro global en vez de tocarresponsedirecto, para que funcione igual con Express y con Fastify.
Preferí lanzar excepciones semánticas de Nest (NotFoundException, ConflictException, ForbiddenException) desde el service y dejar que el filtro las traduzca, en lugar de armar respuestas de error a mano en el controlador.
Enhancers: global, por controlador o por handler
Guards, interceptores, pipes y filtros son enhancers, y podés atarlos en tres alcances:
flowchart TD
A[Enhancer] --> B{¿Qué alcance?}
B -->|Toda la app| G["Global<br/>APP_GUARD / APP_INTERCEPTOR<br/>APP_FILTER / APP_PIPE<br/>o app.useGlobalX()"]
B -->|Un controlador| C["@UseGuards() / @UseInterceptors()<br/>@UseFilters() / @UsePipes()<br/>a nivel de clase"]
B -->|Un handler| H["Los mismos decoradores<br/>a nivel de método"]
G --> R[Se ejecuta global → controlador → handler]
C --> R
H --> R
El binding con DI: por qué APP_GUARD y no useGlobalGuards
Hay dos formas de registrar un enhancer global y no son equivalentes:
// Opción A — sin DI: la instancia se crea a mano, fuera del contenedor.
// El guard NO puede inyectar providers (Reflector, repositorios, config).
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard(new Reflector())); // frágil, acoplado
}
// Opción B — con DI: lo registrás como provider con un token especial.
// Nest lo instancia dentro del contenedor y le inyecta todo lo que pida.
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}
Los tokens son cuatro, uno por tipo de enhancer:
| Token | Enhancer | Equivalente manual en main.ts |
|---|---|---|
APP_GUARD | Guard global | app.useGlobalGuards() |
APP_INTERCEPTOR | Interceptor global | app.useGlobalInterceptors() |
APP_PIPE | Pipe global | app.useGlobalPipes() |
APP_FILTER | Exception filter global | app.useGlobalFilters() |
La ventaja de los tokens APP_* es que el enhancer participa de la inyección de dependencias: puede inyectar el Reflector, tu ConfigService, un repositorio o cualquier provider del módulo. La variante app.useGlobalX(new ...) te obliga a construir las dependencias a mano y rompe el testeo aislado. Para todo enhancer global que necesite algo del contenedor, usá los tokens. (La DI avanzada y los custom providers son el tema del capítulo 4.)
Regla práctica de alcance:
- Global: políticas que aplican a casi toda la app (filtro de errores, interceptor de logging,
ValidationPipe, guard de autenticación base). - Controlador: cuando todos los endpoints de ese recurso comparten una regla (por ejemplo, un
@UseGuards(AdminGuard)en unAdminController). - Handler: la excepción puntual (un endpoint con un interceptor de cache específico, o uno público con
@Public()que saltea el guard global).
Antipatrones
Lógica de negocio en guards o interceptores
// ANTIPATRÓN: el guard calcula, consulta y decide reglas de negocio.
@Injectable()
export class CanCreateProjectGuard implements CanActivate {
constructor(private readonly projects: ProjectsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const { user } = context.switchToHttp().getRequest();
const count = await this.projects.countActiveByOwner(user.id); // negocio
const limit = user.plan === 'premium' ? 20 : 5; // negocio
return count < limit;
}
}
El guard terminó siendo un método de service disfrazado. Problemas: la regla no es testeable como negocio puro (necesitás un ExecutionContext falso), no se reusa desde un job o un comando CLI, y devolver 403 para “llegaste al límite de tu plan” es semánticamente incorrecto (debería ser un 409/422 de dominio).
// MEJOR: el guard solo autoriza acceso; la invariante vive en el service.
@Injectable()
export class ProjectsService {
constructor(private readonly projects: ProjectsRepository) {}
async create(user: User, dto: CreateProjectDto): Promise<Project> {
const count = await this.projects.countActiveByOwner(user.id);
const limit = user.plan === 'premium' ? 20 : 5;
if (count >= limit) {
throw new ConflictException('Alcanzaste el límite de proyectos de tu plan');
}
return this.projects.create(user.id, dto);
}
}
El guard queda solo con @Roles('user'). La regla de negocio es una invariante del dominio, testeable sin HTTP, y lanza una excepción semántica que el filtro traduce al status correcto.
try/catch regado en vez de un exception filter
// ANTIPATRÓN: cada handler arma su propia respuesta de error.
@Get(':id')
async findOne(@Param('id') id: string) {
try {
const user = await this.users.findOne(id);
if (!user) return { error: 'not found', code: 404 };
return user;
} catch (e) {
return { error: 'algo salió mal', code: 500, detail: e.message }; // filtra detalle
}
}
Cada endpoint reinventa el formato del error (a veces code, a veces statusCode), a veces filtra e.message de un 500, y el ruido de try/catch ahoga la lógica. La respuesta correcta:
// MEJOR: el handler solo describe el camino feliz; los errores fluyen al filtro.
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.users.findOne(id);
if (!user) throw new NotFoundException(`Usuario ${id} no existe`);
return user;
}
El AllExceptionsFilter global da el formato uniforme y esconde el detalle sensible en un solo lugar. El handler se lee como pseudocódigo del caso de negocio.
Autorización en el service
Meter if (user.role !== 'admin') throw ... dentro de cada método del service dispersa la política de acceso, la duplica y la mezcla con negocio. La decisión de acceso pertenece al guard (declarativa, visible en el endpoint, cortando temprano). Reservá el service para invariantes de dominio, no para permisos.
Exponer stack traces
Devolver err.stack o el error crudo al cliente filtra rutas del filesystem, versiones de dependencias y a veces valores internos. El stack va al logger; al cliente le llega un mensaje neutro y un statusCode. Esto se conecta con el capítulo 10: Seguridad (fuga de información).
Transformar la respuesta en el controlador
Armar el sobre { data, timestamp } a mano en cada handler duplica el formato y acopla el controlador a la representación de salida. Ese envoltorio es trabajo de un interceptor global: el handler devuelve la entidad y nada más.
Checklist
- Tenés claro el orden: middleware → guards → interceptores (pre) → pipes → handler → interceptores (post) → exception filters.
- El middleware solo hace cosas de transporte HTTP de bajo nivel (CORS, helmet, compresión, correlation-id, logging crudo).
- La autorización está en guards (
canActivate), no en el service ni en middleware. - Las invariantes de negocio (límites de plan, reglas de dominio) están en el service, no en guards.
- Los interceptores usan
mappara transformar la respuesta ytappara logging/métricas, sin mutar el valor contap. - La respuesta uniforme (sobre
{ data, ... }) la arma un interceptor global, no cada handler. - Hay
timeouten los interceptores para requests que pueden colgarse. - La validación y transformación de entrada está en pipes/DTOs, no en el handler.
- Un
AllExceptionsFilterglobal centraliza el formato de error; no haytry/catchdecorativos en los handlers. - El filtro nunca expone
stackni el mensaje interno de un500; ese detalle va al logger. - Los enhancers globales que necesitan DI se registran con
APP_GUARD/APP_INTERCEPTOR/APP_PIPE/APP_FILTER, no connewenmain.ts. - El alcance de cada enhancer (global/controlador/handler) es el mínimo que cubre el caso.
Con el pipeline ordenado y cada preocupación en su capa, el siguiente paso es exprimir el rendimiento de ese pipeline: adaptador Fastify, caching, compresión, complejidad algorítmica y perfilado del event loop. Seguí con el capítulo 7: Rendimiento →.