Skip to content

NestJS — Dependency Injection

TL;DR — Le conteneur IoC de Nest est un graphe résolu au boot : chaque provider a un token, une factory (implicite ou explicite) et un scope. @Injectable() enregistre la classe, @Inject(TOKEN) lève l'ambiguïté quand le type n'est pas la clé. Maîtrise les 4 formes de provider, les 3 scopes et forwardRef — tout le reste découle.

🧠 Mental model

Imagine une Map<Token, Factory> que Nest construit en parcourant tes Module.providers. Quand un constructeur dit constructor(private a: A), Nest lit la métadonnée TypeScript design:paramtypes (grâce à emitDecoratorMetadata: true), récupère le token A, et appelle sa factory. Si la factory dépend de B, il résout B d'abord, etc. — c'est un tri topologique.

   AppModule.providers = [A, {provide: B_TOKEN, useFactory: ...}, C]


   ┌─────────────────────────────────┐
   │  Token graph (resolved at boot) │
   │                                 │
   │   A ──► (no deps)               │
   │   B_TOKEN ──► [A]               │
   │   C ──► [A, B_TOKEN]            │
   └─────────────────────────────────┘


   Singleton instances cached per (module, scope)

Le token est par défaut la classe elle-même (référence d'objet). Pour les interfaces (qui n'existent pas à l'exécution), tu utilises un symbol ou une string comme token : @Inject('USER_REPO'). C'est le seul moyen de découpler interface ↔ implémentation côté JS.

Mental shortcut : provider = (token, factory, scope). Tout ce que tu écris dans providers: [...] sera réduit à ce triplet.

Pourquoi un conteneur IoC plutôt que new ? (le raisonnement d'un staff)

Tu viens de PHP/TS où tu as sûrement fait du new Service(new Repo(new Pool(...))) ou utilisé un container Symfony/Laravel. Le DI de Nest résout 4 problèmes qui ne se voient qu'à l'échelle :

Problème sans DICe que le conteneur apporte
Couplage à l'implémentation : new StripeAdapter() cloue le call-site au type concretLe call-site dépend d'un token (interface/Symbol). On swap l'impl sans toucher le consommateur → testable, mockable, multi-env.
Wiring transitif manuel : changer le constructeur d'une feuille casse 30 call-sitesLe graphe est résolu une fois au boot. Tu déclares les deps, Nest fait le tri topologique.
Cycle de vie ad hoc : qui ferme la connexion Redis ? qui la partage ?Singleton garanti par (module, token) + hooks OnModuleInit/OnModuleDestroy → graceful shutdown déterministe.
Pas de point d'extension : ajouter un PSP = toucher N fichiersMulti-providers + dynamic modules → ajout = un fichier + une ligne.

Le coût : un graphe résolu au boot signifie qu'une erreur de wiring est une erreur de démarrage, pas de runtime. C'est voulu — tu veux que l'app refuse de démarrer plutôt que de crasher sous charge sur une dep undefined. C'est le principe « fail fast at boot ».

Le piège mental n°1 : DI ≠ « magie globale »

Une dep n'est visible que si (a) elle est dans les providers du module courant, ou (b) elle est exports-ée par un module importé. Il n'y a pas de scope global implicite (sauf @Global(), à utiliser avec parcimonie). La frontière de visibilité, c'est le module — c'est l'unité d'encapsulation du graphe. Deux modules peuvent fournir le même token avec des impls différentes ; chacun voit la sienne (isolation par module).

🛠️ Code minimal

ts
import { Injectable, Inject, Module } from '@nestjs/common';

// Interface (compile-time only)
export interface UserRepo {
  findById(id: string): Promise<{ id: string; name: string } | null>;
}

// Token (runtime)
export const USER_REPO = Symbol('USER_REPO');

@Injectable()
class InMemoryUserRepo implements UserRepo {
  private data = new Map([['1', { id: '1', name: 'Ada' }]]);
  async findById(id: string) { return this.data.get(id) ?? null; }
}

@Injectable()
class UsersService {
  constructor(@Inject(USER_REPO) private readonly repo: UserRepo) {}
  greet(id: string) {
    return this.repo.findById(id).then(u => (u ? `Hi ${u.name}` : '404'));
  }
}

@Module({
  providers: [
    UsersService,
    { provide: USER_REPO, useClass: InMemoryUserRepo }, // swap-able
  ],
  exports: [UsersService],
})
export class UsersModule {}

🎯 Patterns courants

1. Les 4 formes de custom provider.

ts
// useValue : constante / mock
{ provide: 'CONFIG', useValue: { port: 3000 } }

// useClass : alias / surcharge
{ provide: AbstractMailer, useClass: SendgridMailer }

// useFactory : construction dynamique avec deps
{
  provide: 'DB',
  useFactory: async (cfg: ConfigService) => createPool(cfg.get('DB_URL')),
  inject: [ConfigService],
}

// useExisting : alias d'un autre token (pas une nouvelle instance)
{ provide: 'LEGACY_LOGGER', useExisting: Logger }

useExisting est subtil : il ne crée pas une nouvelle instance, il pointe vers la même. Utile pour migrer un ancien token sans dupliquer.

Piège useClass{ provide: AbstractMailer, useClass: SendgridMailer } instancie un SendgridMailer distinct de celui que tu aurais déclaré séparément dans providers. Si tu veux que AbstractMailer et SendgridMailer pointent vers la même instance (singleton partagé), utilise useExisting, pas useClass. C'est la différence aliasing (même instance) vs binding (nouvelle instance).

2. Scopes : DEFAULT / REQUEST / TRANSIENT.

ts
@Injectable({ scope: Scope.REQUEST })
export class TenantContext { tenantId!: string; }
  • DEFAULT (singleton, par défaut) : une instance par module pour toute l'app. Le plus performant.
  • REQUEST : une instance par requête HTTP (ou par message RPC/WS). Permet de stocker du contexte (tenant, user). Contamine : tout provider qui en dépend devient REQUEST aussi.
  • TRANSIENT : nouvelle instance à chaque injection. Utile pour des providers stateful sans contexte requête (ex: builder).

3. Dynamic modules (forRoot / forRootAsync). Pattern config-driven.

ts
interface DbOptions { url: string; poolSize?: number }
interface AsyncDbOptions {
  imports?: any[];
  useFactory: (...args: any[]) => Promise<DbOptions> | DbOptions;
  inject?: any[];
}

@Module({})
export class DatabaseModule {
  static forRoot(opts: DbOptions): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [
        { provide: 'DB_OPTS', useValue: opts },
        { provide: 'DB', useFactory: (o: DbOptions) => createPool(o), inject: ['DB_OPTS'] },
      ],
      exports: ['DB'],
      global: true,
    };
  }

  // Variante async : l'objet d'options est lui-même un provider résolu via useFactory
  // (typiquement il dépend de ConfigService). Le reste du graphe est identique.
  static forRootAsync(opts: AsyncDbOptions): DynamicModule {
    return {
      module: DatabaseModule,
      imports: opts.imports ?? [],
      providers: [
        { provide: 'DB_OPTS', useFactory: opts.useFactory, inject: opts.inject ?? [] },
        { provide: 'DB', useFactory: (o: DbOptions) => createPool(o), inject: ['DB_OPTS'] },
      ],
      exports: ['DB'],
      global: true,
    };
  }
}

