> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# NestJS 12 a fondo: arquitectura, novedades y una API completa paso a paso
- URL: https://blog.devutilites.com/nestjs-12-a-fondo/
- Published: 2026-09-29T18:33:26.000Z
- Updated: 2026-09-29T18:33:26.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Backend

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.

🧪

****Todo el código de este artículo está probado.** Lo he montado y ejecutado con ****NestJS 12.1.1** (CLI 12.0.8), TypeScript 6.0, Zod 4.6, Drizzle 0.45, Vitest 4.1 y Node.js 24 (y también arranca en Node 20.20), tanto con Express 5.2 como con Fastify 5.12\. Las salidas de consola que verás son reales, incluidos un par de fallos que me encontré por el camino y que te cuento para que no te pasen.

## 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](https://nestjs.com/?ref=blog.devutilites.com)
- **Documentación:** [docs.nestjs.com](https://docs.nestjs.com/?ref=blog.devutilites.com) (y la [guía de migración a la 12](https://docs.nestjs.com/migration-guide?ref=blog.devutilites.com))
- **Código fuente:** [github.com/nestjs/nest](https://github.com/nestjs/nest?ref=blog.devutilites.com) · [notas de la versión 12.0.0](https://github.com/nestjs/nest/releases/tag/v12.0.0?ref=blog.devutilites.com)
- **Paquetes npm:** [@nestjs/core](https://www.npmjs.com/package/@nestjs/core?ref=blog.devutilites.com), [@nestjs/common](https://www.npmjs.com/package/@nestjs/common?ref=blog.devutilites.com) y el CLI [@nestjs/cli](https://www.npmjs.com/package/@nestjs/cli?ref=blog.devutilites.com)
- **CLI (código):** [github.com/nestjs/nest-cli](https://github.com/nestjs/nest-cli?ref=blog.devutilites.com)

### 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@12` con Node 20.20, npm avisa de que `@nestjs/schematics` 12 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

```bash
# 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:dev
```

Si no quieres instalar nada global, o tu máquina tiene un Node antiguo:

```bash
# 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-api
```

En 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:

```text
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.ts
```

Fí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.

```typescript
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 ESM
```

### Los 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:

```typescript
// 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:

```typescript
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));
```

```text
$ 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.

⚠️

****Consecuencia práctica:** si importas una clase inyectable con `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:

![Diagrama de módulos de la API de ejemplo: AppModule importa ConfigModule, DatabaseModule, AuthModule y TasksModule; debajo, la cadena de resolución ConfigService, DB_CLIENT, DB, TasksService y TasksController](https://blog.devutilites.com/content/images/2026/09/nestjs-modulos.png)

Cada módulo encapsula sus providers; solo lo que exporta (o lo global) se puede inyectar desde fuera. Diagrama: devutilites.

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:

```typescript
@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

```typescript
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**:

![Diagrama del ciclo de vida de una petición en NestJS: middleware, guards, interceptores, pipes y controlador; a la vuelta, interceptores y respuesta; cualquier excepción va a los exception filters](https://blog.devutilites.com/content/images/2026/09/nestjs-ciclo.png)

El orden no cambia nunca. Saber en qué capa va cada cosa es la mitad de dominar Nest. Diagrama: devutilites.

| 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:

```typescript
@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:

```typescript
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 localhost
```

Fastify 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](https://blog.devutilites.com/express-a-fondo/) y a [Fastify](https://blog.devutilites.com/fastify-a-fondo/).

## 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](https://standardschema.dev/?ref=blog.devutilites.com) 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:

```typescript
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):

```bash
$ 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`:

```typescript
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()`.

💡

****class-validator no desaparece.** El equipo de Nest ha dicho explícitamente que el flujo con decoradores de class-validator sigue plenamente soportado y sin planes de retirarlo. Elige según el caso: clases DTO si ya las tienes; esquemas si ya usas Zod en el frontend o quieres compartirlos.

### 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:

```typescript
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:

```typescript
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:

```text
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.

📝

****Un detalle que noté:** con `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:

```typescript
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.

```typescript
// 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.

```typescript
// 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` (alias `update`): 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-declarations` con SWC, `--parallel` para monorepos, `includeLibraryAssets`…
- El esquema `decorator` genera decoradores con `Reflector.createDecorator()`, y desaparece el esquema `angular`.

```bash
# 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/logging
```

### 10\. 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()` y `httpAdapter.setCookie()`/`clearCookie()`, iguales en Express y Fastify, sin `cookie-parser` ni `@fastify/cookie`. Con firma y rotación de secretos.
- **Protección CSRF** con `app.enableCsrfProtection()`. No usa tokens: se basa en la cabecera `Sec-Fetch-Site` que envían todos los navegadores modernos (con respaldo en `Origin`/`Host`), el mismo algoritmo que `CrossOriginProtection` de 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**: `FileInterceptor` y 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:

```text
$ 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:

1. Rama nueva y tests en verde en la 11.
2. `npm i -g @nestjs/cli@latest` y `nest upgrade --dry-run`. Leer el informe entero.
3. `nest upgrade`, `npm install` y `npx tsc --noEmit`. Arreglar lo que diga TypeScript 6 (el `rootDir` que avisa arriba es el más común).
4. Tests. Si usas Jest con Node anterior a 24.9, fallarán con `ERR_REQUIRE_ASYNC_MODULE`: sube Node o pasa a Vitest.
5. 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

```bash
# 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/multipart
```

⚠️

****Por qué libSQL y no better-sqlite3.** Mi primera opción fue better-sqlite3, pero las versiones recientes de npm ya no ejecutan los scripts de instalación de las dependencias hasta que los apruebas (`npm 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.

```text
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.ts
```

### Configuració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:

```typescript
// 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>;
```

```bash
# .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=fastify
```

Si falta algo, la aplicación **no arranca** y te dice exactamente qué pasa. Salida real con un secreto demasiado corto:

```text
$ docker run --rm -e JWT_SECRET=corto nest-tareas
ERROR [ExceptionHandler] Error: Config validation error: JWT_SECRET: JWT_SECRET debe tener al menos 32 caracteres
```

### Base 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`:

```typescript
// 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:

```typescript
// 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();
  }
}
```

💡

En un proyecto real, en lugar del `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)

