Inicio / Artículos / Tokens de inyección en NestJS: por qué usarClassCan puede duplicar silenciosamente los singulares

Tokens de inyección en NestJS: por qué usarClassCan puede duplicar silenciosamente los singulares

Comprender los tipos, tokens y proveedores en NestJS DI, por qué las interfaces no pueden ser inyectadas, y cómo useExisting evita dos instancias del mismo servicio con estado.

1672 palabras

La inyección de dependencias en NestJS parece sencilla al principio: se declara un parámetro del constructor y el framework proporciona una instancia lista para usar. Esa facilidad oculta una correspondencia entre los tipos de TypeScript, los tokens en tiempo de ejecución y las definiciones de proveedores que, si no se comprenden bien, generan errores difíciles de detectar. Ejemplos de esto son cachés duplicados, grupos de bases de datos adicionales o mocks que nunca se resuelven. Este artículo crea un modelo mental preciso de cómo NestJS resuelve las dependencias, explica por qué las interfaces no pueden servir como claves de inyección y muestra cómo una sola opción de proveedor determina si un servicio tendrá una instancia o dos.

De la conexión automática a los tokens explícitos

El primer encuentro típico con la inyección de dependencias en NestJS suele ser con un constructor como este:

constructor(
  private readonly emailService: EmailService,
) {}

Para una dependencia de clase, NestJS lee la metadatos que TypeScript emite sobre los tipos de los parámetros del constructor (activado mediante emitDecoratorMetadata) y utiliza la propia clase para buscar el proveedor correspondiente. No se necesita ninguna anotación adicional.

Los conjuntos de código más grandes suelen verse de manera diferente:

constructor(
  @Inject('EMAIL_SERVICE')
  private readonly emailService: EmailService,
) {}

Si el parámetro ya está tipado, ¿qué agrega @Inject() y por qué al cambiar useClass a useExisting puede variar si se obtiene una instancia o dos? Para responder a esto es necesario separar tres conceptos que suelen compartir un mismo nombre.

El tipo, el token y el proveedor son tres cosas diferentes

