Parte III · NestJS

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.

CORENESTJS Tiempo de lectura: ~110 min Prerrequisitos: capítulo 1 (TypeScript, decoradores, reflect-metadata)

9.1 Qué vas a poder hacer al terminar

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.

Cronologí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).

Nest 10 → Nest 11: los cambios que más rompen La mayoría de incompatibilidades no vienen de Nest sino de Express 5, que actualiza 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:

ProblemaCómo lo resuelve Nest
Cada proyecto tiene una estructura distintaMódulos, controladores, providers y una convención de nombres generada por el CLI
Acoplamiento a implementaciones concretasContenedor IoC: se inyectan abstracciones a través de tokens
Tests que necesitan parchear el sistema de módulosTest.createTestingModule() con overrideProvider (capítulo 13)
Preocupaciones transversales duplicadas y validación de entrada inconsistenteGuards, interceptores, pipes y filtros declarativos (capítulo 10); ValidationPipe global sobre DTOs decorados
Documentación de la API siempre desactualizadaOpenAPI generado desde el código real (sección 9.14)
process.env leído en cualquier rincónConfigModule 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.

Analogía Express te da un solar y unos ladrillos: puedes construir lo que quieras, incluida una casa sin cimientos. Nest te da un sistema prefabricado con planos normalizados: tardas más en la primera pared, pero cualquier albañil que llegue después sabe cómo está montada la casa y dónde pasan las tuberías. Y a diferencia de un chalet «de autor», puedes cambiar la fachada (Express por Fastify) sin tocar la estructura.

9.3.2 Para quién NO compensa Nest

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

CriterioExpressFastifyKoaAdonisJStRPCNestJS
Estructura impuestaNingunaNinguna (plugins)NingunaAlta (tipo Laravel)Baja (routers)Alta y explícita
Inyección de dependenciasNoNoNoSí (contenedor IoC)NoSí, de primera clase
TypeScriptTipos externosBueno, infiere esquemasTipos externosNativoExtremo a extremoNativo y requerido
TestabilidadManual (mocks de módulo)Manual + inject()ManualBuenaBuenaExcelente (@nestjs/testing)
Rendimiento bruto (JSON)ReferenciaEl más alto del ecosistemaAlgo mejor que ExpressMedioEl del anfitriónEl del adaptador, menos una capa fina
Curva de aprendizajeMuy bajaBajaBajaMediaBaja si dominas TSMedia-alta
EcosistemaEnorme, veteranoAmplio y crecientePequeñoAutocontenidoEnfocado (React/Next)Muy amplio: módulos oficiales para casi todo
Caso de uso idealAPI pequeña, prototipoAlto tráfico, gatewayMiddleware a medidaMonolito full-stackMonorepo TS con cliente propioAPI 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 BootNestJSMatiz importante
@SpringBootApplicationAppModule + NestFactory.createNest no escanea paquetes: hay que declarar en providers
@Configuration + @BeanProvider con useFactoryEquivalente casi literal
@Component / @Service@Injectable()Nest no distingue estereotipos
@RestController@Controller()Igual, con decoradores de método
@AutowiredInyección por constructor implícitaNo 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 + registerAsMismo objetivo: configuración tipada e inyectable
Interfaz + implementación (nominal)Interfaz + tokenEn TypeScript la interfaz se borra: hace falta un token
Un hilo por peticiónUn solo hilo y event loopDiferencia crítica: nada de ThreadLocal, nada de bloquear
El error mental más caro al llegar de Java o .NET En Spring, un singleton atiende cada petición en un hilo distinto y bloquear ese hilo solo afecta a esa petición. En Node hay un único hilo: un servicio singleton que guarde estado de la petición en un campo (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.
La propiedad más valiosa de este diseño: fail fast Toda la resolución de dependencias ocurre en el arranque, no en la primera petición. Si falta un provider, si hay un ciclo o si un módulo no exporta lo que otro necesita, el proceso muere al arrancar con un mensaje concreto. Con una readiness probe, eso significa que una versión mal cableada nunca llega a recibir tráfico.
src/main.ts · las cuatro fábricas y las opciones que importan
// 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ón
HttpAdapterHost: 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.

terminal · instalación y creación del proyecto
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).
SchematicAliasQué generaEjemplo
modulemoClase con @Module({}), y la importa en el módulo padrenest g mo tareas
controllercoControlador + spec, y lo añade a controllersnest g co tareas
services@Injectable() + spec, y lo añade a providersnest g s tareas
providerprIgual que service pero sin el sufijo .servicenest g pr cache
resourceresCRUD completo: módulo, controlador, servicio, DTOs, entidad y specsnest g res tareas
guardguClase que implementa CanActivatenest g gu auth/jwt
interceptoritcClase que implementa NestInterceptornest g itc common/logging
pipepiClase que implementa PipeTransformnest g pi common/parse-uuid
filterfClase que implementa ExceptionFilternest g f common/http-error
middlewaremiClase que implementa NestMiddlewarenest g mi common/correlation
gatewaygaGateway de WebSockets (@WebSocketGateway)nest g ga chat
resolverrResolver de GraphQLnest g r tareas
decoratordDecorador personalizadonest g d common/current-user
classclClase suelta (DTO, entidad, objeto de valor)nest g cl tareas/dto/create-tarea
interfaceitfInterfaz sueltanest g itf tareas/tarea
configurationconfignest-cli.json con valores por defectonest g config
appapplicationSub-aplicación: convierte el proyecto en monorepo y crea apps/nest g app admin
librarylibLibrería en libs/ con alias en pathsnest g lib shared
terminal · generar, construir, ejecutar y monorepo
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
Por features, no por tipos de fichero Esta estructura agrupa por capacidad de negocio (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.tsTareasModule, create-tarea.dto.tsCreateTareaDto, jwt-auth.guard.tsJwtAuthGuard, tarea.entity.tsTarea.
src/main.ts · plantilla de producción comentada
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); });
Lo que 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).
src/app.module.ts · el módulo raíz solo compone
@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.

