9. NestJS: arquitectura, módulos e inyección de dependencias
Con Express puedes construir una API en veinte líneas; el problema aparece dos años después, cuando esa API tiene ciento veinte rutas, tres desarrolladores nuevos y nadie recuerda por qué la validación se hace en cuatro sitios distintos. NestJS existe para resolver ese problema concreto: impone una arquitectura, aporta un contenedor de inversión de control y convierte el sistema de tipos en la herramienta de diseño principal del backend. Este capítulo explica el framework por dentro —el escáner de módulos, el contenedor IoC, la resolución de dependencias, los scopes— porque casi todos los errores «inexplicables» de Nest se entienden en cuanto sabes qué está haciendo el contenedor.
9.1 Qué vas a poder hacer al terminar
- Explicar qué ocurre exactamente entre
NestFactory.create(AppModule)y la primera petición atendida: escáner de módulos, contenedor IoC, grafo de dependencias y adaptador HTTP. - Diseñar la estructura de un proyecto por features, con módulos cohesionados y fronteras claras, en lugar de una carpeta
services/con cuarenta ficheros. - Leer un error
Nest can't resolve dependencies of X (?, Y)y saber en menos de un minuto si falta unexports, unimports, un@Injectable()o un token. - Escribir módulos dinámicos configurables con el patrón
forRoot/forRootAsync, a mano y conConfigurableModuleBuilder. - Elegir con criterio entre los cinco tipos de provider y entender por qué no puedes inyectar una interfaz.
- Decidir cuándo un scope
REQUESTestá justificado y cuándo es preferibleAsyncLocalStorage, midiendo el coste. - Diseñar controladores REST correctos: recursos, anidamiento, códigos de estado y versionado.
- Configurar la aplicación con
@nestjs/config, configuración tipada por espacios de nombres y validación del entorno con Joi o con Zod. - Implementar un apagado ordenado que no pierda peticiones durante un despliegue en Kubernetes.
- Documentar la API con OpenAPI y generar desde ella un cliente TypeScript para el Angular de la parte II.
9.2 Historia y contexto: del «cada proyecto es distinto» a un framework con arquitectura
Para entender NestJS hay que entender el vacío que vino a llenar. Durante casi una década «backend en Node» significó Express, y Express es deliberadamente minimalista: un enrutador, una cadena de middleware y nada más. Esa libertad total tuvo un coste que se pagó en mantenimiento.
- Sin arquitectura impuesta. Express no opina sobre dónde va la lógica de negocio. El resultado típico es lógica dentro de los route handlers, SQL en el mismo fichero que la validación y cero separación entre transporte y dominio.
- Sin inyección de dependencias. Cada módulo hacía
requirede sus colaboradores, es decir, dependía de implementaciones concretas. Sustituir la pasarela de pago en un test obligaba a parchear el sistema de módulos (proxyquire,jest.mock) en lugar de pasar otro objeto. - Sin tipos.
req.bodyera un agujero negro y el contrato de la API vivía en la documentación, cuando existía. - Cada equipo reinventaba la estructura. Dos proyectos Express de la misma empresa podían no parecerse en nada; incorporar a alguien costaba semanas de arqueología.
2010. TJ Holowaychuk publica Express, inspirado en Sinatra. Se convierte en el estándar de facto y sigue siendo la base sobre la que corre la mayoría del Node en producción.
2013–2016. Aparecen alternativas que atacan piezas concretas: Koa (middleware con promesas y async/await), Hapi (configuración declarativa, de Walmart Labs) y Fastify (2016, obsesionado con el rendimiento y con la serialización JSON precompilada desde esquemas).
2016. Angular 2 se reescribe en TypeScript con módulos, decoradores e inyección de dependencias basada en metadatos. Ese modelo mental resulta enormemente productivo en equipos grandes.
2017. Kamil Myśliwiec publica NestJS con una tesis explícita: llevar al backend la arquitectura de Angular construyendo encima de Express en lugar de sustituirlo. Nest no reimplementa el servidor HTTP: lo envuelve.
2018–2019. Nest 5 y 6 consolidan la plataforma: @nestjs/platform-fastify, microservicios con varios transportes, GraphQL, WebSockets y CLI propio. Nace la idea de framework agnóstico del transporte.
2020–2023. Nest 7 a 10: @nestjs/config, módulos dinámicos estandarizados, ConfigurableModuleBuilder (Nest 9), compilación con SWC, DevTools y snapshots del grafo. Nest 10 se asienta sobre Express 4 y Fastify 4.
Enero de 2025 · Nest 11. Salto de dependencias mayores: Express 5 y Fastify 5 por defecto, Node.js 20 como mínimo, @nestjs/cache-manager v3 sobre Keyv y un ConsoleLogger configurable capaz de emitir JSON. La plantilla del CLI pasa a la configuración plana de ESLint 9 (eslint.config.mjs).
path-to-regexp. Consecuencias prácticas: los comodines de ruta deben ir nombrados
(@Get('*splat') o @Get('{*splat}') en lugar de @Get('*')) y los parámetros opcionales se escriben con llaves ({:id}) en vez de :id?. Con Fastify, todos los plugins @fastify/* deben subir a la versión 5. La posición de Nest en el ecosistema es hoy inequívoca: es el framework de referencia para backend Node en entornos empresariales. No es el más rápido ni el más ligero, y no pretende serlo. Su propuesta de valor es la previsibilidad: cualquiera que conozca Nest puede abrir un proyecto Nest ajeno y saber dónde está cada cosa. En un producto que vive cinco años y pasa por veinte manos, eso vale más que unos miles de peticiones por segundo.
9.3 Definición y filosofía
NestJS es un framework de aplicación para Node.js, escrito en TypeScript, que aporta una arquitectura modular y un contenedor de inversión de control, y que delega el transporte en un adaptador intercambiable. Cada parte de esa definición tiene consecuencias prácticas:
- De aplicación, no solo HTTP. Un servidor HTTP es un uso de Nest. Con
NestFactory.createMicroservice()obtienes un servicio que escucha en TCP, Redis, NATS, MQTT, RabbitMQ, Kafka o gRPC; conNestFactory.createApplicationContext()obtienes el contenedor de DI sin servidor alguno, ideal para un comando de consola, un worker de colas o una tarea programada. El grafo de módulos es el mismo. - Agnóstico del transporte. El framework no habla con
reqyres: habla con unHttpAdapter. Por eso puedes cambiar Express por Fastify sin tocar controladores ni servicios. - Arquitectura modular. La unidad de organización no es la carpeta, es el módulo: una frontera de encapsulación real, verificada en el arranque. Lo que un módulo no exporta, no existe para los demás.
- Contenedor IoC. Tú declaras qué necesita cada clase; el framework decide cuándo y cómo construirlo. Eso es lo que hace el código testeable sin trucos.
- TypeScript de primera clase. No es un envoltorio de tipos sobre una librería JavaScript: los metadatos de tipos del constructor son el mecanismo de resolución de dependencias (capítulo 1).
| Problema | Cómo lo resuelve Nest |
|---|---|
| Cada proyecto tiene una estructura distinta | Módulos, controladores, providers y una convención de nombres generada por el CLI |
| Acoplamiento a implementaciones concretas | Contenedor IoC: se inyectan abstracciones a través de tokens |
| Tests que necesitan parchear el sistema de módulos | Test.createTestingModule() con overrideProvider (capítulo 13) |
| Preocupaciones transversales duplicadas y validación de entrada inconsistente | Guards, interceptores, pipes y filtros declarativos (capítulo 10); ValidationPipe global sobre DTOs decorados |
| Documentación de la API siempre desactualizada | OpenAPI generado desde el código real (sección 9.14) |
process.env leído en cualquier rincón | ConfigModule con validación al arrancar y configuración tipada |
9.3.1 «Convención sobre configuración», con moderación
Nest sigue el principio de convención sobre configuración, pero de forma moderada, y la matización importa. Rails o Spring Boot deducen mucho por convención: si el fichero se llama así, si la clase acaba en tal sufijo, el framework hace magia. Nest exige que todo esté declarado explícitamente: un provider no se registra por estar en una carpeta, sino por aparecer en el array providers de un módulo; un controlador no se enruta por llamarse *.controller.ts, sino por estar en controllers. La ventaja es que el grafo de la aplicación es legible y verificable, y Nest falla en el arranque si algo no encaja. La desventaja es la ceremonia; el CLI existe para que no la escribas a mano.
9.3.2 Para quién NO compensa Nest
- Funciones serverless con arranque en frío crítico. Escanear el grafo e instanciar el contenedor cuesta decenas o cientos de milisegundos. Para una función que debe responder en 20 ms, un handler plano es mejor. Nest se puede usar en serverless —hay recetas oficiales para cachear la instancia entre invocaciones— pero estás pagando un precio.
- Microservicios diminutos de tres endpoints que no van a crecer: la estructura pesa más que el problema.
- Prototipos desechables y scripts de un solo uso.
- Equipos de una persona que no conocen la DI y necesitan entregar mañana. La curva no es la sintaxis: es entender módulos, scopes y el ciclo de petición.
- Proyectos que rechazan los decoradores por convicción o que no pueden activar
emitDecoratorMetadata.
A cambio, Nest brilla exactamente donde más duele el mantenimiento: API con muchos recursos, equipos de varias personas, ciclo de vida largo, necesidad de testear en serio y requisitos transversales (autenticación, auditoría, trazabilidad, multi-tenencia).
9.4 Comparación con las alternativas
| Criterio | Express | Fastify | Koa | AdonisJS | tRPC | NestJS |
|---|---|---|---|---|---|---|
| Estructura impuesta | Ninguna | Ninguna (plugins) | Ninguna | Alta (tipo Laravel) | Baja (routers) | Alta y explícita |
| Inyección de dependencias | No | No | No | Sí (contenedor IoC) | No | Sí, de primera clase |
| TypeScript | Tipos externos | Bueno, infiere esquemas | Tipos externos | Nativo | Extremo a extremo | Nativo y requerido |
| Testabilidad | Manual (mocks de módulo) | Manual + inject() | Manual | Buena | Buena | Excelente (@nestjs/testing) |
| Rendimiento bruto (JSON) | Referencia | El más alto del ecosistema | Algo mejor que Express | Medio | El del anfitrión | El del adaptador, menos una capa fina |
| Curva de aprendizaje | Muy baja | Baja | Baja | Media | Baja si dominas TS | Media-alta |
| Ecosistema | Enorme, veterano | Amplio y creciente | Pequeño | Autocontenido | Enfocado (React/Next) | Muy amplio: módulos oficiales para casi todo |
| Caso de uso ideal | API pequeña, prototipo | Alto tráfico, gateway | Middleware a medida | Monolito full-stack | Monorepo TS con cliente propio | API empresarial, equipo grande, vida larga |
9.4.1 Para quien viene de Java: Nest frente a Spring Boot
La equivalencia conceptual es tan directa que sirve como diccionario. No es casualidad: ambos implementan el mismo patrón de contenedor IoC con inyección por constructor.
| Spring Boot | NestJS | Matiz importante |
|---|---|---|
@SpringBootApplication | AppModule + NestFactory.create | Nest no escanea paquetes: hay que declarar en providers |
@Configuration + @Bean | Provider con useFactory | Equivalente casi literal |
@Component / @Service | @Injectable() | Nest no distingue estereotipos |
@RestController | @Controller() | Igual, con decoradores de método |
@Autowired | Inyección por constructor implícita | No hace falta anotar el parámetro salvo con tokens |
@Qualifier("x") | @Inject(TOKEN) | Obligatorio siempre que el tipo no sea una clase |
@Scope("request") | @Injectable({ scope: Scope.REQUEST }) | En Node el coste relativo es mucho mayor (ver 9.9.5) |
application.yml + @ConfigurationProperties | @nestjs/config + registerAs | Mismo objetivo: configuración tipada e inyectable |
| Interfaz + implementación (nominal) | Interfaz + token | En TypeScript la interfaz se borra: hace falta un token |
| Un hilo por petición | Un solo hilo y event loop | Diferencia crítica: nada de ThreadLocal, nada de bloquear |
this.usuarioActual) mezclará datos entre usuarios concurrentes, y una operación bloqueante congelará todas las peticiones del proceso. Los servicios de Nest deben ser sin estado; para contexto por petición, ver la sección 9.9.5. 9.5 Arquitectura interna: qué pasa realmente al arrancar
Nest tiene fama de «mágico», y esa fama solo existe mientras no sabes qué hace. En realidad hace cuatro cosas muy concretas y en un orden fijo: escanear el grafo de módulos, instanciar los providers resolviendo sus dependencias, registrar las rutas en un adaptador HTTP y ejecutar los hooks de arranque.
┌──────────────────────────────────────────────────────────────────┐
│ TU APLICACIÓN Módulos · Controladores · Servicios · DTOs │
└──────────────────────────────┬───────────────────────────────────┘
│ decoradores + metadatos de tipos
┌──────────────────────────────┴───────────────────────────────────┐
│ NÚCLEO (@nestjs/core) │
│ DependenciesScanner ─► NestContainer (IoC) ◄─ Injector │
│ InstanceLoader · RoutesResolver/RouterExplorer │
│ ApplicationConfig (pipes/guards/interceptores/filtros GLOBALES) │
└──────────────────────────────┬───────────────────────────────────┘
│ interfaz HttpAdapter (la abstracción)
┌──────────────────┴──────────────────┐
┌────────┴─────────┐ ┌────────┴──────────┐
│ platform-express │ │ platform-fastify │
│ ExpressAdapter │ │ FastifyAdapter │
│ Express 4 / 5 │ │ Fastify 4 / 5 │
└────────┬─────────┘ └────────┬──────────┘
└──────────► node:http / node:https ◄─┘
MISMO NÚCLEO, MISMO GRAFO, OTRO TRANSPORTE:
NestFactory.createMicroservice() → TCP, Redis, NATS, MQTT, RabbitMQ, Kafka, gRPC
NestFactory.createApplicationContext() → sin servidor: CLI, cron, worker de colas
La clave del diagrama es la línea de la interfaz HttpAdapter: tus controladores nunca llaman a Express, devuelven un valor y Nest lo escribe en la respuesta a través del adaptador. Ese único punto de indirección hace que el framework sea agnóstico del transporte, y explica por qué usar @Res() en crudo «te saca» del framework (sección 9.10.1).
await NestFactory.create(AppModule)
│
├─(1) ADAPTADOR: sin argumentos → ExpressAdapter; new FastifyAdapter() → Fastify
├─(2) CONTENEDOR IoC + ApplicationConfig. Se registra InternalCoreModule, que
│ provee ModuleRef · ApplicationConfig · HttpAdapterHost · LazyModuleLoader
├─(3) ESCANEO · DependenciesScanner.scan(AppModule)
│ a) recorre recursivamente los 'imports' de cada @Module, calculando un TOKEN
│ por módulo (clase + metadatos dinámicos); si el token ya existe, NO lo
│ registra otra vez (deduplicación)
│ b) por cada módulo registra: controllers · providers · injectables · exports
│ → resultado: un Map de módulos, es decir, EL GRAFO
├─(4) INSTANCIACIÓN · InstanceLoader.createInstancesOfDependencies()
│ por cada provider, el Injector:
│ a) lee Reflect.getMetadata('design:paramtypes', Clase) y los @Inject
│ b) busca cada dependencia: 1º providers del PROPIO módulo
│ 2º EXPORTS de los módulos importados (recursivo)
│ 3º módulos @Global
│ c) si no la encuentra → "Nest can't resolve dependencies of X (?)" y ABORTA
│ d) si la encuentra, la instancia primero (recursión en profundidad) y
│ después construye: new Clase(dep1, dep2, ...)
├─(5) ENRUTADO · RoutesResolver + RouterExplorer
│ compone la ruta final (prefijo global + versión + host + path) y registra en
│ el adaptador un handler envuelto por guards → interceptores → pipes → filtros
├─(6) MiddlewareModule: ejecuta el configure() de los módulos NestModule
└─(7) app.init(): hooks onModuleInit y luego onApplicationBootstrap (sección 9.13)
await app.listen(3000) ──► el adaptador abre el socket. YA HAY SERVICIO.
// 1) Aplicación HTTP con Express (adaptador por defecto)
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['error', 'warn', 'log'], // o una instancia de LoggerService, o false
bufferLogs: true, // guarda los logs hasta tener el logger definitivo
abortOnError: false, // propaga el error en vez de process.exit(1)
bodyParser: true, // false si quieres montarlo tú con límites propios
rawBody: true, // conserva req.rawBody: firmas de webhooks
snapshot: process.env.NODE_ENV !== 'production', // grafo para las Nest DevTools
});
// 2) La misma aplicación sobre Fastify: cambia UNA línea, no los controladores
const fast = await NestFactory.create<NestFastifyApplication>(
AppModule, new FastifyAdapter({ logger: false, trustProxy: true }));
// 3) Microservicio: sin servidor HTTP, mismo grafo de módulos
const micro = await NestFactory.createMicroservice(AppModule, {
transport: Transport.TCP, options: { host: '0.0.0.0', port: 3001 } });
// 4) Contexto de aplicación: SOLO el contenedor de DI. Ideal para un comando o un cron,
// porque obtienes tus servicios ya cableados sin abrir ningún puerto.
const ctx = await NestFactory.createApplicationContext(AppModule, { logger: false });
await ctx.get(InformesService).generarCierreMensual();
await ctx.close(); // dispara los hooks de destrucciónHttpAdapterHost: cuando necesitas el objeto de bajo nivel
Si un filtro de excepciones o un módulo de infraestructura necesita el servidor subyacente, no lo importes directamente: inyecta HttpAdapterHost y usa adapterHost.httpAdapter. Así tu código sigue funcionando si mañana cambias de plataforma. 9.6 Instalación y el CLI
El CLI no es un lujo: es lo que hace tolerable la explicitud de Nest, porque genera los ficheros y además los registra en el módulo correspondiente, que es justo la parte que se olvida al hacerlo a mano.
npm i -g @nestjs/cli # o sin instalar: npx @nestjs/cli@latest new api
nest --version # 11.x.x
nest new api-tareas
# ? Which package manager would you like to use? › npm / yarn / pnpm
# CREATE api-tareas/src/{main.ts,app.module.ts,app.controller.ts,app.service.ts}
# CREATE api-tareas/test/app.e2e-spec.ts
# CREATE api-tareas/{nest-cli.json,tsconfig.json,tsconfig.build.json,eslint.config.mjs}
nest new api --strict # tsconfig con strict: true (¡hazlo siempre!)
nest new api --package-manager pnpm # evita la pregunta interactiva
nest new api --skip-git --skip-install # plantillas propias o monorepo existente
nest info # versiones de Node y de los paquetes de Nest--strict no viene activado por defecto
La plantilla del CLI genera un tsconfig.json permisivo para no asustar a quien empieza. En un proyecto profesional arranca con nest new --strict o edita el tsconfig.json antes de la primera línea: activar strict con 300 ficheros escritos cuesta semanas (capítulo 1). | Schematic | Alias | Qué genera | Ejemplo |
|---|---|---|---|
module | mo | Clase con @Module({}), y la importa en el módulo padre | nest g mo tareas |
controller | co | Controlador + spec, y lo añade a controllers | nest g co tareas |
service | s | @Injectable() + spec, y lo añade a providers | nest g s tareas |
provider | pr | Igual que service pero sin el sufijo .service | nest g pr cache |
resource | res | CRUD completo: módulo, controlador, servicio, DTOs, entidad y specs | nest g res tareas |
guard | gu | Clase que implementa CanActivate | nest g gu auth/jwt |
interceptor | itc | Clase que implementa NestInterceptor | nest g itc common/logging |
pipe | pi | Clase que implementa PipeTransform | nest g pi common/parse-uuid |
filter | f | Clase que implementa ExceptionFilter | nest g f common/http-error |
middleware | mi | Clase que implementa NestMiddleware | nest g mi common/correlation |
gateway | ga | Gateway de WebSockets (@WebSocketGateway) | nest g ga chat |
resolver | r | Resolver de GraphQL | nest g r tareas |
decorator | d | Decorador personalizado | nest g d common/current-user |
class | cl | Clase suelta (DTO, entidad, objeto de valor) | nest g cl tareas/dto/create-tarea |
interface | itf | Interfaz suelta | nest g itf tareas/tarea |
configuration | config | nest-cli.json con valores por defecto | nest g config |
app | application | Sub-aplicación: convierte el proyecto en monorepo y crea apps/ | nest g app admin |
library | lib | Librería en libs/ con alias en paths | nest g lib shared |
nest g resource tareas
# ? What transport layer do you use? › REST API
# ? Would you like to generate CRUD entry points? › Yes
# CREATE src/tareas/tareas.{module,controller,service}.ts + specs
# CREATE src/tareas/dto/{create,update}-tarea.dto.ts
# CREATE src/tareas/entities/tarea.entity.ts
# UPDATE src/app.module.ts ← el CLI cablea el módulo por ti
nest g s tareas --dry-run # -d: enseña qué haría, sin escribir nada
nest g co tareas --no-spec # sin fichero de test
nest g s common/clock --flat # sin crear subcarpeta con el nombre
nest g mo tareas --skip-import # NO lo importa en el módulo padre
nest g s tareas -p admin # --project: en un monorepo, en qué app
nest g s modules/facturacion/impuestos # la ruta es el ámbito; el último, el nombre
nest start --watch # -w. Equivale a 'npm run start:dev'
nest start --debug --watch # inspector en 9229 para depurar con VS Code
nest build # tsc por defecto → dist/
nest build -b swc # --builder swc: ~20x más rápido, SIN chequeo de tipos
nest add @nestjs/swagger # instala y ejecuta el schematic del paquete
nest g app admin # 1ª vez: mueve el proyecto a apps/ y activa monorepo
nest g lib shared # libs/shared con alias "@app/shared" en tsconfig
nest start admin -w # arranca una app concreta del monorepo-b swc es rápido porque NO comprueba los tipos
SWC transpila sin analizar el sistema de tipos. Es magnífico en desarrollo y en tests, pero si lo usas en el build de producción sin ejecutar tsc --noEmit en CI estarás desplegando código que jamás ha pasado el compilador. La combinación correcta: SWC para iterar, tsc --noEmit como paso obligatorio del pipeline. 9.7 Estructura del proyecto
api-tareas/
├─ src/
│ ├─ main.ts ← punto de entrada: crea y configura la app
│ ├─ app.module.ts ← módulo raíz: SOLO compone otros módulos
│ ├─ config/ ← configuración tipada (sección 9.12)
│ │ ├─ app.config.ts registerAs('app', ...); database.config.ts, etc.
│ │ └─ env.validation.ts esquema Joi o Zod del entorno
│ ├─ common/ ← transversal, SIN lógica de negocio
│ │ ├─ decorators/ filters/ guards/ interceptors/ pipes/
│ │ ├─ dto/ PaginationQueryDto, IdParamDto
│ │ └─ types/ tipos e interfaces compartidos
│ ├─ modules/ ← UN MÓDULO POR FEATURE (el corazón)
│ │ ├─ tareas/
│ │ │ ├─ tareas.module.ts @Module: cablea todo lo de dentro
│ │ │ ├─ tareas.controller.ts HTTP: sin lógica de negocio
│ │ │ ├─ tareas.service.ts casos de uso / lógica de aplicación
│ │ │ ├─ dto/ create-tarea.dto.ts, update-tarea.dto.ts
│ │ │ ├─ entities/ tarea.entity.ts (MikroORM, parte IV)
│ │ │ └─ tareas.service.spec.ts
│ │ ├─ proyectos/ usuarios/ auth/
│ └─ shared/ ← infraestructura: database/ mailer/ logger/
├─ test/ ← e2e (Jest + supertest, capítulo 13)
├─ .env ← NUNCA en el repositorio
├─ .env.example ← SÍ en el repositorio, con valores ficticios
├─ nest-cli.json ← configuración del CLI y plugins
└─ tsconfig.json / tsconfig.build.json / package.json
modules/tareas/ contiene su controlador, su servicio y sus DTOs) en lugar de por tipo técnico (controllers/, services/). Razones prácticas: al implementar una historia de usuario tocas una carpeta en vez de cinco, y las fronteras del módulo coinciden con las del dominio, lo que permite extraer un microservicio mañana sin arqueología. La regla de oro: si borrando una carpeta desaparece exactamente una funcionalidad, la estructura es buena. Convenciones de nombre: tareas.module.ts → TareasModule, create-tarea.dto.ts → CreateTareaDto, jwt-auth.guard.ts → JwtAuthGuard, tarea.entity.ts → Tarea. async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
// La configuración ya está validada (9.12): si faltaba una variable, no llegamos aquí.
const config = app.get(ConfigService);
// 1) Prefijo global. 'exclude' deja fuera lo que no debe llevarlo.
app.setGlobalPrefix('api', { exclude: ['health', 'metrics'] });
// 2) Versionado por URI: /api/v1/tareas (ver 9.10.4)
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1', prefix: 'v' });
// 3) Cabeceras de seguridad y compresión. Con Fastify: app.register(fastifyHelmet).
app.use(helmet());
app.use(compression()); // con proxy inverso delante, mejor comprimir allí
// 4) CORS explícito: nunca 'origin: true' en producción (capítulo 12)
app.enableCors({ origin: config.getOrThrow<string[]>('app.corsOrigins'),
credentials: true, maxAge: 86_400 });
// 5) Validación global de TODA entrada: la mejor relación seguridad/esfuerzo
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // elimina propiedades no declaradas en el DTO
forbidNonWhitelisted: true, // y responde 400 si llegan
transform: true, // convierte el payload en instancia del DTO
transformOptions: { enableImplicitConversion: true }, // '3' → 3 en params/query
disableErrorMessages: config.get('app.env') === 'production',
}));
// 6) Filtro global: respuestas de error homogéneas (capítulo 10)
app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapterHost)));
// 7) OpenAPI con fábrica PEREZOSA: el documento se construye al pedir /docs
if (config.get('app.env') !== 'production') {
const cfg = new DocumentBuilder().setTitle('API de Tareas')
.setVersion('1.0').addBearerAuth().build();
SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, cfg),
{ jsonDocumentUrl: 'docs/json' });
}
// 8) Sin esto, onModuleDestroy y compañía NO se ejecutan con SIGTERM (9.13)
app.enableShutdownHooks();
// 9) 0.0.0.0 porque dentro de un contenedor 'localhost' no es accesible desde fuera
await app.listen(config.getOrThrow<number>('app.port'), '0.0.0.0');
}
// Sin este catch, un fallo de arranque deja una promesa rechazada y un código de salida engañoso
bootstrap().catch((error) => { console.error(error); process.exit(1); });main.ts configura NO existe en los tests
Los pipes y filtros globales registrados en main.ts no se aplican en un test creado con Test.createTestingModule(), porque ese código nunca se ejecuta. Es la causa número uno de tests que pasan con datos inválidos. Solución preferible: registrar los globales como providers del módulo raíz con los tokens APP_PIPE, APP_FILTER, APP_GUARD y APP_INTERCEPTOR, que además permiten inyectar dependencias en ellos (capítulos 10 y 13). @Module({
imports: [
// La configuración va PRIMERO: el resto la necesita en su propia inicialización.
ConfigModule.forRoot({ isGlobal: true, cache: true,
envFilePath: [`.env.${process.env.NODE_ENV ?? 'development'}`, '.env'],
load: [appConfig, databaseConfig], validate: validateEnv }),
DatabaseModule, // infraestructura
AuthModule, UsuariosModule, TareasModule, // features
],
// Ni controladores ni lógica: el módulo raíz es un ÍNDICE, no un cajón de sastre. El
// interceptor global va como provider para que SÍ pueda inyectar dependencias y SÍ se
// aplique en los tests de integración.
providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],
})
export class AppModule {}9.8 Módulos a fondo
Un módulo de Nest no es una carpeta ni un espacio de nombres: es una frontera de encapsulación con semántica propia dentro del contenedor. Cada módulo tiene su ámbito de providers, y el contenedor solo permite atravesar esa frontera por donde el módulo lo autoriza. Entender esto resuelve aproximadamente la mitad de los errores de arranque de Nest.
@Module({
// 1) imports: OTROS MÓDULOS (o módulos dinámicos) cuyos exports quiero usar.
// Nunca providers ni controladores: es el error de novato número uno.
imports: [UsuariosModule, DatabaseModule],
// 2) controllers: los que Nest debe instanciar y ENRUTAR. Nunca se exportan.
controllers: [TareasController],
// 3) providers: todo lo instanciable por el contenedor DENTRO de este módulo.
providers: [TareasService, TareasPolicy, TareasMapper],
// 4) exports: el subconjunto que podrán inyectar los módulos que me importen.
// Lo que no está aquí es PRIVADO del módulo.
exports: [TareasService],
})
export class TareasModule {}exports: [ConfigModule]): quien lo importe obtendrá también lo que ese módulo exporta, lo que permite construir un módulo «paquete» de infraestructura. 9.8.1 Encapsulación modular: el error que verás mil veces
Si TareasService necesita UsuariosService no basta con importar el módulo: el módulo de origen tiene que exportarlo. Sin las dos condiciones, el contenedor no ve nada.
@Module({
controllers: [UsuariosController],
providers: [UsuariosService],
// Falta exports: UsuariosService es PRIVADO de este módulo.
})
export class UsuariosModule {}
@Module({ imports: [UsuariosModule], // importado, sí...
providers: [TareasService] })
export class TareasModule {}
/* Al arrancar:
Nest can't resolve dependencies of the TareasService (?).
Please make sure that the argument UsuariosService at index
[0] is available in the TareasModule context.
Potential solutions:
- If UsuariosService is a provider, is it part of the current
TareasModule?
- If UsuariosService is exported from a separate @Module, is
that module imported within TareasModule?
El segundo punto es el nuestro: falta el exports. */@Module({
controllers: [UsuariosController],
providers: [UsuariosService, UsuariosRepository],
// Publico SOLO el servicio: el repositorio sigue siendo un
// detalle interno y nadie de fuera se salta la lógica.
exports: [UsuariosService],
})
export class UsuariosModule {}
@Module({ imports: [UsuariosModule], // ...y ahora sí exporta
providers: [TareasService] })
export class TareasModule {}
@Injectable()
export class TareasService {
// Misma instancia singleton que usa UsuariosController:
// exportar no duplica nada.
constructor(private readonly usuarios: UsuariosService) {}
}Nest can't resolve dependencies of the TareasService (?, ConfigService) el paréntesis es la lista de parámetros del constructor en orden y el ? marca el que no ha podido resolver: si está en la posición 0, el problema es el primer parámetro. El mensaje indica además en qué contexto de módulo buscaba, que es la pista definitiva sobre qué imports revisar. 9.8.2 Módulos compartidos y módulos globales
Un módulo compartido es un módulo normal que exporta lo que otros necesitan y que se importa en cada consumidor; como los módulos son singletons por token, importarlo en diez sitios crea una sola instancia. Un módulo global (@Global()) registra sus exports en un ámbito visible para toda la aplicación sin que nadie lo importe: es cómodo y por eso es peligroso.
// Cajón de sastre global: el equivalente moderno de una
// variable global. Nadie declara que depende de esto.
@Global()
@Module({
providers: [MailerService, PdfService, S3Service, CacheService],
exports: [MailerService, PdfService, S3Service, CacheService],
})
export class UtilsModule {}
/* · Al leer TareasModule no sabes de qué depende: el grafo miente.
· Su test falla con "can't resolve MailerService" porque el
módulo global no está montado en el TestingModule.
· Nadie se atreve a borrar nada: no se sabe quién lo usa. */// Un módulo por responsabilidad, importado explícitamente.
@Module({ providers: [MailerService], exports: [MailerService] })
export class MailerModule {}
// El consumidor DECLARA su dependencia: el grafo dice la verdad.
@Module({ imports: [MailerModule, DatabaseModule],
providers: [TareasService] })
export class TareasModule {}
/* · Ves la superficie de dependencias completa de un vistazo.
· El test solo monta lo que de verdad usa, y puedes
sustituir MailerModule por un doble en un e2e. */¿Cuándo está justificado @Global()? En pocos casos, y todos comparten un rasgo: son infraestructura verdaderamente transversal, estable y de la que depende casi todo, como ConfigModule.forRoot({ isGlobal: true }) o un módulo de trazabilidad. Fuera de ahí, la regla es explícito antes que cómodo.
9.8.3 Módulos dinámicos: forRoot, forRootAsync, forFeature
Un módulo estático produce siempre la misma configuración. Un módulo dinámico es un método estático que devuelve los metadatos del módulo en tiempo de ejecución, en función de las opciones recibidas. Es el mecanismo de todos los módulos configurables del ecosistema: ConfigModule.forRoot(), JwtModule.registerAsync(), MikroOrmModule.forRoot(), BullModule.registerQueue().
| Convención | Qué significa | Dónde se llama |
|---|---|---|
forRoot(opts) / register(opts) | Configura el módulo una vez para toda la aplicación, con valores literales | En el módulo raíz |
forRootAsync(opts) / registerAsync(opts) | Igual, pero las opciones vienen de una factoría que puede inyectar dependencias y ser asíncrona | En el módulo raíz |
forFeature(x) | Registra recursos concretos de un módulo ya configurado (una entidad, una cola, un cliente) | En cada módulo de feature |
La distinción no es cosmética: forRoot recibe valores, y si esos valores dependen de la configuración tendrías que leer process.env a mano, perdiendo la validación. forRootAsync inyecta el ConfigService y devuelve las opciones desde una factoría que además puede ser async (por ejemplo, para pedir un secreto a Vault antes de arrancar).
// --- notifications.types.ts ---
export const NOTIF_OPTS = Symbol('NOTIF_OPTS'); // token propio: sin colisiones
export interface NotifOptions {
proveedor: 'email' | 'sms'; remitente: string; apiKey: string; reintentos?: number;
}
export interface NotifAsyncOptions {
imports?: ModuleMetadata['imports']; // para que la factoría pueda inyectar
inject?: any[]; isGlobal?: boolean;
useFactory: (...a: any[]) => Promise<NotifOptions> | NotifOptions;
}
// --- notifications.module.ts ---
@Module({}) // vacío: los metadatos los devuelven los estáticos
export class NotificationsModule {
static forRoot(o: NotifOptions): DynamicModule {
return this.armar([{ provide: NOTIF_OPTS, useValue: o }]);
}
static forRootAsync(a: NotifAsyncOptions): DynamicModule {
return this.armar(
[{ provide: NOTIF_OPTS, useFactory: a.useFactory, inject: a.inject ?? [] }],
a.imports, a.isGlobal);
}
/** Registro de un canal concreto: el equivalente a forFeature. */
static forFeature(canal: string): DynamicModule {
const token = `CANAL_${canal.toUpperCase()}`;
return { module: NotificationsModule, exports: [token],
providers: [{ provide: token, inject: [NotificationsService],
useFactory: (s: NotificationsService) => s.canal(canal) }] };
}
// Punto ÚNICO donde se declara la estructura: si mañana añades un provider, las dos
// variantes lo heredan. Evitar esa duplicación es la mitad del valor del patrón.
private static armar(opciones: Provider[], imports: ModuleMetadata['imports'] = [],
global = false): DynamicModule {
return {
module: NotificationsModule, // OBLIGATORIO: la clase anfitriona
global, imports, // 'global' se decide aquí, en tiempo de ejecución
// El sender concreto se elige en el CABLEADO; el servicio solo conoce el token, y
// declarar 'inject' hace que el contenedor garantice el orden de creación.
providers: [...opciones, NotificationsService,
{ provide: 'SENDER', inject: [NOTIF_OPTS],
useFactory: (o: NotifOptions) =>
o.proveedor === 'email' ? new EmailSender(o) : new SmsSender(o) }],
exports: [NotificationsService],
};
}
}
// --- notifications.service.ts: las opciones se inyectan como cualquier provider ---
@Injectable()
export class NotificationsService {
constructor(@Inject(NOTIF_OPTS) private readonly opts: NotifOptions,
@Inject('SENDER') private readonly sender: Sender) {}
}
// --- app.module.ts: las tres formas de consumirlo ---
@Module({ imports: [
// (a) Síncrono: valores conocidos en tiempo de compilación.
NotificationsModule.forRoot({ proveedor: 'email', remitente: 'no-reply@ejemplo.es',
apiKey: 'clave-dev', reintentos: 3 }),
// (b) Asíncrono con inyección: lo habitual en producción, porque la apiKey viene de la
// configuración VALIDADA y no de un literal en el código.
NotificationsModule.forRootAsync({ inject: [ConfigService],
useFactory: (c: ConfigService) => ({
proveedor: c.getOrThrow<'email' | 'sms'>('notif.proveedor'),
remitente: c.getOrThrow<string>('notif.remitente'),
apiKey: c.getOrThrow<string>('notif.apiKey') }) }),
] })
export class AppModule {}
// (c) En un módulo de feature: NotificationsModule.forFeature('facturacion')forRoot con opciones distintas crean DOS módulos
El token interno de un módulo dinámico se calcula a partir de la clase y de sus metadatos dinámicos; por eso MikroOrmModule.forFeature([Tarea]) y forFeature([Usuario]) conviven sin pisarse. La cara b: si llamas dos veces a forRoot con configuraciones diferentes tendrás dos instancias del módulo y de sus providers, casi siempre sin darte cuenta. Llámalo exactamente una vez, en el raíz. // Desde Nest 9, ConfigurableModuleBuilder genera esa maquinaria: la clase base con
// forRoot()/forRootAsync(), el token de opciones y los tipos auxiliares.
export const {
ConfigurableModuleClass, // clase base con los métodos generados
MODULE_OPTIONS_TOKEN, // token con el que inyectar las opciones
OPTIONS_TYPE, ASYNC_OPTIONS_TYPE, // tipos, solo para tipar firmas propias
} = new ConfigurableModuleBuilder<NotifOptions>()
.setClassMethodName('forRoot') // por defecto: 'register'
.setFactoryMethodName('createNotifOptions') // por defecto: 'create'
.setExtras<{ isGlobal?: boolean }>({ isGlobal: false }, // opciones del MÓDULO
(definition, extras) => ({ ...definition, global: extras.isGlobal }))
.build();
// --- notifications.module.ts ---
@Module({ providers: [NotificationsService], exports: [NotificationsService] })
export class NotificationsModule extends ConfigurableModuleClass {
// Extender SOLO si necesitas añadir algo a la definición generada.
static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
const base = super.forRoot(options);
return { ...base, providers: [...(base.providers ?? []), senderProvider()] };
}
}
// El consumidor obtiene gratis forRoot({...}), forRootAsync({ inject, useFactory })
// y el 'extra': forRoot({ ..., isGlobal: true })9.8.4 Referencias circulares entre módulos
Dos módulos que se importan mutuamente producen un ciclo: para instanciar A hace falta B y para B hace falta A. Nest lo detecta y aborta con A circular dependency has been detected inside @Module(). Hay tres respuestas, en orden creciente de calidad.
// ---------- (1) PARCHE: forwardRef en los DOS lados ----------
@Module({ imports: [forwardRef(() => UsuariosModule)],
providers: [TareasService], exports: [TareasService] })
export class TareasModule {}
@Injectable()
export class TareasService { // ciclo entre providers, no entre módulos
constructor(@Inject(forwardRef(() => UsuariosService))
private readonly usuarios: UsuariosService) {}
}
// ---------- (2) APLAZAR LA RESOLUCIÓN: ModuleRef ----------
@Injectable()
export class TareasService {
private usuarios!: UsuariosService;
constructor(private readonly moduleRef: ModuleRef) {}
onModuleInit(): void { // en el constructor no se pide nada: no hay ciclo que resolver
this.usuarios = this.moduleRef.get(UsuariosService, { strict: false });
}
}
// ---------- (3) SOLUCIÓN REAL: extraer un tercer módulo ----------
// Casi siempre el ciclo revela una TERCERA responsabilidad escondida. "Cuando se completa
// una tarea, notificar al usuario" no pertenece a Tareas ni a Usuarios: pertenece a
// Notificaciones, que depende de los dos. Alternativa igual de válida: que uno publique
// un evento de dominio en lugar de llamar directamente al otro.
@Module({ imports: [TareasModule, UsuariosModule], providers: [NotificacionesService] })
export class NotificacionesModule {}
// Tareas y Usuarios dejan de conocerse y el grafo vuelve a ser acíclico.index.ts que reexporta todo un módulo (export * from './tareas.service') convierte cualquier importación en una importación de todo. Basta con que dos ficheros de módulos distintos importen desde el barril para crear un ciclo en la carga de módulos de Node, que se manifiesta como algo aparentemente absurdo: Cannot read properties of undefined (reading 'name') o un decorador que recibe undefined como clase. Importa por ruta concreta dentro del backend y reserva los barriles para las librerías compartidas. 9.8.5 El grafo de módulos de una aplicación real
┌──────────────┐
│ AppModule │ raíz: solo compone
└──────┬───────┘
┌───────────────────┬───────┴────────────┬──────────────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ ConfigModule │ │ DatabaseModule │ │ AuthModule │ │ TareasModule │
│ .forRoot({ │ │ MikroOrmModule │ │ imports: │ │ imports: │
│ isGlobal })│ │ .forRootAsync() │ │ UsuariosModule │ │ UsuariosMod.│
│ @Global │ │ exports: EM │ │ JwtModule │ │ DatabaseMod.│
└──────┬───────┘ └────────┬─────────┘ │ exports: Auth │ │ MailerMod. │
│ visible para │ └────────┬────────┘ └──────┬───────┘
│ TODOS sin ▼ ▼ ▼
│ importarlo ┌────────────────────────────────────────────────────┐
└────────────►│ UsuariosModule │
│ providers: UsuariosService (exportado) │
│ UsuariosRepository (PRIVADO) │
└────────────────────────────────────────────────────┘
LECTURA DEL GRAFO
· Una flecha = "importa", y solo se atraviesa por los exports del destino.
· UsuariosModule se importa DOS veces (Auth y Tareas) → UNA sola instancia: los
módulos son singletons por token dentro del contenedor.
· UsuariosRepository no está en exports → invisible para Auth y para Tareas. Si Auth
necesitara acceso directo a la BD, sería señal de una frontera mal trazada.
· ConfigModule es @Global: nadie lo importa y todos lo ven, justificado porque es
infraestructura estable de la que depende literalmente todo.
· Ninguna flecha es bidireccional: el grafo es acíclico. Ese es el objetivo.
snapshot y Nest DevTools
Con NestFactory.create(AppModule, { snapshot: true }), Nest serializa el grafo completo (módulos, providers, aristas) y lo expone a las Nest DevTools, que lo dibujan y permiten detectar ciclos y providers huérfanos; también puedes obtenerlo desde el código con el provider SerializedGraph del núcleo. Es la herramienta de diagnóstico de referencia cuando la aplicación tiene cincuenta módulos. 9.9 Inyección de dependencias a fondo
9.9.1 IoC y DI: no son lo mismo
- Inversión de control (IoC) es un principio: el flujo de control lo dirige el framework, no tu código («don't call us, we'll call you»). Nest es IoC en varios frentes: decide cuándo instanciar tus clases, cuándo invocar tus controladores y en qué orden ejecutar tus interceptores.
- Inyección de dependencias (DI) es un patrón concreto de IoC: en lugar de que un objeto cree o busque a sus colaboradores, los recibe desde fuera (por constructor, propiedad o método).
- Contenedor IoC es la pieza que implementa la DI: mantiene el registro de tokens a proveedores, construye el grafo de objetos y gestiona los ciclos de vida. En Nest es el
NestContainer.
La relación con el principio de inversión de dependencias (la D de SOLID) es estrecha pero distinta. El principio dice que los módulos de alto nivel no deben depender de los de bajo nivel, sino ambos de abstracciones. La DI es el mecanismo que permite cumplirlo, pero no lo garantiza: si inyectas una clase concreta estás usando DI y violando el principio a la vez. Cumplirlo exige que el servicio dependa de una abstracción (en TypeScript, una interfaz representada por un token) y que sea el módulo quien elija la implementación, como en 9.9.4.
9.9.2 Cómo resuelve Nest las dependencias
Aquí se cobra la deuda del capítulo 1: los tipos de TypeScript se borran al compilar, así que Nest no puede «leer» el tipo del parámetro en tiempo de ejecución… salvo que el compilador lo haya dejado escrito. Eso hace emitDecoratorMetadata: para cada clase decorada emite una llamada que guarda los tipos del constructor en la clave design:paramtypes mediante reflect-metadata.
// ---------- Lo que escribes ----------
@Injectable()
export class TareasService {
constructor(
private readonly usuarios: UsuariosService,
@Inject(TAREAS_REPO) private readonly repo: TareasRepository,
@Optional() @Inject('METRICAS') private readonly metricas?: Metricas,
) {}
}
// ---------- Lo que el compilador emite (simplificado) ----------
TareasService = __decorate([Injectable(), __param(1, Inject(TAREAS_REPO)),
__param(2, Optional()), __param(2, Inject('METRICAS')),
__metadata('design:paramtypes', [UsuariosService, Object, Object]), // ← la clave
], TareasService);
// ---------- Cómo lo lee el Injector ----------
Reflect.getMetadata('design:paramtypes', TareasService); // [UsuariosService, Object, Object]
// Regla: para cada posición, si hay @Inject se usa ese token; si no, la CLASE del tipo. Las
// posiciones 1 y 2 son 'Object' porque una interfaz o un tipo no-clase se borra a 'Object':
// sin @Inject serían IRRESOLUBLES.import 'reflect-metadata' cargado una vez (Nest lo hace por ti). 2.
"experimentalDecorators": true y 3. "emitDecoratorMetadata": true en tsconfig.json. Si desactivas la emisión de metadatos —o usas un transpilador configurado sin ella (esbuild, o SWC y Babel mal configurados)— toda la DI por tipo deja de funcionar y verás dependencias irresolubles en clases perfectamente escritas. Con SWC hay que activar decoratorMetadata en .swcrc; el CLI ya lo configura al usar -b swc. 9.9.3 El contenedor y la resolución, en un diagrama
┌───────────────────────── NestContainer: Map<token de módulo, Module> ─────────────┐
│ ┌─── Module: TareasModule ────────────────────────────────┐ ┌─ UsuariosModule ─┐│
│ │ imports : [UsuariosModule, DatabaseModule] │ │ providers: ││
│ │ providers: Map<token, InstanceWrapper> │ │ UsuariosService ││
│ │ TareasService → { instance, isResolved, scope } │ │ UsuariosRepo ││
│ │ TAREAS_REPO → { instance, ... } │ │ exports : ││
│ │ controllers: TareasController → { instance, ... } │ │ Set{ Usuarios- ││
│ │ exports : Set{ TareasService } ← la puerta pública │ │ Service } ││
│ └─────────────────────────────────────────────────────────┘ └──────────────────┘│
└──────────────────────────────────────────────────────────────────────────────────┘
RESOLUCIÓN DE new TareasController(TareasService)
├─ lee design:paramtypes → [TareasService]
├─ para ese token: ¿está en TareasModule.providers? → resolver y usar
│ ¿no? ¿en los EXPORTS de algún import? → búsqueda recursiva
│ ¿no? ¿en algún módulo @Global?
│ ¿no? → ERROR: can't resolve dependencies … (?) y ABORTA
└─ resolver TareasService = repetir con SUS dependencias (recursión en profundidad).
Cuando todas están listas: new TareasService(dep1, dep2), y la instancia queda
en el InstanceWrapper: quien pida después ese token recibirá EL MISMO objeto.
CACHÉ POR SCOPE DEFAULT → 1 por token y módulo, toda la vida del proceso
REQUEST → 1 por petición (clave: contextId), se descarta al final
TRANSIENT → 1 NUEVA por cada consumidor que la inyecta
9.9.4 Los tipos de provider
Un provider es la receta que le das al contenedor para producir el valor asociado a un token. El token es la clave de búsqueda: una clase, un string o un Symbol. Hay cinco formas de escribir la receta.
| Forma | Sintaxis | Cuándo usarla | Riesgo |
|---|---|---|---|
| Clase estándar | providers: [TareasService] | El 90 % de los casos: azúcar de { provide: X, useClass: X } | Ninguno |
useClass | { provide: TOKEN, useClass: Impl } | Elegir la implementación de una abstracción; cambiarla por entorno | La clase elegida también debe ser instanciable por el contenedor |
useValue | { provide: TOKEN, useValue: obj } | Constantes, configuración calculada, instancias de librerías, dobles de test | El objeto no pasa por el contenedor: sus dependencias no se inyectan |
useFactory | { provide: T, useFactory: fn, inject: [...] } | Cuando la creación requiere lógica, configuración o es asíncrona | inject debe coincidir en orden con los parámetros de la factoría |
useExisting | { provide: ALIAS, useExisting: Real } | Segundo nombre para el mismo objeto: renombrados progresivos | No crea instancia nueva: si esperabas dos objetos, no los tendrás |
export const PASARELA_PAGO = Symbol('PASARELA_PAGO'); // Symbol para lo propio
export const REDIS = 'REDIS_CLIENT'; // string si quieres nombre legible
const providers: Provider[] = [
TareasService, // clase estándar
{ provide: PASARELA_PAGO, // useClass: según el entorno
useClass: process.env.NODE_ENV === 'production' ? StripeGateway : PasarelaFalsa },
{ provide: 'RELOJ', useValue: { ahora: () => new Date() } }, // useValue
{ provide: 'TARIFAS', // useFactory con inject
useFactory: (c: ConfigService, r: TarifasRepository) =>
new CalculadoraTarifas(c.getOrThrow('iva'), r),
inject: [ConfigService, TarifasRepository] }, // ← MISMO ORDEN, siempre
{ provide: REDIS, // provider ASÍNCRONO:
// Nest ESPERA a que la promesa resuelva antes de dar la app por iniciada, así que
// la conexión existe con seguridad antes de la primera petición.
useFactory: async (c: ConfigService) => {
const cliente = createClient({ url: c.getOrThrow<string>('redis.url') });
await cliente.connect();
return cliente;
}, inject: [ConfigService] },
{ provide: 'LoggerAntiguo', useExisting: Logger }, // ALIAS del mismo objeto
];@Injectable(): cuándo es obligatorio de verdad
Técnicamente solo es imprescindible en clases que tienen dependencias que inyectar, porque es el decorador el que provoca la emisión de design:paramtypes: una clase sin constructor funciona como provider sin él. Dicho esto, ponlo siempre: el día que alguien añada un parámetro al constructor, el error aparecerá en un sitio que parecía correcto. Los controladores son la excepción, porque @Controller() ya cumple esa función. Por qué no puedes inyectar una interfaz
Es donde más gente tropieza al llegar de Java o C#. Una interfaz de TypeScript no existe en tiempo de ejecución: el compilador la registra como Object y el contenedor no tiene token con el que buscar. La solución es introducir un token explícito que represente la abstracción.
export interface PasarelaPago {
cobrar(importe: number, tarjeta: string): Promise<string>;
reembolsar(idPago: string): Promise<void>;
}
@Injectable()
export class PedidosService {
// La interfaz se borra: design:paramtypes registra 'Object'.
constructor(private readonly pasarela: PasarelaPago) {}
}
// Y poner la interfaz en providers ni compila: no es un valor.
@Module({ providers: [PedidosService, StripeGateway] })
export class PedidosModule {}
/* Nest can't resolve dependencies of the PedidosService (?).
Please make sure that the argument Object at index [0] is
available in the PedidosModule context. */// 1) La abstracción y su TOKEN viven juntos y no dependen de nadie.
export const PASARELA_PAGO = Symbol('PASARELA_PAGO');
export interface PasarelaPago {
cobrar(importe: number, tarjeta: string): Promise<string>;
reembolsar(idPago: string): Promise<void>;
}
// 2) El servicio depende SOLO de la abstracción.
@Injectable()
export class PedidosService {
constructor(@Inject(PASARELA_PAGO) private readonly p: PasarelaPago) {}
}
// 3) El MÓDULO elige la implementación: inversión de dependencias
// de SOLID, hecha de verdad.
@Module({
providers: [PedidosService,
{ provide: PASARELA_PAGO, useClass: StripeGateway }],
exports: [PedidosService],
})
export class PedidosModule {}
// Cambiar de proveedor = UNA línea. Testear = useValue con un doble.abstract class PasarelaPago { abstract cobrar(...): Promise<string>; }, inyectar constructor(private p: PasarelaPago) sin @Inject y registrar { provide: PasarelaPago, useClass: StripeGateway }. Se lee mejor y da autocompletado; el precio es que la clase se emite en el bundle y que fuerza herencia en la implementación. Ambos enfoques son correctos: elige uno y sé coherente en todo el proyecto. multi: true
A diferencia de Angular, el contenedor de Nest no soporta multi-providers: un token corresponde exactamente a un valor, y registrar dos providers con el mismo token hace que el último gane silenciosamente. Cualquier tutorial que use multi: true en Nest está equivocado. El patrón de «lista de extensiones» se implementa con una factoría que agrega, como este ejemplo. export const REGLAS_PEDIDO = Symbol('REGLAS_PEDIDO');
export interface ReglaPedido { validar(p: Pedido): Promise<string | null> } // null = válido
@Module({
providers: [ReglaStockSuficiente, ReglaLimiteCredito, ReglaPaisPermitido,
// Un ÚNICO token cuyo valor es el array de todas las reglas. Cada regla sigue siendo
// un provider normal y puede inyectar repositorios o configuración.
{ provide: REGLAS_PEDIDO, useFactory: (...reglas: ReglaPedido[]) => reglas,
inject: [ReglaStockSuficiente, ReglaLimiteCredito, ReglaPaisPermitido] }],
exports: [REGLAS_PEDIDO],
})
export class ValidacionesModule {}
// El consumidor no conoce ninguna regla concreta: solo la lista. Añadir una regla es
// crear la clase y añadirla al inject: principio Abierto/Cerrado aplicado con DI.
@Injectable()
export class PedidosService {
constructor(@Inject(REGLAS_PEDIDO) private readonly reglas: ReglaPedido[]) {}
}9.9.5 Scopes: DEFAULT, REQUEST y TRANSIENT
| Scope | Instancias | Vida | Coste | Cuándo usarlo |
|---|---|---|---|---|
Scope.DEFAULT | Una por token y módulo | Todo el proceso | Nulo | Siempre, salvo razón muy concreta. Es el valor por defecto |
Scope.REQUEST | Una por petición | La petición | Alto: instancia el subárbol en cada petición | Necesitas el request en lo profundo del grafo y no hay otra vía |
Scope.TRANSIENT | Una por consumidor | La del consumidor | Bajo | El provider guarda estado propio por consumidor (un logger con el nombre de la clase que lo usa) |
@Injectable() // DEFAULT implícito: singleton
export class TarifasService {}
@Injectable({ scope: Scope.TRANSIENT }) // una instancia por consumidor
export class ContextoLogger { private prefijo = ''; }
@Injectable({ scope: Scope.REQUEST }) // una instancia por petición
export class ContextoPeticion {
readonly tenantId: string;
constructor(@Inject(REQUEST) req: Request) { // REQUEST viene de @nestjs/core
this.tenantId = String(req.headers['x-tenant-id'] ?? 'publico');
}
}
@Controller({ path: 'tareas', scope: Scope.REQUEST }) // los controladores también
export class TareasController {}
// Providers DURABLES: multi-tenencia sin pagar el coste por petición. Con una
// ContextIdStrategy propia, Nest reutiliza el subárbol de cada tenant en vez de recrearlo.
@Injectable({ scope: Scope.REQUEST, durable: true })
export class ConexionTenant {}
// EL «BUBBLING»: el scope REQUEST SUBE por la cadena de inyección. Si AuditoriaService es
// REQUEST, TareasService y TareasController pasan a serlo también: con 1.000 peticiones por
// segundo, 3.000 objetos nuevos por segundo en lugar de 3 en total, más presión del
// recolector y más latencia. TRANSIENT, en cambio, no contagia hacia arriba.// Se quiere el usuario actual en la auditoría, así que se marca
// REQUEST... y se contagia media aplicación.
@Injectable({ scope: Scope.REQUEST })
export class AuditoriaService {
constructor(@Inject(REQUEST) private readonly req: Request) {}
registrar(accion: string) { this.log(this.req.user?.id, accion); }
}
// Efectos colaterales que aparecen semanas después:
// · TareasService y su controlador se vuelven REQUEST.
// · Un @Cron() que lo inyecte FALLA: no hay petición, así que
// no hay contexto que inyectar.
// · onModuleInit no se ejecuta como esperas, y las cachés
// internas del servicio dejan de servir de nada.import { AsyncLocalStorage } from 'node:async_hooks';
interface Ctx { requestId: string; userId?: string; tenantId: string }
// Singleton: coste cero. El contexto viaja con la cadena
// asíncrona, no con el grafo de dependencias.
@Injectable()
export class ContextoService {
private readonly als = new AsyncLocalStorage<Ctx>();
ejecutarCon<T>(c: Ctx, fn: () => T): T { return this.als.run(c, fn); }
get actual(): Ctx | undefined { return this.als.getStore(); }
}
// Un middleware abre el ámbito una vez por petición:
@Injectable()
export class ContextoMiddleware implements NestMiddleware {
constructor(private readonly ctx: ContextoService) {}
use(req: Request, _res: Response, next: NextFunction) {
this.ctx.ejecutarCon({ requestId: randomUUID(), tenantId: 'acme' },
() => next());
}
}
// AuditoriaService vuelve a ser singleton y funciona igual en
// HTTP, en un cron y en un worker de colas: 1 objeto para toda
// la vida del proceso, no N por petición.
@Injectable()
export class AuditoriaService {
constructor(private readonly ctx: ContextoService) {}
registrar(a: string) { this.log({ ...this.ctx.actual, accion: a }); }
}Scope.REQUEST
No está prohibido: hay que pagarlo a sabiendas. Se justifica cuando la identidad del recurso depende de la petición y no basta con un dato: multi-tenencia con una conexión distinta por cliente, o una unidad de trabajo transaccional por petición si tu ORM no ofrece otra vía; en ese caso, añade durable: true. Este mismo AsyncLocalStorage es lo que usa internamente el RequestContext de MikroORM (capítulo 14) para dar a cada petición su propio EntityManager sin marcar nada como REQUEST; si no quieres escribirlo a mano, el paquete de terceros nestjs-cls empaqueta el patrón con integración para Nest. 9.9.6 ModuleRef: acceso imperativo al contenedor
| Método | Qué hace | Devuelve | Aviso |
|---|---|---|---|
get(token, opts?) | Recupera un provider ya instanciado | La instancia (síncrono) | Solo scope DEFAULT. Con { strict: false } busca en toda la app |
resolve(token, ctxId?) | Resuelve providers con scope | Promise | Cada llamada crea una instancia nueva salvo que reutilices el contextId |
create(Clase) | Instancia una clase no registrada como provider, inyectándole sus dependencias | Promise | No queda en caché: tú gestionas su vida |
introspect(token) | Informa del scope de un provider | { scope } | Útil al escribir librerías genéricas |
@Injectable()
export class ExportadorService {
constructor(private readonly moduleRef: ModuleRef) {}
// (1) get: selección dinámica de estrategia. Alternativa al 'switch' con 'new', porque
// el objeto viene del contenedor con sus propias dependencias ya resueltas.
async exportar(formato: 'csv' | 'xlsx' | 'pdf', datos: Tarea[]) {
const tokens = { csv: CsvExporter, xlsx: XlsxExporter, pdf: PdfExporter };
return this.moduleRef.get(tokens[formato], { strict: false }).generar(datos);
}
// (2) resolve: un provider con scope fuera de una petición HTTP (consumidor de cola).
async procesarTrabajo(tenantId: string) {
const contextId = ContextIdFactory.create();
// 'request' sintético para que los providers que inyectan REQUEST encuentren algo:
this.moduleRef.registerRequestByContextId({ tenantId }, contextId);
const a = await this.moduleRef.resolve(ConexionTenant, contextId);
const b = await this.moduleRef.resolve(ConexionTenant, contextId);
return a === b; // true: mismo contextId → misma instancia
}
// (3) create: instanciar una clase que NO es provider, con DI completa.
async ejecutarComando(nombre: string) {
const Clase = this.registro.buscar(nombre); // Type<Comando>
return (await this.moduleRef.create(Clase)).ejecutar();
}
}ModuleRef es la puerta de atrás: úsala poco
Cada moduleRef.get() es una dependencia que no aparece en el constructor y que por tanto no se ve en el grafo ni la comprueba el compilador: es el Service Locator, un antipatrón cuando se usa por comodidad. Justifícalo solo con resolución realmente dinámica o para romper un ciclo que aún no puedes eliminar. Para cargar módulos completos bajo demanda existe LazyModuleLoader, que es la vía correcta para reducir el arranque en frío en serverless. 9.10 Controladores
Un controlador es la capa de traducción entre el protocolo HTTP y tu aplicación: recibe una petición, la convierte en una llamada a un caso de uso y convierte el resultado en una respuesta. Todo lo demás —reglas de negocio, transacciones, orquestación— pertenece a los servicios.
@Controller('tareas') // → /api/v1/tareas con prefijo y versión globales
export class TareasController {
@Get() listar() {} // GET /tareas
@Get('vencidas') vencidas() {} // GET /tareas/vencidas
@Get(':id') detalle() {} // GET /tareas/42
@Get(':id/comentarios') comentarios() {} // GET /tareas/42/comentarios
@Post() crear() {} // POST /tareas → 201 por defecto
@Put(':id') reemplazar() {} // PUT /tareas/42
@Patch(':id') actualizar() {} // PATCH /tareas/42
@Delete(':id') eliminar() {} // DELETE /tareas/42
@Head(':id') cabecera() {} // HEAD /tareas/42
@Options() opciones() {} // OPTIONS /tareas
@All('proxy/*splat') todo() {} // cualquier verbo
}
@Controller({ // forma con objeto: todas las opciones a la vez
path: 'tareas', version: '2', // 'version' sobrescribe la versión por defecto
host: ':cuenta.ejemplo.es', // enrutado por SUBDOMINIO (soporte según adaptador)
scope: Scope.DEFAULT, // rara vez conviene cambiarlo
})
export class TareasV2Controller {
@Get() listar(@HostParam('cuenta') cuenta: string) { return this.svc.de(cuenta); }
}
@Controller(['tareas', 'todos']) // varios prefijos: útil en migraciones de rutas
export class TareasCompatController {}@Get(':id') antes de @Get('vencidas'), la petición a /tareas/vencidas entrará por el método de detalle con id = 'vencidas'. Las rutas estáticas siempre antes que las paramétricas. Y recuerda la diferencia de versiones: con Express 4 (Nest 10) valía @All('*'); con Express 5 (Nest 11) el comodín debe ir nombrado, @All('{*splat}'), y los parámetros opcionales pasan de :id? a {:id}. 9.10.1 Decoradores de parámetro (y el problema de @Res)
| Decorador | Equivalente en Express | Uso típico |
|---|---|---|
@Param() / @Param('id') | req.params | Identificadores de la ruta; siempre llegan como string |
@Query() / @Query('page') | req.query | Filtros, paginación, ordenación |
@Body() / @Body('email') | req.body | El DTO de entrada. Úsalo sin argumento y valida el objeto completo |
@Headers() / @Headers('authorization') | req.headers | Idempotencia, trazas, negociación de contenido |
@Ip() | req.ip | Auditoría y límite de tasa; requiere trustProxy tras un balanceador |
@Session() | req.session | Solo con sesiones de servidor (express-session) |
@HostParam() | — | Fragmento variable del subdominio |
@Req() / @Request() | req | Último recurso: acopla el controlador a la plataforma |
@Res() / @Response() | res | Peligroso: desactiva el manejo de respuesta de Nest |
Cuando inyectas @Res(), Nest asume que tú envías la respuesta y desactiva su propio manejo del valor devuelto: se pierden la serialización automática, el @HttpCode, los interceptores que transforman la respuesta y la ClassSerializerInterceptor. Y si olvidas llamar a res.send(), la petición se queda colgada hasta el timeout del cliente.
@Get(':id')
@UseInterceptors(ClassSerializerInterceptor) // ← ya no se aplica
@HttpCode(200) // ← ignorado
async detalle(@Param('id') id: string, @Res() res: Response) {
const tarea = await this.servicio.buscar(id);
res.setHeader('X-Total', '1');
// Si salta una excepción antes de esta línea, el filtro global no
// puede responder: el control ya no es de Nest y la petición queda
// colgada hasta que el cliente se rinda.
res.json(tarea);
}// (a) Lo habitual: no toques la respuesta, usa decoradores.
@Get(':id')
@Header('X-Total', '1')
@UseInterceptors(ClassSerializerInterceptor)
detalle(@Param('id', ParseUUIDPipe) id: string) {
return this.servicio.buscar(id); // Nest serializa y responde
}
// (b) Si necesitas 'res' para algo puntual (cookies, cabeceras
// calculadas), usa passthrough: devuelves el valor y Nest
// conserva todo su comportamiento.
@Get(':id/etag')
async conEtag(@Param('id') id: string,
@Res({ passthrough: true }) res: Response) {
const tarea = await this.servicio.buscar(id);
res.setHeader('ETag', tarea.version);
return tarea; // ← Nest sigue al mando
}
// (c) Ficheros: StreamableFile en lugar de manipular 'res'.
@Get(':id/informe.pdf')
async informe(@Param('id') id: string) {
return new StreamableFile(await this.servicio.pdf(id),
{ type: 'application/pdf', disposition: 'attachment' });
}@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // DELETE sin cuerpo → 204 explícito
async eliminar(@Param('id') id: string): Promise<void> { await this.svc.eliminar(id); }
@Get('legacy') @Redirect('/api/v1/tareas', 301) // redirección estática
legacy() {}
@Get('ir') // devolver { url, statusCode }
redirigir(@Query('a') a: string) { // SOBRESCRIBE @Redirect en runtime
return { url: `/api/v1/tareas/${a}`, statusCode: 302 };
}
// Nest resuelve promesas Y observables. En una respuesta HTTP normal, de un observable
// con varios valores solo se emite el ÚLTIMO; para varios valores hace falta SSE.
@Get('promesa') conPromesa(): Promise<Tarea[]> { return this.svc.listar(); }
@Get('observable') conObservable(): Observable<Tarea[]> { return from(this.svc.listar()); }
@Sse('eventos') eventos(): Observable<MessageEvent> {
return interval(1000).pipe(map((n) => ({ data: { tick: n } })));
}9.10.2 Decoradores personalizados y composición
// --- current-user.decorator.ts ---
/** Extrae el usuario que el guard de autenticación dejó en la petición.
* Con argumento devuelve solo ese campo: @CurrentUser('sub') */
export const CurrentUser = createParamDecorator(
(campo: keyof UsuarioJwt | undefined, ctx: ExecutionContext) => {
// switchToHttp() es lo que lo hace reutilizable: el mismo ExecutionContext
// sirve para HTTP, WebSockets y RPC.
const req = ctx.switchToHttp().getRequest<{ user?: UsuarioJwt }>();
// Defensa en profundidad: si alguien olvida el guard, fallamos en voz alta
// en lugar de devolver undefined silenciosamente.
if (!req.user) throw new UnauthorizedException('No hay usuario en el contexto');
return campo ? req.user[campo] : req.user;
},
);
// --- auth.decorator.ts: un solo decorador que aplica cinco ---
export function Auth(...roles: Rol[]) {
return applyDecorators(
SetMetadata(ROLES_KEY, roles), UseGuards(JwtAuthGuard, RolesGuard), ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Token ausente o inválido' }),
ApiForbiddenResponse({ description: 'El rol no permite esta acción' }),
);
}
// Uso: @Auth(Rol.ADMIN) @Delete(':id') eliminar(@CurrentUser('sub') id: string) {}
// Sin applyDecorators, cada endpoint protegido repetiría cinco decoradores y antes o
// después alguien olvidaría uno; los guards se olvidan en silencio.9.10.3 Diseño de rutas REST y códigos de estado
La URL es un contrato público que vivirá años. Cuatro reglas resuelven el 95 % de las decisiones: los recursos son sustantivos en plural, el verbo lo aporta el método HTTP, el anidamiento expresa pertenencia y los filtros van en la query, no en la ruta.
GET /api/getAllTareas # verbo en la URL
POST /api/crearTarea # idem
POST /api/tarea/eliminar/42 # POST para borrar
GET /api/tarea?id=42 # recurso identificado por query
GET /api/tareas/activas/usuario/7/proyecto/3/comentarios
# anidamiento de 5 niveles: ilegible
PUT /api/tareas/42/completar # ¿verbo o subrecurso?
GET /api/tareas_pendientes # un endpoint nuevo por cada filtro
POST /api/tareas → 200 # creación que no devuelve 201
DELETE /api/tareas/999 → 200 # borrar algo inexistente = éxitoGET /api/v1/tareas?estado=activa&page=2&limit=20&sort=-creadaEn
POST /api/v1/tareas # → 201 + cabecera Location
GET /api/v1/tareas/42 # → 200 | 404
PATCH /api/v1/tareas/42 # → 200 (cambio parcial)
PUT /api/v1/tareas/42 # → 200 (reemplazo completo)
DELETE /api/v1/tareas/42 # → 204 | 404
# Anidamiento de UN nivel para expresar pertenencia:
GET /api/v1/proyectos/3/tareas
POST /api/v1/tareas/42/comentarios # → 201
# Acciones que no son CRUD: subrecurso que representa el ESTADO
PUT /api/v1/tareas/42/estado # { "valor": "completada" }
POST /api/v1/pedidos/9/reembolsos # → 201 (crea un reembolso)| Código | Cuándo usarlo | Excepción de Nest |
|---|---|---|
200 OK | GET correcto; PATCH/PUT que devuelve el recurso | Por defecto |
201 Created | POST que crea un recurso; añade la cabecera Location | Por defecto en @Post() |
202 Accepted | Trabajo aceptado que se procesará después (cola) | @HttpCode(202) |
204 No Content | DELETE correcto, o PUT sin cuerpo de respuesta | @HttpCode(204) |
304 Not Modified | Respuesta condicional con ETag/If-None-Match | Se gestiona con cabeceras |
400 Bad Request | Sintaxis o tipos inválidos; validación fallida | BadRequestException |
401 Unauthorized | No autenticado o token inválido (nombre histórico desafortunado) | UnauthorizedException |
403 Forbidden | Autenticado pero sin permiso para esta acción | ForbiddenException |
404 Not Found | El recurso no existe, o no debes revelar que existe | NotFoundException |
409 Conflict | Duplicado (email ya registrado) o conflicto de estado | ConflictException |
412 Precondition Failed | Bloqueo optimista con If-Match fallido | PreconditionFailedException |
415 Unsupported Media Type | Content-Type no admitido | UnsupportedMediaTypeException |
422 Unprocessable Entity | Sintaxis correcta pero semántica imposible (fin antes del inicio) | UnprocessableEntityException |
429 Too Many Requests | Límite de tasa superado; añade Retry-After | ThrottlerException (@nestjs/throttler) |
500 Internal Server Error | Fallo no previsto. Nunca por un error de entrada | InternalServerErrorException |
503 Service Unavailable | Dependencia caída o apagado en curso; añade Retry-After | ServiceUnavailableException |
ValidationPipe devuelve 400 por defecto y se cambia con errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY. Lo importante no es cuál elijas, sino que toda la API use el mismo criterio y esté documentado. 9.10.4 Versionado de la API
| Estrategia | VersioningType | Aspecto | Ventaja | Inconveniente |
|---|---|---|---|---|
| URI | URI | /api/v2/tareas | Visible, cacheable, trivial de probar en el navegador | Los puristas objetan que la URL identifica al recurso, no su representación |
| Cabecera | HEADER | X-API-Version: 2 | URLs estables | Invisible; hay que configurar la caché con Vary |
| Media type | MEDIA_TYPE | Accept: application/json;v=2 | El más «correcto» según HTTP | Incómodo para clientes y para depurar |
| Personalizada | CUSTOM | Función extractor | Flexibilidad total (subdominio, query, plan del cliente) | La lógica la mantienes tú |
// main.ts — elige UNA estrategia para toda la aplicación
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
// HEADER: { type: VersioningType.HEADER, header: 'X-API-Version' }
// MEDIA_TYPE: { type: VersioningType.MEDIA_TYPE, key: 'v=' }
// CUSTOM: { type: VersioningType.CUSTOM, extractor: (req) => ... }
@Controller({ path: 'tareas', version: '2' }) // versión por controlador
export class TareasV2Controller {}
@Controller('tareas')
export class TareasController {
@Get(':id') @Version(['1', '2']) detalle() {} // un handler para dos versiones
@Post() @Version('2') crearV2() {} // solo v2
}
// health, métricas y webhooks NUNCA se versionan:
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {}9.11 Servicios y capa de aplicación
Si el controlador traduce HTTP, el servicio orquesta el caso de uso: valida invariantes de negocio, coordina repositorios y colaboradores, gestiona la transacción y decide qué error de dominio lanzar. La frontera correcta se comprueba con una pregunta: ¿este servicio seguiría funcionando igual si la llamada llegara por una cola de mensajes o por un comando de consola? Si la respuesta es no, hay HTTP filtrado donde no debe. Los dos antipatrones son simétricos: el controlador gordo (lógica en la capa de transporte) y el servicio dios (una clase con doce dependencias y cuarenta métodos públicos).
| Responsabilidad | Controlador | Servicio |
|---|---|---|
| Enrutado, verbos y códigos de estado | Sí | No |
| Validación de la forma de la entrada (DTO + pipe) | Sí, declarativa | No |
| Reglas de negocio e invariantes | No | Sí |
| Acceso a datos y transacciones | No | Sí |
| Orquestación de varios colaboradores | No | Sí |
| Serialización de la respuesta | Sí (DTO de salida) | No |
Conocer req, res, cabeceras o cookies | Sí | Nunca |
@Controller('tareas')
export class TareasController {
constructor(private readonly em: EntityManager,
private readonly mailer: MailerService) {}
@Post()
async crear(@Body() body: any, @Req() req: Request) {
// 1) Validación a mano, incompleta y sin mensajes útiles
if (!body.titulo || body.titulo.length < 3) {
throw new HttpException('titulo inválido', 400);
}
// 2) Reglas de negocio en la capa de transporte
const abiertas = await this.em.count(Tarea,
{ autor: req.user.id, estado: 'abierta' });
if (abiertas >= 20) throw new HttpException('demasiadas', 400);
// 3) Acceso a datos y transacción en el controlador
const tarea = this.em.create(Tarea, { titulo: body.titulo });
await this.em.persistAndFlush(tarea);
// 4) Efecto secundario que bloquea la respuesta
await this.mailer.enviar(req.user.email, 'Creada', tarea.titulo);
return tarea; // 5) fuga de la entidad: expone campos internos
}
}
// Y su primo: el servicio dios, con doce razones para cambiar y
// cuarenta métodos públicos (crear, exportarPdf, sincronizarConJira,
// recalcularMetricas, enviarResumenSemanal...).
@Injectable()
export class TareasService {
constructor(em: EntityManager, mailer: MailerService, pdf: PdfService,
s3: S3Service, slack: SlackService, metrics: MetricsService,
cache: CacheService, search: ElasticService, users: UsuariosService,
billing: BillingService, audit: AuditService, cfg: ConfigService) {}
}// ---------- Controlador: traducción y nada más ----------
@Controller('tareas')
export class TareasController {
constructor(private readonly tareas: TareasService) {}
@Post() @Auth()
async crear(@Body() dto: CreateTareaDto, // validado por el pipe global
@CurrentUser('sub') autorId: string): Promise<TareaResponseDto> {
return TareaResponseDto.desde(await this.tareas.crear(dto, autorId));
}
}
// ---------- Servicio: el caso de uso completo ----------
@Injectable()
export class TareasService {
private static readonly MAX_ABIERTAS = 20;
constructor(private readonly repo: TareasRepository,
private readonly eventos: EventEmitter2) {}
async crear(dto: CreateTareaDto, autorId: string): Promise<Tarea> {
if (await this.repo.contarAbiertas(autorId) >= TareasService.MAX_ABIERTAS) {
throw new LimiteTareasAbiertasError(TareasService.MAX_ABIERTAS);
}
const tarea = await this.repo.crear({ ...dto, autorId });
// El correo NO bloquea la respuesta ni acopla este servicio al de
// mensajería: se publica un evento de dominio.
this.eventos.emit('tarea.creada', new TareaCreadaEvent(tarea.id));
return tarea;
}
}
// ---------- Y lo demás, en servicios propios que COMPONEN ----------
@Injectable()
export class TareasExportService {
constructor(private readonly tareas: TareasService, // composición
private readonly pdf: PdfService,
private readonly almacen: AlmacenamientoPort) {}
}
// Reglas: más de 5 dependencias en un constructor es señal de alarma; el
// servicio va SIN ESTADO (es un singleton en un solo hilo); y composición
// antes que herencia: inyecta, no extiendas.tarea.completar() valida su propia transición de estado y se testea sin contenedor) y servicios de aplicación limitados a orquestar. Nest no impone nada y encaja bien con arquitectura hexagonal: los puertos son interfaces con token y los adaptadores son los providers que el módulo elige. La sección 9.9.4 es, literalmente, un puerto y su adaptador. 9.12 Configuración
La configuración es el mecanismo por el que el mismo artefacto funciona en desarrollo, en integración y en producción. Dos reglas gobiernan todo lo demás: se valida al arrancar (un servicio no debe llegar a aceptar tráfico si le falta una variable) y nunca se lee process.env fuera del módulo de configuración.
@Injectable()
export class PagosService {
// · Si la variable no existe, 'undefined' se cuela hasta la llamada
// HTTP y revienta con un 500 incomprensible.
// · El tipo es string | undefined: todo número necesita parseInt
// y nadie comprueba el resultado.
// · No se puede saber qué variables necesita la aplicación sin
// buscar 'process.env' por todo el repositorio.
private key = process.env.STRIPE_KEY;
private reintentos = Number(process.env.REINTENTOS) || 3;
private url = process.env.API_URL + '/pagos'; // 'undefined/pagos'
}// --- config/pagos.config.ts ---
export default registerAs('pagos', () => ({
apiKey: process.env.STRIPE_KEY!, // ya validado por el esquema
reintentos: parseInt(process.env.PAGOS_REINTENTOS ?? '3', 10),
url: `${process.env.API_URL}/pagos`,
}));
// --- pagos.service.ts: configuración inyectada y TIPADA ---
@Injectable()
export class PagosService {
// ConfigType<typeof pagosConfig> deriva el tipo de la factoría:
// una sola fuente de verdad, con autocompletado.
constructor(@Inject(pagosConfig.KEY)
private readonly cfg: ConfigType<typeof pagosConfig>) {}
// this.cfg.reintentos es number, no string | undefined.
}// --- app.module.ts ---
ConfigModule.forRoot({
isGlobal: true, // se registra como @Global: no hay que importarlo en cada módulo
cache: true, // memoiza los accesos a process.env (recomendado en producción)
expandVariables: true, // permite ${OTRA_VAR} dentro del .env
envFilePath: ['.env.local', `.env.${process.env.NODE_ENV}`, '.env'], // por prioridad
ignoreEnvFile: process.env.NODE_ENV === 'production', // en prod, variables del entorno
load: [appConfig, databaseConfig, pagosConfig], // configuración por namespaces
validate: validateEnv, // validación propia (Zod). Alternativa: validationSchema (Joi)
});
// --- OPCIÓN A: Joi. En forRoot: validationSchema: envSchema,
// validationOptions: { allowUnknown: true, abortEarly: false }
export const envSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(32).required(), // longitud real, no simbólica
STRIPE_KEY: Joi.string().pattern(/^sk_/).required(),
});
// --- OPCIÓN B: Zod (mismo resultado, y el tipo sale del esquema) ---
const esquema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000), // coerce: '3000' → 3000
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_KEY: z.string().startsWith('sk_'),
});
export type Env = z.infer<typeof esquema>; // DRY: una sola definición
export function validateEnv(config: Record<string, unknown>): Env {
const r = esquema.safeParse(config);
// Fallar aquí es el objetivo: el proceso muere ANTES de escuchar en el puerto, con la
// lista completa de variables mal puestas en el log de arranque.
if (!r.success) throw new Error(`Configuración inválida: ${r.error.message}`);
return r.data;
}
// --- Lectura puntual ---
const puerto = config.get<number>('app.port', 3000); // con valor por defecto
const url = config.getOrThrow<string>('database.url'); // lanza si falta: preferible.env va en .gitignore siempre; lo que se versiona es .env.example con las claves y valores ficticios. Un secreto que ha llegado al historial de Git se considera comprometido para siempre: rotarlo es la única solución, porque borrar el commit no borra los forks ni las cachés. En producción los secretos vienen del gestor de la plataforma (secretos de Kubernetes, AWS Secrets Manager, Vault), nunca de un fichero dentro de la imagen. 9.13 Ciclo de vida de la aplicación
| Interfaz y hook | Cuándo se ejecuta | Uso típico |
|---|---|---|
OnModuleInit · onModuleInit() | Cuando se han resuelto las dependencias del módulo anfitrión | Precargar caché, comprobar un esquema, suscribirse a un canal |
OnApplicationBootstrap · onApplicationBootstrap() | Cuando todos los módulos están inicializados, antes de escuchar | Registrarse en un service discovery, arrancar consumidores |
OnModuleDestroy · onModuleDestroy() | Tras recibir una señal de terminación | Dejar de aceptar trabajo nuevo, cancelar temporizadores |
BeforeApplicationShutdown · beforeApplicationShutdown(signal?) | Cuando han terminado todos los onModuleDestroy; después se cierran las conexiones | Esperar a los trabajos en curso, vaciar buffers de logs |
OnApplicationShutdown · onApplicationShutdown(signal?) | Cuando las conexiones ya están cerradas | Cerrar la base de datos y Redis, último envío de métricas |
ARRANQUE APAGADO (SIGTERM / SIGINT)
NestFactory.create() kubelet envía SIGTERM
│ │ (requiere enableShutdownHooks())
escaneo del grafo onModuleDestroy() ── 1
│ (todos los módulos, en orden INVERSO
instanciación de providers al de inicialización)
│ │
onModuleInit() ── 1 beforeApplicationShutdown(signal) ── 2
(por módulo, en el orden de resolución: · aquí se drenan las peticiones
primero aquellos de los que se depende) · aquí se espera a los jobs en curso
│ │
onApplicationBootstrap() ── 2 se cierran las conexiones (app.close())
(cuando TODOS están listos) │
│ onApplicationShutdown(signal) ── 3
app.listen() → socket abierto · cerrar BD, Redis, colas
│ │
►►► SERVICIO EN FUNCIONAMIENTO ◄◄◄ el proceso termina
EN KUBERNETES, EL ORDEN REAL DE LOS EVENTOS
1. El pod pasa a "Terminating" y se elimina de los Endpoints del Service. A la vez llega
SIGTERM al contenedor, y los dos pasos NO están sincronizados: el balanceador puede
seguir enviando tráfico unos cientos de milisegundos.
2. Por eso: un preStop con 'sleep 5' (o poner la readiness a fallo) ANTES de cerrar, y
solo después drenar. Si el proceso no termina en terminationGracePeriodSeconds
(30 s por defecto), llega SIGKILL y se pierden las peticiones en vuelo.
@Injectable()
export class ColaConsumer implements OnApplicationBootstrap, BeforeApplicationShutdown {
private enCurso = 0;
private aceptando = true;
onApplicationBootstrap() { this.suscribirse(); } // todo el grafo está listo
async beforeApplicationShutdown(signal?: string) {
this.aceptando = false; // 1) no coger trabajo nuevo
this.logger.log(`Apagando por ${signal}; ${this.enCurso} trabajos en curso`);
const limite = Date.now() + 20_000; // 2) esperar a los que quedan, con
while (this.enCurso > 0 && Date.now() < limite) { // un tope por debajo del
await new Promise((r) => setTimeout(r, 200)); // terminationGracePeriodSeconds
}
}
}
// main.ts: sin esta línea NINGUNO de los hooks de destrucción se ejecuta con SIGTERM. Nest
// registra escuchadores de señales del proceso, y ese coste mínimo de memoria es la razón
// de que no esté activado por defecto.
app.enableShutdownHooks();9.14 Documentación de la API con OpenAPI
La documentación escrita a mano se desactualiza en dos semanas. @nestjs/swagger genera el documento OpenAPI a partir del código que realmente se ejecuta: rutas, DTOs, códigos de respuesta y esquemas de seguridad. De ese documento sale además el cliente TypeScript del frontend.
// --- main.ts ---
const config = new DocumentBuilder()
.setTitle('API de Tareas').setDescription('API del manual full-stack').setVersion('1.0')
.addTag('tareas', 'Gestión de tareas')
.addBearerAuth() // esquema de seguridad JWT
.addServer('https://api.ejemplo.es', 'Producción')
.build();
// Fábrica perezosa: el documento se construye al pedir /docs, no en el arranque.
SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, config),
{ jsonDocumentUrl: 'docs/json' }); // el JSON queda en /docs/json (por defecto /docs-json)
// --- dto/create-tarea.dto.ts ---
export class CreateTareaDto {
@ApiProperty({ example: 'Revisar el informe', minLength: 3, maxLength: 120 })
@IsString() @Length(3, 120)
titulo!: string;
@ApiPropertyOptional({ enum: Prioridad, default: Prioridad.MEDIA })
@IsEnum(Prioridad) @IsOptional()
prioridad?: Prioridad;
}
// --- tareas.controller.ts ---
@ApiTags('tareas') @ApiBearerAuth()
@Controller('tareas')
export class TareasController {
@Post()
@ApiOperation({ summary: 'Crea una tarea' })
@ApiCreatedResponse({ type: TareaResponseDto })
@ApiBadRequestResponse({ description: 'Datos inválidos' })
@ApiConflictResponse({ description: 'Ya existe una tarea con ese título' })
crear(@Body() dto: CreateTareaDto) { return this.svc.crear(dto); }
}
// --- nest-cli.json: el plugin evita repetir @ApiProperty en cada campo ---
{ "compilerOptions": { "plugins": [{ "name": "@nestjs/swagger", "options": {
"classValidatorShim": true, // deriva las reglas de class-validator al esquema
"introspectComments": true, // usa los comentarios /** */ como 'description'
"dtoFileNameSuffix": [".dto.ts", ".entity.ts"] } }] } }
// Con el plugin, tipo, opcionalidad y ejemplo se infieren del TypeScript: solo escribes
// @ApiProperty para añadir lo que el compilador no puede saber.# 1) Exportar el esquema (en CI, o con un script que arranca la app y lo escribe)
curl -s http://localhost:3000/docs/json > openapi.json
# 2a) Cliente con servicios de Angular inyectables sobre HttpClient
npx @openapitools/openapi-generator-cli generate -i openapi.json \
-g typescript-angular -o src/app/api
npx ng-openapi-gen --input openapi.json --output src/app/api # 2b) alternativa Angular
npx openapi-typescript openapi.json -o src/app/api/schema.d.ts # 2c) solo los tipos
# 3) Regenera en CI y falla si hay diferencias sin cometer: así el contrato entre
# backend y frontend no puede desincronizarse sin que alguien se entere.9.15 Express o Fastify como adaptador
| Aspecto | Express (por defecto) | Fastify |
|---|---|---|
| Paquete y clase | @nestjs/platform-express, NestExpressApplication | @nestjs/platform-fastify, NestFastifyApplication + FastifyAdapter |
| Versión en Nest 11 | Express 5 | Fastify 5 (plugins @fastify/* v5) |
| Rendimiento | Referencia | Muy superior en benchmarks de JSON puro; la diferencia se diluye en cuanto hay base de datos |
| Middleware | Todo el ecosistema de Express | Compatibilidad parcial vía middie; lo idiomático son plugins y hooks |
| Seguridad y estáticos | helmet, compression, express-session | @fastify/helmet, @fastify/compress, @fastify/static, @fastify/multipart |
Tipos de @Req()/@Res() | Request / Response | FastifyRequest / FastifyReply: reply.code(), reply.send() |
const app = await NestFactory.create<NestFastifyApplication>(
AppModule, new FastifyAdapter({ trustProxy: true, bodyLimit: 1_048_576 }));
await app.register(fastifyHelmet); // los plugins se REGISTRAN, no se usan con use()
await app.register(fastifyCompress, { encodings: ['gzip', 'br'] });
await app.listen(3000, '0.0.0.0'); // dentro de un contenedor, siempre 0.0.0.0HttpAdapter. Lo que sí se rompe: cualquier app.use() con middleware de Express, los handlers que manipulan @Res() con la API de Express, la subida de ficheros (FileInterceptor frente a @fastify/multipart), las sesiones, los estáticos y las plantillas. Recomendación: empieza con Express salvo que el transporte sea tu cuello de botella demostrado con datos. En una API que consulta a PostgreSQL la latencia la domina la base de datos, no el parser HTTP: cambiar de adaptador para ganar un 3 % de un 5 % es optimización prematura. 9.16 Errores comunes y cómo solucionarlos
| Error | Causa real | Solución |
|---|---|---|
Nest can't resolve dependencies of the X (?, Y) | El ? marca el parámetro irresoluble: el provider no está en providers, su módulo no lo exports, o no has importado ese módulo | Lee la posición del ? y el contexto de módulo del mensaje; añade el provider, el exports o el imports que falte |
… argument Object at index [0] … | Estás inyectando una interfaz o un tipo que se borra al compilar | Token con @Inject(TOKEN), o una clase abstracta como token (9.9.4) |
A circular dependency has been detected | Dos módulos o dos providers se referencian mutuamente; a menudo por un fichero barril | forwardRef() en ambos lados como parche; extraer un tercer módulo como solución real; importar por ruta concreta |
Compila, pero el servicio inyectado es undefined | Falta @Injectable(), así que no se emitieron los metadatos de tipos | Añadirlo; ponerlo siempre, incluso en clases sin dependencias |
| Todos los providers se recrean en cada petición; sube la latencia y la memoria | Un provider profundo tiene Scope.REQUEST y el scope ha burbujeado hacia arriba | Sustituirlo por un singleton con AsyncLocalStorage, o marcarlo durable (9.9.5) |
| La DI falla en todo el proyecto tras cambiar el compilador | emitDecoratorMetadata desactivado, o esbuild/SWC sin decoratorMetadata | Activarlo en tsconfig.json y en .swcrc; no uses esbuild con DI por tipo |
| Un provider funciona en un módulo y en otro no | Has importado el módulo equivocado (nombres parecidos), o un forRoot duplicado ha creado un segundo módulo | Revisar los imports; llamar a forRoot una sola vez, en el raíz |
Cannot read properties of undefined en el arranque | Se usa una dependencia en el constructor cuando aún no está lista, o hay un ciclo de carga de módulos de Node | Mover la lógica a onModuleInit(); romper el ciclo de import |
/tareas/vencidas entra por @Get(':id') | Las rutas se registran en el orden de declaración de los métodos | Declarar las rutas estáticas antes que las paramétricas |
Missing parameter name de path-to-regexp al actualizar | Express 5 (Nest 11) exige comodines nombrados | @All('{*splat}') en lugar de @All('*'); {:id} en lugar de :id? |
| Los pipes y filtros globales no se aplican en los tests | main.ts no se ejecuta en un TestingModule | Registrarlos como providers con APP_PIPE, APP_FILTER, APP_GUARD |
| Al desplegar se pierden peticiones y quedan conexiones abiertas en la BD | Falta app.enableShutdownHooks(), o el apagado no drena | Activar los hooks y drenar en beforeApplicationShutdown (9.13) |
9.17 Buenas y malas prácticas
Haz esto
- Un módulo por feature, con
exportsmínimos: la encapsulación es la característica, no el obstáculo. - Depende de abstracciones con token o clase abstracta cuando la implementación pueda cambiar (pasarelas, almacenamiento, mensajería).
- Configuración validada al arrancar y tipada por espacios de nombres con
registerAs. - Servicios sin estado y de scope
DEFAULT; el contexto por petición, conAsyncLocalStorage. - Globales como providers (
APP_PIPE,APP_FILTER,APP_GUARD) para que también se apliquen en los tests. getOrThrowen lugar degetpara todo lo obligatorio, y el CLI para generar: evita olvidos de registro y mantiene la convención de nombres.- Apagado ordenado con
enableShutdownHooks()y drenaje del trabajo en curso. - OpenAPI desde el código, con el cliente del frontend generado en CI.
Evita esto
@Global()por comodidad, y sobre todo unUtilsModuleglobal que lo exporta todo.process.envdisperso por servicios y controladores.ModuleRefcomo Service Locator para esquivar el diseño de dependencias, oforwardRefcomo solución: es un parche que oculta una frontera mal trazada.Scope.REQUEST«porque es cómodo»: contagia hacia arriba y rompe crons y workers.@Res()sinpassthroughsalvo streaming deliberado.- Lógica de negocio en el controlador, y devolver entidades del ORM en lugar de DTOs.
- Servicios con más de cinco dependencias o con cuarenta métodos públicos.
- Ficheros barril dentro del backend: son la fábrica de dependencias circulares.
- Verbos en las URLs y un endpoint nuevo por cada filtro.
9.18 Preguntas frecuentes
¿NestJS es más lento que Express?
¿Cuál es exactamente la diferencia entre IoC y DI?
¿Por qué no puedo inyectar una interfaz de TypeScript?
Object en design:paramtypes y el contenedor no tiene token con el que buscar; el error es argument Object at index [0]. Las soluciones son un token explícito (Symbol + @Inject) o usar una clase abstracta como token, ya que las clases sí existen en runtime.¿Cuándo uso forRoot y cuándo forRootAsync?
forRoot cuando las opciones son valores literales conocidos en tiempo de compilación; forRootAsync cuando dependen de algo que hay que resolver primero: la configuración validada, un secreto de un gestor externo, un cliente ya conectado. La regla práctica en producción es forRootAsync con inject: [ConfigService], porque así la configuración pasa por la validación del arranque en lugar de leerse a mano de process.env.¿Cómo se resuelve una dependencia circular «de verdad»?
forwardRef() en ambos lados hace que arranque, pero el ciclo sigue ahí y el orden de inicialización se vuelve frágil. La solución es de diseño: casi siempre el ciclo indica que hay una tercera responsabilidad escondida que ambos módulos comparten y que debe extraerse a un módulo del que ambos dependan, o que uno de los dos debería comunicarse por eventos en lugar de por llamada directa. Antes de nada, comprueba si el ciclo lo ha creado un index.ts de barril.¿Los providers son singletons? ¿Y si importo el módulo en diez sitios?
forRoot dos veces con opciones distintas, el token del módulo cambia y tendrás dos instancias, casi siempre sin querer.¿Es mala práctica usar Scope.REQUEST?
REQUEST se vuelve REQUEST, lo que multiplica las instanciaciones por petición y rompe el consumo desde un cron o un worker, donde no hay petición. Se justifica cuando la identidad del recurso depende de la petición —multi-tenencia con conexión por cliente—, y ahí conviene añadir durable: true. Para el 95 % de los casos, AsyncLocalStorage con un singleton es la respuesta correcta.¿La lógica de negocio va en el servicio o en la entidad?
tarea.completar() valida su propia transición de estado y se testea sin contenedor). Las que necesitan consultar o coordinar varias cosas —«un usuario no puede tener más de veinte tareas abiertas»— van al servicio de aplicación, que además gestiona la transacción. El controlador nunca. Nest funciona igual de bien con servicios anémicos que con dominio rico: no impone estilo.¿Cómo testeo un servicio que depende de otros diez?
Test.createTestingModule({ providers: [...] }) registrando dobles para cada dependencia, o con overrideProvider(X).useValue(doble) sobre el módulo real. Pero si necesitas diez dobles, el test te está diciendo algo sobre el diseño: probablemente ese servicio tenga demasiadas responsabilidades. La testabilidad no es un beneficio accidental de la DI, es un indicador de la calidad del diseño (capítulo 13).¿Qué diferencia hay entre un middleware y un guard o un interceptor?
ExecutionContext ni sabe qué controlador atenderá. Los guards, interceptores, pipes y filtros son de Nest, conocen la clase y el método destino, pueden leer metadatos con Reflector y participan en la DI con normalidad. Regla: si tu lógica necesita saber a qué handler va la petición, no es un middleware. El capítulo 10 desarrolla el orden exacto.En una entrevista me preguntan «¿qué aporta Nest frente a Express?»
9.19 Ejercicios
9.1 Crea un proyecto con nest new --strict y genera un recurso proyectos con CRUD completo. Explica qué ficheros ha creado y qué línea ha añadido a app.module.ts.
9.2 Provoca deliberadamente el error Nest can't resolve dependencies: haz que ProyectosService inyecte UsuariosService sin exportarlo. Copia el mensaje completo e identifica la posición del ? y el contexto de módulo.
9.3 Registra un provider con token Symbol que devuelva un reloj ({ ahora(): Date }) e inyéctalo en un servicio. En un test, sustitúyelo por un reloj fijo y comprueba que el servicio es determinista.
9.4 Escribe un NotificationsModule dinámico con forRoot y forRootAsync que elija entre dos implementaciones (correo y SMS) según las opciones. Verifica con un test que forRootAsync recibe la configuración del ConfigService.
9.5 Define el puerto AlmacenamientoPort (subir, descargar, borrar) con token, y dos adaptadores: sistema de ficheros local y S3. El módulo debe elegir uno según NODE_ENV.
9.6 Configura la aplicación con @nestjs/config, validación con Zod y tres espacios de nombres (app, database, jwt). Comprueba que al borrar una variable obligatoria el proceso muere antes de escuchar en el puerto.
9.7 Implementa @CurrentUser() con createParamDecorator y un decorador compuesto @Auth(...roles) con applyDecorators. Documenta ambos con OpenAPI.
9.8 Reescribe el módulo del ejercicio 9.4 con ConfigurableModuleBuilder, incluyendo un extra isGlobal. Compara las líneas de código de ambas versiones.
9.9 Implementa contexto de petición con AsyncLocalStorage: un middleware que genere un identificador de correlación y un logger que lo incluya en cada línea. Demuestra con una prueba de carga que la versión con Scope.REQUEST instancia N veces más objetos.
9.10 Provoca una dependencia circular entre dos módulos, resuélvela primero con forwardRef y después extrayendo un tercer módulo. Documenta qué cambió en el grafo, usando snapshot: true.
9.11 Implementa un apagado ordenado que rechace peticiones nuevas, espere hasta 20 segundos a las en curso y cierre la base de datos. Verifícalo enviando SIGTERM con una petición lenta en vuelo.
9.12 Publica el esquema OpenAPI en CI, genera el cliente TypeScript para Angular y añade un paso que falle si el cliente generado difiere del que está en el repositorio.
Solución comentada · 9.4: módulo dinámico configurable
La implementación del módulo es la de la sección 9.8.3; lo que falta —y es lo que de verdad se rompe— es el test que comprueba el cableado de la variante asíncrona:
it('forRootAsync toma las opciones del ConfigService', async () => {
const mod = await Test.createTestingModule({
imports: [
// ConfigModule real con valores en memoria: se verifica la integración, no un doble.
ConfigModule.forRoot({ load: [() => ({ notif: { proveedor: 'sms' } })] }),
NotificationsModule.forRootAsync({
inject: [ConfigService],
useFactory: (c: ConfigService) => ({
proveedor: c.getOrThrow<'email' | 'sms'>('notif.proveedor'),
remitente: 'x', apiKey: 'y' }),
}),
],
}).compile();
expect(mod.get('SENDER')).toBeInstanceOf(SmsSender); // la opción eligió el adaptador
});
Tres detalles separan una implementación correcta de una frágil: (1) la propiedad module del DynamicModule es obligatoria y debe ser la propia clase anfitriona; (2) la factoría del sender declara inject: [NOTIF_OPTS], y por eso el contenedor garantiza que las opciones existen antes de construirlo, sin que tú controles el orden; (3) imports en la variante asíncrona es imprescindible para que la factoría pueda inyectar providers de módulos que este módulo no conoce.
Solución comentada · 9.5: puerto con token e interfaz, y dos adaptadores
// almacenamiento.port.ts — la ABSTRACCIÓN no depende de nadie
export const ALMACENAMIENTO = Symbol('ALMACENAMIENTO');
export interface AlmacenamientoPort {
subir(clave: string, datos: Buffer, tipo: string): Promise<string>;
descargar(clave: string): Promise<Buffer>;
borrar(clave: string): Promise<void>;
}
// adapters/local.adapter.ts — 'implements' hace que el compilador verifique el contrato
@Injectable()
export class AlmacenamientoLocal implements AlmacenamientoPort {
constructor(@Inject(almacenConfig.KEY)
private readonly cfg: ConfigType<typeof almacenConfig>) {}
async subir(clave: string, datos: Buffer): Promise<string> {
const ruta = join(this.cfg.directorio, clave);
await writeFile(ruta, datos);
return `file://${ruta}`;
}
async descargar(clave: string) { return readFile(join(this.cfg.directorio, clave)); }
async borrar(clave: string) { await rm(join(this.cfg.directorio, clave)); }
}
@Injectable()
export class AlmacenamientoS3 implements AlmacenamientoPort { /* mismo contrato */ }
// almacenamiento.module.ts — el MÓDULO decide; nadie más lo sabe
@Module({
providers: [AlmacenamientoLocal, AlmacenamientoS3,
// useFactory en lugar de un ternario en useClass: así la decisión pasa por la
// configuración VALIDADA y no por process.env leído a mano.
{ provide: ALMACENAMIENTO,
useFactory: (c: ConfigService, local: AlmacenamientoLocal, s3: AlmacenamientoS3) =>
c.getOrThrow<string>('app.env') === 'production' ? s3 : local,
inject: [ConfigService, AlmacenamientoLocal, AlmacenamientoS3] }],
exports: [ALMACENAMIENTO], // se exporta el TOKEN, no las clases concretas
})
export class AlmacenamientoModule {}
@Injectable()
export class AdjuntosService { // el consumidor solo conoce la abstracción
constructor(@Inject(ALMACENAMIENTO) private readonly almacen: AlmacenamientoPort) {}
}
Fíjate en lo que se exporta: el token, no los adaptadores. Así ningún módulo consumidor puede depender de AlmacenamientoS3 ni por accidente, y mañana puedes añadir un tercer adaptador (Azure Blob) tocando un solo fichero. Esta es la inversión de dependencias de SOLID implementada con el contenedor de Nest, y es el mismo patrón que usarás para pasarelas de pago, mensajería y caché.
9.20 Resumen del capítulo
- Nest resuelve el problema del mantenimiento, no el del rendimiento. Aporta arquitectura, contenedor IoC y abstracciones transversales; su valor es la previsibilidad en equipos y proyectos grandes.
- El arranque tiene cuatro fases: escaneo del grafo, instanciación de providers resolviendo dependencias, registro de rutas en el adaptador y hooks de inicialización. Todo se valida antes de escuchar en el puerto: fail fast.
- El módulo es una frontera real. Lo que no está en
exportses privado, y la mitad de los errores de Nest son unexportso unimportsque falta; el mensaje de error dice exactamente dónde mirar. - Los módulos dinámicos (
forRoot,forRootAsync,forFeature) devuelven metadatos en tiempo de ejecución, yConfigurableModuleBuildergenera ese patrón por ti. - La DI funciona porque el compilador emite los tipos en
design:paramtypes. De ahí se deriva todo: no puedes inyectar interfaces, necesitas tokens, yemitDecoratorMetadatano es negociable. - Cinco tipos de provider y ningún multi-provider: la lista de plugins se construye con una factoría que agrega. Y el scope
REQUESTburbujea hacia arriba y multiplica las instanciaciones; para contexto por petición,AsyncLocalStoragecon un singleton. - El controlador traduce HTTP; el servicio ejecuta el caso de uso. Si el servicio conoce
req, la frontera está mal trazada. - La configuración se valida al arrancar (Joi o Zod), se tipa con
registerAsy se lee congetOrThrow. Los secretos, nunca en el repositorio. - Sin
enableShutdownHooks()no hay apagado ordenado, y sin drenaje se pierden peticiones en cada despliegue. OpenAPI se genera desde el código y de ahí sale el cliente del frontend: un contrato, una sola fuente de verdad.
9.21 Recursos adicionales
- Documentación oficial de NestJS — la referencia; los capítulos de Modules, Providers y Custom providers son de lectura obligada.
- Custom providers — tokens,
useFactory, providers asíncronos y opcionales. - Dynamic modules — el patrón
forRootyConfigurableModuleBuilder. - Injection scopes — el bubbling del scope y los providers durables.
- Lifecycle events — tabla oficial del orden de los hooks.
- Configuration —
@nestjs/config, espacios de nombres y validación del entorno. - OpenAPI (Swagger) — decoradores, plugin del CLI y generación de clientes.
- Guía de migración a Nest 11 — los cambios de Express 5 y Fastify 5, uno por uno.
- Nest DevTools — visualización del grafo de módulos y detección de ciclos; y el código fuente de NestJS, porque leer
injector.tsydependencies-scanner.tsaclara en una tarde más que cualquier tutorial.