4. Circular deps — forwardRef. Quand A dépend de B et B dépend de A.

ts
@Injectable()
export class A { constructor(@Inject(forwardRef(() => B)) private b: B) {} }

@Module({
  providers: [A, B],
  // côté imports inter-modules :
  // imports: [forwardRef(() => OtherModule)]
})
export class M {}

C'est un smell : si tu en as besoin, demande-toi si tu ne devrais pas extraire une 3e classe C que A et B utilisent. Utilise forwardRef comme dernier recours.

5. Token-based injection (Symbol).

ts
export const RATE_LIMIT = Symbol('RATE_LIMIT');
@Injectable() class Service { constructor(@Inject(RATE_LIMIT) private rl: number) {} }
@Module({ providers: [{ provide: RATE_LIMIT, useValue: 100 }, Service] })

Pourquoi Symbol > string ? Pas de collision, IDE-friendly, refactor sûr.

6. Dynamic providers pour plug-in pattern.

ts
const PLUGINS = Symbol('PLUGINS');
@Module({
  providers: [
    PluginA, PluginB,
    { provide: PLUGINS, useFactory: (a, b) => [a, b], inject: [PluginA, PluginB] },
  ],
  exports: [PLUGINS],
})
export class PluginsModule {}

// Consumer
@Injectable()
class Runner { constructor(@Inject(PLUGINS) private plugins: Plugin[]) {} }

C'est le pattern multi-providers d'Angular sans le sucre multi: true — tu agrèges à la main.

🔍 Comment Nest résout réellement les deps (sous le capot)

Quand tu écris constructor(private readonly cats: CatsService), TypeScript émet (grâce à emitDecoratorMetadata) une métadonnée design:paramtypes = [CatsService] accessible via Reflect.getMetadata('design:paramtypes', SomeClass). Au boot, Nest :

  1. Lit Module.providers pour construire le registre (token → providerDefinition).
  2. Pour chaque provider class, lit design:paramtypes → liste des tokens deps.
  3. Pour chaque @Inject(TOKEN) sur un param, écrase le token déduit par TypeScript par celui du décorateur.
  4. Construit le DAG (graphe acyclique dirigé) deps → providers, fait un tri topologique.
  5. Instancie en remontant les feuilles vers la racine : feuilles d'abord (no deps), puis providers qui les consomment.
  6. Met en cache l'instance par (module, scope) pour le scope DEFAULT.

Implications pratiques :

  • Si une dep n'est pas dans providers du module ou dans les exports d'un module importé, résolution échoue avec "Nest can't resolve dependencies of X (?). Please make sure that the argument Y at index [0] is available in the Z context."
  • Le (?) dans le message d'erreur correspond à un param dont le type n'est pas exportable (ex : interface). C'est le signe qu'il faut @Inject(TOKEN) avec un Symbol/string.
  • Tu peux marquer une dep optionnelle : constructor(@Optional() @Inject('FOO') private foo?: Foo). Sans @Optional, Nest crash si la dep manque.

🧭 Résolution impérative — ModuleRef (échapper au constructeur)

90 % du temps tu déclares tes deps dans le constructeur et Nest les résout. Mais parfois le token n'est connu qu'à l'exécution (routing par discriminant, plug-in chargé dynamiquement, factory qui choisit l'adapter selon la donnée). C'est le rôle de ModuleRef — le handle programmatique sur le conteneur.

ts
import { Injectable, OnModuleInit, Type } from '@nestjs/common';
import { ModuleRef, ContextIdFactory } from '@nestjs/core';

@Injectable()
export class PspRouter implements OnModuleInit {
  private adapters = new Map<string, PaymentAdapter>();

  constructor(private readonly moduleRef: ModuleRef) {}

  onModuleInit() {
    // get() : récupère un SINGLETON déjà instancié. strict:false → cherche dans tout le graphe, pas que le module local.
    for (const [name, type] of [['stripe', StripeAdapter], ['adyen', AdyenAdapter]] as [string, Type<PaymentAdapter>][]) {
      this.adapters.set(name, this.moduleRef.get(type, { strict: false }));
    }
  }

  charge(psp: string, amount: number) {
    const adapter = this.adapters.get(psp);
    if (!adapter) throw new Error(`unknown_psp:${psp}`);
    return adapter.charge(amount);
  }
}

Deux méthodes, deux sémantiques — c'est le piège n°1 :

