Accueil / Articles / Tokens d’injection NestJS : pourquoi useClass peut dupliquer silencieusement des singletons

Tokens d’injection NestJS : pourquoi useClass peut dupliquer silencieusement des singletons

Comprendre les types, les tokens et les fournisseurs dans le DI de NestJS, pourquoi les interfaces ne peuvent pas être injectées, et comment useExisting évite deux instances du même service à état.

1672 mots

L’injection de dépendances dans NestJS semble simple au premier abord : il suffit de déclarer un paramètre dans le constructeur, et le framework fournit une instance prête à l’emploi. Cette facilité cache en réalité une correspondance entre les types TypeScript, les tokens de temps de exécution et les définitions des fournisseurs ; si cette correspondance n’est pas bien comprise, cela entraîne des bugs difficiles à détecter. On peut ainsi rencontrer des caches dupliqués, des pools de bases de données supplémentaires ou des mocks qui ne se résolvent jamais. Cet article propose un modèle mental précis de la manière dont NestJS résout les dépendances, explique pourquoi les interfaces ne peuvent pas servir de clés d’injection, et montre comment une seule option de fournisseur détermine si un service disposera d’une instance ou de deux.

De l’interconnexion automatique aux tokens explicites

La première rencontre typique avec l’injection de dépendances dans NestJS concerne un constructeur comme celui-ci :

constructor(
  private readonly emailService: EmailService,
) {}

Pour une dépendance de classe, NestJS lit les métadonnées émises par TypeScript concernant les types des paramètres de constructeur (activé par emitDecoratorMetadata) et utilise la classe elle-même pour trouver le fournisseur correspondant. Aucune annotation supplémentaire n’est nécessaire.

Les bases de code plus grandes ont souvent un aspect différent :

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

Si le paramètre est déjà typé, qu’ajoute @Inject() et pourquoi le passage de useClass à useExisting peut-il faire varier le nombre d’instances obtenues (une ou deux) ? Pour y répondre, il faut distinguer trois concepts qui portent souvent le même nom.

Le type, le token et le fournisseur sont trois choses différentes

