Providers y DI avanzada

Por: Artiko
nestjsbuenas-practicasdependency-injectionprovidersinjection-scopesarquitecturatypescript

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

ScopeInstanciasCuándo se creaCosto
DEFAULT (singleton)1 por aplicaciónAl arrancar (o lazy)Nulo
REQUEST1 por request HTTPEn cada request entranteAlto: instancia toda la subcadena por request
TRANSIENT1 por cada consumidor que lo inyectaEn cada inyecciónMedio: 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

Checklist

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.