MéthodePour quel scopeRetour
moduleRef.get(Token)DEFAULT uniquement (singleton déjà construit). Lève si le provider est REQUEST/TRANSIENT.Synchrone
moduleRef.resolve(Token)TRANSIENT / REQUEST — crée une nouvelle sous-arborescence isolée.Promise

Pour résoudre un provider REQUEST hors d'une requête HTTP (ex : depuis un job BullMQ qui doit reconstruire un contexte tenant), tu fabriques un contextId et tu l'enregistres :

ts
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId({ tenant: { id: tenantId } }, contextId);
const ctx = await this.moduleRef.resolve(TenantContext, contextId);
// chaque resolve() avec un contextId neuf = une instance REQUEST fraîche et isolée

Quand un staff sort ModuleRef : dispatch dynamique par token runtime, lazy loading (LazyModuleLoader), reconstruire un contexte REQUEST dans un worker. Quand il ne le sort pas : pour économiser un @Inject — c'est un anti-pattern (service locator déguisé) qui casse la lisibilité du graphe et la détection des deps manquantes au boot. Règle : ModuleRef quand le token est dynamique, jamais quand il est statique.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

  • Nest 7 : forwardRef déjà présent. Scope.REQUEST instable sur certaines combinaisons. Pas de ModuleRef.resolve() pour les transient (à venir).
  • Nest 8 : ModuleRef.resolve() typed proprement, supporte each: true pour récupérer toutes les implémentations multi-providers.
  • Nest 9 : Reflector.getAllAndOverride() arrive — pas du DI pur mais souvent confondu. Améliorations sur la résolution des useFactory async (parallélisation contrôlée).
  • Nest 10 : LazyModuleLoader.load() stable — tu peux résoudre un module entier à la demande, ce qui ouvre la voie au plug-in dynamique runtime. Le scope REQUEST génère désormais des warnings clairs en cas de "bleed" sur des providers DEFAULT.
  • Nest 11 : DI plus stricte : injecter un provider REQUEST dans un controller DEFAULT lève un warning explicite (avant : silencieux puis crash). Les useFactory ont une meilleure detection des cycles (message d'erreur pointe les tokens en boucle).

⚠️ Pitfalls

  1. Oublier emitDecoratorMetadata. Sans ça, Nest ne peut pas lire paramtypes et lève Cannot resolve dependency. Le tsconfig.json doit contenir "emitDecoratorMetadata": true et "experimentalDecorators": true.
  2. Injecter une interface. Les interfaces TS n'existent pas au runtime. Tu dois utiliser un token (@Inject('TOKEN')), sinon Nest ne sait pas quoi résoudre.
  3. REQUEST scope qui contamine. Si un controller dépend d'un service REQUEST, le controller devient REQUEST → instancié à chaque requête → perte de perf énorme. Audite avec LOG_LEVELS=debug.
  4. Circular dep silencieuse. Sans forwardRef, tu obtiens undefined injecté (au lieu d'une erreur claire). Toujours vérifier console.log(this.dep) dans OnModuleInit si tu suspectes.
  5. Re-injecter via new. new MyService() court-circuite le container — pas de deps résolues, pas de scope. Toujours passer par le DI.
  6. Mélanger useValue et useFactory pour la config. Si tu peux utiliser useValue (constante au boot), fais-le ; useFactory n'est nécessaire que si tu dois résoudre des deps ou faire de l'async.
  7. Oublier inject: [] dans useFactory. Sans cette ligne, tes paramètres seront undefined. C'est l'erreur n°1 des débutants.
  8. @Global() sur un module qui exporte tout. Le @Global rend les exports disponibles partout. Si tu exportes 20 providers internes, tu pollues le scope global. Restreins exports au strict nécessaire.

🔭 Observabilité du graphe DI (debugger en prod)

Une erreur de wiring est une erreur de boot — donc l'observabilité du graphe se joue au démarrage, pas en runtime. Les outils qu'un staff connaît :

  • NestFactory.create(AppModule, { logger: ['debug'] }) ou LOG_LEVELS=debug : Nest logge chaque instanciation (InstanceLoader), ce qui rend visible une réinstanciation REQUEST anormale (le même provider construit N fois par requête).
  • SerializedGraph (Nest 9+) : app.get(SerializedGraph) expose le graphe sérialisé (nodes = providers, edges = deps, scope par node). En CI, on peut snapshot-tester ce graphe pour détecter une régression de scope (un singleton qui bascule REQUEST suite à un mauvais @Inject).
  • Message d'erreur de résolution : le (?) à l'index [n] pointe le paramètre non résolu. Lis-le comme « le param n°n du constructeur n'a pas de token résolvable » — typiquement une interface injectée sans @Inject, ou un provider non exporté par le module importé.
  • Cycle non déclaré : symptôme = dep undefined sans erreur (avant Nest 11) ou warning explicite (Nest 11+). Diagnostic rapide : console.log de la dep dans OnModuleInit ; si undefined, c'est un cycle → forwardRef ou extraction d'une 3e classe.
ts
// Snapshot-test du graphe en CI : verrouille les scopes contre une régression silencieuse
import { SerializedGraph } from '@nestjs/core';

const app = await NestFactory.create(AppModule, { preview: true }); // preview = construit le graphe sans démarrer les hooks
const graph = app.get(SerializedGraph).toJSON();
const requestScoped = Object.values(graph.nodes).filter((n: any) => n.metadata?.scope === 'Request');
expect(requestScoped.map((n: any) => n.label).sort()).toEqual(['TenantContext']); // SEUL TenantContext doit être REQUEST

Le mode preview: true est précieux : il instancie le graphe (donc valide tout le wiring et les scopes) sans lancer les onModuleInit ni ouvrir les sockets — idéal en CI pour un test de structure rapide.

🩺 Scope REQUEST : l'économie réelle de la contamination

C'est le sujet où les seniors se font piéger. Le Scope.REQUEST est viral vers le haut : si C (DEFAULT) dépend de B (REQUEST), alors C devient REQUEST ; et tout A qui dépend de C aussi. La règle exacte :

Un provider hérite du scope le plus court de sa chaîne de deps. REQUEST < TRANSIENT < DEFAULT en durée de vie. Un singleton qui touche un provider REQUEST cesse d'être un singleton.

Le coût mesurable. Pour chaque requête, Nest doit réinstancier toute la sous-arborescence contaminée (appel des constructeurs + résolution des deps). Sur une chaîne Controller → Service → Repo → CacheService toute en REQUEST, c'est 4 new + 4 résolutions par requête, plus du GC. À 5k req/s, c'est mesurable (p99 qui dérive, CPU qui monte). Pire : les providers REQUEST ne peuvent pas être injectés dans des singletons « vrais » sans les contaminer, donc un Logger global qui veut le tenantId te force à tout passer en REQUEST.

Comment un staff limite le rayon de contamination :

  1. Garde le provider REQUEST mince et en bout de chaîne. TenantContext ne porte qu'un id. On l'injecte uniquement au niveau repository, pas dans toute la couche service.
  2. Préfère AsyncLocalStorage (CLS) pour le contexte ambiant. C'est l'alternative idiomatique moderne : un store par requête sans toucher au scope DI. Le service reste DEFAULT/singleton (donc rapide) et lit le contexte au moment de l'appel, pas à la construction.
ts
import { AsyncLocalStorage } from 'node:async_hooks';
import { Injectable, NestMiddleware } from '@nestjs/common';

interface RequestStore { tenantId: string; userId?: string; requestId: string }

// Singleton — partagé par toute l'app, AUCUNE contamination de scope.
@Injectable()
export class RequestContext {
  private readonly als = new AsyncLocalStorage<RequestStore>();
  run<T>(store: RequestStore, fn: () => T): T { return this.als.run(store, fn); }
  get tenantId(): string {
    const s = this.als.getStore();
    if (!s) throw new Error('out_of_request_context');
    return s.tenantId;
  }
}

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private readonly ctx: RequestContext) {}
  use(req: any, _res: any, next: () => void) {
    this.ctx.run(
      { tenantId: req.tenant.id, userId: req.user?.id, requestId: crypto.randomUUID() },
      next,
    );
  }
}

