NestJS 12 a fondo: arquitectura, novedades y una API completa paso a paso
Módulos, inyección de dependencias, el ciclo de vida de una petición, todo lo nuevo de NestJS 12 y 12.1 (ESM, Standard Schema, cookies, CSRF…) y una API real con Zod, Drizzle, JWT, tests y Docker, probada línea a línea.
Contenido
Si vienes de Express, seguramente te ha pasado: el proyecto empieza con un app.js de cuarenta líneas y, un año después, es una carpeta llena de rutas, "servicios" que no se sabe muy bien qué son, middlewares que se ejecutan en un orden que nadie recuerda y una lógica de validación distinta en cada endpoint. Express no tiene la culpa: es una librería mínima y te deja hacer lo que quieras. El problema es precisamente ese.
NestJS nació para resolverlo. Es un framework para construir aplicaciones de servidor con Node.js y TypeScript que te da una arquitectura desde el primer minuto: módulos, controladores, servicios, inyección de dependencias y un camino claro para cada cosa (validar, autenticar, registrar, gestionar errores, probar). Por debajo sigue usando Express o Fastify, así que no renuncias a su ecosistema.
En este artículo vamos a verlo a fondo: la filosofía, cómo funciona por dentro (decoradores, metadatos, el contenedor de dependencias y el viaje de una petición), todo lo nuevo de NestJS 12 y 12.1, y una API completa de tareas con Zod, Drizzle y SQLite, JWT y roles, interceptores, filtros, cookies, Swagger, tests y Docker para desplegarla en Coolify.
Qué es NestJS
NestJS lo creó Kamil Myśliwiec en 2017. La idea era traer al servidor la estructura que Angular había llevado al frontend: todo organizado en módulos, clases decoradas y dependencias que se inyectan en lugar de importarse a mano. Hoy es uno de los frameworks de Node.js más usados para APIs "serias", con licencia MIT, más de 70.000 estrellas en GitHub y un ecosistema oficial enorme: configuración, JWT, Passport, Swagger, GraphQL, WebSockets, microservicios (Redis, NATS, Kafka, gRPC, RabbitMQ…), colas con BullMQ, tareas programadas, caché, health checks…
Conviene tener claro qué no es: NestJS no es un servidor HTTP. No parsea peticiones ni gestiona sockets. Eso lo hace el adaptador: por defecto Express, y opcionalmente Fastify. Nest se sienta encima y aporta la arquitectura. Por eso puedes cambiar de uno a otro sin tocar tus controladores, como veremos más adelante con la misma API funcionando en los dos.
La filosofía: por qué Nest es como es
Hay tres ideas que lo explican casi todo:
- Arquitectura opinada. Nest te dice dónde va cada cosa. La lógica de negocio va en providers (servicios); la capa HTTP, en controladores; y todo se agrupa en módulos por funcionalidad (usuarios, pedidos, pagos…). Un desarrollador nuevo abre el proyecto y sabe dónde buscar.
- Inyección de dependencias (DI). Tus clases no crean sus dependencias: las piden en el constructor y el framework se las da. Esto hace que el código esté desacoplado y, sobre todo, que sea fácil de probar: en un test sustituyes la base de datos por un doble con una línea.
- Decoradores y metadatos.
@Controller(),@Get(),@Injectable(),@Body()… Los decoradores no ejecutan nada por sí mismos: dejan anotaciones sobre tus clases. Al arrancar, Nest lee esas anotaciones y monta la aplicación a partir de ellas.
El precio de todo esto es una curva de aprendizaje más larga que la de Express y más "ceremonia": para un endpoint necesitas un módulo, un controlador y un servicio. En un microservicio de tres rutas es excesivo; en una API con cuarenta endpoints y cinco personas tocándola, es lo que evita el caos.
Descarga e instalación
Enlaces oficiales
- Web: nestjs.com
- Documentación: docs.nestjs.com (y la guía de migración a la 12)
- Código fuente: github.com/nestjs/nest · notas de la versión 12.0.0
- Paquetes npm: @nestjs/core, @nestjs/common y el CLI @nestjs/cli
- CLI (código): github.com/nestjs/nest-cli
Requisitos
- Node.js 20.19+ o 22.12+ para ejecutar una aplicación NestJS 12 (la rama 21 no está soportada). Lo necesita porque los paquetes ahora son ESM y los proyectos CommonJS los cargan con
require(esm). - Para el CLI, mejor Node 24 LTS. Al ejecutar
@nestjs/cli@12con Node 20.20, npm avisa de que@nestjs/schematics12 pide Node 22.22.3+ o 24.15+. Yo generé el proyecto con Node 24 y la aplicación compilada arranca sin problemas en el Node 20.20 del servidor, pero para desarrollar usa la LTS actual. - npm, pnpm, yarn o bun (este último, nuevo en la 12).
Instalar el CLI y crear un proyecto
# 1. Instala el CLI de forma global
npm i -g @nestjs/cli
# 2. Comprueba la versión
nest --version # 12.0.8 cuando escribo esto
# 3. Crea el proyecto (pregunta gestor de paquetes, CommonJS o ESM y si quieres @nestjs/observe)
nest new mi-api
# ...o sin preguntas:
nest new mi-api --package-manager npm --skip-git --no-observe
# 4. Arranca en modo desarrollo (recompila y reinicia al guardar)
cd mi-api
npm run start:devSi no quieres instalar nada global, o tu máquina tiene un Node antiguo:
# Sin instalar nada global, con npx:
npx @nestjs/cli@12 new mi-api
# En un contenedor con Node 24, si tu máquina tiene un Node antiguo:
docker run --rm -it -v "$PWD":/w -w /w node:24-slim npx @nestjs/cli@12 new mi-apiEn cuanto arranca, tienes un "Hello World!" en http://localhost:3000. Esto es lo que genera nest new en la versión 12 cuando eliges ESM:
mi-api/
├── .oxlintrc.json # linter (oxlint sustituye a ESLint)
├── .prettierrc
├── nest-cli.json # configuración del CLI
├── package.json # "type": "module" -> proyecto ESM
├── tsconfig.json
├── tsconfig.build.json
├── vitest.config.ts # tests unitarios (*.spec.ts)
├── vitest.config.e2e.ts # tests de extremo a extremo (*.e2e-spec.ts)
├── src/
│ ├── main.ts # arranque: NestFactory.create(AppModule)
│ ├── app.module.ts # módulo raíz
│ ├── app.controller.ts
│ ├── app.controller.spec.ts
│ └── app.service.ts
└── test/
└── app.e2e-spec.tsFíjate en tres cosas nuevas respecto a proyectos de Nest 11: "type": "module" en el package.json, Vitest en lugar de Jest y oxlint en lugar de ESLint. Y en los imports verás siempre la extensión .js aunque el fichero sea .ts: en ESM de Node es obligatorio, y TypeScript la resuelve al fichero .ts correspondiente.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap(); // "await" de nivel superior: esto ya es ESMLos scripts del proyecto
| Script | Para qué sirve |
|---|---|
npm run start:dev | Compila y reinicia al guardar. El día a día. |
npm run build | Compila a dist/ con nest build. |
npm run start:prod | Ejecuta node dist/main (lo que usarás en producción). |
npm test / npm run test:e2e | Tests unitarios y de extremo a extremo con Vitest. |
npm run lint | oxlint con reglas que usan los tipos (--type-aware). |
npm run deploy | Nuevo: nest deploy, despliegue en Mau, la nube oficial de Nest (de pago). |
Las piezas básicas: módulo, controlador y servicio
Antes de meternos en las tripas, el ejemplo pequeño que genera el CLI. Son tres ficheros y ya contienen toda la filosofía:
// app.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service.js';
@Controller()
export class AppController {
// Nest ve que el constructor pide un AppService y se lo da, ya creado
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}- El servicio (
@Injectable()) contiene la lógica. No sabe nada de HTTP. - El controlador (
@Controller()) traduce HTTP a llamadas al servicio. Declara rutas con@Get(),@Post()… y extrae datos con@Body(),@Param(),@Query(),@Headers(). - El módulo (
@Module()) declara qué controladores y providers forman parte de él, qué módulos importa y qué comparte con los demás (exports).
Nadie escribe new AppService(). El controlador lo pide en su constructor y Nest se lo entrega. Veamos cómo lo sabe.
Cómo funciona por dentro
Decoradores y reflect-metadata
TypeScript tiene una opción, emitDecoratorMetadata, que hace algo muy útil: cuando una clase lleva un decorador, el compilador guarda los tipos de los parámetros de su constructor en un metadato llamado design:paramtypes. Con la librería reflect-metadata se pueden leer en tiempo de ejecución. Compruébalo tú mismo:
import 'reflect-metadata';
import { Injectable } from '@nestjs/common';
@Injectable()
class Logger {}
@Injectable()
class Mailer {}
@Injectable()
class UsersService {
constructor(
private readonly logger: Logger,
private readonly mailer: Mailer,
) {}
}
// TypeScript (con emitDecoratorMetadata) guardó los tipos del constructor:
console.log(Reflect.getMetadata('design:paramtypes', UsersService));
// Y @Injectable() dejó su propia marca:
console.log(Reflect.getMetadataKeys(UsersService));$ node dist/demos/metadata.demo.js
[ [class Logger], [class Mailer] ]
[ 'design:paramtypes', '__injectable__', 'scope:options' ]Eso es todo el "truco". Nest lee design:paramtypes, ve que UsersService necesita un Logger y un Mailer, los busca en su registro, los crea si hace falta y llama al constructor con ellos. Los decoradores de rutas funcionan igual: @Get(':id') guarda en el método un metadato con el verbo y la ruta, y al arrancar Nest recorre los controladores y registra cada ruta en Express o Fastify.
import type { AuthService }, el tipo desaparece al compilar y Nest no puede saber qué inyectar. Con isolatedModules (activado en los proyectos de la 12), TypeScript te avisa con un error. Las clases que se inyectan se importan con import normal; import type solo para interfaces y tipos.Módulos y el contenedor de inyección de dependencias
Al arrancar, NestFactory.create(AppModule) recorre el árbol de módulos a partir del raíz, construye un grafo de dependencias y crea las instancias en el orden correcto: primero las que no dependen de nada, después las que dependen de esas, y así hasta los controladores. Este es el grafo de la API que construiremos:

La regla de oro de los módulos es la encapsulación: un provider solo es visible dentro de su módulo. Para usarlo desde otro, su módulo tiene que exportarlo y el otro módulo tiene que importarlo. La excepción son los módulos marcados con @Global() (o isGlobal: true), cuyos exports están disponibles en todas partes; úsalo con moderación, para cosas realmente transversales como la configuración o la base de datos.
Un "provider" no tiene por qué ser una clase. Nest admite cinco formas:
@Module({
providers: [
// 1. Clase (la forma corta de { provide: TasksService, useClass: TasksService })
TasksService,
// 2. Otra clase detrás del mismo "token": útil para cambiar implementaciones
{ provide: MailService, useClass: process.env.NODE_ENV === 'test' ? FakeMailService : SmtpMailService },
// 3. Un valor ya construido (constantes, objetos de configuración, dobles en tests)
{ provide: 'APP_NAME', useValue: 'API de tareas' },
// 4. Una fábrica, que puede ser asíncrona y pedir otras dependencias
{
provide: DB_CLIENT,
inject: [ConfigService],
useFactory: async (config: ConfigService) => createClient({ url: config.get('DATABASE_URL') }),
},
// 5. Un alias de otro provider
{ provide: 'LOGGER', useExisting: Logger },
],
})
export class EjemploModule {}Cuando el token no es una clase (un string o un Symbol, como DB_CLIENT), hay que pedirlo explícitamente con @Inject(DB_CLIENT), porque no hay tipo que leer de los metadatos.
Scopes: cuántas instancias hay de cada cosa
import { Injectable, Scope } from '@nestjs/common';
// DEFAULT (singleton): una instancia para toda la aplicación. Lo normal.
@Injectable()
export class TasksService {}
// REQUEST: una instancia NUEVA por cada petición HTTP.
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {}
// TRANSIENT: una instancia nueva por cada clase que la inyecta.
@Injectable({ scope: Scope.TRANSIENT })
export class PrefixedLogger {}Por defecto todo es singleton: una instancia compartida por todas las peticiones, creada al arrancar. Es lo más rápido y lo que quieres en el 99 % de los casos. El scope REQUEST es útil para cosas como el inquilino de una aplicación multi-tenant, pero tiene un coste que sorprende: se contagia hacia arriba. Si TasksService depende de algo con scope REQUEST, TasksService también pasa a crearse en cada petición, y el controlador que lo usa también. Úsalo con cuidado.
El ciclo de vida de una petición
Esta es la parte que más cuesta al principio y la que más rentabiliza entender. Cuando llega una petición, pasa por una serie de "capas" en un orden fijo:

| Capa | Qué hace | Para qué se usa |
|---|---|---|
| Middleware | Funciones al estilo Express, antes de que Nest sepa qué controlador atiende la petición. | Id de petición, CORS, compresión, logs de acceso. |
| Guards | Deciden si la petición puede continuar. Devuelven true o false (403) o lanzan una excepción. | Autenticación, roles, permisos, límites por plan. |
| Interceptores | Envuelven la ejecución del controlador: código antes y después, con RxJS. | Medir tiempos, caché, transformar la respuesta, timeouts. |
| Pipes | Validan y transforman los argumentos del método (@Body(), @Param()…). | Validación con Zod o class-validator, convertir "42" en 42. |
| Controlador | Tu método. Recibe los datos ya validados y llama al servicio. | Solo orquestar: nada de lógica de negocio aquí. |
| Exception filters | Atrapan cualquier excepción de las capas anteriores y construyen la respuesta de error. | Formato de error común, registrar errores 500. |
Cada una de estas piezas se puede aplicar a tres niveles: global (toda la aplicación), controlador (con @UseGuards(), @UseInterceptors()… sobre la clase) o ruta (sobre el método). Se ejecutan en ese orden: global → controlador → ruta.
Lifecycle hooks
Además del ciclo de cada petición, la aplicación tiene su propio ciclo de vida, y tus providers pueden engancharse a él implementando interfaces:
@Injectable()
export class CacheService implements OnModuleInit, OnApplicationShutdown {
async onModuleInit() {
// Se llama cuando el módulo ha resuelto sus dependencias: calentar caché, abrir conexiones…
}
async onApplicationShutdown(signal?: string) {
// Se llama al cerrar (app.close() o SIGTERM con enableShutdownHooks)
}
}El orden completo es onModuleInit → onApplicationBootstrap al arrancar, y onModuleDestroy → beforeApplicationShutdown → onApplicationShutdown al cerrar. Novedad de la 12: estos hooks ahora se invocan según el nivel en la jerarquía de componentes, así que si tus tests o tu arranque dependían de un orden concreto entre providers relacionados, revísalo al migrar.
Express o Fastify: los adaptadores
Nest habla con el servidor HTTP a través de un HttpAdapter. El de Express viene por defecto (@nestjs/platform-express); para Fastify instalas @nestjs/platform-fastify y lo pasas al crear la app:
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
await app.listen(3000, '0.0.0.0'); // con Fastify, sin '0.0.0.0' solo escucha en localhostFastify es más rápido en pruebas sintéticas, pero en una API real la diferencia suele quedar oculta por la base de datos. Lo importante: si tu código no toca el objeto req/res directamente (y Nest te da herramientas para no hacerlo), el mismo código funciona en los dos. La API de este artículo lo hace: la he probado entera con ambos y las respuestas son idénticas. Si quieres profundizar en cada uno, tengo artículos dedicados a Express y a Fastify.
Todo lo nuevo de NestJS 12
La versión 12.0.0 salió el 27 de agosto de 2026 y la 12.1 el 23 de septiembre. Es una versión mayor con cuatro ejes: paquetes ESM, soporte de primera para Standard Schema (Zod, Valibot, ArkType…), un CLI reescrito y observabilidad nativa. Vamos una por una.
1. Paquetes ESM
Todos los paquetes del núcleo se publican ahora como módulos ES. Tu aplicación CommonJS sigue funcionando sin cambios gracias a require(esm), que Node incluye desde las versiones 20.19 y 22.12; por eso son el mínimo. Los proyectos nuevos te preguntan si quieres CommonJS o ESM. Mi recomendación para proyectos nuevos es ESM: await de nivel superior, alineado con el resto del ecosistema y sin sorpresas con librerías que ya solo publican ESM.
Lo que sí debes revisar: scripts de arranque propios, configuraciones de bundler y, sobre todo, Jest. Lo vemos en la migración.
2. Validación con Standard Schema
Standard Schema es una especificación común que implementan Zod, Valibot, ArkType y otras librerías de validación. Nest 12 la adopta: los decoradores @Body(), @Query(), @Param() y @RawBody() aceptan una opción schema, y el nuevo StandardSchemaValidationPipe la aplica:
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
password: z.string().min(8),
displayName: z.string().max(50).optional(),
});
// El tipo sale del esquema: una sola fuente de verdad
export type CreateUserDto = z.infer<typeof createUserSchema>;
// main.ts
app.useGlobalPipes(new StandardSchemaValidationPipe());
// users.controller.ts
@Post()
create(@Body({ schema: createUserSchema }) dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Get(':id')
findOne(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
return this.usersService.findOne(id);
}La gran ventaja frente a class-validator es que el esquema es la única fuente de verdad: valida en tiempo de ejecución y de él sale el tipo de TypeScript con z.infer. No hay clases DTO que mantener en paralelo, ni metadatos de reflexión. Además, el pipe solo valida parámetros que declaran un schema y deja pasar el resto, así que puedes registrarlo globalmente junto a ValidationPipe y migrar poco a poco. Así responde la API de ejemplo cuando algo no cuadra (salida real):
$ curl -s -XPOST localhost:3000/api/tasks -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"title":"x","priority":"urgente"}'
{
"message": [
"title: Too small: expected string to have >=3 characters",
"priority: Invalid option: expected one of \"baja\"|\"media\"|\"alta\""
],
"error": "Bad Request",
"statusCode": 400
}Cada mensaje lleva delante la ruta del campo. Si prefieres otro formato (por ejemplo, un 422 con un array de objetos), el pipe acepta exceptionFactory:
app.useGlobalPipes(
new StandardSchemaValidationPipe({
exceptionFactory: (issues) =>
new UnprocessableEntityException({
message: 'Validation failed',
errors: issues.map((issue) => ({
path: issue.path?.map((s) => (typeof s === 'object' ? s.key : s)).join('.'),
message: issue.message,
})),
}),
}),
);Y un detalle muy bueno: Swagger entiende estos esquemas. Zod 4.2+ implementa la extensión Standard JSON Schema, así que @nestjs/swagger 12 genera la documentación de la petición directamente desde el esquema, sin configurar nada. En mi API, el parámetro ?done= aparece en el OpenAPI generado como {"type":"string","enum":["true","false"]}, sacado del z.enum().
3. Serialización con Standard Schema
El mismo enfoque, pero para lo que sale de la API. StandardSchemaSerializerInterceptor pasa la respuesta del controlador por un esquema y envía el resultado. Es la forma más segura de garantizar que un campo interno (un hash de contraseña, un token) no se filtra nunca por error:
import { SerializeOptions, StandardSchemaSerializerInterceptor, UseInterceptors } from '@nestjs/common';
import { z } from 'zod';
// Lo que SALE de la API: sin passwordHash, sin campos internos
const userResponseSchema = z.object({
id: z.number(),
email: z.email(),
displayName: z.string().nullable(),
});
@UseInterceptors(StandardSchemaSerializerInterceptor)
@SerializeOptions({ schema: userResponseSchema })
@Get(':id')
findOne(@Param('id', { schema: z.coerce.number() }) id: number) {
return this.usersService.findOne(id); // devuelve el registro completo; el interceptor lo recorta
}4. @nestjs/config con Standard Schema
El módulo de configuración deja de depender de Joi: validationSchema acepta cualquier Standard Schema. Si la configuración no es válida, la aplicación no arranca, que es exactamente lo que quieres. Lo verás en la API de ejemplo. Joi sigue funcionando si subes a Joi 18+ y mueves sus opciones a validationOptions.libraryOptions.
5. Diagnóstico de conflictos de rutas
Un clásico de todos los frameworks: declaras @Get(':id') y, más abajo, @Get('search'). Las rutas se registran en el orden en que las declaras, así que /tasks/search encaja con :id (con id = "search") y tu ruta de búsqueda nunca se ejecuta. Nest 12 añade dos opciones para detectarlo y evitarlo:
const app = await NestFactory.create(AppModule, {
routeConflictPolicy: { duplicate: 'error', shadow: 'warn' },
routeResolutionStrategy: 'specificity',
});Con shadow: 'warn', al declarar las rutas en el orden malo, esto es lo que vi al arrancar:
WARN [NestApplication] Route GET /api/tasks/search (TasksController#search) is shadowed by
GET /api/tasks/:id (TasksController#findOne). The first-registered route will match all
matching requests on order-sensitive adapters.Con duplicate: 'error', dos rutas idénticas impiden el arranque. Y con routeResolutionStrategy: 'specificity', Nest registra primero los segmentos literales y después los de parámetro, independientemente del orden en que los escribas. Lo comprobé dejando search después de :id: la búsqueda funcionó y el aviso desapareció. Las dos opciones vienen desactivadas por defecto para no romper nada.
shadow: 'warn' y las rutas en el orden correcto (sin specificity), Nest también avisa, esta vez de que :id queda tapado por search para esa URL concreta. Es técnicamente cierto y es lo que quieres, pero hace ruido en los logs. Activar specificity lo resuelve.6. Códigos de error legibles por máquinas
Las excepciones HTTP aceptan un errorCode que se añade al cuerpo de la respuesta. Así el cliente puede reaccionar a un identificador estable en lugar de comparar textos que cambian con cada traducción:
throw new NotFoundException(`La tarea ${id} no existe`, { errorCode: 'TASK_NOT_FOUND' });
// Respuesta real:
// {"message":"La tarea 999 no existe","error":"Not Found","statusCode":404,"errorCode":"TASK_NOT_FOUND"}7. Logs estructurados
El ConsoleLogger trata los objetos que pasas después del mensaje como parámetros de la misma entrada (antes generaba una línea por cada uno). En modo JSON van anidados en params, o en la raíz con flattenParams. Viene activado; structuredParams: false recupera el comportamiento anterior.
// En el código:
this.logger.log(`${req.method} ${req.url}`, { ms, handler: 'TasksController.create', user: 'ana' });
// En la consola (una sola entrada, con sus parámetros):
// [Nest] 1 - 09/29/2026, 5:47:16 PM LOG [HTTP] POST /api/tasks { ms: 10, handler: 'TasksController.create', user: 'ana' }
// En producción, en JSON para Loki, Datadog o similares:
const app = await NestFactory.create(AppModule, {
logger: new ConsoleLogger({ json: true }),
});8. Observabilidad nativa: @nestjs/observe
NestJS Observe es la plataforma oficial de monitorización (APM). A diferencia de un agente genérico de Node, que solo ve "POST /orders" y la base de datos, el SDK se engancha al ciclo de vida de Nest mediante la opción instrument y te muestra el tiempo por clase y método: OrdersService.recalculate, guards, interceptores, colas y crons, sin instrumentar nada a mano. nest new --observe lo configura por ti.
// app.module.ts
import { createObserveModule } from '@nestjs/observe';
export const { ObserveModule, ObserveInstrument } = createObserveModule();
@Module({
imports: [
ObserveModule.forRoot({
appKey: process.env.OBSERVE_APP_KEY!,
appSecret: process.env.OBSERVE_APP_SECRET!,
serviceId: 'api-tareas',
}),
],
})
export class AppModule {}
// main.ts
const app = await NestFactory.create(AppModule, { instrument: ObserveInstrument });
// Con Fastify, las opciones pasan al TERCER argumento:
const app2 = await NestFactory.create(AppModule, new FastifyAdapter(), { instrument: ObserveInstrument });Es un servicio de pago con plan gratuito (300.000 eventos al mes y 3 días de retención cuando escribo esto). Es totalmente opcional: nada en Nest 12 depende de él. Yo no lo he conectado a una cuenta; el fragmento anterior es el de la documentación oficial.
9. El CLI reescrito
El CLI se ha reescrito entero en ESM y trae comandos y opciones nuevas:
nest upgrade(aliasupdate): migra un proyecto de la 11 a la 12. Lo vemos abajo con una salida real.nest deploy: despliega en Mau, la plataforma de hosting oficial.- Rspack como bundler por defecto para monorepos; las opciones de webpack quedan obsoletas.
- oxlint en lugar de ESLint y Vitest en lugar de Jest en los proyectos ESM (los CommonJS siguen con Jest).
- bun como gestor de paquetes soportado.
- Nuevas opciones:
--no-type-check,--emit-declarationscon SWC,--parallelpara monorepos,includeLibraryAssets… - El esquema
decoratorgenera decoradores conReflector.createDecorator(), y desaparece el esquemaangular.
# Compilar con Rspack (sustituye a webpack, que queda obsoleto)
nest build --builder rspack
# Compilar sin comprobar tipos (más rápido; útil en CI si ya pasaste tsc aparte)
nest build --no-type-check
# Monorepo: compilar todos los proyectos en paralelo
nest build --all --parallel 4
# Crear un proyecto usando bun como gestor de paquetes
nest new mi-api --package-manager bun
# Generar piezas sueltas (igual que siempre)
nest g resource tasks # módulo + controlador + servicio + DTOs + tests
nest g guard auth/roles
nest g interceptor common/logging10. Lo nuevo de la 12.1: cookies, CSRF, cabeceras de seguridad y subidas con Fastify
La 12.1 trae cuatro cosas que antes necesitaban paquetes externos y que yo he probado una por una:
- Cookies integradas:
@Cookies(),@SignedCookies()yhttpAdapter.setCookie()/clearCookie(), iguales en Express y Fastify, sincookie-parserni@fastify/cookie. Con firma y rotación de secretos. - Protección CSRF con
app.enableCsrfProtection(). No usa tokens: se basa en la cabeceraSec-Fetch-Siteque envían todos los navegadores modernos (con respaldo enOrigin/Host), el mismo algoritmo queCrossOriginProtectionde Go. - Cabeceras de seguridad con
app.useSecurityHeaders(): las mismas cabeceras y valores por defecto que helmet 8, sin instalar helmet. - Subida de ficheros con Fastify:
FileInterceptory compañía desde@nestjs/platform-fastify/multipart, respaldados por@fastify/multipart, con los mismos decoradores y validadores que en Express.
Las cuatro están en la API de ejemplo con sus salidas reales. El changelog de la 12.1 también menciona prefijos globales múltiples (un array en setGlobalPrefix), pero en la 12.1.1 que he instalado la firma de tipos sigue aceptando solo un string, así que no lo he usado.
Cambios incompatibles y cómo migrar desde NestJS 11
| Cambio | Qué hacer |
|---|---|
| Paquetes en ESM | Normalmente nada. Revisa arranques propios, bundler y test runner. |
| Node 20.19+ / 22.12+ | Actualiza Node. La rama 21 no vale. |
| Lifecycle hooks por nivel de jerarquía | Revisa si dependías del orden entre providers en init/teardown y tests. |
NATS v3: nats pasa a @nats-io/transport-node | Cambia el paquete; los payloads se leen con msg.json(). |
GraphQL: fuera subscriptions-transport-ws; GraphiQL por defecto | Pasa a graphql-ws (clientes incluidos) y playground → graphiql. |
| @nestjs/config valida con Standard Schema | Con Joi: sube a Joi 18+ y mueve sus opciones a validationOptions.libraryOptions. |
Pipes: ArgumentMetadata ahora es genérico | Ajusta las firmas de tus pipes propios si el compilador se queja. |
| Logs estructurados activados | structuredParams: false si quieres el formato anterior. |
| webpack en el CLI, obsoleto | Pasa a --builder rspack. |
La mayor parte lo hace solo el comando nest upgrade. Primero, siempre, en modo simulación. Esta es la salida real sobre un proyecto recién creado con el CLI de la 11:
$ npx @nestjs/cli@12 upgrade --dry-run
🚀 Upgrading your project to NestJS v12...
Changes applied:
✔ Updated NestJS packages: @nestjs/common@^12.0.0, @nestjs/core@^12.0.0, @nestjs/testing@^12.0.0,
@nestjs/platform-express@^12.0.0, @nestjs/cli@^12.0.0, @nestjs/schematics@^12.0.0
✔ Updated "typescript" from "^5.7.3" to "^6.0.0" (required by @nestjs/cli and @nestjs/schematics v12)
✔ Updated "ts-jest" from "^29.2.5" to "^29.4.0" (first release supporting Jest 30)
✔ Scheduled "npm install"
Please review:
⚠ TypeScript 6 deprecates several legacy "tsconfig.json" options (e.g. "baseUrl",
"esModuleInterop" defaults changed). Run "npx tsc --noEmit" after installing...
⚠ Jest: the NestJS 12 packages are ESM-only and Jest can only require() ESM modules on
Node.js v24.9+ (older versions fail with ERR_REQUIRE_ASYNC_MODULE). Run your test suite
on Node.js 24.9+, or consider migrating to Vitest...
Action required:
➜ tsconfig.build.json does not set "rootDir". TypeScript 6 requires it explicitly (error TS5011);
add `"rootDir": "./src"` to its "compilerOptions" to keep the dist layout unchanged.
Dry run enabled. No files written to disk.Lo aplica todo menos pasar tu proyecto a ESM, a Vitest o a oxlint, que quedan a tu ritmo. Los pasos que sigo yo:
- Rama nueva y tests en verde en la 11.
npm i -g @nestjs/cli@latestynest upgrade --dry-run. Leer el informe entero.nest upgrade,npm installynpx tsc --noEmit. Arreglar lo que diga TypeScript 6 (elrootDirque avisa arriba es el más común).- Tests. Si usas Jest con Node anterior a 24.9, fallarán con
ERR_REQUIRE_ASYNC_MODULE: sube Node o pasa a Vitest. - Probar a mano los flujos críticos y desplegar primero en un entorno de pruebas.
Manos a la obra: una API de tareas completa
Ahora sí, el ejemplo grande. Una API de tareas con todo lo que tendría un proyecto real:
- CRUD de tareas en SQLite con Drizzle ORM (a través de libSQL).
- Validación con Zod + Standard Schema y, en el login, con class-validator, para que veas los dos estilos conviviendo.
- Autenticación con JWT y autorización por roles con guards globales.
- Middleware, interceptores (logs y timeout), un pipe propio y un filtro de excepciones.
- Configuración validada, Swagger, cookies, CSRF y cabeceras de seguridad.
- Tests unitarios y e2e, y un Dockerfile listo para Coolify.
- Funcionando igual con Express y con Fastify, cambiando una variable de entorno.
Instalación y estructura
# Proyecto base
npx @nestjs/cli@12 new api-tareas --package-manager npm --skip-git --no-observe
cd api-tareas
# Dependencias de la API
npm i @nestjs/config @nestjs/jwt @nestjs/swagger @nestjs/platform-fastify \
zod class-validator class-transformer drizzle-orm @libsql/client
# Solo si vas a usar Fastify con Swagger UI o subida de ficheros
npm i @fastify/static @fastify/multipartnpm install-scripts approve better-sqlite3), y aun aprobado intentó compilar con node-gyp, que no estaba en la imagen node:24-slim. @libsql/client trae binarios precompilados para cada plataforma, sin scripts de instalación, y Drizzle lo soporta de serie (drizzle-orm/libsql). Si ves avisos de install-scripts en tus instalaciones, ahora sabes de dónde vienen.src/
├── main.ts # arranque: adaptador, Swagger, seguridad
├── app.module.ts # módulo raíz: pipes, interceptores, filtro globales
├── app.controller.ts # GET /health
├── config/
│ └── env.schema.ts # variables de entorno validadas con Zod
├── database/
│ ├── schema.ts # tabla "tasks" con Drizzle
│ └── database.module.ts # cliente libSQL/SQLite + Drizzle, global
├── auth/
│ ├── roles.ts # decoradores @Roles() y @Public()
│ ├── login.dto.ts # DTO con class-validator
│ ├── users.service.ts
│ ├── auth.service.ts # emite el JWT
│ ├── auth.guard.ts # comprueba el JWT
│ ├── roles.guard.ts # comprueba el rol
│ ├── auth.controller.ts # POST /api/auth/login
│ └── auth.module.ts
├── tasks/
│ ├── task.schemas.ts # esquemas Zod de entrada
│ ├── tasks.service.ts # acceso a datos
│ ├── tasks.controller.ts # CRUD /api/tasks
│ └── tasks.module.ts
├── preferences/
│ └── preferences.controller.ts # cookies (Nest 12.1)
└── common/
├── request-id.middleware.ts
├── logging.interceptor.ts
├── timeout.interceptor.ts
├── all-exceptions.filter.ts
└── trim-lower.pipe.tsConfiguración validada con Zod
Primero, las variables de entorno. Un esquema de Zod define qué necesita la aplicación y con qué formato; ConfigModule lo valida al arrancar:
// src/config/env.schema.ts
import { z } from 'zod';
// Una sola fuente de verdad para las variables de entorno:
// si falta algo o tiene mal formato, la aplicación no arranca.
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().default('file:data/tareas.db'),
JWT_SECRET: z.string().min(32, 'JWT_SECRET debe tener al menos 32 caracteres'),
ADAPTER: z.enum(['express', 'fastify']).default('express'),
});
export type Env = z.infer<typeof envSchema>;# .env (NUNCA al repositorio)
JWT_SECRET=cambia-esto-por-un-secreto-largo-de-verdad-123
DATABASE_URL=file:data/tareas.db
COOKIE_SECRET=otro-secreto-distinto
# ADAPTER=fastifySi falta algo, la aplicación no arranca y te dice exactamente qué pasa. Salida real con un secreto demasiado corto:
$ docker run --rm -e JWT_SECRET=corto nest-tareas
ERROR [ExceptionHandler] Error: Config validation error: JWT_SECRET: JWT_SECRET debe tener al menos 32 caracteresBase de datos: Drizzle y un módulo global
El esquema de la tabla con Drizzle. De aquí salen también los tipos Task y NewTask:
// src/database/schema.ts
import { sql } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const tasks = sqliteTable('tasks', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
description: text('description'),
priority: text('priority', { enum: ['baja', 'media', 'alta'] }).notNull().default('media'),
done: integer('done', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
});
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;Y el módulo que crea la conexión. Es un buen ejemplo de providers de fábrica asíncronos que dependen de otros providers (ConfigService), de tokens con Symbol y de un lifecycle hook para cerrar la conexión al apagar:
// src/database/database.module.ts
import { Global, Inject, Module, OnApplicationShutdown } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, type Client } from '@libsql/client';
import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql';
import * as schema from './schema.js';
export const DB = Symbol('DB');
export const DB_CLIENT = Symbol('DB_CLIENT');
export type Database = LibSQLDatabase<typeof schema>;
@Global() // DB se puede inyectar en cualquier módulo sin importarlo
@Module({
providers: [
{
provide: DB_CLIENT,
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
const client = createClient({ url: config.getOrThrow<string>('DATABASE_URL') });
// En un proyecto real, esto lo hacen las migraciones de drizzle-kit
await client.execute(`CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
priority TEXT NOT NULL DEFAULT 'media',
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)`);
return client;
},
},
{
provide: DB,
inject: [DB_CLIENT],
useFactory: (client: Client): Database => drizzle(client, { schema }),
},
],
exports: [DB],
})
export class DatabaseModule implements OnApplicationShutdown {
constructor(@Inject(DB_CLIENT) private readonly client: Client) {}
onApplicationShutdown() {
this.client.close();
}
}CREATE TABLE IF NOT EXISTS usa drizzle-kit: npx drizzle-kit generate crea los ficheros SQL de migración a partir de schema.ts y npx drizzle-kit migrate los aplica. Lo he dejado así para que el ejemplo arranque con un solo comando.Los esquemas de entrada (y la trampa de Zod 4)
// src/tasks/task.schemas.ts
import { z } from 'zod';
const title = z.string().trim().min(3).max(120);
const description = z.string().max(2000);
const priority = z.enum(['baja', 'media', 'alta']);
export const createTaskSchema = z.object({
title,
description: description.optional(),
priority: priority.default('media'),
});
// Ojo: NO uses createTaskSchema.partial(). En Zod 4, partial() conserva los .default(),
// y un PATCH con {"done": true} volvería a poner priority = 'media' sin que nadie lo pida.
export const updateTaskSchema = z
.object({
title: title.optional(),
description: description.optional(),
priority: priority.optional(),
done: z.boolean().optional(),
})
.refine((v) => Object.keys(v).length > 0, 'Envía al menos un campo');
export const listTasksQuerySchema = z.object({
done: z.enum(['true', 'false']).transform((v) => v === 'true').optional(),
priority: z.enum(['baja', 'media', 'alta']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
export const taskIdSchema = z.coerce.number().int().positive();
export type CreateTaskDto = z.infer<typeof createTaskSchema>;
export type UpdateTaskDto = z.infer<typeof updateTaskSchema>;
export type ListTasksQuery = z.infer<typeof listTasksQuerySchema>;updateTaskSchema = createTaskSchema.partial(). Parece lo natural. Pero en Zod 4, partial() conserva los valores por defecto: un PATCH con {"done": true} rellenaba priority con "media" y machacaba la prioridad que ya tenía la tarea. No da ningún error; simplemente corrompe datos en silencio. La solución es definir el esquema de actualización sin .default(), como arriba, y tener un test que lo compruebe (lo tienes más abajo).// MAL: parece lo lógico, pero en Zod 4 partial() CONSERVA los valores por defecto
export const updateTaskSchema = createTaskSchema.partial();
// PATCH /api/tasks/1 {"done": true}
// => {"id":1,"title":"Probar NestJS 12","priority":"media","done":true}
// ^^^^^^^^^^^^^^^^^ ¡era "alta"!El servicio: toda la lógica de datos
// src/tasks/tasks.service.ts
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { and, desc, eq, like, type SQL } from 'drizzle-orm';
import { DB, type Database } from '../database/database.module.js';
import { tasks, type Task } from '../database/schema.js';
import type { CreateTaskDto, ListTasksQuery, UpdateTaskDto } from './task.schemas.js';
@Injectable()
export class TasksService {
constructor(@Inject(DB) private readonly db: Database) {}
async findAll(q: ListTasksQuery): Promise<Task[]> {
const filters: SQL[] = [];
if (q.done !== undefined) filters.push(eq(tasks.done, q.done));
if (q.priority) filters.push(eq(tasks.priority, q.priority));
return this.db
.select()
.from(tasks)
.where(and(...filters))
.orderBy(desc(tasks.id))
.limit(q.limit)
.offset(q.offset);
}
search(term: string): Promise<Task[]> {
return this.db.select().from(tasks).where(like(tasks.title, `%${term}%`)).limit(20);
}
async findOne(id: number): Promise<Task> {
const [task] = await this.db.select().from(tasks).where(eq(tasks.id, id));
if (!task) {
throw new NotFoundException(`La tarea ${id} no existe`, { errorCode: 'TASK_NOT_FOUND' });
}
return task;
}
async create(dto: CreateTaskDto): Promise<Task> {
const [task] = await this.db.insert(tasks).values(dto).returning();
return task;
}
async update(id: number, dto: UpdateTaskDto): Promise<Task> {
await this.findOne(id); // 404 si no existe
const [task] = await this.db.update(tasks).set(dto).where(eq(tasks.id, id)).returning();
return task;
}
async remove(id: number): Promise<void> {
await this.findOne(id);
await this.db.delete(tasks).where(eq(tasks.id, id));
}
}Fíjate en que el servicio lanza excepciones HTTP (NotFoundException) con su errorCode. Hay quien prefiere excepciones de dominio propias y traducirlas en un filtro; para una API de este tamaño, esto es más que suficiente.
El controlador: HTTP y nada más
// src/tasks/tasks.controller.ts
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '../auth/roles.js';
import { SearchTermPipe } from '../common/trim-lower.pipe.js';
import {
createTaskSchema, listTasksQuerySchema, taskIdSchema, updateTaskSchema,
type CreateTaskDto, type ListTasksQuery, type UpdateTaskDto,
} from './task.schemas.js';
import { TasksService } from './tasks.service.js';
@ApiTags('tareas')
@ApiBearerAuth()
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll(@Query({ schema: listTasksQuerySchema }) query: ListTasksQuery) {
return this.tasksService.findAll(query);
}
// Debe ir ANTES de ':id': si no, '/tasks/search' encajaría como id = 'search'
@Get('search')
search(@Query('q', SearchTermPipe) q: string) {
return this.tasksService.search(q);
}
@Get(':id')
findOne(@Param('id', { schema: taskIdSchema }) id: number) {
return this.tasksService.findOne(id);
}
@Post()
create(@Body({ schema: createTaskSchema }) dto: CreateTaskDto) {
return this.tasksService.create(dto);
}
@Patch(':id')
update(
@Param('id', { schema: taskIdSchema }) id: number,
@Body({ schema: updateTaskSchema }) dto: UpdateTaskDto,
) {
return this.tasksService.update(id, dto);
}
@Delete(':id')
@Roles(['admin']) // solo los administradores pueden borrar
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', { schema: taskIdSchema }) id: number) {
return this.tasksService.remove(id);
}
}Cada parámetro declara su esquema, y el tipo del argumento sale del esquema. @Query({ schema: listTasksQuerySchema }) convierte ?limit=5 (un string) en el número 5 gracias a z.coerce, y rechaza ?limit=500. El módulo, casi vacío:
// src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller.js';
import { TasksService } from './tasks.service.js';
@Module({
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService], // otros módulos que importen TasksModule podrán inyectarlo
})
export class TasksModule {}Autenticación con JWT y roles
Empezamos por los decoradores. Reflector.createDecorator() crea a la vez el decorador y la "clave" para leerlo, con tipos, sin SetMetadata ni constantes de texto sueltas:
// src/auth/roles.ts
import { Reflector } from '@nestjs/core';
export type Role = 'admin' | 'lector';
// Reflector.createDecorator crea el decorador y la clave de metadatos a la vez,
// con tipos: @Roles(['admin']) y reflector.get(Roles, handler) devuelve Role[].
export const Roles = Reflector.createDecorator<Role[]>();
// Marca rutas que no necesitan token (login, health…)
export const Public = Reflector.createDecorator<boolean>({ transform: () => true });El login usa el estilo clásico de Nest con class-validator, que convive sin problemas con los esquemas de Zod: ValidationPipe valida los parámetros tipados con una clase y StandardSchemaValidationPipe los que llevan schema.
// src/auth/login.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length, Matches } from 'class-validator';
// El estilo "clásico" de Nest: una clase con decoradores de class-validator.
// Lo valida ValidationPipe; convive sin problemas con StandardSchemaValidationPipe.
export class LoginDto {
@ApiProperty({ example: 'ana' })
@IsString()
@Matches(/^[a-z0-9_]+$/, { message: 'usuario: solo minúsculas, números y _' })
username: string;
@ApiProperty({ example: 'contraseña-de-ana' })
@IsString()
@Length(8, 128)
password: string;
}// src/auth/users.service.ts
import { Injectable } from '@nestjs/common';
import { scryptSync, timingSafeEqual } from 'node:crypto';
import type { Role } from './roles.js';
interface User { id: number; username: string; role: Role; salt: string; hash: Buffer }
const hash = (password: string, salt: string) => scryptSync(password, salt, 32);
// Usuarios de ejemplo en memoria. En una aplicación real vendrían de la base de datos.
@Injectable()
export class UsersService {
private readonly users: User[] = [
{ id: 1, username: 'ana', role: 'admin', salt: 's1', hash: hash('contraseña-de-ana', 's1') },
{ id: 2, username: 'luis', role: 'lector', salt: 's2', hash: hash('contraseña-de-luis', 's2') },
];
verify(username: string, password: string): User | null {
const user = this.users.find((u) => u.username === username);
if (!user) return null;
return timingSafeEqual(hash(password, user.salt), user.hash) ? user : null;
}
}// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { LoginDto } from './login.dto.js';
import { UsersService } from './users.service.js';
@Injectable()
export class AuthService {
constructor(
private readonly users: UsersService,
private readonly jwt: JwtService,
) {}
async login({ username, password }: LoginDto) {
const user = this.users.verify(username, password);
if (!user) {
throw new UnauthorizedException('Usuario o contraseña incorrectos', {
errorCode: 'BAD_CREDENTIALS',
});
}
const payload = { sub: user.id, username: user.username, role: user.role };
return { access_token: await this.jwt.signAsync(payload) };
}
}El guard de autenticación comprueba el token de todas las rutas, salvo las marcadas con @Public(), y deja el usuario en request.user:
// src/auth/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Public } from './roles.js';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// ¿La ruta o su controlador llevan @Public()?
const isPublic = this.reflector.getAllAndOverride(Public, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const [type, token] = (request.headers.authorization ?? '').split(' ');
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('Falta el token', { errorCode: 'MISSING_TOKEN' });
}
try {
// Guardamos el usuario en la petición para los guards y controladores siguientes
request.user = await this.jwt.verifyAsync(token);
} catch {
throw new UnauthorizedException('Token no válido o caducado', { errorCode: 'INVALID_TOKEN' });
}
return true;
}
}El de roles lee @Roles() de la ruta o del controlador. getAllAndOverride da prioridad a lo declarado en el método sobre lo declarado en la clase:
// src/auth/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.js';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride(Roles, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true; // la ruta no pide ningún rol
const { user } = context.switchToHttp().getRequest();
return required.includes(user?.role); // false => 403 Forbidden
}
}// src/auth/auth.controller.ts
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service.js';
import { LoginDto } from './login.dto.js';
import { Public } from './roles.js';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@Post('login')
@HttpCode(200)
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
}Y el módulo. La clave está en APP_GUARD: registrar los guards globales como providers (y no con app.useGlobalGuards() en main.ts) permite que reciban dependencias inyectadas (JwtService, Reflector) y que se apliquen también en los tests e2e:
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller.js';
import { AuthGuard } from './auth.guard.js';
import { AuthService } from './auth.service.js';
import { RolesGuard } from './roles.guard.js';
import { UsersService } from './users.service.js';
@Module({
imports: [
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '1h' },
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
UsersService,
// Guards globales registrados como providers: así pueden inyectar dependencias.
// Se ejecutan en este orden: primero autenticación, después roles.
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AuthModule {}Middleware, interceptores, pipe y filtro
Un middleware que asigna un identificador a cada petición. Ojo: los middlewares reciben la petición y la respuesta "crudas" de Node (con Fastify, no el FastifyRequest), por eso uso los tipos de node:http:
// src/common/request-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
// Los middlewares reciben la petición y respuesta "crudas" de Node
// (con Fastify, no el FastifyRequest), así que trabajamos con node:http.
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: IncomingMessage & { id?: string }, res: ServerResponse, next: () => void) {
const id = (req.headers['x-request-id'] as string) ?? randomUUID();
req.headers['x-request-id'] = id;
res.setHeader('x-request-id', id);
next();
}
}Un interceptor de logs. Todo lo que va antes de next.handle() se ejecuta antes del controlador; lo que va en el pipe(), después. Usa los logs estructurados de la 12:
// src/common/logging.interceptor.ts
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('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest();
const start = performance.now();
// Lo que va ANTES de next.handle() se ejecuta antes del controlador;
// lo que va en el pipe(), después, cuando el controlador ha respondido.
return next.handle().pipe(
tap(() => {
const ms = Math.round(performance.now() - start);
// Nest 12: el objeto se registra como parámetros estructurados del mismo mensaje
this.logger.log(`${req.method} ${req.url}`, {
ms,
handler: `${context.getClass().name}.${context.getHandler().name}`,
user: req.user?.username,
});
}),
);
}
}Un interceptor de timeout, gracias a RxJS, en unas pocas líneas:
// src/common/timeout.interceptor.ts
import {
CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException,
} from '@nestjs/common';
import { catchError, Observable, throwError, timeout, TimeoutError } from 'rxjs';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly ms = 5000) {}
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
timeout(this.ms),
catchError((err) =>
throwError(() =>
err instanceof TimeoutError
? new RequestTimeoutException('La petición ha tardado demasiado', { errorCode: 'TIMEOUT' })
: err,
),
),
);
}
}Un pipe propio que normaliza un término de búsqueda:
// src/common/trim-lower.pipe.ts
import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
// Un pipe propio: normaliza un texto de búsqueda y rechaza los demasiado cortos.
@Injectable()
export class SearchTermPipe implements PipeTransform<unknown, string> {
transform(value: unknown, metadata: ArgumentMetadata): string {
const term = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (term.length < 2) {
throw new BadRequestException(`${metadata.data ?? 'valor'}: mínimo 2 caracteres`, {
errorCode: 'SEARCH_TOO_SHORT',
});
}
return term;
}
}Y un filtro de excepciones que atrapa todo. Responde a través del HttpAdapterHost, así que funciona igual con Express que con Fastify, y nunca envía al cliente el detalle de un error 500:
// src/common/all-exceptions.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
// Atrapa TODO lo que no se haya gestionado. Usa el adaptador HTTP para responder,
// así funciona igual con Express que con Fastify.
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('Errores');
constructor(private readonly adapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost) {
const { httpAdapter } = this.adapterHost;
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const isHttp = exception instanceof HttpException;
const status = isHttp ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const body = isHttp
? exception.getResponse()
: { statusCode: 500, message: 'Error interno', errorCode: 'INTERNAL' };
if (!isHttp) {
// El detalle va al log, nunca al cliente
this.logger.error('Excepción no controlada', { path: httpAdapter.getRequestUrl(req), exception: String(exception) });
}
httpAdapter.reply(
ctx.getResponse(),
typeof body === 'string' ? { statusCode: status, message: body } : body,
status,
);
}
}El módulo raíz y el arranque
El AppModule junta todo. Los pipes, interceptores y filtros globales se registran con APP_PIPE, APP_INTERCEPTOR y APP_FILTER por la misma razón que los guards:
// src/app.module.ts
import {
MiddlewareConsumer, Module, NestModule, StandardSchemaValidationPipe, ValidationPipe,
} from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { AppController } from './app.controller.js';
import { AuthModule } from './auth/auth.module.js';
import { AllExceptionsFilter } from './common/all-exceptions.filter.js';
import { LoggingInterceptor } from './common/logging.interceptor.js';
import { RequestIdMiddleware } from './common/request-id.middleware.js';
import { TimeoutInterceptor } from './common/timeout.interceptor.js';
import { envSchema } from './config/env.schema.js';
import { DatabaseModule } from './database/database.module.js';
import { TasksModule } from './tasks/tasks.module.js';
import { PreferencesController } from './preferences/preferences.controller.js';
@Module({
imports: [
// Nest 12: validationSchema acepta cualquier Standard Schema (aquí, Zod)
ConfigModule.forRoot({ isGlobal: true, validationSchema: envSchema }),
DatabaseModule,
AuthModule,
TasksModule,
],
controllers: [AppController, PreferencesController],
providers: [
// Registrados aquí (y no en main.ts) para que también se apliquen en los tests e2e
{ provide: APP_PIPE, useValue: new StandardSchemaValidationPipe() },
{ provide: APP_PIPE, useValue: new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useValue: new TimeoutInterceptor(5000) },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*path');
}
}// src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Public } from './auth/roles.js';
@Controller()
export class AppController {
@Public()
@Get('health')
health() {
return { status: 'ok', uptime: Math.round(process.uptime()) };
}
}Y main.ts, donde elegimos adaptador, activamos las novedades de seguridad de la 12.1 y montamos Swagger en /docs:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { INestApplication, NestApplicationOptions } from '@nestjs/common';
import { AppModule } from './app.module.js';
const options: NestApplicationOptions = {
// Nest 12.1: secreto para firmar cookies (admite varios, para rotarlos)
cookies: { secret: process.env.COOKIE_SECRET ?? 'cambia-este-secreto' },
// Nest 12: una ruta duplicada impide arrancar; una tapada por otra, avisa
routeConflictPolicy: { duplicate: 'error', shadow: 'warn' },
// Nest 12: registra primero las rutas literales ('search') y después las de parámetro (':id')
routeResolutionStrategy: 'specificity',
};
async function createApp(): Promise<INestApplication> {
if (process.env.ADAPTER === 'fastify') {
// Con un adaptador explícito, las opciones pasan al TERCER argumento
return NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), options);
}
return NestFactory.create<NestExpressApplication>(AppModule, options);
}
async function bootstrap() {
const app = await createApp();
app.setGlobalPrefix('api', { exclude: ['health'] });
app.enableShutdownHooks(); // cierra la BD al recibir SIGTERM (Coolify, Docker…)
// Nest 12.1: cabeceras de seguridad al estilo helmet y protección CSRF, sin paquetes extra
app.useSecurityHeaders({ contentSecurityPolicy: false }); // CSP desactivada por Swagger UI
app.enableCsrfProtection({ trustedOrigins: ['https://mi-front.ejemplo.com'] });
const config = new DocumentBuilder()
.setTitle('API de tareas')
.setVersion('1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, config));
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
console.log(`Escuchando en ${await app.getUrl()} con ${process.env.ADAPTER ?? 'express'}`);
}
await bootstrap();Probándola con curl
Compilo y arranco (npm run build && node dist/main.js). Esta es una sesión real, recortando solo los tokens:
# Salud (ruta pública, fuera del prefijo /api)
$ curl -s localhost:3000/health
{"status":"ok","uptime":4}
# Sin token
$ curl -s localhost:3000/api/tasks
{"message":"Falta el token","error":"Unauthorized","statusCode":401,"errorCode":"MISSING_TOKEN"}
# Login con un campo de más: lo rechaza ValidationPipe (whitelist + forbidNonWhitelisted)
$ curl -s -XPOST localhost:3000/api/auth/login -H 'content-type: application/json' \
-d '{"username":"ana","password":"mala-contraseña","extra":1}'
{"message":["property extra should not exist"],"error":"Bad Request","statusCode":400}
# Login bueno: guardamos el token
$ TOKEN=$(curl -s -XPOST localhost:3000/api/auth/login -H 'content-type: application/json' \
-d '{"username":"ana","password":"contraseña-de-ana"}' | jq -r .access_token)
# Crear
$ curl -s -XPOST localhost:3000/api/tasks -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"title":"Probar NestJS 12","priority":"alta"}'
{"id":1,"title":"Probar NestJS 12","description":null,"priority":"alta","done":false,"createdAt":"2026-09-29 17:47:16"}
# Listar con filtros (query validada y convertida por Zod)
$ curl -s "localhost:3000/api/tasks?priority=alta&limit=5" -H "authorization: Bearer $TOKEN"
[{"id":1,"title":"Probar NestJS 12","description":null,"priority":"alta","done":false,...}]
$ curl -s "localhost:3000/api/tasks?limit=500" -H "authorization: Bearer $TOKEN"
{"message":["limit: Too big: expected number to be <=100"],"error":"Bad Request","statusCode":400}
# id que no es un número / que no existe
$ curl -s localhost:3000/api/tasks/abc -H "authorization: Bearer $TOKEN"
{"message":["Invalid input: expected number, received NaN"],"error":"Bad Request","statusCode":400}
$ curl -s localhost:3000/api/tasks/999 -H "authorization: Bearer $TOKEN"
{"message":"La tarea 999 no existe","error":"Not Found","statusCode":404,"errorCode":"TASK_NOT_FOUND"}
# Búsqueda con el pipe propio
$ curl -s "localhost:3000/api/tasks/search?q=a" -H "authorization: Bearer $TOKEN"
{"message":"q: mínimo 2 caracteres","error":"Bad Request","statusCode":400,"errorCode":"SEARCH_TOO_SHORT"}
# Borrar como "lector" (luis) y como "admin" (ana)
$ curl -s -o /dev/null -w '%{http_code}\n' -XDELETE localhost:3000/api/tasks/2 -H "authorization: Bearer $TOKEN_LUIS"
403
$ curl -s -o /dev/null -w '%{http_code}\n' -XDELETE localhost:3000/api/tasks/2 -H "authorization: Bearer $TOKEN"
204Y lo que se ve en el log mientras tanto, con las rutas mapeadas y los logs estructurados del interceptor:
[Nest] 1 - LOG [RouterExplorer] Mapped {/api/tasks, GET} route
[Nest] 1 - LOG [RouterExplorer] Mapped {/api/tasks/search, GET} route
[Nest] 1 - LOG [RouterExplorer] Mapped {/api/tasks/:id, GET} route
...
[Nest] 1 - LOG [NestApplication] Nest application successfully started
Escuchando en http://127.0.0.1:3000 con express
[Nest] 1 - LOG [HTTP] POST /api/tasks { ms: 10, handler: 'TasksController.create', user: 'ana' }
[Nest] 1 - LOG [HTTP] PATCH /api/tasks/1 { ms: 7, handler: 'TasksController.update', user: 'luis' }
[Nest] 1 - LOG [HTTP] DELETE /api/tasks/2 { ms: 10, handler: 'TasksController.remove', user: 'ana' }Después arranqué exactamente el mismo dist/ con ADAPTER=fastify y repetí la sesión: mismas respuestas, mismos códigos, mismas cabeceras. Lo único que tuve que añadir fue @fastify/static, porque Swagger UI lo necesita con Fastify; sin él, Nest no arranca y lo dice claramente: The "@fastify/static" package is missing. Please, make sure to install it to use SwaggerModule.
Cookies (12.1)
Un controlador de preferencias que guarda el tema en una cookie normal y un identificador de visitante en una cookie firmada:
// src/preferences/preferences.controller.ts
import {
Body, Controller, Cookies, Delete, Get, HttpCode, Put, Res, SignedCookies,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { z } from 'zod';
import { Public } from '../auth/roles.js';
const themeSchema = z.enum(['claro', 'oscuro']);
// Nest 12.1: cookies integradas, iguales en Express y en Fastify, sin cookie-parser
@Public()
@Controller('preferences')
export class PreferencesController {
constructor(private readonly adapterHost: HttpAdapterHost) {}
@Get()
read(@Cookies('theme') theme?: string, @SignedCookies('visitor') visitor?: string) {
return { theme: theme ?? 'claro', visitor: visitor ?? null };
}
@Put('theme')
@HttpCode(204)
setTheme(
@Body('theme', { schema: themeSchema }) theme: z.infer<typeof themeSchema>,
@Res({ passthrough: true }) res: unknown,
) {
const { httpAdapter } = this.adapterHost;
httpAdapter.setCookie(res, 'theme', theme, { sameSite: 'lax', maxAge: 60 * 60 * 24 * 365 }); // en SEGUNDOS
// Firmada: si el cliente la modifica, @SignedCookies() devuelve false
httpAdapter.setCookie(res, 'visitor', 'v-' + Date.now(), { httpOnly: true, sameSite: 'lax', signed: true });
}
@Delete('theme')
@HttpCode(204)
reset(@Res({ passthrough: true }) res: unknown) {
this.adapterHost.httpAdapter.clearCookie(res, 'theme');
}
}$ curl -s -c cookies.txt -i -XPUT localhost:3000/api/preferences/theme \
-H 'content-type: application/json' -d '{"theme":"oscuro"}'
HTTP/1.1 204 No Content
set-cookie: theme=oscuro; Path=/; Max-Age=31536000; SameSite=Lax
set-cookie: visitor=s%3Av-1790704174614.Zf4qnqHzgvGzvNliMsSwQQq%2BbwaUwJBVXnWm1inUado; Path=/; HttpOnly; SameSite=Lax
$ curl -s -b cookies.txt localhost:3000/api/preferences
{"theme":"oscuro","visitor":"v-1790704174614"}
# Cambio a mano un carácter del valor firmado en cookies.txt...
$ curl -s -b cookies.txt localhost:3000/api/preferences
{"theme":"oscuro","visitor":null}Al manipular la cookie firmada, @SignedCookies() deja de devolverla. Dos avisos importantes:
maxAgeva en segundos, como en el RFC y en@fastify/cookie. Enres.cookie()de Express iba en milisegundos: si copias un valor antiguo, tu cookie vivirá mil veces más.- Si usas
signed: truesin configurarcookies.secret, la petición falla con un 500 (Cannot sign cookie "visitor": no cookie secret is configured). Me pasó en los tests e2e: la app de test se crea concreateNestApplication(), no conmain.ts, y hay que pasarle también el secreto.
CSRF y cabeceras de seguridad (12.1)
Con las dos líneas de main.ts, cada respuesta lleva las cabeceras de helmet:
$ curl -s -D - -o /dev/null localhost:3000/health
HTTP/1.1 200 OK
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Origin-Agent-Cluster: ?1
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-DNS-Prefetch-Control: off
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 0
x-request-id: 9b4e35b3-cdb6-45c7-956a-523e1aa00cd6Y una petición que el navegador marca como de otro sitio se rechaza antes de llegar a guards o controladores:
# Una petición que el navegador marca como de otro sitio: rechazada antes de llegar a nada
$ curl -s -XPOST localhost:3000/api/tasks -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-H 'Origin: https://malo.example' -H 'Sec-Fetch-Site: cross-site' -d '{"title":"csrf"}'
{"message":"Cross-origin request detected from Sec-Fetch-Site header","error":"Forbidden","statusCode":403}Las peticiones sin Sec-Fetch-Site ni Origin (curl, otros servidores, apps móviles) pasan, porque el CSRF es un ataque que solo se da desde navegadores. Si tu frontend está en otro dominio, añádelo a trustedOrigins. Y si Swagger UI se queja de la CSP, desactívala solo para esa ruta o ajústala; yo la he desactivado en el ejemplo por simplicidad.
Subida de ficheros con Fastify (12.1)
// src/demos/upload.main.ts
import {
Controller, FileTypeValidator, MaxFileSizeValidator, Module, ParseFilePipe, Post, UploadedFile, UseInterceptors,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter, type NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { FileInterceptor, type UploadedMultipartFile } from '@nestjs/platform-fastify/multipart';
// Nest 12.1: FileInterceptor también con Fastify (por debajo, @fastify/multipart)
@Controller('files')
class FilesController {
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
upload(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 200 * 1024 }), // 200 KB
new FileTypeValidator({ fileType: 'image/png' }),
],
}),
)
file: UploadedMultipartFile,
) {
return { name: file.originalname, type: file.mimetype, bytes: file.size };
}
}
@Module({ controllers: [FilesController] })
class UploadModule {}
const app = await NestFactory.create<NestFastifyApplication>(UploadModule, new FastifyAdapter());
await app.listen(3001, '0.0.0.0');$ npm i @fastify/multipart
$ curl -s -F "[email protected];type=image/png" localhost:3001/files/avatar
{"name":"avatar.png","type":"image/png","bytes":69}
$ curl -s -F "[email protected];type=text/plain" localhost:3001/files/avatar
{"message":"Validation failed (current file type is text/plain, expected type is image/png)","error":"Bad Request","statusCode":400}No hace falta registrar @fastify/multipart: el adaptador lo hace solo en cuanto usas un interceptor de subida. Ten en cuenta que aplica límites más estrictos que Multer: por defecto, 1 MiB por fichero (el bodyLimit de Fastify); si necesitas más, se ajusta con la opción multipart del FastifyAdapter o en el propio interceptor.
Tests: unitarios y de extremo a extremo
Aquí es donde la inyección de dependencias devuelve todo lo que cuesta. Un test unitario del controlador sustituye el servicio por un doble sin tocar una línea del controlador:
// src/tasks/tasks.controller.spec.ts
import { NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { TasksController } from './tasks.controller.js';
import { TasksService } from './tasks.service.js';
describe('TasksController', () => {
let controller: TasksController;
// Un doble del servicio: el controlador no sabe (ni le importa) que no hay base de datos
const service = {
findAll: vi.fn(),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
search: vi.fn(),
};
beforeEach(async () => {
vi.resetAllMocks();
const moduleRef = await Test.createTestingModule({
controllers: [TasksController],
providers: [{ provide: TasksService, useValue: service }],
}).compile();
controller = moduleRef.get(TasksController);
});
it('crea una tarea delegando en el servicio', async () => {
const task = { id: 1, title: 'Probar', priority: 'alta', done: false };
service.create.mockResolvedValue(task);
await expect(controller.create({ title: 'Probar', priority: 'alta' })).resolves.toEqual(task);
expect(service.create).toHaveBeenCalledWith({ title: 'Probar', priority: 'alta' });
});
it('propaga el 404 del servicio', async () => {
service.findOne.mockRejectedValue(new NotFoundException());
await expect(controller.findOne(99)).rejects.toBeInstanceOf(NotFoundException);
});
});Los guards son clases normales: se prueban creándolas y pasándoles un contexto falso:
// src/auth/roles.guard.spec.ts
import type { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard.js';
// Construye un ExecutionContext falso con el usuario que queramos
const contextWith = (role?: string) =>
({
getHandler: () => () => {},
getClass: () => class {},
switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }),
}) as unknown as ExecutionContext;
describe('RolesGuard', () => {
const reflector = new Reflector();
const guard = new RolesGuard(reflector);
it('deja pasar si la ruta no pide roles', () => {
vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
expect(guard.canActivate(contextWith('lector'))).toBe(true);
});
it('bloquea a quien no tiene el rol', () => {
vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
expect(guard.canActivate(contextWith('lector'))).toBe(false);
expect(guard.canActivate(contextWith('admin'))).toBe(true);
});
});El test e2e levanta la aplicación entera (con sus guards, pipes y filtros globales, por eso los registramos como providers) contra una base de datos SQLite en memoria, y le lanza peticiones reales con supertest. Incluye el test que atrapa la trampa de Zod de antes:
// test/tasks.e2e-spec.ts
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { App } from 'supertest/types.js';
import { AppModule } from '../src/app.module.js';
describe('API de tareas (e2e)', () => {
let app: INestApplication<App>;
let admin: string;
let lector: string;
const login = async (username: string, password: string) =>
(await request(app.getHttpServer()).post('/auth/login').send({ username, password }).expect(200))
.body.access_token as string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication({ logger: false, cookies: { secret: 'secreto-de-pruebas' } });
await app.init();
admin = await login('ana', 'contraseña-de-ana');
lector = await login('luis', 'contraseña-de-luis');
});
afterAll(() => app.close());
it('rechaza peticiones sin token con un errorCode estable', async () => {
const res = await request(app.getHttpServer()).get('/tasks').expect(401);
expect(res.body.errorCode).toBe('MISSING_TOKEN');
});
it('valida el cuerpo con el esquema de Zod', async () => {
const res = await request(app.getHttpServer())
.post('/tasks')
.auth(admin, { type: 'bearer' })
.send({ title: 'x', priority: 'urgente' })
.expect(400);
expect(res.body.message).toHaveLength(2);
});
it('crea, actualiza parcialmente y borra', async () => {
const server = app.getHttpServer();
const { body: created } = await request(server)
.post('/tasks')
.auth(admin, { type: 'bearer' })
.send({ title: 'Probar NestJS 12', priority: 'alta' })
.expect(201);
expect(created).toMatchObject({ id: 1, priority: 'alta', done: false });
// El PATCH solo toca "done": la prioridad NO debe volver a 'media'
const { body: updated } = await request(server)
.patch(`/tasks/${created.id}`)
.auth(lector, { type: 'bearer' })
.send({ done: true })
.expect(200);
expect(updated).toMatchObject({ done: true, priority: 'alta' });
await request(server).delete(`/tasks/${created.id}`).auth(lector, { type: 'bearer' }).expect(403);
await request(server).delete(`/tasks/${created.id}`).auth(admin, { type: 'bearer' }).expect(204);
await request(server).get(`/tasks/${created.id}`).auth(admin, { type: 'bearer' }).expect(404);
});
it('guarda y lee cookies firmadas', async () => {
const agent = request.agent(app.getHttpServer());
await agent.put('/preferences/theme').send({ theme: 'oscuro' }).expect(204);
const { body } = await agent.get('/preferences').expect(200);
expect(body.theme).toBe('oscuro');
expect(body.visitor).toMatch(/^v-\d+$/);
});
});Las variables de entorno de test van en la configuración de Vitest:
// vitest.config.e2e.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
include: ['**/*.e2e-spec.ts'],
// Se aplican ANTES de importar los tests: con ESM, los import se evalúan
// antes que cualquier "process.env.X = ..." escrito en el propio fichero.
env: {
DATABASE_URL: ':memory:',
JWT_SECRET: 'secreto-de-pruebas-con-al-menos-32-caracteres',
},
},
});process.env.DATABASE_URL = ':memory:' al principio del fichero de test. No funcionó: la tarea creada tenía el id 6 en lugar del 1, porque estaba escribiendo en la base de datos de desarrollo. En ESM, todos los import se evalúan antes que el código del fichero, así que ConfigModule.forRoot() ya había leído el .env cuando mi línea se ejecutó. La solución es definir esas variables en test.env de Vitest, que se aplica antes de cargar nada.$ npm test
✓ src/auth/roles.guard.spec.ts (2 tests) 8ms
✓ src/tasks/tasks.controller.spec.ts (2 tests) 209ms
Test Files 2 passed (2)
Tests 4 passed (4)
$ npm run test:e2e
✓ test/tasks.e2e-spec.ts (4 tests) 414ms
Test Files 1 passed (1)
Tests 4 passed (4)
$ npm run lint
Found 0 warnings and 0 errors.Documentación OpenAPI con Swagger
Con SwaggerModule.setup('docs', …) tienes Swagger UI en /docs y el JSON en /docs-json. Los parámetros validados con Zod aparecen documentados automáticamente, y los DTO de class-validator se documentan con @ApiProperty(). Para que Swagger UI envíe el token, addBearerAuth() en el DocumentBuilder y @ApiBearerAuth() en el controlador. Si quieres menos decoradores, el plugin del CLI ("plugins": ["@nestjs/swagger"] en nest-cli.json) deduce buena parte de la documentación de los tipos y los comentarios.
Desplegarla en Coolify (o en cualquier Docker)
Un Dockerfile de dos etapas: compila con todas las dependencias y se queda solo con las de producción:
# Dockerfile
# --- Etapa 1: compilar ---
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# --- Etapa 2: imagen final ---
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p data && chown node:node data
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD ["node", "dist/main.js"]# .dockerignore
node_modules
dist
data
.env
coverage$ docker build -t nest-tareas .
$ docker run -d --name tareas -e JWT_SECRET=un-secreto-de-produccion-muy-largo-123456 \
-v tareas-data:/app/data -p 3000:3000 nest-tareas
$ docker ps --filter name=tareas --format '{{.Status}}'
Up 15 seconds (healthy)La imagen final ocupa unos 300 MB (Node Alpine más las dependencias: Swagger UI, class-validator, los dos adaptadores…). Si quieres reducirla, quita el adaptador que no uses y considera empaquetar con Rspack. En Coolify:
- Build pack Dockerfile, puerto 3000 y health check en
/health(la imagen ya traewget). - Variables
JWT_SECRETyCOOKIE_SECRETen el panel, marcadas como de ejecución (no de build). - Un volumen persistente en
/app/datapara el fichero SQLite; sin él, cada despliegue empieza con la base de datos vacía. Para varias réplicas, cambia a PostgreSQL (Drizzle lo soporta con muy pocos cambios) o a un servidor libSQL/Turso. - Escuchar en
0.0.0.0(ya lo hacemain.ts) yenableShutdownHooks()para que el despliegue sin cortes cierre bien las conexiones cuando Coolify pare el contenedor viejo. - Detrás del proxy de Coolify el
Hostse conserva, así que el CSRF funciona; si tu frontend vive en otro dominio, añádelo atrustedOrigins.
Características de NestJS de un vistazo
| Característica | NestJS 12 |
|---|---|
| Lenguaje | TypeScript (también admite JavaScript) |
| Licencia | MIT |
| Versión actual | 12.1.1 (CLI 12.0.8), septiembre de 2026 |
| Node.js | 20.19+ / 22.12+ para ejecutar; Node 24 LTS recomendado |
| Módulos | ESM (con soporte CommonJS vía require(esm)) |
| Servidor HTTP | Express (por defecto) o Fastify, intercambiables |
| Arquitectura | Módulos, controladores, providers, inyección de dependencias |
| Validación | Standard Schema (Zod, Valibot, ArkType) y class-validator |
| Seguridad integrada | Cookies firmadas, CSRF, cabeceras tipo helmet (12.1) |
| Documentación API | @nestjs/swagger (OpenAPI 3), también desde esquemas Zod |
| Más allá de HTTP | GraphQL, WebSockets, microservicios (Redis, NATS, Kafka, gRPC, RabbitMQ, MQTT), colas, cron |
| Testing | @nestjs/testing + Vitest (ESM) o Jest (CommonJS), supertest para e2e |
| Herramientas | CLI con generadores, oxlint, Rspack, monorepos, nest upgrade |
| Observabilidad | @nestjs/observe (servicio oficial, opcional) |
Errores comunes (y cómo salir de ellos)
"Nest can't resolve dependencies of…"
El error más famoso de Nest. Lo provoqué a propósito con un MailService que su módulo no exportaba:
Nest can't resolve dependencies of the UsersService (?). Please make sure that the argument
MailService at index [0] is available in the UsersModule module.
Potential solutions:
- Is UsersModule a valid NestJS module?
- If MailService is a provider, is it part of the current UsersModule?
- If MailService is exported from a separate @Module, is that module imported within UsersModule?El (?) marca la posición del argumento que no encuentra. Casi siempre es una de estas tres cosas: el provider no está en el módulo, su módulo no lo exporta, o el módulo que lo necesita no importa al que lo exporta.
@Module({
providers: [MailService],
exports: [MailService], // <- sin esto, nadie fuera de MailModule puede inyectarlo
})
export class MailModule {}Dependencias circulares
Si AuthService necesita UsersService y UsersService necesita AuthService, Nest no puede decidir cuál crear primero. forwardRef() lo desbloquea, pero tómalo como una señal de diseño: normalmente hay un tercer servicio esperando a ser extraído.
// users.service.ts
@Injectable()
export class UsersService {
constructor(
@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService,
) {}
}
// Y si son módulos los que se importan mutuamente:
@Module({ imports: [forwardRef(() => AuthModule)] })
export class UsersModule {}Otros que me he encontrado
- Imports sin
.jsen ESM:import { X } from './x'falla en ejecución conERR_MODULE_NOT_FOUND. Escribe siempre./x.js. - Jest y Nest 12 en Node anterior a 24.9:
ERR_REQUIRE_ASYNC_MODULE. Sube Node o pasa a Vitest. - Guards globales con
app.useGlobalGuards(new AuthGuard()): no reciben dependencias ni se aplican en los tests e2e. MejorAPP_GUARD. - Scope REQUEST sin querer: una sola dependencia con ese scope convierte en "por petición" toda la cadena que la usa, con su coste de rendimiento.
- Rutas tapadas: activa
routeConflictPolicyyrouteResolutionStrategy: 'specificity'en proyectos nuevos. - Fastify que "no responde" en Docker: escucha en
127.0.0.1por defecto; pasa'0.0.0.0'alisten(). - Lógica en los controladores: funciona, pero pierdes la reutilización y los tests fáciles. El controlador solo orquesta.
¿Cuándo elegir NestJS?
Sí, si la API va a crecer, si trabaja más de una persona en ella, si quieres una estructura que no dependa de la disciplina de cada uno, o si vas a necesitar microservicios, colas, GraphQL o WebSockets con la misma forma de trabajar. También si vienes de Angular, Spring o ASP.NET: te sentirás en casa.
Probablemente no, si es un servicio pequeño de pocas rutas, una función serverless o algo que tiene que ejecutarse en el edge (Cloudflare Workers, Deno Deploy…). Ahí Hono es mucho más ligero, y Fastify o Express a pelo te dan más control con menos ceremonia.
Si dudas entre los cuatro, he escrito una comparativa de Hono, Fastify, Express y NestJS con pruebas de rendimiento reales en el mismo servidor y una guía para decidir según tu caso.
Conclusión
NestJS 12 es una versión de madurez. No cambia la forma de trabajar (módulos, DI, decoradores siguen donde estaban), pero moderniza todo lo de alrededor: ESM, Standard Schema para no duplicar tipos y validaciones, un CLI más rápido con Rspack, Vitest y oxlint, y en la 12.1 cosas que antes eran tres paquetes (cookies, CSRF, helmet) pasan a ser una línea. La migración desde la 11 es razonable gracias a nest upgrade, con el aviso de Jest como principal escollo.
Lo que más me ha gustado al montar la API: poder validar con Zod en los parámetros y que Swagger lo entienda solo, y que exactamente el mismo código corra en Express y en Fastify. Lo que más me ha costado: dos fallos silenciosos, el partial() de Zod 4 y el orden de los imports en ESM, que no tienen nada que ver con Nest pero que te encontrarás en cuanto uses su nueva forma de trabajar. Ahora ya los conoces.
Sigue con la serie: Hono a fondo, Fastify a fondo, Express a fondo y la comparativa de los cuatro.