Skip to content

Dependency Injection — injecteurs hiérarchiques

TL;DR — Angular est construit autour d'un système de DI hiérarchique : plusieurs injecteurs imbriqués, qui se consultent de bas en haut pour résoudre une dépendance. À chaque niveau (platformrootenvironmentelement/component), on peut enregistrer un provider qui surcharge les niveaux supérieurs. C'est ce qui rend Angular si puissant pour les libs et les tests : on peut remplacer un service entier dans une feature ou dans un test sans toucher le code consommateur. Les outils du métier sont providedIn: 'root' | 'platform' | 'any', les quatre formes de providers (useClass, useValue, useFactory, useExisting) avec leur multi: true, les InjectionToken pour passer des valeurs typées, et les modificateurs @Self, @SkipSelf, @Host, @Optional (ou leurs équivalents en options inject(X, { … })).


🧠 Mental model — ASCII + analogie

Pense à un arbre généalogique avec héritage par défaut : si on te demande "quelle est ta couleur de cheveux ?", tu réponds la tienne ; si tu n'en as pas, on demande à ton parent, puis à ses parents… jusqu'à un ancêtre commun qui en a une, ou un échec final.

                ┌───────────────────────────────┐
                │   PlatformInjector            │  ← unique pour toute la page
                │   (rare : zone.js, etc.)      │
                └────────────────▲──────────────┘

                ┌────────────────┴──────────────┐
                │   RootInjector (= app's       │  ← providedIn: 'root'
                │   EnvironmentInjector)        │     provideHttpClient(), etc.
                └────────────────▲──────────────┘

                ┌────────────────┴──────────────┐
                │   Route's EnvironmentInjector │  ← providers de la route lazy
                │   (lazy feature)              │     ex. provideStoreFeature(...)
                └────────────────▲──────────────┘

                ┌────────────────┴──────────────┐
                │   Component ElementInjector   │  ← providers: [...] sur @Component
                │   (chaque composant peut)     │
                └───────────────────────────────┘

Quand un composant fait inject(MyService), Angular remonte cet arbre du bas vers le haut, en s'arrêtant au premier injecteur qui sait fournir MyService. Si personne ne sait, c'est NullInjectorError.

Analogie de devops : c'est comme la résolution de variables d'environnement dans une CI. La variable du job écrase celle du stage, qui écrase celle du pipeline, qui écrase celle du repo, qui écrase celle de l'org. Sauf que là, c'est typé, et le compilateur sait suivre.

Deux types d'injecteurs cohabitent :

  • EnvironmentInjector : niveau racine, niveau plateforme, niveau d'une route lazy. C'est le contexte "global" (au sens lexical).
  • ElementInjector : un par composant/directive du template. Hérite à la fois de son parent dans le DOM et de l'EnvironmentInjector le plus proche.

L'algorithme de résolution, en détail (ce qu'un staff doit savoir)

Quand inject(Token) s'exécute dans un composant, Angular ne fait pas une simple remontée linéaire. Il y a deux chaînes parallèles et une règle de jonction :

  1. Chaîne ElementInjector — on remonte les ElementInjector du composant courant vers la racine du DOM (parent → grand-parent → … → composant racine). Chaque providers: [...] sur un @Component/@Directive ajoute un cran ici.
  2. Quand la chaîne ElementInjector est épuisée, on bascule sur la chaîne EnvironmentInjector : Route.providers (de la route la plus profonde vers la racine) → RootInjector (providedIn: 'root') → PlatformInjectorNullInjector.
  3. Le NullInjector est le terminus : il lève NullInjectorError (NG0201), sauf si l'injection est { optional: true }, auquel cas il retourne null.
inject(X) dans <child>


ElementInjector(child) ──▶ ElementInjector(parent) ──▶ … ──▶ ElementInjector(root component)
                                                                      │  (chaîne DOM épuisée)

Route.providers(feuille) ──▶ Route.providers(parent) ──▶ RootInjector ──▶ PlatformInjector ──▶ NullInjector → NG0201

Point subtil de jonction (@Host) : { host: true } arrête la recherche au composant hôte de la directive — il borne la remontée ElementInjector à la frontière du template courant, sans jamais atteindre l'EnvironmentInjector. C'est exactement ce dont a besoin un NgControl pour trouver son ControlContainer sans remonter dans tout l'arbre.

Résolution paresseuse et mémoïsation : une instance n'est créée qu'au premier inject, puis cachée dans l'injecteur qui l'a fournie. Deux composants frères qui injectent le même providedIn: 'root' partagent l'instance ; deux composants qui le redéclarent chacun dans leur providers en ont une chacun. Le providedIn n'est qu'un sucre : il revient à ajouter le provider sur l'injecteur cible (root, platform, ou « le premier EnvironmentInjector » pour 'any') à la demande, ce qui préserve le tree-shaking.

Contexte d'injection : la règle qui explique 80 % des NG0203

inject() n'est légal que pendant une « fenêtre d'injection » : exécution d'un constructeur/champ de classe DI, d'une factory de provider, d'un APP_INITIALIZER, ou explicitement sous runInInjectionContext(injector, fn). Hors de cette fenêtre (callback async, setTimeout, handler d'event, .then()), c'est NG0203.

ts
import { inject, DestroyRef, runInInjectionContext, EnvironmentInjector } from '@angular/core';
import { HttpClient } from '@angular/common/http';

class DataService {
  private readonly destroyRef = inject(DestroyRef);   // ✅ pendant la construction
  private readonly env = inject(EnvironmentInjector);  // capturé pour plus tard

  load(): void {
    // ❌ inject(HttpClient) ici lèverait NG0203 si on était dans un callback détaché.
    // ✅ on rejoue un contexte d'injection à la demande :
    runInInjectionContext(this.env, () => {
      const http = inject(HttpClient);
      http.get('/x').subscribe();
    });
  }
}

DestroyRef (Angular 16+) remplace OnDestroy pour le code non-composant : inject(DestroyRef).onDestroy(() => cleanup()). C'est le mécanisme derrière takeUntilDestroyed(). Le réflexe staff : capturer tout ce dont on a besoin (services, DestroyRef, EnvironmentInjector) en champs de classe pendant la construction, jamais dans un callback.


🛠️ Code minimal

ts
// logger.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class LoggerService {
  log(msg: string): void { console.log('[log]', msg); }
}
ts
// app.component.ts
import { Component, inject } from '@angular/core';
import { LoggerService } from './logger.service';