Maintenant PayslipRepository reste singleton et fait this.ctx.tenantId à chaque appel — zéro réinstanciation, contexte garanti par le store ALS. En prod, c'est ce que fait nestjs-cls sous le capot. Règle de décision : contexte ambiant lu à l'exécution → ALS ; dépendance qui doit être construite différemment par requête (rare) → Scope.REQUEST.

ApprochePerfIsolationQuand
Scope.REQUESTCoûteux (réinstanciation)Forte, garantie par le typeLe provider doit être construit par requête
AsyncLocalStorage / nestjs-clsQuasi nul (singleton)Forte, garantie par le storeContexte ambiant (tenant, trace, user) — défaut recommandé
Param explicite (tenantId passé partout)NulMaximale (rien d'implicite)Hot path critique, peu de couches

🧪 Testing

ts
import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';
import { USER_REPO } from './tokens';

it('greets known user', async () => {
  const fakeRepo = { findById: jest.fn().mockResolvedValue({ id: '1', name: 'Ada' }) };

  const moduleRef = await Test.createTestingModule({
    providers: [
      UsersService,
      { provide: USER_REPO, useValue: fakeRepo },
    ],
  }).compile();

  const svc = moduleRef.get(UsersService);
  await expect(svc.greet('1')).resolves.toBe('Hi Ada');
  expect(fakeRepo.findById).toHaveBeenCalledWith('1');
});

Pour les scopes REQUEST/TRANSIENT, utilise moduleRef.resolve(Token) (async) au lieu de moduleRef.get(Token). Sinon get lève une erreur sur les non-default scopes. Subtilité de test : deux resolve() sans contextId partagé renvoient deux instances différentes (TRANSIENT) ; pour tester l'isolation par requête, passe le même contextId aux résolutions censées partager le contexte :

ts
import { ContextIdFactory } from '@nestjs/core';

const ctxId = ContextIdFactory.create();
const a = await moduleRef.resolve(TenantContext, ctxId); // même requête
const b = await moduleRef.resolve(TenantContext, ctxId);
expect(a).toBe(b); // même instance car même contextId
ts
// Override d'un provider dans un test
const moduleRef = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(USER_REPO)
  .useValue(fakeRepo)
  .compile();

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH multi-tenant (fiches de paye)

Qui — Une scale-up FR (50 ETP) éditant un SIRH pour PME et ETI (≈ 600 clients, 80k bulletins/mois). Multi-tenant strict : chaque société voit ses données et seulement les siennes.

Problème métier — Le contexte tenant doit être disponible partout (repo, audit, mailer, exports), mais en mode singleton les services se contaminent. Sans DI propre, l'équipe avait du setTenantId() global, source de fuites cross-tenant deux fois par trimestre.

Comment ce concept aide — Un TenantContext en Scope.REQUEST portant l'id du tenant courant. Un middleware le pose au début de chaque requête. Les services qui en ont besoin l'injectent ; les repos délèguent à TenantContext.tenantId plutôt qu'à un paramètre passé partout. Pour éviter la contamination du graphe entier en REQUEST, on garde le TenantContext mince et on ne l'injecte qu'à la frontière (repository).

ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
  constructor(@Inject(REQUEST) private readonly req: any) {}
  get tenantId(): string {
    const id = this.req.tenant?.id;
    if (!id) throw new Error('tenant_missing');
    return id;
  }
}

@Injectable({ scope: Scope.REQUEST })
export class PayslipRepository {
  constructor(private readonly tenant: TenantContext, private readonly db: PrismaService) {}
  list() {
    return this.db.payslip.findMany({ where: { tenantId: this.tenant.tenantId } });
  }
}