```typescript
// 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>;
```

🐞

****El fallo que me encontré probando.** Mi primera versión definía `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).

```typescript
// 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

```typescript
// 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

```typescript
// 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:

```typescript
// 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:

```typescript
// 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`.

```typescript
// 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;
}
```

```typescript
// 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;
  }
}
```

```typescript
// 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`:

```typescript
// 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:

```typescript
// 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
  }
}
```

```typescript
// 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:

```typescript
// 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`:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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');
  }
}
```

```typescript
// 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`:

```typescript
// 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:

```bash
# 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"
204
```

Y lo que se ve en el log mientras tanto, con las rutas mapeadas y los logs estructurados del interceptor:

```text
[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**:

```typescript
// 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');
  }
}
```

```bash
$ 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:

- `maxAge` va en **segundos**, como en el RFC y en `@fastify/cookie`. En `res.cookie()` de Express iba en **milisegundos**: si copias un valor antiguo, tu cookie vivirá mil veces más.
- Si usas `signed: true` sin configurar `cookies.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 con `createNestApplication()`, no con `main.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:

```bash
$ 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-523e1aa00cd6
```

Y una petición que el navegador marca como de otro sitio se rechaza antes de llegar a guards o controladores:

```bash
# 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)

```typescript
// 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');
```

```bash
$ npm i @fastify/multipart

$ curl -s -F "file=@avatar.png;type=image/png" localhost:3001/files/avatar
{"name":"avatar.png","type":"image/png","bytes":69}

$ curl -s -F "file=@notas.txt;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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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',
    },
  },
});
```

🐞

****Otro fallo real, típico de ESM.** Al principio puse `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.

```text
$ 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
# 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"]
```

```text
# .dockerignore
node_modules
dist
data
.env
coverage
```

```bash
$ 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 trae `wget`).
- Variables `JWT_SECRET` y `COOKIE_SECRET` en el panel, marcadas como de ejecución (no de build).
- Un **volumen persistente** en `/app/data` para 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 hace `main.ts`) y `enableShutdownHooks()` para que el despliegue sin cortes cierre bien las conexiones cuando Coolify pare el contenedor viejo.
- Detrás del proxy de Coolify el `Host` se conserva, así que el CSRF funciona; si tu frontend vive en otro dominio, añádelo a `trustedOrigins`.

## 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:

```text
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.

```typescript
@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.

```typescript
// 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 `.js` en ESM:** `import { X } from './x'` falla en ejecución con `ERR_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. Mejor `APP_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 `routeConflictPolicy` y `routeResolutionStrategy: 'specificity'` en proyectos nuevos.
- **Fastify que "no responde" en Docker:** escucha en `127.0.0.1` por defecto; pasa `'0.0.0.0'` a `listen()`.
- **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](https://blog.devutilites.com/hono-a-fondo/) es mucho más ligero, y [Fastify](https://blog.devutilites.com/fastify-a-fondo/) o [Express](https://blog.devutilites.com/express-a-fondo/) 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](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/) 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](https://blog.devutilites.com/hono-a-fondo/), [Fastify a fondo](https://blog.devutilites.com/fastify-a-fondo/), [Express a fondo](https://blog.devutilites.com/express-a-fondo/) y la [comparativa de los cuatro](https://blog.devutilites.com/hono-fastify-express-nestjs-comparativa/).