@Component({ standalone: true, selector: 'app-root', template: `<button (click)="hi()">Hi</button>` })
export class AppComponent {
  private readonly log = inject(LoggerService);
  hi(): void { this.log.log('hello'); }
}
ts
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { provideHttpClient } from '@angular/common/http';

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient()],
});

Ici LoggerService est tree-shakable : tant que personne n'appelle inject(LoggerService), il n'est pas dans le bundle final.


🎯 Patterns courants

1. providedIn: 'root' | 'platform' | 'any'

ts
// Singleton pour toute l'app (cas standard)
@Injectable({ providedIn: 'root' })
export class CacheService {}

// Singleton pour TOUTE la page (utile si plusieurs apps Angular tournent en même temps)
@Injectable({ providedIn: 'platform' })
export class CrossAppBus {}

// Une instance par EnvironmentInjector / module lazy
@Injectable({ providedIn: 'any' })
export class FeatureScopedCounter {}

'root' est le défaut sain. 'platform' ne sert que dans les cas de micro-frontends Angular avec plusieurs bootstrapApplication partageant la page. 'any' produit une nouvelle instance par EnvironmentInjector (typiquement, une par route lazy) — utile pour des services qui doivent être isolés par feature.

2. Les quatre formes de providers

ts
import { Provider, InjectionToken, inject, isDevMode } from '@angular/core';
import { LoggerService } from './logger.service';
import { LocalLoggerService } from './local-logger.service';
import { Analytics, RealAnalytics, NoopAnalytics } from './analytics';

// ✅ Toujours des InjectionToken typés — JAMAIS de string brute comme token.
const API_KEY = new InjectionToken<string>('API_KEY');
const IS_PROD = new InjectionToken<boolean>('IS_PROD', {
  providedIn: 'root',
  factory: () => isDevMode() === false,
});
const WRITE_LOG = new InjectionToken<Pick<LoggerService, 'log'>>('WRITE_LOG');

const providers: Provider[] = [
  // useClass : remplacer une classe par une autre (NOUVELLE instance)
  { provide: LoggerService, useClass: LocalLoggerService },

  // useValue : injecter une valeur déjà construite (constante, mock)
  { provide: API_KEY, useValue: 'abcd-1234' },

  // useFactory : construction dynamique, avec accès à inject()
  // La factory tourne DANS un contexte d'injection : inject() y est légal.
  {
    provide: Analytics,
    useFactory: (): Analytics =>
      inject(IS_PROD) ? new RealAnalytics(inject(API_KEY)) : new NoopAnalytics(),
  },

  // useExisting : alias d'un autre token (MÊME instance — pas une copie)
  { provide: WRITE_LOG, useExisting: LoggerService },
];

Anti-pattern à proscrire : { provide: 'API_KEY', useValue: … } avec une string comme token. On y perd le type (inject('API_KEY') retourne unknown), et deux strings identiques dans deux libs se télescopent silencieusement. Toujours un InjectionToken<T>. Le seul moment où une string-token est tolérable, c'est dans du code legacy NgModule qu'on n'a pas fini de migrer.

3. InjectionToken pour les valeurs

ts
import { InjectionToken, inject } from '@angular/core';

export interface AppConfig { apiUrl: string; debug: boolean; }

export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG', {
  providedIn: 'root',
  factory: () => ({ apiUrl: 'https://api.example.com', debug: false }),
});

// Consommation
const cfg = inject(APP_CONFIG);   // type AppConfig — pas de cast

Les InjectionToken sont la bonne façon de typer des constantes non-classes (URLs, configs, capabilities). Comparé à 'API_URL' en string, il n'y a pas de collision possible et le type est conservé.

4. Multi-providers (pour les plugins, interceptors)

ts
import { HTTP_INTERCEPTORS } from '@angular/common/http';

@Injectable() class LoggingInterceptor implements HttpInterceptor { intercept(req, next) { return next.handle(req); } }
@Injectable() class AuthInterceptor    implements HttpInterceptor { intercept(req, next) { return next.handle(req); } }