Gains chiffrés — 0 fuite cross-tenant en 18 mois post-refacto (vs 2/trimestre avant), audit RGPD passé sans réserve, le pattern est devenu obligatoire en code review (lint custom qui bloque db.payslip.findMany sans tenantId).

Scénario 2 — Banque privée : pluggable auth modules

Qui — Une banque privée FR (180 ETP) modernisant son back-office. Plusieurs canaux d'auth coexistent : SSO interne SAML (collaborateurs), JWT custom (apps mobiles clients), mTLS (partenaires institutionnels), API keys (intégrations comptables).

Problème métier — Chaque canal a sa stratégie de validation, son extraction d'identité, ses claims. Sans DI propre, le code dérive vers un mega-Guard à 12 if peu maintenable.

Comment ce concept aide — Un port AuthStrategy injecté en multi-providers, chaque stratégie est un provider à part. Un CompositeAuthGuard itère sur les stratégies et prend la première qui matche. Ajouter une nouvelle stratégie = un fichier + une ligne dans providers. Le pattern multi-providers d'Angular est répliqué avec un Symbol agrégateur.

ts
export const AUTH_STRATEGIES = Symbol('AUTH_STRATEGIES');
export interface AuthStrategy {
  canHandle(req: any): boolean;
  authenticate(req: any): Promise<{ id: string; claims: any } | null>;
}

@Injectable() class SamlStrategy implements AuthStrategy { /* ... */ }
@Injectable() class JwtStrategy implements AuthStrategy { /* ... */ }
@Injectable() class MtlsStrategy implements AuthStrategy { /* ... */ }
@Injectable() class ApiKeyStrategy implements AuthStrategy { /* ... */ }

@Module({
  providers: [
    SamlStrategy, JwtStrategy, MtlsStrategy, ApiKeyStrategy,
    {
      provide: AUTH_STRATEGIES,
      useFactory: (...s: AuthStrategy[]) => s,
      inject: [SamlStrategy, JwtStrategy, MtlsStrategy, ApiKeyStrategy],
    },
    CompositeAuthGuard,
  ],
  exports: [CompositeAuthGuard, AUTH_STRATEGIES],
})
export class AuthModule {}

Gains chiffrés — Ajout d'une 5e stratégie (FranceConnect+ pour des clients particuliers réglementés) en 1.5 jour, 0 régression sur les 4 stratégies existantes (chacune testée isolément), audit sécurité ACPR validé du premier coup.

Scénario 3 — E-commerce : providers de paiement customisables par boutique

Qui — Un éditeur SaaS e-commerce FR (35 ETP, 4 200 boutiques marchandes hébergées). Chaque marchand configure son propre PSP (Stripe, Adyen, Mollie, MangoPay, Lyra). Plus de 60% des boutiques utilisent au moins 2 PSPs (fallback ou répartition par carte).

Problème métier — Le code historique faisait un switch (psp) partout, impossible d'ajouter un PSP sans toucher 12 fichiers. Les tests E2E paiement étaient en panne 3 mois sur l'année.

Comment ce concept aide — Un dynamic module PaymentsModule.forRootAsync() qui charge la liste des PSPs à activer depuis la config, et un factory provider qui retourne le bon adapter selon le PSP du marchand. Les tests utilisent useValue avec un fake PSP qui retourne des résultats déterministes.

ts
@Module({})
export class PaymentsModule {
  static forRootAsync(opts: AsyncPaymentsOptions): DynamicModule {
    return {
      module: PaymentsModule,
      imports: opts.imports,
      providers: [
        { provide: 'PSP_CONFIG', useFactory: opts.useFactory, inject: opts.inject ?? [] },
        StripeAdapter, AdyenAdapter, MollieAdapter, MangoPayAdapter, LyraAdapter,
        {
          provide: PaymentRouter,
          useFactory: (cfg: PspConfig, s, a, m, mp, l) => new PaymentRouter(cfg, { stripe: s, adyen: a, mollie: m, mangopay: mp, lyra: l }),
          inject: ['PSP_CONFIG', StripeAdapter, AdyenAdapter, MollieAdapter, MangoPayAdapter, LyraAdapter],
        },
      ],
      exports: [PaymentRouter],
    };
  }
}

Gains chiffrés — Intégration d'un 6e PSP (Checkout.com pour le luxe) en 5 jours, couverture E2E paiement remontée à 92%, MTTR sur incidents paiement divisé par 3 (chaque adapter loggé séparément).

🛠️ Exemple end-to-end

Use case — SIRH multi-tenant. On expose GET /v1/payslips qui retourne les bulletins du tenant courant, avec un cache par tenant invalidé en cas de mutation. On illustre les 4 formes de provider, REQUEST scope ciblé, dynamic module pour la conf de cache, et lifecycle hooks.

ts
// src/tenant/tenant-context.ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
  constructor(@Inject(REQUEST) private readonly req: any) {}
  get tenantId(): string {
    const id = this.req.tenant?.id;
    if (!id) throw new Error('tenant_missing');
    return id;
  }
  get userId(): string | undefined { return this.req.user?.id; }
}

TenantContext est REQUEST-scoped car il dépend de la requête courante. On le garde minimal pour ne pas contaminer trop de providers en aval.

ts
// src/tenant/tenant.module.ts
import { Module, MiddlewareConsumer, NestModule, Global } from '@nestjs/common';
import { TenantContext } from './tenant-context';
import { TenantResolverMiddleware } from './tenant-resolver.middleware';

@Global()
@Module({
  providers: [TenantContext],
  exports: [TenantContext],
})
export class TenantModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TenantResolverMiddleware).forRoutes('*');
  }
}

Le module est @Global() car TenantContext est utilisé partout. On l'importe une seule fois dans AppModule.

ts
// src/cache/cache.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import Redis from 'ioredis';