src/modules/tareas/tareas.module.ts · anatomía de @Module
@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 {}
Qué significa exactamente «exportar» Exportar no crea una instancia nueva ni copia nada: publica el token del provider en la interfaz pública del módulo, y la instancia sigue siendo la misma (un singleton por token en su módulo de origen). Es un permiso de visibilidad, no un mecanismo de creación. Un módulo puede además reexportar un módulo importado (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.

usuarios.module.tsINCORRECTO
@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.          */
usuarios.module.tsCORRECTO
@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) {}
}
Cómo leer el mensaje de error en diez segundos En 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.

shared/utils.module.tsINCORRECTO
// 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.  */
shared/mailer/mailer.module.tsCORRECTO
// 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ónQué significaDónde se llama
forRoot(opts) / register(opts)Configura el módulo una vez para toda la aplicación, con valores literalesEn 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íncronaEn 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).

shared/notifications/ · un módulo dinámico propio, escrito a mano
// --- 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')
Dos 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.
notifications.module-definition.tsFORMA MODERNA: ConfigurableModuleBuilder
// 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.

circular.ts
// ---------- (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.
La causa oculta más frecuente: los ficheros barril Un 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.
Ver el grafo de verdad: 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

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.

metadata.ts · lo que ve el contenedor
// ---------- 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.
Los tres requisitos que nunca puedes olvidar 1. 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.

FormaSintaxisCuándo usarlaRiesgo
Clase estándarproviders: [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 entornoLa 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 testEl 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íncronainject debe coincidir en orden con los parámetros de la factoría
useExisting{ provide: ALIAS, useExisting: Real }Segundo nombre para el mismo objeto: renombrados progresivosNo crea instancia nueva: si esperabas dos objetos, no los tendrás
providers.ts · las cinco formas y el provider asíncrono
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.

pedidos · DI con interfazINCORRECTO
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.                  */
pedidos · DI con tokenCORRECTO
// 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.
Alternativa elegante: la clase abstracta como token Las clases sí existen en runtime, así que puedes declarar 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.
Precisión importante: Nest NO tiene 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.
validaciones.module.tsPATRÓN DE PLUGINS
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

ScopeInstanciasVidaCosteCuándo usarlo
Scope.DEFAULTUna por token y móduloTodo el procesoNuloSiempre, salvo razón muy concreta. Es el valor por defecto
Scope.REQUESTUna por peticiónLa peticiónAlto: instancia el subárbol en cada peticiónNecesitas el request en lo profundo del grafo y no hay otra vía
Scope.TRANSIENTUna por consumidorLa del consumidorBajoEl provider guarda estado propio por consumidor (un logger con el nombre de la clase que lo usa)
scopes.ts
@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.
auditoria.service.tsINCORRECTO
// 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.
contexto.service.tsCORRECTO
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 }); }
}
Cuándo sí usar 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étodoQué haceDevuelveAviso
get(token, opts?)Recupera un provider ya instanciadoLa instancia (síncrono)Solo scope DEFAULT. Con { strict: false } busca en toda la app
resolve(token, ctxId?)Resuelve providers con scopePromiseCada llamada crea una instancia nueva salvo que reutilices el contextId
create(Clase)Instancia una clase no registrada como provider, inyectándole sus dependenciasPromiseNo queda en caché: tú gestionas su vida
introspect(token)Informa del scope de un provider{ scope }Útil al escribir librerías genéricas
module-ref.ts · los tres usos legítimos
@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.