Los principiantes a menudo los tratan como un único concepto porque, en los casos simples, comparten un identificador:

  • Tipo: lo que utiliza el compilador de TypeScript para verificar tu código. Solo existe en tiempo de compilación.
  • Token: la clave en tiempo de ejecución que NestJS utiliza para encontrar un proveedor en su contenedor.
  • Provider: el registro que indica a NestJS cómo generar un valor para un token, ya sea instanciando una clase, reutilizando otro proveedor, devolviendo un valor fijo o llamando a una fábrica.
  • Con la inyección basada únicamente en clases, una clase cumple doble función: como tipo de TypeScript y como token de NestJS:

    CustomLoggerService
           ↓
      NestJS Token
           +
     TypeScript Type
    

    Un token personalizado separa estas funciones. En el siguiente constructor, la cadena y la anotación de tipo tienen propósitos completamente diferentes:

    constructor(
      @Inject('logger')
      private readonly logger: CustomLoggerService,
    )
    

    Desglosadas, las responsabilidades son las siguientes:

    @Inject('logger')
           ↓
      NestJS Token (Finds the provider in memory)
    
    : CustomLoggerService
           ↓
      TypeScript Type (Gives you IDE autocomplete)
    

    El token es lo que NestJS utiliza para encontrar la instancia en tiempo de ejecución. La anotación de tipo solo proporciona al compilador y a tu editor información sobre su estructura, para la verificación de tipos y el autocompletado. Una forma abreviada útil: el token localiza la caja, mientras que el tipo describe su contenido. Ten en cuenta que NestJS no verifica si ambos coinciden; si el proveedor detrás de 'logger' devuelve algo distinto, TypeScript no lo detectará.

    Por qué no se puede inyectar una interfaz

    Una solicitud frecuente por parte de los desarrolladores nuevos en NestJS es depender de una interfaz para mantener el código desacoplado, por ejemplo al especificar un parámetro como IMailService. El primer intento natural falla:

    // Won't work at runtime
    constructor(
      private readonly mailService: IMailService,
    ) {}
    

    Al iniciar, NestJS muestra Nest can't resolve dependencies of the UserService (?). El signo de interrogación marca el parámetro que no pudo identificar.

    La razón es que las interfaces no existen en JavaScript. Cuando tsc compila tu código, todas las interface y todas las anotaciones de tipo puro se eliminan. NestJS depende de metadatos que permanecen en el programa en ejecución, y una interface no deja nada tras de sí que pueda leerse; los metadatos generados para ese parámetro se reducen al tipo genérico Object, que no coincide con ningún proveedor.

    Las clases son diferentes: se compilan en funciones constructoras reales de JavaScript, por lo que siguen existiendo en tiempo de ejecución y pueden funcionar como claves.

    class CustomLoggerService
            ↓
    exists at runtime
            ↓
    can be used as a DI token
    
    interface IMailer
            ↓
    erased during compilation
            ↓
    cannot be used as a runtime DI token
    

    Cuando se programa contra una abstracción, es necesario proporcionar explícitamente un token en tiempo de ejecución, como @Inject('MAIL_SERVICE'), y registrar un proveedor bajo ese mismo token. Una clase abstracta es otra alternativa que vale la pena conocer: dado que se compila en una función real, puede funcionar tanto como tipo como token sin necesidad de @Inject().

    La trampa del singleton duplicado: useClass versus useExisting

    Una vez que se utilizan tokens personalizados, cada módulo debe indicarle a NestJS cómo resolverlos, y es aquí donde con frecuencia surge un error sutil. Considere este módulo:

    @Module({
      providers: [
        CustomLoggerService,
        {
          provide: 'APP_LOGGER',
          useClass: CustomLoggerService, // The trap
        },
      ],
    })
    export class CommonModule {}
    

    A primera vista parece que 'APP_LOGGER' es simplemente otro nombre para CustomLoggerService. Pero no lo es. El módulo ahora contiene dos registros independientes de proveedores, y con el ámbito singleton por defecto, cada uno obtiene su propia instancia:

    • El token de clase CustomLoggerService se resuelve al construir la clase, lo que genera la instancia A.
    • El token de cadena 'APP_LOGGER' se resuelve al construir nuevamente la clase, lo que genera la instancia B.

    El problema solo aparece con el estado. Si el registrador mantiene un búfer en memoria, almacena una cola, cuenta las solicitudes para el control de velocidad o posee una conexión, los consumidores que inyectan la clase y aquellos que inyectan el token de cadena están comunicándose con objetos diferentes que nunca ven los datos del otro.

    La solución: un alias con useExisting

    Cuando lo que se desea es un nombre adicional para un proveedor ya registrado, use useExisting. Esto indica a NestJS que no cree nada nuevo y que resuelva el token con la instancia existente en su lugar:

    @Module({
      providers: [
        CustomLoggerService,
        {
          provide: 'APP_LOGGER',
          useExisting: CustomLoggerService, // Points to the existing singleton
        },
      ],
    })
    export class CommonModule {}
    

    Una forma sencilla de visualizar la diferencia es mediante cajas y etiquetas:

    • useClass crea un segundo cuadro. El token de clase etiqueta al cuadro A, mientras que el token de cadena etiqueta al cuadro B.
    • useExisting crea un único cuadro y le asigna ambas etiquetas de nombre.

    Con el alias establecido, un consumidor que inyecte CustomLoggerService y otro que inyecte 'APP_LOGGER' recibirán el mismo objeto, por lo que una comprobación estricta de igualdad entre ellos dará como resultado true.

    useClass sigue siendo la opción adecuada cuando realmente se desea una instancia separada, o cuando el token es la única forma de registrar la clase, como se explicará en la siguiente sección.

    Tokens como separador arquitectónico

    Para un servicio CRUD pequeño, los tokens personalizados pueden parecer algo innecesario. Su ventaja se hace evidente cuando es probable que cambie la implementación. Supongamos que docenas de controladores registran sus operaciones a través de un servicio basado en Winston. En lugar de importar WinstonLoggerService en cada uno de ellos, los controladores dependen únicamente de un token y una interfaz:

    constructor(
      @Inject('LOGGER') private readonly logger: LoggerInterface
    ) {}
    

    El módulo decide qué implementación se encuentra detrás de ese token:

    {
      provide: 'LOGGER',
      useClass: WinstonLoggerService,
    }
    

    Cambiar a un backend de registro en la nube, como Google Cloud Logging o AWS CloudWatch, se convierte entonces en un cambio de una sola línea en el módulo, sin necesidad de editar ningún controlador:

    {
      provide: 'LOGGER',
      useClass: CloudLoggerService,
    }
    

    Esa misma separación facilita las pruebas, ya que un módulo de prueba puede vincular 'LOGGER' a un stub sin tocar el código que se está probando.

    Un refinamiento práctico: las cadenas simples son fáciles de escribir mal y pueden colisionar entre módulos. Definir los tokens una vez como constantes exportadas, o como valores Symbol, mantiene su consistencia y permite que el compilador detecte errores de escritura.

    Patrones de proveedores a primera vista

    Las cuatro formas de proveedores que más se encontrarán, y cuándo es apropiado usar cada una:

    • Proveedor de clase (useClass): instanciar una clase para un token. Úselo para vincular un token de abstracción a una implementación concreta, o para cambiar las implementaciones según el entorno. Cada registro crea su propia instancia.
    • Proveedor de alias (useExisting): dirigir un token hacia un proveedor que ya existe. Úselo para exponer una única instancia bajo varios nombres sin duplicar el estado.
  • Proveedor de valor (useValue): devuelve un valor fijo, como un objeto de configuración, una constante o un modelo simulado en pruebas.
  • Proveedor de fábrica (useFactory): calcula el valor mediante una función, opcionalmente con dependencias inyectadas listadas en inject. Úsalo cuando la creación depende de la configuración o es asíncrona, como en el caso de las conexiones a bases de datos.
  • No olvide los exports

    Un obstáculo clásico es un token configurado correctamente en CommonModule que UserModule aún no puede resolver. El array providers controla qué registra un módulo para su propio uso. El array exports controla qué pone a disposición de los módulos que lo importan. Un token personalizado como 'LOGGER' debe aparecer en exports (y el módulo que lo consume debe importar CommonModule) antes de que alguien externo pueda inyectarlo. Cuando exporte un alias creado con useExisting, verifique que los consumidores también puedan acceder al proveedor al que apunta, o exporte ambos tokens.

    Puntos clave

    • Un tipo solo existe en tiempo de compilación, un token es la clave en tiempo de ejecución, y un proveedor define cómo se satisface esa clave; mantenga separados estos tres conceptos al leer código de inyección de dependencias.
  • Las interfaces se eliminan durante la compilación, por lo que necesitan un token explícito mediante @Inject(), o una clase abstracta que exista en tiempo de ejecución.
  • Registrar la misma clase bajo dos tokens con useClass crea dos instancias singulares; utilice useExisting cuando solo desea un alias.
  • Los tokens de abstracción le permiten intercambiar implementaciones y simulaciones desde un mismo lugar; defínalos como constantes o símbolos compartidos.
  • Cuando un token no puede resolverse en otro módulo, revise primero exports y imports.
  • Para cada inyección, haga tres preguntas: ¿cuál es el token?, ¿qué implementación lo respalda? y, ¿se trata de un registro separado o de un alias?
  • Lecturas relacionadas

  • Laravel o NestJS: sopesando la arquitectura, la velocidad y la compatibilidad con el equipo — Una comparación práctica de Laravel y NestJS que abarca la arquitectura, los ORMs, las configuraciones de seguridad por defecto, la velocidad de ejecución y entrega, la curva de aprendizaje y cuándo es adecuado cada uno.
  • Seis reglas de DDD para estructurar dominios en aplicaciones NestJS — Aprenda seis reglas prácticas de diseño orientado a dominios para organizar los módulos, entidades y eventos de NestJS, de modo que las funcionalidades permanezcan aisladas y fáciles de mantener.
  • Diseño para solicitudes fallidas en Angular: estados, interceptores y reintentos — Aprende cómo clasificar los fallos HTTP en Angular, limpiar el estado de carga, centralizar el manejo en interceptores, reintentar de forma segura y mostrar mensajes con los que los usuarios puedan actuar.
  • Comprendiendo los principios SOLID a través de ejemplos prácticos de código — Esta guía explica en detalle los cinco principios SOLID con ejemplos de código concretos, mostrando cómo se aplican en proyectos reales y aplicaciones React.
  • Enseñar a Claude Code tu Monorepo NestJS: enrutamiento, reglas y habilidades — Cómo configurar CLAUDE.md, reglas, habilidades y permisos para que Claude Code coloque el código en el servicio NestJS correcto y siga las convenciones de su equipo.