const interceptorProviders: Provider[] = [
  { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
  { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor,    multi: true },
];

Avec multi: true, le résultat de l'injection est un tableau de toutes les valeurs fournies. C'est ainsi qu'Angular implémente les interceptors HTTP, les validateurs, les APP_INITIALIZER. Note : depuis Angular 15+, les interceptors fonctionnels (provideHttpClient(withInterceptors([…]))) sont préférés pour les nouvelles apps.

5. Modificateurs : @Self, @SkipSelf, @Host, @Optional

ts
import { Component, inject } from '@angular/core';

@Component({ /* ... */ })
export class ChildComponent {
  // chercher uniquement dans l'injecteur de CE composant — sinon erreur
  private localOnly = inject(LocalService, { self: true });

  // sauter le composant courant, démarrer la recherche au parent
  private parentOnly = inject(LocalService, { skipSelf: true });

  // s'arrêter à la limite du composant host (utile pour les directives)
  private hostOnly = inject(LocalService, { host: true });

  // null si introuvable, plutôt qu'une erreur
  private maybe = inject(MaybeService, { optional: true });
}

Cas d'usage classique : un service partagé entre un composant parent et ses enfants, mais que chaque sous-arbre peut redéfinir. Avec skipSelf, un parent peut consommer la version du grand-parent même s'il en a redéfini une pour ses enfants.

6. EnvironmentInjector vs ElementInjector

ts
import { Component, inject, EnvironmentInjector, createEnvironmentInjector, runInInjectionContext } from '@angular/core';

@Component({ /* ... */ })
export class HostComponent {
  private readonly parentEnv = inject(EnvironmentInjector);

  spawnIsolatedFeature() {
    const featureEnv = createEnvironmentInjector(
      [{ provide: FeatureConfig, useValue: { theme: 'dark' } }],
      this.parentEnv
    );
    runInInjectionContext(featureEnv, () => {
      const cfg = inject(FeatureConfig); // dark
      console.log(cfg.theme);
    });
  }
}

Très utile pour les micro-features dynamiques (plugins chargés à l'exécution), les tests isolés, ou les factories qui ont besoin d'un sous-graphe de providers.

7. Configuration runtime via APP_INITIALIZER / provideAppInitializer

ts
import { provideAppInitializer, inject } from '@angular/core';
import { ConfigService } from './config.service';

export const appConfig: ApplicationConfig = {
  providers: [
    ConfigService,
    provideAppInitializer(async () => {
      const cfg = inject(ConfigService);
      await cfg.loadFromServer();
    }),
  ],
};

provideAppInitializer (Angular 19+) remplace APP_INITIALIZER + { multi: true, useFactory: ... }. La factory tourne dans un contexte d'injection, peut être async, et bloque le bootstrap jusqu'à résolution. Cas d'usage : charger une config JSON à l'init, vérifier une session, télécharger un feature flag.

8. Dynamic providers via withFeature(...)

ts
// auth.feature.ts
export function withAuth(): EnvironmentProviders {
  return makeEnvironmentProviders([
    AuthService,
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
  ]);
}

// main.ts
bootstrapApplication(AppComponent, {
  providers: [provideHttpClient(), withAuth()],
});

Pattern recommandé pour exposer une feature configurable depuis une lib : un seul appel withAuth() injecte tous les providers nécessaires.


🔄 Versions — Angular 16 / 17 / 18 / 19 / 20

VersionApport principal sur la DI
16 (mai 2023)inject() exposé publiquement comme alternative au constructeur. DestroyRef injectable.
17 (nov 2023)Schematics standalone par défaut, donc la DI passe massivement par provideXxx() au niveau application. provideRouter, provideHttpClient deviennent canoniques.
18 (mai 2024)provideExperimentalZonelessChangeDetection. Stabilité de makeEnvironmentProviders.
19 (nov 2024)Helpers inject(X, { optional: true, skipSelf: true, … }) cleaner ; les anciens décorateurs @Optional(), @Self(), @SkipSelf(), @Host() restent valides mais moins idiomatiques.
20 (mai 2025)DI inchangée dans ses fondamentaux, mais tout l'écosystème lib utilise désormais provideXxx() plutôt que des NgModules. provideZonelessChangeDetection() stable.

À retenir : la forme des providers évolue (de modules à fonctions provideXxx), mais les règles de résolution sont inchangées depuis Angular 2. C'est la stabilité qui rend la DI Angular si fiable.


🔬 Observabilité & debug du graphe DI (niveau staff)

Quand un NullInjectorError (NG0201) tombe en prod et qu'on ne reproduit pas en local, lire la stack ne suffit pas : il faut inspecter le graphe d'injecteurs.

1. Inspecter un injecteur depuis un nœud du DOM (dev tools). Sur une instance de composant, l'API runtime expose l'injecteur résolveur — pratique en console ou dans un test :

ts
// API de debug dev-only : exposée sur le global `ng` quand le mode dev est actif
// (PAS un import depuis '@angular/core'). $0 = élément sélectionné dans l'inspecteur.
const inj = ng.getInjector($0);                  // -> Injector du nœud DOM
inj.get(MyService, null);                        // null si non résolu à ce nœud (pas de throw)
inj.get(MyService, null, { optional: true });    // équivalent typé moderne

L'Angular DevTools (onglet Injector Tree, Angular 17+) visualise la chaîne complète Element → Environment → Root → Platform et chaque provider est déclaré. C'est l'outil n°1 pour répondre à « pourquoi cette instance et pas l'autre ? ».

2. Les InjectFlags numériques (legacy) ≠ les options modernes. Avant Angular 14, les modificateurs étaient un bitfield. On les croise encore dans du code lib ; la correspondance :

Option moderne inject(X, {…})DécorateurInjectFlags (legacy)
{ optional: true }@Optional()InjectFlags.Optional (8)
{ self: true }@Self()InjectFlags.Self (2)
{ skipSelf: true }@SkipSelf()InjectFlags.SkipSelf (4)
{ host: true }@Host()InjectFlags.Host (1)

Les bits se combinent (SkipSelf | Optional). Préférer toujours la forme options en code applicatif : elle est tree-shakable et lisible.

3. @defer et la frontière d'injecteur. Un bloc @defer charge son contenu paresseusement, mais n'introduit pas de nouvel EnvironmentInjector : les composants différés héritent de l'injecteur de leur position dans le template, comme n'importe quel composant. Le piège : croire qu'un service déclaré dans un @defer est isolé — il ne l'est pas. Pour isoler, c'est Route.providers ou un EnvironmentInjector enfant explicite.

4. Initializers d'environnement pour l'instrumentation. provideEnvironmentInitializer(fn) (Angular 19+) exécute fn à la création de chaque EnvironmentInjector (root + chaque route lazy) — l'endroit idéal pour brancher du tracing OpenTelemetry, enregistrer un span par feature, ou logger la composition du graphe en dev :

ts
import { provideEnvironmentInitializer, inject, EnvironmentInjector, isDevMode } from '@angular/core';

provideEnvironmentInitializer(() => {
  if (isDevMode()) {
    const env = inject(EnvironmentInjector);
    console.debug('[DI] nouvel EnvironmentInjector', env);
  }
});

5. Cost d'un graphe profond. La résolution est O(profondeur de la chaîne d'injecteurs) au premier inject, puis O(1) (mémoïsé). Une hiérarchie de 30 composants imbriqués qui injectent tous un service 'root' paie 30 remontées la première fois chacun — négligeable. Le vrai coût en perf vient des factories lourdes exécutées au mauvais moment (ex. une factory qui parse 2 Mo de JSON au bootstrap) : la mesurer avec un performance.mark autour du provideAppInitializer, pas dans la résolution DI elle-même.


⚠️ Pitfalls

  1. providedIn: 'any' mal compris — On s'attend à un singleton, on a une instance par module lazy. Les services qui maintiennent un état partagé (cache, store) doivent rester en 'root'.
  2. Surcharger un singleton dans un composant — Mettre un service dans providers: [Svc] au niveau composant crée une nouvelle instance pour ce sous-arbre. Si on s'attendait à partager l'état avec le reste de l'app, on a un bug silencieux.
  3. useExisting vs useClassuseExisting est un alias (mêmes instances), useClass est une nouvelle classe (instances différentes même si la classe est identique).
  4. multi: true partiellement — Si un seul provider d'un token est multi: true et un autre ne l'est pas, Angular lève une erreur. Tout ou rien.
  5. inject() hors contexteNG0203. Solution : capturer en champ de classe, ou runInInjectionContext.
  6. Providers dans une route et héritage — Les providers définis dans Route.providers sont disponibles pour la route et ses enfants seulement. Pas pour les routes sœurs.
  7. NullInjectorError — Souvent une dépendance non importée (oubli de provideHttpClient), ou un service @Injectable() sans providedIn qu'on n'a pas mis dans providers.
  8. Forward references — Quand A dépend de B qui dépend de A (déclaration de classe dans un mauvais ordre), forwardRef(() => B) est nécessaire. Souvent un signe d'un design à revoir.
  9. InjectionToken sans factory — Si tu crées un token sans factory et que personne ne le provide, c'est NullInjectorError. Fournir une factory par défaut, ou utiliser { optional: true }.
  10. Tests avec singletons partagés — Sans TestBed.resetTestingModule() entre tests, l'état d'un service 'root' survit. Pour de l'iso totale, fournir le service localement dans TestBed.configureTestingModule({ providers: [{ provide: Svc, useClass: Svc }] }).
  11. useFactory qui appelle inject() mal placé — La factory tourne automatiquement dans un contexte d'injection ; pas besoin de runInInjectionContext. Mais si tu encapsules la factory dans un wrapper async, tu casses le contexte.
  12. Provider scoping inattendu en SSR — En SSR, chaque requête a son propre EnvironmentInjector. Un singleton stateful (providedIn: 'root') qui mémorise des données par requête fuite entre utilisateurs. Toujours rendre les services SSR-safe (pas d'état partagé).
  13. InjectionToken redéfini dans plusieurs imports — Si deux libs définissent un token avec la même description string, Angular les voit comme différents (égalité par référence). Pas de collision, mais pas non plus de partage : c'est conforme mais piégeur en debug.
  14. HostInjector vs ElementInjector — Pour les directives, @Host s'arrête au composant qui contient la directive. Pour un service défini dans le composant parent du composant host, il faudra inject(Svc, { skipSelf: true }) plutôt que { host: true }.

🌳 Cas d'usage typiques par scope

ScopeCas d'usage
providedIn: 'root'API client, store global, cache app-wide, services utilitaires (logger, i18n)
providedIn: 'platform'Bus d'événements entre plusieurs apps Angular cohabitant sur la même page (micro-frontends)
providedIn: 'any'Service stateful qui doit être isolé par feature lazy (ex. wizard scoped)
Route.providersProviders lazy : provideStoreFeature, config par feature, mocks en test e2e
Component.providersÉtat local fortement couplé à un composant (modal, wizard étape, drag-drop state)
Directive.providersRare ; surtout pour des directives qui partagent un service entre elles dans un sous-arbre (forms typés)

C'est la cartographie qu'on aimerait avoir en tête quand on hésite "où mettre ce provider ?". La règle de pouce : viser le plus large possible (root) sauf si l'isolation est explicitement nécessaire.


🧪 Testing — TestBed providers

ts
import { TestBed } from '@angular/core/testing';
import { LoggerService } from './logger.service';
import { CounterService } from './counter.service';

describe('CounterService', () => {
  let counter: CounterService;
  let logger: jasmine.SpyObj<LoggerService>;

  beforeEach(() => {
    logger = jasmine.createSpyObj<LoggerService>('LoggerService', ['log']);
    TestBed.configureTestingModule({
      providers: [
        CounterService,
        { provide: LoggerService, useValue: logger },
      ],
    });
    counter = TestBed.inject(CounterService);
  });

  it('logue à chaque increment', () => {
    counter.increment();
    expect(logger.log).toHaveBeenCalledWith('inc');
  });
});

Surcharger un InjectionToken dans un test

ts
import { APP_CONFIG } from './config';

TestBed.configureTestingModule({
  providers: [{ provide: APP_CONFIG, useValue: { apiUrl: 'http://mock', debug: true } }],
});

Tester un service avec inject()

ts
import { TestBed } from '@angular/core/testing';
import { inject } from '@angular/core';
import { APP_CONFIG } from './config';

it('utilise la config injectée', () => {
  TestBed.configureTestingModule({
    providers: [{ provide: APP_CONFIG, useValue: { apiUrl: 'X', debug: false } }],
  });
  TestBed.runInInjectionContext(() => {
    const cfg = inject(APP_CONFIG);
    expect(cfg.apiUrl).toBe('X');
  });
});

Tester une factory provider

ts
import { TestBed } from '@angular/core/testing';
import { APP_CONFIG } from './config';

it('utilise la config par défaut quand rien n’est fourni', () => {
  TestBed.runInInjectionContext(() => {
    const cfg = inject(APP_CONFIG);
    expect(cfg.debug).toBe(false);
  });
});

it('surcharge la config en test', () => {
  TestBed.configureTestingModule({
    providers: [{ provide: APP_CONFIG, useValue: { apiUrl: 'X', debug: true } }],
  });
  TestBed.runInInjectionContext(() => {
    const cfg = inject(APP_CONFIG);
    expect(cfg.debug).toBe(true);
  });
});

Tester un multi-provider

ts
import { HTTP_INTERCEPTORS } from '@angular/common/http';

TestBed.configureTestingModule({
  providers: [
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
    { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
  ],
});
const interceptors = TestBed.inject(HTTP_INTERCEPTORS);
expect(interceptors.length).toBe(2);

Surcharger un provider après création

ts
TestBed.overrideProvider(LoggerService, { useValue: { log: () => {} } });

Utile pour des tests qui partagent une config, mais qui veulent ponctuellement changer un provider.


🎬 Cas d'usage concrets

Scénario 1 — SaaS RH multi-tenant, configuration injectée par sous-domaine

Une plateforme SaaS RH sert plusieurs centaines de clients (entreprises) avec des configurations distinctes : couleurs, logos, modules activés (paie, congés, ATS), conventions collectives, devises. Chaque tenant est servi via un sous-domaine (acme.hr-saas.io, globex.hr-saas.io).

L'équipe utilise un InjectionToken<TenantConfig> initialisé par un APP_INITIALIZER qui lit le sous-domaine, appelle l'API /api/config?tenant=acme et stocke la config. Tous les services en aval (theming, feature flags, devise par défaut) reçoivent ce token via inject(TENANT_CONFIG). Avantage : zéro if (tenant === 'acme') éparpillé dans le code — toute la variabilité est concentrée dans la config, et le typage du token assure que les options possibles sont connues à la compilation.

Pour les tests, chaque test instancie un faux tenant via providers: [{ provide: TENANT_CONFIG, useValue: { tenantId: 'test', modules: ['ats'], currency: 'EUR' } }]. Plus de mocks éparpillés ni de variables globales.

Scénario 2 — E-commerce, theme service per-route

Un marketplace fashion héberge plusieurs marques sous un même back-office Angular : chaque marque a sa palette, sa typo et son favicon. Plutôt qu'un service ThemeService global qu'il faut reconfigurer à chaque navigation, l'équipe le déclare au niveau de la route : providers: [ThemeService] dans la config de route de chaque marque.

Quand l'utilisateur navigue de /brands/acme à /brands/globex, Angular détruit l'instance précédente du ThemeService (avec son OnDestroy qui retire la classe CSS) et en crée une nouvelle pour la nouvelle route. Le service n'est plus partagé entre marques, ce qui élimine la classe de bugs où un thème « bavait » d'une marque sur l'autre lors d'une navigation rapide.

Le tech lead documente la règle : « si un service a un état lié à un contexte de navigation, l'injecter au niveau de la route, pas en 'root' ». Cette règle a évité 3 régressions au cours du dernier trimestre.

Scénario 3 — Cabinet juridique, injector custom pour exports PDF

Le portail d'un cabinet juridique génère des PDF (mémoires, conclusions, courriers) via une lib lourde (pdf-lib + polices custom). L'équipe ne veut pas charger cette lib dans le bundle principal, et veut isoler totalement l'exécution (la génération PDF crée des Worker qui doivent être nettoyés).

Solution : un EnvironmentInjector enfant créé à la volée quand l'utilisateur clique sur « Exporter ». L'injector embarque les providers PDF (PdfFontLoader, PdfRenderer, WorkerPool) et est détruit à la fin du flux. Le code utilise runInInjectionContext(childInjector, () => generatePdf(doc)) pour exécuter la génération dans le contexte de cet injector.

Bénéfice : pas de pollution du graphe global, et la lib PDF est import()-ée dynamiquement avec un loadComponent factice pour s'assurer du code-splitting. Le bundle principal ne contient pas un octet de pdf-lib.


🛠️ Exemple end-to-end

Use case : multi-tenant SaaS RH. Le sous-domaine détermine la config du tenant ; un InjectionToken la diffuse à toute l'app ; un ThemeService est fourni au niveau de la route de la zone « marque blanche ».

ts
// tenant.types.ts
export interface TenantConfig {
  tenantId: string;
  displayName: string;
  modules: ReadonlyArray<'ats' | 'payroll' | 'leaves' | 'analytics'>;
  currency: 'EUR' | 'CHF' | 'USD';
  primaryColor: string;
}
ts
// tenant.token.ts
import { InjectionToken } from '@angular/core';
import { TenantConfig } from './tenant.types';

export const TENANT_CONFIG = new InjectionToken<TenantConfig>('TENANT_CONFIG');
ts
// tenant.state.ts — état porté par la DI, pas par une closure de module
import { Injectable } from '@angular/core';
import { TenantConfig } from './tenant.types';

/** Un titulaire d'état mutable, mais fourni par DI → une instance par
 *  EnvironmentInjector de requête en SSR. AUCUN champ `static`, AUCUNE
 *  variable de module : c'est ce qui rend le bootstrap SSR-safe. */
@Injectable({ providedIn: 'root' })
export class TenantState {
  private value: TenantConfig | null = null;
  set(cfg: TenantConfig): void { this.value = cfg; }
  get(): TenantConfig {
    if (!this.value) {
      throw new Error('TENANT_CONFIG lu avant la résolution de l’initializer.');
    }
    return this.value;
  }
}
ts
// tenant.bootstrap.ts
import { provideAppInitializer, EnvironmentProviders, makeEnvironmentProviders, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { DOCUMENT } from '@angular/common';
import { firstValueFrom } from 'rxjs';
import { TENANT_CONFIG } from './tenant.token';
import { TenantConfig } from './tenant.types';
import { TenantState } from './tenant.state';

function detectTenantId(host: string): string {
  return host.split('.')[0] || 'default';
}

export function provideTenant(): EnvironmentProviders {
  return makeEnvironmentProviders([
    TenantState,

    // provideAppInitializer (Angular 19+) : factory async, dans un contexte
    // d'injection, qui BLOQUE le bootstrap jusqu'à résolution.
    provideAppInitializer(async () => {
      const http = inject(HttpClient);
      // DOCUMENT plutôt que `window` global → fonctionne en SSR.
      const host = inject(DOCUMENT).location?.hostname ?? 'default';
      const cfg = await firstValueFrom(
        http.get<TenantConfig>(`/api/config?tenant=${detectTenantId(host)}`),
      );
      inject(TenantState).set(cfg); // l'état atterrit dans une instance DI'd
    }),

    // Le token reste l'API publique de lecture ; il délègue au state DI'd.
    { provide: TENANT_CONFIG, useFactory: () => inject(TenantState).get() },
  ]);
}

Pourquoi ce refactor (et pas la version « closure » naïve) — Stocker la config dans un let resolved capturé par les deux factories paraît marcher, mais cette variable vit au niveau module, partagée par tous les EnvironmentInjector. En SSR, deux requêtes concurrentes pour deux tenants se télescopent : la requête B écrase le resolved de la requête A entre son initializer et son rendu → fuite inter-tenant (le bug exact du Pitfall 12 et de l'Exercice 5). En portant l'état dans un TenantState fourni en 'root', chaque requête SSR a sa propre instance, isolée par son EnvironmentInjector de requête. Règle d'or : aucun état mutable hors d'une instance fournie par la DI. Idem pour window → utiliser inject(DOCUMENT), sans quoi le bootstrap crashe côté serveur (window is not defined).

ts
// theme.service.ts (scoped à la route)
import { Injectable, OnDestroy, inject } from '@angular/core';
import { TENANT_CONFIG } from './tenant.token';

@Injectable()
export class ThemeService implements OnDestroy {
  private readonly config = inject(TENANT_CONFIG);
  private readonly styleEl: HTMLStyleElement;

  constructor() {
    this.styleEl = document.createElement('style');
    this.styleEl.textContent = `:root { --primary: ${this.config.primaryColor}; }`;
    document.head.appendChild(this.styleEl);
  }

  ngOnDestroy(): void {
    this.styleEl.remove();
  }
}
ts
// app.routes.ts
import { Routes } from '@angular/router';
import { ThemeService } from './theme.service';

export const routes: Routes = [
  {
    path: 'brand',
    providers: [ThemeService], // instance créée à l'entrée, détruite à la sortie
    loadChildren: () => import('./brand/brand.routes').then((m) => m.BRAND_ROUTES),
  },
];
ts
// dashboard.component.ts (consommateur typique)
import { Component, inject } from '@angular/core';
import { TENANT_CONFIG } from '../tenant.token';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    <h1>Bonjour, {{ tenant.displayName }}</h1>
    <p>Modules actifs : {{ tenant.modules.join(', ') }}</p>
    <p>Devise par défaut : {{ tenant.currency }}</p>
  `,
})
export class DashboardComponent {
  protected readonly tenant = inject(TENANT_CONFIG);
}
ts
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { provideTenant } from './app/tenant.bootstrap';

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient(), provideRouter(routes), ...provideTenant()],
});

Le tenant est chargé avant que la moindre route s'affiche (grâce à APP_INITIALIZER). Tous les services et composants accèdent au token via inject(TENANT_CONFIG) — sans if éparpillés. Le ThemeService est scopé à la zone /brand, donc instancié à l'entrée et détruit à la sortie.


🔁 Quand utiliser / éviter

Utiliser quandÉviter quand
Service singleton partagé app-wide ('root')Service à état mais "scoped par feature" → préférer 'any' ou provider local
Plugins (multi-providers HTTP_INTERCEPTORS, validateurs custom)Sur-utiliser useFactory : si la logique est complexe, c'est souvent un service à part entière
Configs typées via InjectionTokenConfigs partagées entre runtimes (SSR vs browser) sans précaution → vérifier le scope
Tests avec injection de mocksFaire de la "DI à la main" en passant des dépendances en arguments — utiliser le système
@SkipSelf/@Optional pour des hiérarchies de composants (formulaires emboîtés)Hiérarchies trop profondes : si tu te bats avec les modificateurs, repenser le design

🏗️ Pattern : provider configurable pour une lib

Quand on écrit une lib Angular, le bon pattern pour exposer une configuration typée + optionnelle :

ts
// lib/config.ts
import { InjectionToken, EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';

export interface MyLibConfig {
  apiBase: string;
  retries?: number;
  cache?: boolean;
}

export const MY_LIB_CONFIG = new InjectionToken<MyLibConfig>('MY_LIB_CONFIG');

const DEFAULT: Required<MyLibConfig> = {
  apiBase: '/api',
  retries: 3,
  cache: true,
};

export function provideMyLib(config: Partial<MyLibConfig> = {}): EnvironmentProviders {
  return makeEnvironmentProviders([
    { provide: MY_LIB_CONFIG, useValue: { ...DEFAULT, ...config } },
    MyLibService,
  ]);
}
ts
// consommation
import { provideMyLib } from 'my-lib';

bootstrapApplication(AppComponent, {
  providers: [provideMyLib({ apiBase: 'https://prod/api' })],
});

Avantages :

  • Une seule fonction publique à apprendre (provideMyLib).
  • Type-safe.
  • Compatible avec la convention Angular moderne (provideRouter, provideHttpClient).
  • Les valeurs par défaut sont au même endroit que la config.

🤖 Stack-integration — un client LLM injectable (consommer un agent Claude)

Cas réel de cette stack : l'app Angular parle à un backend NestJS qui relaie un agent Claude (claude-opus-4-8 en flagship, claude-sonnet-4-6 pour l'équilibre coût/latence, claude-haiku-4-5 pour le throughput). Le piège junior est de faire new EventSource() ou fetch() en dur dans un composant. Le pattern senior : un service de streaming injectable, configuré via provideXxx() + InjectionToken, exactement comme provideHttpClient. La DI devient le point de couture qui rend tout testable et reconfigurable (prod vs mock vs e2e).

ts
// llm/llm.config.ts
import { InjectionToken, EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';

export interface LlmConfig {
  /** endpoint NestJS qui proxifie l'agent (jamais la clé Anthropic côté browser) */
  readonly streamUrl: string;
  readonly model: 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-haiku-4-5';
  readonly maxTokens: number;
}

export const LLM_CONFIG = new InjectionToken<LlmConfig>('LLM_CONFIG');

export function provideLlm(config: LlmConfig): EnvironmentProviders {
  return makeEnvironmentProviders([{ provide: LLM_CONFIG, useValue: config }]);
}
ts
// llm/llm.client.ts — streaming SSE → signal, sous zoneless, avec Stop
import { Injectable, signal, inject, DestroyRef } from '@angular/core';
import { LLM_CONFIG } from './llm.config';

type ChatState =
  | { kind: 'idle' }
  | { kind: 'streaming'; text: string }
  | { kind: 'done'; text: string }
  | { kind: 'error'; message: string };

@Injectable({ providedIn: 'root' })
export class LlmClient {
  private readonly cfg = inject(LLM_CONFIG);
  private readonly state = signal<ChatState>({ kind: 'idle' });
  private controller: AbortController | null = null;

  readonly snapshot = this.state.asReadonly();

  constructor() {
    // annule un stream en cours si le contexte d'injection meurt (ex: SSR teardown)
    inject(DestroyRef).onDestroy(() => this.stop());
  }

  async send(prompt: string): Promise<void> {
    this.stop(); // un seul stream à la fois
    this.controller = new AbortController();
    this.state.set({ kind: 'streaming', text: '' });

    try {
      const res = await fetch(this.cfg.streamUrl, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ prompt, model: this.cfg.model, maxTokens: this.cfg.maxTokens }),
        signal: this.controller.signal,
      });
      if (!res.body) throw new Error('no stream body');

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';
      let frame = 0; // rAF pending pour coalescer les writes signal

      // boucle de lecture : on accumule le texte token par token.
      // PERF (failure mode) : un flux Anthropic peut émettre des dizaines de
      // chunks SSE par frame. Faire `state.set` par chunk déclenche autant de
      // passes de change-detection (même zoneless) → jank et layout thrash sur
      // un long markdown. On COALESCE les writes sur le rythme d'affichage (rAF) :
      // le buffer accumule à pleine vitesse, le signal ne publie qu'≤ 1×/frame.
      const flush = () => {
        frame = 0;
        this.state.set({ kind: 'streaming', text: buffer });
      };
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        frame ||= requestAnimationFrame(flush);
      }
      if (frame) cancelAnimationFrame(frame);
      this.state.set({ kind: 'done', text: buffer }); // publication finale garantie
    } catch (e) {
      if ((e as Error).name === 'AbortError') return; // Stop volontaire : pas une erreur
      this.state.set({ kind: 'error', message: (e as Error).message });
    } finally {
      this.controller = null;
    }
  }

  /** Stop câblé au bouton ET au teardown : annule côté client; le serveur NestJS
   *  voit le `req` se fermer (close event) et coupe l'appel Anthropic via son
   *  propre AbortController → pas de tokens facturés dans le vide. */
  stop(): void {
    this.controller?.abort();
    this.controller = null;
  }
}
ts
// chat.component.ts — rendu du stream depuis le signal
import { Component, inject } from '@angular/core';
import { LlmClient } from './llm/llm.client';

@Component({
  selector: 'app-chat',
  standalone: true,
  template: `
    @let s = client.snapshot();
    @switch (s.kind) {
      @case ('streaming') {
        <pre>{{ s.text }}</pre>
        <button (click)="client.stop()">Stop</button>
      }
      @case ('done') { <pre>{{ s.text }}</pre> }
      @case ('error') { <p class="err">{{ s.message }}</p> }
    }
  `,
})
export class ChatComponent {
  protected readonly client = inject(LlmClient);
}
ts
// main.ts — la config LLM se branche comme n'importe quel provideXxx
bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideLlm({ streamUrl: '/api/agent/stream', model: 'claude-sonnet-4-6', maxTokens: 1024 }),
  ],
});

Pourquoi c'est la bonne abstraction DI :

  • Testable : en e2e, on remplace tout le flux par { provide: LLM_CONFIG, useValue: { streamUrl: '/mock/stream', … } } — zéro mock manuel, l'app ne sait pas qu'elle parle à un faux serveur.
  • Reconfigurable par feature : une route « brouillon rapide » peut surcharger provideLlm(... haiku ...) dans Route.providers pour basculer sur claude-haiku-4-5 sans toucher le composant.
  • Annulation propagée : le Stop côté client (AbortController) ferme le fetch, ce qui ferme la connexion ; le NestJS détecte la fermeture et abort() son appel SDK Anthropic. Le coût en tokens s'arrête des deux côtés. Le même AbortController est rejoué au DestroyRef.onDestroy pour ne jamais laisser fuiter un stream lors d'une navigation.
  • Pas de clé côté browser : la clé Anthropic vit côté NestJS (DI'd via forRootAsync, jamais new Anthropic() dans un champ). Le browser ne connaît qu'une URL relative.

🧰 Cas avancés et anti-patterns

Forward references — quand et pourquoi

ts
import { forwardRef } from '@angular/core';

@Component({
  selector: 'app-tab',
  providers: [{ provide: TabContainer, useExisting: forwardRef(() => TabContainerComponent) }],
})
export class TabComponent {}

@Component({
  selector: 'app-tab-container',
  /* ... */
})
export class TabContainerComponent implements TabContainer {}

forwardRef est nécessaire quand on référence une classe déclarée plus bas dans le même fichier (ou en cycle d'imports). C'est presque toujours le signe d'un design à reconsidérer : extraire l'abstraction dans un fichier séparé (une interface + un InjectionToken) est généralement plus propre.

Anti-pattern : service géant qui injecte 15 dépendances

Si un service dépend de plus de 5-6 autres services, il est probablement responsable de trop de choses. Décomposer en plusieurs services collaborant, ou utiliser un pattern de facade : un service léger qui orchestre des sous-services internes.

Provider local + service global mélangés

ts
// Composant qui se déclare son propre Logger
@Component({
  providers: [{ provide: LoggerService, useClass: ScopedLoggerService }],
})
export class FeatureComponent {
  private logger = inject(LoggerService); // ScopedLoggerService (instance locale)
  private cache = inject(CacheService);   // depuis root (singleton)
}

C'est légal et utile (un logger qui ajoute un préfixe par feature), mais documenter clairement. Sinon, un nouveau dev qui s'attend à un singleton est piégé.

Lazy provider via Route.providers

ts
export const featureRoutes: Routes = [
  {
    path: '',
    providers: [
      FeatureStore,                                              // service feature-scoped
      { provide: API_BASE, useValue: 'https://feature.api/v2' }, // override de l'API
    ],
    loadComponent: () => import('./feature.component').then(m => m.FeatureComponent),
  },
];

Le FeatureStore n'est instancié que lors de l'activation de la route, et détruit quand on la quitte. Excellent pour les flows wizard ou les sections admin lourdes.

🔗 Liens


🏋️ Exercices

Progression : on construit, on durcit pour la prod, puis on casse et on répare. Fais-les dans l'ordre.

1. Scope conscient — le compteur qui ne devrait pas être partagé

Objectif : prouver empiriquement la différence root / Component.providers / 'any'. Crée un CounterService { count = signal(0); inc() }. Affiche-le dans deux composants frères montés en même temps. Fais trois variantes : (a) providedIn: 'root', (b) providers: [CounterService] sur chaque composant, (c) providedIn: 'any' chargé via deux routes lazy. Observe quels compteurs bougent ensemble. Indice : en (a) les deux frères partagent le signal ; en (b) chacun a le sien ; en (c) chaque route lazy a sa propre instance mais les composants d'une même route la partagent. Écris un test qui le verrouille via TestBed.

2. provideFeature() type-safe avec défauts

Objectif : packager une feature en une seule fonction publique à la provideRouter. Écris provideRateLimiter(config?: Partial<RateLimiterConfig>): EnvironmentProviders qui merge config sur des défauts Required<RateLimiterConfig>, expose un RATE_LIMITER_CONFIG token, et fournit un RateLimiterService. Le service doit lire la config via inject. Indice : makeEnvironmentProviders([{ provide: TOKEN, useValue: { ...DEFAULT, ...config } }, RateLimiterService]). Teste qu'un override partiel conserve les autres défauts.

3. Multi-provider de validateurs branchés à l'exécution

Objectif : implémenter un système de plugins via multi: true. Définis VALIDATORS = new InjectionToken<DocValidator[]>('VALIDATORS'). Fournis 3 validateurs en multi: true. Écris un ValidationPipeline qui inject(VALIDATORS) (un tableau) et les applique en chaîne. Ajoute un 4ᵉ validateur depuis une Route.providers et vérifie qu'il n'est visible que sur cette route. Indice : inject(VALIDATORS) retourne le tableau cumulé de la chaîne d'injecteurs visible à ce point ; une route enfant voit les multi du root plus les siens.

4. Production-grade — le client LLM injectable avec Stop, retry et cost-guard

Objectif : transformer le LlmClient de la section AI en service prod. Pars du LlmClient ci-dessus. Ajoute : (a) un InjectionToken LLM_RETRY avec une politique (max retries, backoff) injectée ; (b) un retry seulement sur erreurs réseau/5xx, jamais sur AbortError ni 4xx ; (c) un costGuard qui refuse d'envoyer si prompt.length dépasse un budget injecté ; (d) une garantie qu'un seul stream tourne à la fois et que naviguer ailleurs l'annule. Indice : capture DestroyRef et EnvironmentInjector en champ ; la politique de retry est un InjectionToken<RetryPolicy> pour rester reconfigurable par route ; le cost-guard est un autre token, ce qui le rend mockable en test.

5. Casse-le puis répare — la fuite SSR du singleton stateful

Objectif : reproduire et corriger une fuite de données inter-requêtes en SSR. Crée un UserContextService en providedIn: 'root' qui mémorise currentUser dans un champ mutable. Simule deux requêtes SSR concurrentes qui écrivent des users différents (chacune a son EnvironmentInjector, mais vérifie ce qui se passe si tu mets l'état dans un static ou un module-level singleton). Observe la fuite. Puis corrige : état porté par l'injecteur de requête, pas par un module global. Indice : un vrai providedIn: 'root' est par EnvironmentInjector de requête en SSR, donc safe ; la fuite vient d'un static/variable de module ou d'un état caché dans une closure de factory partagée. La règle : aucun état mutable hors d'une instance fournie par DI.

6. Casse-le — la dépendance circulaire et le bon refactor

Objectif : provoquer un cycle de DI, le faire compiler avec forwardRef, puis l'éliminer par design. Fais que OrderService injecte PaymentService qui injecte OrderService. Constate le NG0200 (cyclic dependency). Fais-le compiler avec forwardRef. Puis supprime le cycle proprement. Indice : la sortie propre est d'extraire le contrat partagé dans un InjectionToken<OrderReader> (interface en lecture seule) ; un seul des deux services l'implémente, l'autre dépend du token. forwardRef est un pansement, pas la solution.


🎤 En entretien

Q : « Quelle est la différence exacte entre useClass et useExisting ? » R : useClass crée une nouvelle instance de la classe cible (deux tokens pointant useClass vers la même classe → deux instances) ; useExisting est un alias qui résout vers une instance déjà existante d'un autre token (même objet, partagé). On utilise useExisting pour exposer un service sous plusieurs interfaces sans le dupliquer.

Q : « providedIn: 'root' vs 'any', et quand 'any' est-il un piège ? » R : 'root' = un seul singleton pour toute l'app. 'any' = une instance par EnvironmentInjector (donc par route lazy + le root). Le piège : on attend un singleton, on a du state dupliqué par feature — interdit pour un cache ou un store partagé. 'any' ne se justifie que pour un service délibérément isolé par feature.

Q : « Pourquoi inject() lève-t-il NG0203 dans un setTimeout et comment le contourner proprement ? » R : inject() n'est valide que dans une fenêtre de contexte d'injection (construction, factory, initializer). Un callback async sort de cette fenêtre. La bonne pratique : capturer les dépendances en champs pendant la construction ; si on a vraiment besoin d'injecter tardivement, capturer EnvironmentInjector et rejouer runInInjectionContext(env, () => inject(X)).

Q : « En SSR, un service providedIn: 'root' qui garde l'utilisateur courant dans un champ : danger ou pas ? » R : Pas par construction — chaque requête SSR a son propre EnvironmentInjector, donc sa propre instance ; le champ est isolé par requête. Le danger réel vient d'un état hors instance : static, variable de module, ou closure partagée dans une factory (le piège classique du let resolved capturé par deux factories de provider). Règle : aucun état mutable en dehors d'une instance fournie par la DI.

Q : « Un service déclaré dans un bloc @defer est-il isolé du reste de l'arbre ? » R : Non. @defer charge le code paresseusement mais n'ouvre pas de nouvel EnvironmentInjector : le contenu différé hérite de l'injecteur de sa position dans le template. Pour une vraie isolation de providers, c'est Route.providers, Component.providers, ou un createEnvironmentInjector enfant explicite — pas @defer.

Q : « On débugge un NG0201 qui n'arrive qu'en prod. Quelle est ta démarche ? » R : D'abord reproduire le graphe, pas le symptôme : Angular DevTools → onglet Injector Tree pour voir où le provider est (ou n'est pas) déclaré le long de la chaîne Element → Environment → Root. Côté code, getInjector($node).get(Token, null) confirme la résolution à un nœud précis. Causes fréquentes : un provideXxx() oublié dans une route lazy (visible en local car chargé eagerly), un token redéfini par référence dans deux libs, ou un provider attendu en 'root' mais déclaré dans une Route.providers sœur. Le fix n'est jamais « ajouter { optional: true } » sauf si l'absence est légitime.


📌 Récap final

  • Hiérarchie : platform → root → environment (par route lazy) → element (par composant). Recherche du bas vers le haut.
  • Scopes : providedIn: 'root' (singleton app), 'platform' (singleton page, rare), 'any' (par EnvironmentInjector).
  • 4 providers : useClass (substitue une classe), useValue (constante/mock), useFactory (construction dynamique avec inject), useExisting (alias).
  • multi: true : valeur cumulée dans un tableau ; à la base des interceptors HTTP, des validateurs, des initializers.
  • InjectionToken<T> : typage propre pour valeurs non-classes ; combinable avec une factory par défaut.
  • Modificateurs : @Self/@SkipSelf/@Host/@Optional (ou options de inject(X, { … })).
  • EnvironmentInjector : sous-graphe DI dynamique pour plugins, micro-frontends, tests isolés.
  • Tests : TestBed.configureTestingModule({ providers: [...] }), overrideProvider, runInInjectionContext.
  • En 2026, structurer ses libs autour de provideXxx(): EnvironmentProviders plutôt que de NgModule.forRoot() est devenu la norme.

Bibliothèque tech perso — Achref