tareas.controller.ts · @Controller y decoradores de método
@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 {}
El orden de declaración de las rutas IMPORTA Nest registra las rutas en el orden en que aparecen los métodos en la clase y el enrutador usa la primera coincidencia: si declaras @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)

DecoradorEquivalente en ExpressUso típico
@Param() / @Param('id')req.paramsIdentificadores de la ruta; siempre llegan como string
@Query() / @Query('page')req.queryFiltros, paginación, ordenación
@Body() / @Body('email')req.bodyEl DTO de entrada. Úsalo sin argumento y valida el objeto completo
@Headers() / @Headers('authorization')req.headersIdempotencia, trazas, negociación de contenido
@Ip()req.ipAuditoría y límite de tasa; requiere trustProxy tras un balanceador
@Session()req.sessionSolo con sesiones de servidor (express-session)
@HostParam()Fragmento variable del subdominio
@Req() / @Request()reqÚltimo recurso: acopla el controlador a la plataforma
@Res() / @Response()resPeligroso: desactiva el manejo de respuesta de Nest

Cuando inyectas @Res(), Nest asume que 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.

tareas.controller.tsINCORRECTO
@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);
}
tareas.controller.tsCORRECTO
// (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' });
}
respuestas.controller.ts · códigos, cabeceras, redirecciones y asincronía
@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

common/decorators/createParamDecorator + applyDecorators
// --- 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.

rutasINCORRECTO
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 = éxito
rutasCORRECTO
GET    /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ódigoCuándo usarloExcepción de Nest
200 OKGET correcto; PATCH/PUT que devuelve el recursoPor defecto
201 CreatedPOST que crea un recurso; añade la cabecera LocationPor defecto en @Post()
202 AcceptedTrabajo aceptado que se procesará después (cola)@HttpCode(202)
204 No ContentDELETE correcto, o PUT sin cuerpo de respuesta@HttpCode(204)
304 Not ModifiedRespuesta condicional con ETag/If-None-MatchSe gestiona con cabeceras
400 Bad RequestSintaxis o tipos inválidos; validación fallidaBadRequestException
401 UnauthorizedNo autenticado o token inválido (nombre histórico desafortunado)UnauthorizedException
403 ForbiddenAutenticado pero sin permiso para esta acciónForbiddenException
404 Not FoundEl recurso no existe, o no debes revelar que existeNotFoundException
409 ConflictDuplicado (email ya registrado) o conflicto de estadoConflictException
412 Precondition FailedBloqueo optimista con If-Match fallidoPreconditionFailedException
415 Unsupported Media TypeContent-Type no admitidoUnsupportedMediaTypeException
422 Unprocessable EntitySintaxis correcta pero semántica imposible (fin antes del inicio)UnprocessableEntityException
429 Too Many RequestsLímite de tasa superado; añade Retry-AfterThrottlerException (@nestjs/throttler)
500 Internal Server ErrorFallo no previsto. Nunca por un error de entradaInternalServerErrorException
503 Service UnavailableDependencia caída o apagado en curso; añade Retry-AfterServiceUnavailableException
400 o 422: el debate eterno La convención de este libro: 400 cuando el cuerpo no cumple el contrato (falta un campo obligatorio, un tipo no coincide) y 422 cuando el contrato se cumple pero la regla de negocio lo rechaza. El 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

EstrategiaVersioningTypeAspectoVentajaInconveniente
URIURI/api/v2/tareasVisible, cacheable, trivial de probar en el navegadorLos puristas objetan que la URL identifica al recurso, no su representación
CabeceraHEADERX-API-Version: 2URLs establesInvisible; hay que configurar la caché con Vary
Media typeMEDIA_TYPEAccept: application/json;v=2El más «correcto» según HTTPIncómodo para clientes y para depurar
PersonalizadaCUSTOMFunción extractorFlexibilidad total (subdominio, query, plan del cliente)La lógica la mantienes tú
versionado.ts
// 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 {}
Estrategia de compatibilidad, no solo de versionado Crear una v2 es caro: duplica controladores, DTOs, documentación y tests. Antes de versionar comprueba si el cambio es realmente incompatible. Estos no rompen a nadie: añadir un campo opcional a la respuesta, añadir un endpoint, añadir un parámetro de query opcional. Estos sí: eliminar o renombrar un campo, cambiar su tipo, hacer obligatorio un campo de entrada, cambiar un código de estado o el significado de una operación. Si versionas, publica la fecha de retirada de la v1, mide el uso por versión y avisa antes de apagarla: un versionado sin política de retirada solo multiplica el código a mantener.

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).