export interface TenantCacheOptions {
  url: string;
  defaultTtl: number;
  keyPrefix: string;
}

export const TENANT_CACHE_OPTS = Symbol('TENANT_CACHE_OPTS');
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');

@Module({})
export class TenantCacheModule {
  static forRootAsync(opts: {
    imports?: any[];
    useFactory: (...args: any[]) => Promise<TenantCacheOptions> | TenantCacheOptions;
    inject?: any[];
  }): DynamicModule {
    return {
      module: TenantCacheModule,
      imports: opts.imports ?? [],
      providers: [
        { provide: TENANT_CACHE_OPTS, useFactory: opts.useFactory, inject: opts.inject ?? [] },
        {
          provide: REDIS_CLIENT,
          useFactory: (o: TenantCacheOptions) => new Redis(o.url, { keyPrefix: o.keyPrefix }),
          inject: [TENANT_CACHE_OPTS],
        },
        TenantCacheService,
      ],
      exports: [TenantCacheService],
      global: true,
    };
  }
}

Dynamic module — la config est dynamique (lue de l'env via ConfigService), les providers utilisent useFactory (pour async/deps) et useValue (pour la constante d'options).

ts
// src/cache/tenant-cache.service.ts
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS_CLIENT, TENANT_CACHE_OPTS, TenantCacheOptions } from './cache.module';
import { TenantContext } from '../tenant/tenant-context';

@Injectable()
export class TenantCacheService implements OnModuleDestroy {
  constructor(
    @Inject(REDIS_CLIENT) private readonly redis: Redis,
    @Inject(TENANT_CACHE_OPTS) private readonly opts: TenantCacheOptions,
    private readonly tenant: TenantContext,
  ) {}

  private tenantKey(key: string) { return `t:${this.tenant.tenantId}:${key}`; }

  async get<T>(key: string): Promise<T | null> {
    const raw = await this.redis.get(this.tenantKey(key));
    return raw ? (JSON.parse(raw) as T) : null;
  }

  async set<T>(key: string, value: T, ttl?: number): Promise<void> {
    await this.redis.set(this.tenantKey(key), JSON.stringify(value), 'EX', ttl ?? this.opts.defaultTtl);
  }

  async invalidate(pattern: string): Promise<void> {
    const keys = await this.redis.keys(this.tenantKey(pattern));
    if (keys.length) await this.redis.del(...keys);
  }

  async onModuleDestroy() { await this.redis.quit(); }
}

Le service est en scope DEFAULT (singleton) mais consomme un TenantContext REQUEST. Nest le rend automatiquement REQUEST → on accepte ici le coût pour avoir la garantie d'isolation. onModuleDestroy ferme la connexion Redis proprement (graceful shutdown).

ts
// src/payslips/payslips.service.ts
import { Injectable } from '@nestjs/common';
import { PayslipRepository } from './payslip.repository';
import { TenantCacheService } from '../cache/tenant-cache.service';

@Injectable()
export class PayslipsService {
  constructor(
    private readonly repo: PayslipRepository,
    private readonly cache: TenantCacheService,
  ) {}

  async listForCurrentTenant() {
    const cached = await this.cache.get<any[]>('payslips:list');
    if (cached) return cached;
    const rows = await this.repo.findAllForCurrentTenant();
    await this.cache.set('payslips:list', rows, 300);
    return rows;
  }

  async invalidate() { await this.cache.invalidate('payslips:*'); }
}
ts
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TenantModule } from './tenant/tenant.module';
import { TenantCacheModule } from './cache/cache.module';
import { PayslipsModule } from './payslips/payslips.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TenantModule,
    TenantCacheModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (cfg: ConfigService) => ({
        url: cfg.getOrThrow('REDIS_URL'),
        defaultTtl: 300,
        keyPrefix: 'sirh:',
      }),
      inject: [ConfigService],
    }),
    PayslipsModule,
  ],
})
export class AppModule {}

Cet exemple combine : (a) module statique (PayslipsModule), (b) module dynamique (TenantCacheModule.forRootAsync), (c) module global (TenantModule), (d) REQUEST scope (TenantContext), (e) lifecycle hook (onModuleDestroy), (f) custom token (Symbol), (g) factory provider. C'est le squelette typique d'une fonctionnalité multi-tenant en prod.

🤖 DI appliqué : servir un agent LLM (Anthropic) depuis NestJS

Le DI n'est pas qu'un sujet d'école — c'est exactement l'outil qui rend un backend d'agent IA testable, observable et swap-able. Le réflexe junior est new Anthropic() dans un champ de service. Le réflexe staff est un client LLM injecté via forRootAsync : configuré une fois, mocké en test, instrumenté au boot.

1. Le client LLM comme provider DI (jamais new dans un champ)

ts
// llm/llm.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';

export const LLM_CLIENT = Symbol('LLM_CLIENT');
export interface LlmModuleOptions {
  imports?: any[];
  useFactory: (...args: any[]) => Promise<{ apiKey: string; maxRetries?: number }> | { apiKey: string; maxRetries?: number };
  inject?: any[];
}

@Module({})
export class LlmModule {
  static forRootAsync(opts: LlmModuleOptions): DynamicModule {
    return {
      module: LlmModule,
      imports: opts.imports ?? [],
      providers: [
        { provide: 'LLM_OPTS', useFactory: opts.useFactory, inject: opts.inject ?? [] },
        {
          provide: LLM_CLIENT,
          useFactory: (o: { apiKey: string; maxRetries?: number }) =>
            // Le SDK gère les retries (429/5xx) avec backoff — on délègue, on ne réinvente pas.
            new Anthropic({ apiKey: o.apiKey, maxRetries: o.maxRetries ?? 3 }),
          inject: ['LLM_OPTS'],
        },
      ],
      exports: [LLM_CLIENT],
      global: true,
    };
  }
}

