Providers y DI avanzada
Providers y DI avanzada
En el capítulo 3 vimos que el principio de inversión de dependencias (DIP) exige que tus servicios dependan de abstracciones y no de implementaciones concretas. Pero TypeScript borra las interfaces en tiempo de compilación, así que no podés inyectar IPaymentGateway directamente: no existe en runtime. Aquí es donde el sistema de custom providers de Nest deja de ser un detalle y pasa a ser la herramienta que hace posible el desacoplamiento real.
Este capítulo cubre la mecánica fina del contenedor IoC: las cuatro formas de definir un provider, cómo usar tokens para inyectar abstracciones, los tres injection scopes y su costo de rendimiento, y cómo salir de las dependencias circulares sin tapar el problema de diseño que casi siempre las causa.
El provider “shorthand” es azúcar sintáctico
Cuando escribís providers: [CatsService], Nest expande eso a la forma canónica de un provider:
providers: [
{
provide: CatsService, // el token
useClass: CatsService, // cómo se construye
},
];
Todo provider tiene dos partes: un token (la clave con la que se lo pide) y una receta (useClass, useValue, useFactory o useExisting) que le dice al contenedor cómo producir la instancia. Cuando el token y la implementación coinciden, usás el shorthand. Cuando difieren —que es el caso interesante— necesitás la forma completa.
Las cuatro recetas de un provider
useClass: intercambiar implementaciones
useClass asocia un token a una clase concreta que Nest instancia resolviendo sus dependencias. Su valor real aparece cuando el token no es la clase que se construye: te permite elegir la implementación en tiempo de arranque.
const configServiceProvider = {
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
};
@Module({ providers: [configServiceProvider] })
export class AppModule {}
Quien inyecta ConfigService no sabe ni le importa cuál de las dos clases recibió. Ese es DIP en acción: el consumidor depende del token (la abstracción) y la composición decide la concreción.
useValue: constantes, mocks y clientes ya construidos
useValue inyecta un objeto que vos ya creaste. Sirve para constantes de configuración, para instancias de librerías externas que se construyen fuera de Nest (un cliente de Stripe, un PrismaClient) y —clásico— para reemplazar un servicio por un mock en tests.
const mockCatsService = {
findAll: () => ['gato de prueba'],
};
@Module({
providers: [
{
provide: CatsService,
useValue: mockCatsService,
},
],
})
export class TestModule {}
El objeto no pasa por el contenedor: Nest no resuelve dependencias ni ejecuta hooks de ciclo de vida sobre él. Es un valor opaco que se entrega tal cual.
useFactory: construcción dinámica con dependencias
useFactory es la receta más flexible: ejecutás una función (síncrona o async) cuyo valor de retorno se convierte en la instancia. La clave es el array inject: lista los tokens que Nest resuelve antes de llamar a la factory y pasa como argumentos, en orden.
const connectionProvider = {
provide: 'CONNECTION',
useFactory: (options: OptionsProvider, http?: HttpService) => {
const opts = options.get();
return new DatabaseConnection(opts);
},
inject: [
OptionsProvider,
{ token: 'HTTP_OPTIONS', optional: true }, // dependencia opcional
],
};
@Module({ providers: [connectionProvider, OptionsProvider] })
export class AppModule {}
El orden importa: inject[0] llega como primer parámetro, inject[1] como segundo. Una dependencia marcada { token, optional: true } que no exista llega como undefined en vez de romper el arranque. Como la factory puede ser async, es el punto natural para inicializaciones que requieren await (abrir una conexión, leer un secreto remoto).
useExisting: alias hacia otro provider
useExisting crea un alias: un segundo token que apunta a la misma instancia de un provider ya existente. No construye nada nuevo.
@Module({
providers: [
LoggerService,
{
provide: 'AliasedLoggerService',
useExisting: LoggerService,
},
],
})
export class AppModule {}
Pedir 'AliasedLoggerService' y pedir LoggerService devuelve el mismo objeto. Sirve para exponer un provider bajo un nombre de dominio estable mientras migrás el nombre real de la clase, o para dar a un módulo una vista con un token que él controla sin duplicar la instancia (importante para singletons con estado).
flowchart TD
T["Token: ConfigService"] --> R{"¿Qué receta?"}
R -->|useClass| C["Nest instancia la clase<br/>y resuelve sus deps"]
R -->|useValue| V["Devuelve el objeto tal cual<br/>sin resolver deps ni hooks"]
R -->|useFactory| F["Ejecuta la función con<br/>inject[] resueltos en orden"]
R -->|useExisting| E["Alias: reusa la instancia<br/>de otro token"]
Tokens de inyección: la llave del DIP
El token no tiene por qué ser una clase. Puede ser un string, un symbol o —lo mejor para abstracciones— una clase abstracta. Elegir bien el token es lo que hace inyectable a una interfaz que en runtime no existe.
El problema: las interfaces se borran
// port
export interface PaymentGateway {
charge(amount: number): Promise<Receipt>;
}
@Injectable()
export class CheckoutService {
// ❌ No compila con utilidad: `PaymentGateway` no existe en runtime,
// Nest no tiene ningún token que resolver.
constructor(private readonly gateway: PaymentGateway) {}
}
Como la interfaz desaparece al transpilar, Nest no puede inferir qué inyectar. Necesitás un token concreto.
Solución A: token string o symbol
Definís una constante como token y la usás con @Inject():
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');
@Injectable()
export class CheckoutService {
constructor(
@Inject(PAYMENT_GATEWAY)
private readonly gateway: PaymentGateway, // el TIPO sigue siendo la interfaz
) {}
}
@Module({
providers: [
{ provide: PAYMENT_GATEWAY, useClass: StripeGateway },
],
exports: [PAYMENT_GATEWAY],
})
export class PaymentModule {}
Preferí Symbol sobre string plano: un símbolo es único por definición y evita colisiones de token entre módulos. Centralizá esas constantes (por ejemplo en un tokens.ts del dominio) para no repetir strings mágicos.
Solución B: clase abstracta como token (recomendada)
Una clase abstracta sí existe en runtime, así que funciona como token y como tipo al mismo tiempo. Esto elimina el @Inject() y da mejor ergonomía:
// port como clase abstracta
export abstract class PaymentGateway {
abstract charge(amount: number): Promise<Receipt>;
}
@Injectable()
export class CheckoutService {
// ✅ sin @Inject(): el token ES el tipo
constructor(private readonly gateway: PaymentGateway) {}
}
@Module({
providers: [
{ provide: PaymentGateway, useClass: StripeGateway },
],
exports: [PaymentGateway],
})
export class PaymentModule {}
StripeGateway implementa (implements PaymentGateway) el contrato, pero CheckoutService solo conoce la abstracción. Cambiar a PaypalGateway es una línea en el módulo, sin tocar el servicio ni sus tests. Esta es la forma idiomática de DIP en Nest.
Providers “multi”: varias implementaciones bajo un token
A diferencia de Angular, Nest no tiene la opción multi: true. Si registrás dos providers con el mismo token, gana el último y los anteriores quedan inaccesibles. Cuando de verdad necesitás inyectar un array de implementaciones (por ejemplo, varios validadores o varios health indicators), el patrón es una factory que colecta tokens individuales:
export const NOTIFIER = Symbol('NOTIFIER');
export const NOTIFIERS = Symbol('NOTIFIERS');
@Module({
providers: [
EmailNotifier,
SmsNotifier,
PushNotifier,
{
provide: NOTIFIERS,
useFactory: (...notifiers: Notifier[]) => notifiers,
inject: [EmailNotifier, SmsNotifier, PushNotifier],
},
],
exports: [NOTIFIERS],
})
export class NotificationsModule {}
@Injectable()
export class AlertService {
constructor(@Inject(NOTIFIERS) private readonly notifiers: Notifier[]) {}
broadcast(msg: string) {
return Promise.all(this.notifiers.map((n) => n.send(msg)));
}
}
Agregar un canal nuevo es sumarlo al inject. Si esa lista crece mucho o se arma desde varios módulos, existe la librería nestjs-multi-provider, que parcha el decorador @Module para emular el multi de Angular. Para la mayoría de los casos, la factory explícita alcanza y no agrega dependencias.
Injection scopes: el singleton por defecto y sus alternativas
Por defecto, cada provider es un singleton: Nest lo instancia una vez y comparte esa misma instancia en toda la aplicación durante todo su ciclo de vida. Esto es deseable: es lo más rápido y lo más predecible. Nest ofrece dos scopes alternativos, y entender su costo es clave para no degradar el rendimiento sin querer.
| Scope | Instancias | Cuándo se crea | Costo |
|---|---|---|---|
DEFAULT (singleton) | 1 por aplicación | Al arrancar (o lazy) | Nulo |
REQUEST | 1 por request HTTP | En cada request entrante | Alto: instancia toda la subcadena por request |
TRANSIENT | 1 por cada consumidor que lo inyecta | En cada inyección | Medio: no se comparte |
Se declaran en el decorador:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {}
Un provider REQUEST puede inyectar el objeto request nativo:
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class AuditContext {
constructor(@Inject(REQUEST) private readonly req: Request) {}
}
El “burbujeo” de scope y su costo
Aquí está el detalle que la mayoría subestima: el scope burbujea hacia arriba en la cadena de dependencias. Si un controlador depende de un servicio, que depende de un provider REQUEST, entonces el servicio y el controlador se vuelven REQUEST también. No podés tener un singleton que dependa de un request-scoped, porque el singleton se construye una sola vez y el request-scoped cambia por cada request; la única forma de mantener la coherencia es que todos se construyan por request.
flowchart BT
R["Provider REQUEST-scoped<br/>(ej. AuditContext)"] --> S["Service<br/>que lo inyecta"]
S --> C["Controller<br/>que inyecta el service"]
R -.contamina.-> S
S -.contamina.-> C
C -->|toda la cadena se<br/>instancia por request| REQ["1 request = N nuevas instancias"]
La consecuencia de rendimiento es concreta: en vez de resolver la cadena una vez al arrancar, Nest la resuelve en cada request. Bajo carga, eso significa crear y descartar N objetos por cada petición, más presión sobre el garbage collector y la pérdida de cualquier caché o estado que un singleton habría conservado en memoria. En un endpoint de alto tráfico, un solo provider REQUEST mal ubicado puede volver request-scoped a media aplicación y medirse como latencia extra y throughput menor.
El ciclo de vida de un provider request-scoped
sequenceDiagram
participant Cliente
participant Nest as Contenedor Nest
participant Sub as Sub-árbol DI (por request)
Cliente->>Nest: HTTP request entra
Nest->>Nest: Crea contextId para este request
Nest->>Sub: Instancia controller (REQUEST)
Sub->>Sub: Instancia service (REQUEST)
Sub->>Sub: Instancia AuditContext + inyecta REQUEST
Sub-->>Cliente: Ejecuta handler y responde
Note over Sub: Al terminar el request,<br/>todo el sub-árbol se descarta (GC)
Cuándo se justifica y qué usar en su lugar
REQUEST scope se justifica cuando realmente necesitás estado aislado por request dentro de un provider inyectable y no hay forma limpia de pasarlo como argumento: un tenant resuelto por subdominio que toda la capa de datos necesita, o un contexto de auditoría que se enriquece a lo largo del request.
Pero casi siempre lo que querés es contexto por request sin request-scope, y para eso la herramienta correcta es AsyncLocalStorage de Node.js. Guardás el contexto en un store asociado a la cadena async del request y lo leés desde cualquier provider singleton, sin contaminar scopes ni pagar el costo de instanciación:
// als.module.ts — el store como singleton
@Module({
providers: [
{ provide: AsyncLocalStorage, useValue: new AsyncLocalStorage() },
],
exports: [AsyncLocalStorage],
})
export class AlsModule {}
// app.module.ts — middleware que abre el store por request
@Module({ imports: [AlsModule] })
export class AppModule implements NestModule {
constructor(private readonly als: AsyncLocalStorage<Record<string, unknown>>) {}
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, _res, next) => {
this.als.run({ userId: req.headers['x-user-id'] }, () => next());
})
.forRoutes('*path');
}
}
// cualquier servicio SINGLETON lee el contexto
@Injectable()
export class CatsService {
constructor(private readonly als: AsyncLocalStorage<Record<string, unknown>>) {}
getForCurrentUser() {
const userId = this.als.getStore()?.userId;
return this.repo.getForUser(userId);
}
}
Para un DX más pulido (tipado fuerte, ClsService#runWith para tests) está la librería nestjs-cls, que envuelve este patrón con un ClsModule.forRoot({ middleware: { mount: true, setup: ... } }). La regla práctica: antes de marcar algo REQUEST, preguntate si un AsyncLocalStorage singleton resuelve el mismo problema sin el costo.
Durable providers: mitigar el costo cuando REQUEST es inevitable
Si necesitás sí o sí request-scope pero el estado varía por un eje discreto (por ejemplo tenant, no por request individual), los durable providers permiten que Nest cachee un sub-árbol por payload en vez de por request. Con una ContextIdStrategy que agrupa por tenant y durable: true, dos requests del mismo tenant reusan la misma instancia, reduciendo la cantidad de instanciaciones de “una por request” a “una por tenant”. Es una optimización de nicho: la mencionamos para que sepas que existe, no como primera opción.
Dependencias circulares: casi siempre un síntoma
Una dependencia circular ocurre cuando la clase A necesita a B y B necesita a A. Nest no puede resolver el orden de construcción porque cada una espera a la otra. Hay herramientas para destrabarlo, pero antes conviene entender que el ciclo suele señalar un problema de diseño.
forwardRef: destrabar el ciclo
forwardRef() le dice a Nest “este token todavía no está definido, resolvelo más tarde”. Hay que aplicarlo en ambos lados:
@Injectable()
export class CatsService {
constructor(
@Inject(forwardRef(() => CommonService))
private readonly commonService: CommonService,
) {}
}
@Injectable()
export class CommonService {
constructor(
@Inject(forwardRef(() => CatsService))
private readonly catsService: CatsService,
) {}
}
Y si el ciclo es entre módulos, forwardRef() va también en los imports:
@Module({ imports: [forwardRef(() => CatsModule)] })
export class CommonModule {}
@Module({ imports: [forwardRef(() => CommonModule)] })
export class CatsModule {}
Ojo con la advertencia oficial: el orden de instanciación es indeterminado. No escribas código que asuma que un constructor corre antes que el otro.
Por qué forwardRef suele ser un parche
Si CatsService y CommonService se necesitan mutuamente, probablemente comparten una responsabilidad que pertenece a un tercer lugar. La solución de diseño es extraer una abstracción y romper el ciclo, en vez de tolerarlo con forwardRef.
flowchart LR
subgraph Antes["Antipatrón: ciclo con forwardRef"]
A1["CatsService"] <--> B1["CommonService"]
end
subgraph Despues["Refactor: dependencia unidireccional"]
A2["CatsService"] --> P["SharedLogic<br/>(abstracción extraída)"]
B2["CommonService"] --> P
end
En la práctica: mové la lógica que ambos comparten a un provider nuevo del que ambos dependan (grafo unidireccional, sin ciclo), o invertí una de las dos direcciones con un evento (EventEmitter) para que A no llame a B directamente sino que emita y B reaccione. forwardRef queda como último recurso cuando el ciclo es genuinamente irreducible.
ModuleRef: resolución dinámica y lazy
ModuleRef te da acceso programático al contenedor. Sirve para tres cosas: romper un ciclo obteniendo un provider bajo demanda en vez de por constructor, resolver providers scoped desde un singleton, e instanciar clases que no están registradas.
@Injectable()
export class CatsService implements OnModuleInit {
private common: CommonService;
constructor(private readonly moduleRef: ModuleRef) {}
onModuleInit() {
// resolución perezosa: se pide cuando el contenedor ya está armado,
// evitando el ciclo en el constructor
this.common = this.moduleRef.get(CommonService, { strict: false });
}
}
get() recupera singletons del módulo actual; con { strict: false } busca en el contexto global. get() no puede recuperar providers scoped (transient o request): para eso está resolve(), que devuelve una instancia de un sub-árbol DI propio.
// cada resolve() sin contextId devuelve una instancia distinta
const a = await this.moduleRef.resolve(TransientService);
const b = await this.moduleRef.resolve(TransientService);
console.log(a === b); // false
// compartí el sub-árbol pasando un contextId
const contextId = ContextIdFactory.create();
const [x, y] = await Promise.all([
this.moduleRef.resolve(TransientService, contextId),
this.moduleRef.resolve(TransientService, contextId),
]);
console.log(x === y); // true
// resolver un provider request-scoped dentro del contexto del request actual
const ctx = ContextIdFactory.getByRequest(this.request);
const repo = await this.moduleRef.resolve(CatsRepository, ctx);
Y para instanciar una clase que no está en el contenedor (una factory condicional, un handler dinámico), create():
const handler = await this.moduleRef.create(DynamicHandler);
Usá ModuleRef con criterio: reemplaza la inyección declarativa por resolución imperativa, lo que oculta el grafo de dependencias y complica el testeo. Es la herramienta correcta para casos dinámicos genuinos (plugins, estrategias elegidas en runtime), no para saltear un diseño acoplado.
Antipatrones a erradicar
-
Abuso de
REQUESTscope. Marcar un servicio comoREQUEST“por las dudas” contamina toda su cadena de consumidores y multiplica instanciaciones por request. Antes de usarlo, evaluáAsyncLocalStorage. Si igual lo necesitás, mantenelo en las hojas del grafo (providers que nadie más consume) para minimizar el burbujeo. -
forwardRefcomo parche permanente. Destraba el arranque pero deja el acoplamiento bidireccional y el orden de construcción indeterminado. Tratalo como una señal para extraer una abstracción o invertir la dependencia con un evento. -
Providers globales por comodidad. Marcar módulos
@Global()para no tener que importarlos convierte el grafo explícito en dependencias implícitas: cualquier provider puede aparecer en cualquier lado y ya no sabés quién depende de qué. Reservá@Global()para infraestructura transversal real (config, logger) e importá el resto explícitamente. -
Factories con lógica pesada. Una
useFactorycon trabajo bloqueante (parsing enorme, cálculos, I/O sincrónico no esperado) corre durante el bootstrap y demora el arranque de la app. La factory debe componer y construir, no procesar: si necesita I/O, que seaasyncy acotado; la lógica de negocio va en el provider, no en su fábrica. -
useValuecon objetos que necesitan hooks. ComouseValueno pasa por el contenedor, sus métodosonModuleInit/onModuleDestroyno se ejecutan. Si tu objeto necesita ciclo de vida, usáuseClassouseFactory.
Checklist
- ¿Tus abstracciones (ports) se inyectan por token —clase abstracta preferentemente— y no por implementación concreta?
- ¿Centralizaste los tokens
Symbol/string en un archivo por dominio para evitar strings mágicos y colisiones? - ¿Usás
useFactoryconinject[]ordenado para construcción dinámica, y marcásoptional: truelas deps que pueden faltar? - ¿Verificaste que ningún provider
REQUESTesté contaminando cadenas que deberían ser singleton? - ¿Evaluaste
AsyncLocalStorage/nestjs-clsantes de recurrir aREQUESTscope para contexto por request? - ¿Cada
forwardReftiene justificación, o hay uno que en realidad esconde un ciclo resoluble extrayendo una abstracción? - ¿Reservás
@Global()solo para infraestructura transversal y no como atajo para evitar imports? - ¿Tus
useFactorysolo componen/construyen, sin lógica pesada ni I/O bloqueante en el bootstrap? - ¿Sabés que
useValueno dispara hooks de ciclo de vida y elegiste la receta acorde?
Con el contenedor IoC dominado, el próximo paso es blindar lo que entra a tus providers: continuá con Validación y transformación →, donde vemos pipes, DTOs, class-validator vs Zod y la dirección de Standard Schema en Nest 12.