Les débutants les considèrent souvent comme un seul concept car, dans les cas simples, ils partagent un identifiant :

  • Type : ce que le compilateur TypeScript utilise pour vérifier votre code. Il n’existe qu’à l’heure de la compilation.
  • Token : la clé utilisée en temps de exécution par NestJS pour trouver un fournisseur dans son conteneur.
  • Fournisseur : l’enregistrement qui indique à NestJS comment générer une valeur pour un token, que ce soit en instanciant une classe, en réutilisant un autre fournisseur, en retournant une valeur fixe ou en appelant une usine.
  • Avec l’injection basée uniquement sur des classes, une même classe remplit à la fois le rôle du type TypeScript et celui du token NestJS :

    CustomLoggerService
           ↓
      NestJS Token
           +
     TypeScript Type
    

    Un token personnalisé sépare ces rôles. Dans le constructeur suivant, la chaîne de caractères et l’annotation de type servent à des fins complètement différentes :

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

    Décomposées, les responsabilités sont les suivantes :

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

    Le token est ce que NestJS utilise pour trouver l’instance en temps de exécution. L’annotation de type ne sert qu’au compilateur et à votre éditeur pour connaître sa structure, aux fins de vérification de type et d’autocomplétion. Une façon concise de le voir : le token localise la boîte, tandis que le type décrit son contenu. Notez que NestJS ne vérifie pas si les deux correspondent ; si le fournisseur derrière 'logger' renvoie quelque chose d’autre, TypeScript ne le détectera pas.

    Pourquoi on ne peut pas injecter une interface

    Une demande fréquente de la part des développeurs novices en NestJS est d’utiliser une interface afin de maintenir le code découplé, par exemple en spécifiant un paramètre sous la forme de IMailService. La première tentative naturelle échoue :

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

    Lors du démarrage, NestJS affiche Nest can't resolve dependencies of the UserService (?). Le point d’interrogation indique le paramètre qu’il n’a pas pu identifier.

    La raison en est que les interfaces n’existent pas en JavaScript. Lorsque tsc compile votre code, chaque interface ainsi que toute annotation de type pure est supprimée. NestJS dépend de métadonnées qui doivent persister dans le programme en cours d’exécution, or une interface ne laisse rien derrière elle que ce dernier puisse lire ; les métadonnées générées pour ce paramètre se réduisent au type générique Object, qui ne correspond à aucun fournisseur.

    Les classes, en revanche, sont différentes : elles se compilent en véritables fonctions constructeurs JavaScript, ce qui leur permet de persister en temps de exécution et de servir de clés.

    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
    

    Lorsque vous programmez en utilisant une abstraction, vous devez fournir explicitement un jeton de temps d’exécution, comme @Inject('MAIL_SERVICE'), et enregistrer un fournisseur sous ce même jeton. Une classe abstraite constitue une alternative à connaître : puisqu’elle se compile en une fonction réelle, elle peut servir à la fois de type et de jeton sans @Inject().

    Le piège du singleton dupliqué : useClass versus useExisting

    Lorsque des jetons personnalisés sont utilisés, chaque module doit indiquer à NestJS comment les résoudre, et c’est là que des erreurs subtiles apparaissent fréquemment. Examinons ce module :

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

    À première vue, il semble que 'APP_LOGGER' ne soit qu’un autre nom pour CustomLoggerService. Ce n’est pas le cas. Le module contient désormais deux enregistrements de fournisseurs indépendants, et avec la portée singleton par défaut, chacun d’eux obtient sa propre instance :

    • Le token de classe CustomLoggerService est résolu en construisant la classe, ce qui produit l’instance A.
    • Le token de chaîne 'APP_LOGGER' est résolu en construisant à nouveau la classe, ce qui produit l’instance B.

    Le problème ne se manifeste que avec l’état. Si le journaliseur conserve un buffer en mémoire, gère une file d’attente, compte les requêtes pour la limitation de vitesse ou possède une connexion, les consommateurs qui injectent la classe et ceux qui injectent le token de chaîne communiquent avec des objets différents qui ne voient jamais les données de l’autre.

    La solution : un alias avec useExisting

    Lorsque l’intention est d’obtenir un nom supplémentaire pour un fournisseur déjà enregistré, utilisez useExisting. Cela indique à NestJS de ne pas créer quoi que ce soit de nouveau et de résoudre le token vers l’instance existante à la place :

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

    Une façon simple d’imaginer la différence, c’est avec des boîtes et des étiquettes :

    • useClass crée une deuxième boîte. Le jeton de classe étiquette la boîte A, tandis que le jeton de chaîne étiquette la boîte B.
    • useExisting crée une seule boîte et y attache les deux étiquettes de nom.

    Avec cet alias en place, un consommateur qui injecte CustomLoggerService et un autre qui injecte 'APP_LOGGER' reçoivent le même objet, de sorte qu’une vérification d’égalité stricte entre eux donne true.

    useClass reste le choix approprié lorsque l’on souhaite réellement une instance distincte, ou lorsque le jeton est le seul moyen de déclarer la classe, comme expliqué dans la section suivante.

    Les jetons en tant que jointure architecturale

    Pour un petit service CRUD, l’utilisation de tokens personnalisés peut sembler superflue. Leur utilité se révèle lorsque l’implémentation est susceptible de changer. Supposons que des dizaines de contrôleurs utilisent un service basé sur Winston pour enregistrer les logs. Plutôt que d’importer WinstonLoggerService dans chacun d’eux, les contrôleurs dépendent uniquement d’un token et d’une interface :

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

    Le module décide quelle implémentation se trouve derrière ce token :

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

    Passer à un système de journalisation en cloud, comme Google Cloud Logging ou AWS CloudWatch, ne nécessite alors qu’un changement sur une seule ligne dans le module, sans aucune modification des composants qui consomment les logs :

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

    Cette même architecture facilite les tests, car un module de test peut associer 'LOGGER' à une simulation sans avoir à modifier le code à tester.

    Une amélioration pratique : les chaînes de caractères simples sont facilement à saisir incorrectement et peuvent entrer en conflit entre modules. En définissant les tokens une fois comme des constantes exportées, ou comme des valeurs Symbol, on assure leur cohérence et permet au compilateur de détecter les fautes d’orthographe.

    Les modèles de fournisseurs en bref

    Les quatre types de fournisseurs que vous rencontrerez le plus souvent, ainsi que les situations où chacun est approprié :

    • Fournisseur de classe (useClass) : instancie une classe pour un token. Utilisez-le pour lier un token d’abstraction à une implémentation concrète, ou pour changer les implémentations selon l’environnement. Chaque inscription crée sa propre instance.
    • Fournisseur d’alias (useExisting) : fait pointer un token vers un fournisseur déjà existant. Utilisez-le pour exposer une seule instance sous plusieurs noms sans dupliquer l’état.
  • Fournisseur de valeur (useValue) : retournez une valeur fixe, telle qu’un objet de configuration, une constante ou un mock dans les tests.
  • Fournisseur de factory (useFactory) : calculez la valeur à l’aide d’une fonction, éventuellement avec des dépendances injectées listées dans inject. Utilisez-le lorsque la création dépend de la configuration ou est asynchrone, comme c’est le cas pour les connexions à une base de données.
  • N’oubliez pas les exports

    Un obstacle classique est un token correctement configuré dans CommonModule que UserModule ne parvient toujours pas à résoudre. L’array providers contrôle ce qu’un module enregistre pour son propre usage. L’array exports contrôle ce qu’il met à disposition des modules qui l’importent. Un token personnalisé comme 'LOGGER' doit figurer dans exports (et le module qui l’utilise doit importer CommonModule) avant que quiconque à l’extérieur ne puisse le injecter. Lorsque vous exportez un alias créé avec useExisting, assurez-vous que les modules consommateurs peuvent également accéder au fournisseur vers lequel il pointe, ou exportez les deux tokens.

    Points clés

    • Un type n’existe qu’en temps de compilation, un token est la clé en temps d’exécution, et un fournisseur définit comment cette clé est satisfaite ; gardez ces trois éléments distincts lors de la lecture du code DI.
  • Les interfaces sont effacées lors de la compilation, il leur faut donc un token explicite via @Inject(), ou une classe abstraite présente en temps de exécution.
  • Enregistrer la même classe sous deux tokens avec useClass crée deux instances uniques ; utilisez useExisting lorsque vous souhaitez uniquement un alias.
  • Les tokens d’abstraction vous permettent de remplacer les implémentations et les mocks depuis un seul endroit ; définissez-les comme des constantes ou des symboles partagés.
  • Lorsqu’un token ne peut pas être résolu dans un autre module, vérifiez d’abord les éléments exports et imports.
  • Pour chaque injection, posez-vous trois questions : quel est le token, quelle implémentation le soutient, et s’il s’agit d’une registration distincte ou d’un alias ?
  • Lectures complémentaires

  • Laravel ou NestJS ? Évaluation de l’architecture, de la vitesse et de l’adéquation avec l’équipe — Une comparaison pratique de Laravel et NestJS couvrant l’architecture, les ORM, les paramètres de sécurité par défaut, la vitesse d’exécution et de déploiement, la courbe d’apprentissage ainsi que les cas d’usage appropriés pour chacun.
  • Six règles DDD pour structurer les domaines dans les applications NestJS — Apprenez six règles pratiques de conception orientée domaine pour organiser les modules, entités et événements de NestJS afin que les fonctionnalités restent isolées et faciles à maintenir.
  • Concevoir pour les demandes échouées dans Angular : états, intercepteurs et tentatives de réessai — Apprenez à classer les échecs HTTP dans Angular, à gérer correctement l’état de chargement, à centraliser le traitement via des intercepteurs, à réessayer en toute sécurité et à afficher des messages sur lesquels les utilisateurs peuvent agir.
  • Comprendre les principes SOLID à travers des exemples de code concrets — Ce guide explique en détail les cinq principes SOLID à l’aide d’exemples de code concrets, montrant comment ils s’appliquent dans des projets réels et des applications React.
  • Apprendre à Claude Code votre monorepo NestJS : routage, règles et compétences — Comment configurer CLAUDE.md, les règles, les compétences et les permissions afin que Claude Code place le code dans le bon service NestJS et respecte les conventions de votre équipe.