Pourquoi un Symbol + factory plutôt que la classe Anthropic directement comme token ? Parce que l'interface n'est pas la classe : en test tu injectes un fake { messages: { stream: ... } } via useValue, sans toucher le SDK réel. Modèles actuels : flagship claude-opus-4-8, équilibré claude-sonnet-4-6, rapide claude-haiku-4-5.

2. Streaming de tokens en SSE — avec AbortController sur déconnexion client

Le point critique en prod : si le client ferme l'onglet, tu dois annuler l'appel LLM (sinon tu paies des tokens dans le vide). Le req.on('close') câble un AbortController jusqu'au SDK.

ts
// agent/agent.controller.ts
import { Controller, Inject, Post, Req, Res, Body } from '@nestjs/common';
import type { Request, Response } from 'express';
import Anthropic from '@anthropic-ai/sdk';
import { LLM_CLIENT } from '../llm/llm.module';

@Controller('agent')
export class AgentController {
  constructor(@Inject(LLM_CLIENT) private readonly llm: Anthropic) {}

  @Post('chat')
  async chat(@Body() body: { prompt: string }, @Req() req: Request, @Res() res: Response) {
    res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
    res.flushHeaders(); // pousse les headers tout de suite : le client commence à lire le flux sans attendre le 1er token

    const ac = new AbortController();
    req.on('close', () => ac.abort()); // client parti → on annule l'appel LLM (cost guard)

    try {
      const stream = this.llm.messages.stream(
        {
          model: 'claude-sonnet-4-6',
          max_tokens: 1024,
          messages: [{ role: 'user', content: body.prompt }],
        },
        { signal: ac.signal },
      );

      for await (const event of stream) {
        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
          res.write(`data: ${JSON.stringify({ token: event.delta.text })}\n\n`);
        }
      }
      res.write('data: [DONE]\n\n');
    } catch (e) {
      if (!ac.signal.aborted) res.write(`event: error\ndata: ${JSON.stringify({ message: 'stream_failed' })}\n\n`);
    } finally {
      res.end();
    }
  }
}

Le client llm est injecté, pas instancié — donc ce controller se teste avec un fake stream sans réseau. C'est tout le bénéfice du DI appliqué à l'IA.

3. La boucle agentique (tool-use) côté serveur

Un agent = une boucle appel modèle → si tool_use, exécuter l'outil → renvoyer le résultat → reboucler. Les outils sont eux-mêmes des providers DI agrégés en multi-provider (exactement le pattern AUTH_STRATEGIES vu plus haut).

ts
export const AGENT_TOOLS = Symbol('AGENT_TOOLS');
export interface AgentTool {
  name: string;
  schema: Anthropic.Tool;
  run(input: unknown): Promise<unknown>;
}

@Injectable()
export class AgentService {
  constructor(
    @Inject(LLM_CLIENT) private readonly llm: Anthropic,
    @Inject(AGENT_TOOLS) private readonly tools: AgentTool[],
  ) {}

  async run(prompt: string, signal: AbortSignal) {
    const byName = new Map(this.tools.map((t) => [t.name, t]));
    const messages: Anthropic.MessageParam[] = [{ role: 'user', content: prompt }];

    for (let turn = 0; turn < 8; turn++) { // garde-fou : borne le nombre de tours
      const res = await this.llm.messages.create(
        { model: 'claude-sonnet-4-6', max_tokens: 1024, tools: this.tools.map((t) => t.schema), messages },
        { signal },
      );
      messages.push({ role: 'assistant', content: res.content });
      if (res.stop_reason !== 'tool_use') return res; // terminé

      const toolResults: Anthropic.ToolResultBlockParam[] = [];
      for (const block of res.content) {
        if (block.type === 'tool_use') {
          const tool = byName.get(block.name);
          const out = tool ? await tool.run(block.input) : { error: 'unknown_tool' };
          toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(out) });
        }
      }
      messages.push({ role: 'user', content: toolResults });
    }
    throw new Error('agent_max_turns_exceeded');
  }
}

4. Jobs IA en arrière-plan (BullMQ) : idempotence, retry cost-aware

Pour une génération longue, on délègue à un worker. Le Queue et le Worker sont des providers DI. Trois exigences de prod :

  • Idempotence : jobId = generationId → un retry réseau ne relance pas une génération payante en double.
  • Retry cost-aware : retry sur 429/5xx (transitoire), jamais sur une erreur de validation (gaspillage de tokens). Backoff exponentiel.
  • Partial-output : persiste les tokens au fil de l'eau (updateProgress) pour pouvoir reprendre/afficher si le job meurt.
ts
await this.queue.add('generate', { generationId, prompt }, {
  jobId: generationId,                 // idempotence
  attempts: 4,
  backoff: { type: 'exponential', delay: 2000 },
});

Cost-guard / rate-limit à l'edge : un Guard DI (injecté) vérifie un budget tokens/tenant avant d'atteindre le LLM — refus en 429 si dépassé. C'est encore du DI : le guard injecte un BudgetService singleton + le RequestContext (ALS) pour le tenantId. On expose enfin un endpoint MCP en montant un contrôleur dédié qui réutilise exactement ces mêmes providers — le graphe DI est la colonne vertébrale de toute la surface agentique.

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre.

Exercice 1 — Port/Adapter swap-able (implémenter).Objectif : découpler un NotificationService de son canal d'envoi via un token d'interface. Crée une interface Notifier { send(to: string, msg: string): Promise<void> }, un token Symbol NOTIFIER, deux impls (ConsoleNotifier, EmailNotifier), et fais en sorte que NotificationService injecte le port. Bascule l'impl selon NODE_ENV via useClass dans une factory. Indice : { provide: NOTIFIER, useClass: process.env.NODE_ENV === 'prod' ? EmailNotifier : ConsoleNotifier } — mais préfère un useFactory si le choix dépend de ConfigService.