ResponsabilidadControladorServicio
Enrutado, verbos y códigos de estadoNo
Validación de la forma de la entrada (DTO + pipe), declarativaNo
Reglas de negocio e invariantesNo
Acceso a datos y transaccionesNo
Orquestación de varios colaboradoresNo
Serialización de la respuesta (DTO de salida)No
Conocer req, res, cabeceras o cookiesNunca
«controlador gordo» + «servicio dios»INCORRECTO
@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 delgado + servicios por capacidadCORRECTO
// ---------- 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.
¿Y la lógica de dominio pura? En aplicaciones con reglas complejas conviene un nivel más: entidades y objetos de valor con comportamiento propio (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.

process.env dispersoINCORRECTO
@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'
}
configuración tipada por espacios de nombresCORRECTO
// --- 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.
}
config/ · ConfigModule y validación del entorno con Joi y con Zod
// --- 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
Secretos: lo que nunca se sube al repositorio El .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 hookCuándo se ejecutaUso típico
OnModuleInit · onModuleInit()Cuando se han resuelto las dependencias del módulo anfitriónPrecargar caché, comprobar un esquema, suscribirse a un canal
OnApplicationBootstrap · onApplicationBootstrap()Cuando todos los módulos están inicializados, antes de escucharRegistrarse en un service discovery, arrancar consumidores
OnModuleDestroy · onModuleDestroy()Tras recibir una señal de terminaciónDejar de aceptar trabajo nuevo, cancelar temporizadores
BeforeApplicationShutdown · beforeApplicationShutdown(signal?)Cuando han terminado todos los onModuleDestroy; después se cierran las conexionesEsperar a los trabajos en curso, vaciar buffers de logs
OnApplicationShutdown · onApplicationShutdown(signal?)Cuando las conexiones ya están cerradasCerrar 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.
apagado ordenado
@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.

swagger · documento, decoradores y plugin del CLI
// --- 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.
terminal · generar el cliente TypeScript para Angular
# 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

AspectoExpress (por defecto)Fastify
Paquete y clase@nestjs/platform-express, NestExpressApplication@nestjs/platform-fastify, NestFastifyApplication + FastifyAdapter
Versión en Nest 11Express 5Fastify 5 (plugins @fastify/* v5)
RendimientoReferenciaMuy superior en benchmarks de JSON puro; la diferencia se diluye en cuanto hay base de datos
MiddlewareTodo el ecosistema de ExpressCompatibilidad parcial vía middie; lo idiomático son plugins y hooks
Seguridad y estáticoshelmet, compression, express-session@fastify/helmet, @fastify/compress, @fastify/static, @fastify/multipart
Tipos de @Req()/@Res()Request / ResponseFastifyRequest / FastifyReply: reply.code(), reply.send()
main.ts · cambiar de adaptador
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.0
Qué se rompe al cambiar (y cuándo merece la pena) Tus controladores y servicios no cambian: eso es lo que compra la abstracción del HttpAdapter. 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

ErrorCausa realSolució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óduloLee 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 compilarToken con @Inject(TOKEN), o una clase abstracta como token (9.9.4)
A circular dependency has been detectedDos módulos o dos providers se referencian mutuamente; a menudo por un fichero barrilforwardRef() en ambos lados como parche; extraer un tercer módulo como solución real; importar por ruta concreta
Compila, pero el servicio inyectado es undefinedFalta @Injectable(), así que no se emitieron los metadatos de tiposAñadirlo; ponerlo siempre, incluso en clases sin dependencias
Todos los providers se recrean en cada petición; sube la latencia y la memoriaUn provider profundo tiene Scope.REQUEST y el scope ha burbujeado hacia arribaSustituirlo por un singleton con AsyncLocalStorage, o marcarlo durable (9.9.5)
La DI falla en todo el proyecto tras cambiar el compiladoremitDecoratorMetadata desactivado, o esbuild/SWC sin decoratorMetadataActivarlo en tsconfig.json y en .swcrc; no uses esbuild con DI por tipo
Un provider funciona en un módulo y en otro noHas importado el módulo equivocado (nombres parecidos), o un forRoot duplicado ha creado un segundo móduloRevisar los imports; llamar a forRoot una sola vez, en el raíz
Cannot read properties of undefined en el arranqueSe usa una dependencia en el constructor cuando aún no está lista, o hay un ciclo de carga de módulos de NodeMover 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étodosDeclarar las rutas estáticas antes que las paramétricas
Missing parameter name de path-to-regexp al actualizarExpress 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 testsmain.ts no se ejecuta en un TestingModuleRegistrarlos como providers con APP_PIPE, APP_FILTER, APP_GUARD
Al desplegar se pierden peticiones y quedan conexiones abiertas en la BDFalta app.enableShutdownHooks(), o el apagado no drenaActivar los hooks y drenar en beforeApplicationShutdown (9.13)

9.17 Buenas y malas prácticas

Haz esto

  • Un módulo por feature, con exports mí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, con AsyncLocalStorage.
  • Globales como providers (APP_PIPE, APP_FILTER, APP_GUARD) para que también se apliquen en los tests.
  • getOrThrow en lugar de get para 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 un UtilsModule global que lo exporta todo.
  • process.env disperso por servicios y controladores.
  • ModuleRef como Service Locator para esquivar el diseño de dependencias, o forwardRef como 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() sin passthrough salvo 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?
Nest corre sobre Express o Fastify, así que su sobrecarga es la de la capa que envuelve al adaptador: resolver la cadena de guards, interceptores y pipes en cada petición. En benchmarks de «devolver un JSON vacío» se mide y se nota; en una API real, donde cada petición consulta a una base de datos, es despreciable frente a la latencia de E/S. Lo que sí es notablemente más lento es el arranque, porque hay que escanear el grafo e instanciar el contenedor, y eso importa en serverless.
¿Cuál es exactamente la diferencia entre IoC y DI?
IoC es el principio general de que el control lo lleva el framework y no tu código; DI es un patrón concreto que lo aplica a la obtención de colaboradores: en vez de crearlos o buscarlos, los recibes desde fuera. El contenedor IoC es la implementación que resuelve el grafo. Y ojo: usar DI no implica cumplir el principio de inversión de dependencias de SOLID; si inyectas una clase concreta, sigues acoplado a la implementación.
¿Por qué no puedo inyectar una interfaz de TypeScript?
Porque las interfaces se borran al compilar y no existen en tiempo de ejecución. El compilador registra 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?
Sí: por defecto hay una instancia por token dentro de su módulo, y ese módulo se registra una sola vez en el contenedor aunque diez módulos lo importen. Exportar un provider no lo duplica: publica su token. La excepción son los módulos dinámicos: si llamas a 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?
No es mala práctica: es una decisión con coste que hay que tomar a sabiendas. El scope burbujea hacia arriba, así que todo lo que dependa de un provider 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?
Las invariantes que dependen de un solo agregado encajan mejor 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?
Con 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?
El middleware es de la plataforma (Express o Fastify) y se ejecuta antes de que Nest sepa qué ruta se ha invocado: no tiene 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?»
Tres cosas concretas, en este orden: una arquitectura modular verificada en el arranque, un contenedor IoC que hace testeable el código sin parchear el sistema de módulos, y un conjunto de abstracciones transversales (guards, interceptores, pipes, filtros) que evitan duplicar validación, autenticación y trazabilidad. Y una cuarta, más honesta: previsibilidad, que en un equipo grande y un proyecto de vida larga vale más que los milisegundos que cuesta la capa.

9.19 Ejercicios

Nivel 1 · básico

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.

Nivel 2 · intermedio

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.

Nivel 3 · avanzado

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 exports es privado, y la mitad de los errores de Nest son un exports o un imports que 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, y ConfigurableModuleBuilder genera 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, y emitDecoratorMetadata no 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 REQUEST burbujea hacia arriba y multiplica las instanciaciones; para contexto por petición, AsyncLocalStorage con 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 registerAs y se lee con getOrThrow. 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

Siguiente paso Ya sabes cómo se construye y se cablea una aplicación Nest. El capítulo 10 recorre el otro eje del framework: qué le ocurre a una petición entre que entra por el socket y sale la respuesta, con middleware, guards, interceptores, pipes y filtros de excepción en su orden exacto.