Exercice 2 — Dynamic module réutilisable (production-grade).Objectif : transformer un module local en lib configurable avec forRoot et forRootAsync. Écris RateLimitModule.forRootAsync({ useFactory, inject, imports }) qui fournit un RateLimiter paramétré (fenêtre, max). Exporte-le, rends-le global, et ajoute un hook OnModuleDestroy qui flush le store. Indice : l'objet d'options devient lui-même un provider ({ provide: OPTS, useFactory, inject }), consommé par le provider RateLimiter via inject: [OPTS].

Exercice 3 — Multi-provider d'outils d'agent (assembler).Objectif : reproduire le pattern AUTH_STRATEGIES pour des outils LLM. Définis AGENT_TOOLS (Symbol), 3 outils (WeatherTool, SearchTool, CalcTool) chacun @Injectable, agrège-les en useFactory. Injecte le tableau dans AgentService et résous l'outil par name dans la boucle tool-use. Indice : { provide: AGENT_TOOLS, useFactory: (...t) => t, inject: [WeatherTool, SearchTool, CalcTool] }.

Exercice 4 — Contexte multi-tenant sans contaminer le scope (durcir).Objectif : remplacer un Scope.REQUEST viral par AsyncLocalStorage. Pars d'un TenantContext REQUEST injecté dans 4 couches (toutes deviennent REQUEST). Mesure le nombre d'instanciations par requête (log dans les constructeurs). Puis réimplémente avec un RequestContext singleton + ALS + middleware, et re-mesure. Indice : après migration, les constructeurs ne doivent plus se rappeler qu'une fois (au boot), pas par requête.

Exercice 5 — Casse puis répare : circular dep + scope bleed.Objectif : provoquer puis diagnostiquer deux bugs DI classiques. (a) Crée un cycle A → B → A sans forwardRef. Observe l'injection undefined (log dans OnModuleInit). Répare avec forwardRef(() => B). Puis supprime le cycle en extrayant une 3e classe C. (b) Injecte un provider Scope.REQUEST dans un controller DEFAULT. Constate le warning Nest 11 et la réinstanciation. Répare en repassant le contexte par ALS. Indice : pour (a), le symptôme « dep undefined sans erreur claire » = cycle non déclaré ; pour (b), LOG_LEVEL=debug montre la réinstanciation par requête.

Exercice 6 — Cost-guard + AbortController bout en bout (staff).Objectif : construire un endpoint d'agent qui annule l'appel LLM à la déconnexion client ET refuse au-delà d'un budget tenant. Implémente un BudgetGuard (DI) qui lit le tenantId via ALS, vérifie un compteur tokens dans Redis, refuse en 429 si dépassé. Câble req.on('close') → ac.abort() jusqu'au SDK. Vérifie en test (fake LLM) que abort est bien propagé. Indice : injecte le signal dans messages.stream(params, { signal }) ; en test, espionne que le fake reçoit un AbortSignal qui passe aborted=true après close.

🎤 En entretien

Q : « Pourquoi injecter une interface échoue, et comment on contourne ? » R : Les interfaces TS sont effacées à la compilation — design:paramtypes ne contient rien d'utilisable comme token. On injecte donc un token runtime (Symbol/string) via @Inject(TOKEN) et on mappe { provide: TOKEN, useClass/useFactory/useValue }. Le (?) dans le message d'erreur Nest signale exactement ce cas.

Q : « REQUEST scope vs AsyncLocalStorage pour le contexte tenant — que choisis-tu et pourquoi ? » R : ALS par défaut. REQUEST scope est viral (contamine toute la chaîne vers le haut) et réinstancie les providers par requête → coût p99 mesurable. ALS garde les services en singleton et fournit un contexte ambiant lu à l'exécution ; on ne réserve REQUEST qu'aux providers qui doivent être construits différemment par requête (rare).

Q : « Un singleton qui dépend d'un provider REQUEST — que se passe-t-il ? » R : Le singleton cesse d'être un singleton : Nest le re-scope en REQUEST (hérite du scope le plus court de sa chaîne). C'est la contamination. En Nest 11, injecter du REQUEST dans un controller DEFAULT lève un warning explicite. La parade : isoler le provider REQUEST en bout de chaîne, ou passer par ALS.

Q : « Comment tu rends un client SDK tiers (Anthropic, Stripe) testable et configurable ? » R : forRootAsync + token Symbol + useFactory qui dépend de ConfigService. Le client est un provider injecté, jamais un new dans un champ. En test, overrideProvider(TOKEN).useValue(fake). Ça découple la config, centralise les retries/timeouts au boot, et rend tout consommateur mockable sans réseau.

Q : « ModuleRef.get() vs resolve() — quand l'un, quand l'autre, et quel est le risque ? » R : get() est synchrone et ne renvoie que des singletons DEFAULT déjà construits (lève sur REQUEST/TRANSIENT). resolve() est async et crée une sous-arborescence pour les scopes non-default ; avec un contextId partagé on garantit l'isolation par requête. Le risque : utiliser ModuleRef pour des tokens statiques juste pour éviter un @Inject — c'est du service-locator déguisé, qui casse la détection des deps manquantes au boot et masque le graphe. On ne le sort que pour un token dynamique (dispatch runtime, lazy module, reconstruction de contexte dans un worker).

🔁 Quand utiliser / éviter

PatternQuandÉviter quand
useClassSwap d'implémentation par env (prod vs dev)Tu n'as qu'une seule impl pour toujours
useFactoryConstruction async / config-drivenConstruction simple (préfère useClass)
useValueConstantes, mocks, config statiqueObjet complexe avec deps
Scope.REQUESTTenant ID, user courant, tracesToute la couche service (perf killer)
forwardRefCycle inévitable entre 2 servicesCycle évitable par refacto
Dynamic moduleLib réutilisable avec config (DB, mailer, auth)Module métier simple sans options

🔗 Liens

Bibliothèque tech perso — Achref