~9 min de lecture

multi: true : le pattern d'extension qu'Angular utilise partout (et toi jamais)

TL;DR

Un InjectionToken déclaré avec des providers multi: true ne résout pas une valeur mais une liste : chaque provider ajoute son entrée, et inject() te donne le tableau complet. C'est le mécanisme de NG_VALIDATORS, NG_VALUE_ACCESSOR, HTTP_INTERCEPTORS ou des initializers d'application : Angular s'en sert partout pour s'étendre sans se modifier. Ton code peut faire pareil : les features s'enregistrent, l'orchestrateur injecte la liste, et plus aucun service central à rouvrir pour ajouter un cas. Trois pièges au passage : le mix multi/regular dont la garde n'existe qu'en dev, la hiérarchie d'injecteurs qui remplace la liste au lieu de l'étendre, et l'instance dupliquée quand tu oublies useExisting.

Le service qu'on rouvre à chaque sprint

Tu as un tracking analytics à brancher sur plusieurs destinations : GA4 pour le marketing, Matomo pour le client self-hosted, un logger interne pour le debug. Le réflexe naturel donne ce genre de service :

import { Injectable, inject } from '@angular/core';
import { Ga4Tracker } from './ga4-tracker';
import { MatomoTracker } from './matomo-tracker';
import { ConsoleTracker } from './console-tracker';

@Injectable({ providedIn: 'root' })
export class Analytics {
  private readonly ga4 = inject(Ga4Tracker);
  private readonly matomo = inject(MatomoTracker);
  private readonly consoleTracker = inject(ConsoleTracker);

  track(event: string, payload?: Record<string, unknown>): void {
    this.ga4.track(event, payload);
    this.matomo.track(event, payload);
    this.consoleTracker.track(event, payload);
  }
}

Ce code marche, et c'est bien le problème : il va vivre longtemps. Chaque nouveau tracker impose de rouvrir Analytics : un inject(), une ligne dans track(), des tests retouchés alors que le contrat n'a pas bougé. Le jour où le client self-hosted ne veut pas de GA4, tu ajoutes un if sur un flag d'environnement. Puis un deuxième. Six mois plus tard, Analytics connaît tous les trackers et leurs conditions d'activation, et personne n'ose plus y toucher.

Le défaut n'est pas dans le code, il est dans la dépendance : l'orchestrateur connaît ses implémentations par leur nom. Tant que c'est le cas, toute extension passe par lui.

Le pattern en trois pièces

Une interface, un token, des providers multi.

1. L'interface que chaque implémentation respecte :

// tracker.ts
export interface Tracker {
  track(event: string, payload?: Record<string, unknown>): void;
}

2. Le token, typé comme un tableau :

// tracker.token.ts
import { InjectionToken } from '@angular/core';
import { Tracker } from './tracker';

export const TRACKERS = new InjectionToken<readonly Tracker[]>('TRACKERS');

Le typage en readonly Tracker[] n'est pas un détail : avec multi: true, chaque provider fournit un Tracker, mais l'injection retourne le tableau accumulé. Angular type ses tokens multi comme ça (HTTP_INTERCEPTORS est un InjectionToken<readonly HttpInterceptor[]>) : c'est le type que tu reçois à l'arrivée. Type le token en Tracker tout court et TypeScript te laissera appeler injected.track(...) sur ce qui est en réalité un tableau.

3. Les providers, un par implémentation, chacun chez soi :

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { TRACKERS } from './analytics/tracker.token';
import { Ga4Tracker } from './analytics/ga4-tracker';
import { ConsoleTracker } from './analytics/console-tracker';

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: TRACKERS, useClass: Ga4Tracker, multi: true },
    { provide: TRACKERS, useClass: ConsoleTracker, multi: true },
  ],
};

L'orchestrateur, lui, ne connaît plus personne :

import { Injectable, inject } from '@angular/core';
import { TRACKERS } from './tracker.token';

@Injectable({ providedIn: 'root' })
export class Analytics {
  private readonly trackers = inject(TRACKERS, { optional: true }) ?? [];

  track(event: string, payload?: Record<string, unknown>): void {
    for (const tracker of this.trackers) {
      tracker.track(event, payload);
    }
  }
}

Le { optional: true } ?? [] couvre le cas zéro provider : sans lui, inject(TRACKERS) part en NG0201 (No provider found). L'alternative d'Angular pour ses tokens internes : une factory par défaut ({ factory: () => [] }), et l'injection ne lève plus. Dans les deux cas, une liste vide est un cas nominal, pas une erreur.

Ajouter Matomo demain ? Un fichier et une ligne de provider, Analytics et ses tests ne bougent pas. Retirer GA4 pour le client self-hosted ? Sa ligne saute de ce build. C'est l'open/closed principle version DI : ouvert à l'extension, fermé à la modification.

Angular s'en sert partout

Si le pattern te dit quelque chose, c'est normal : provideRouter(routes) enregistre tes routes dans ROUTES, un token multi. provideAppInitializer(fn) (Angular 19+) empile ta fonction d'init à côté des autres. provideHttpClient(withInterceptorsFromDi()) consomme HTTP_INTERCEPTORS, multi lui aussi. Un ControlValueAccessor s'enregistre dans NG_VALUE_ACCESSOR avec multi: true, un validateur custom passe par NG_VALIDATORS. Tout le framework est construit là-dessus.

Même les interceptors fonctionnels de withInterceptors([...]) passent par là : chaque fonction du tableau devient un provider multi: true sur un token dédié. La différence est dans l'écriture de l'ordre, un tableau au site d'appel plutôt que des lignes de providers dispersées. On y revient plus bas : cet ordre est le point aveugle du pattern.

Emballer l'enregistrement dans un provideX()

La convention standalone pour exposer une feature est une fonction provideX(), comme celles du framework ci-dessus. Elle s'applique telle quelle ici :

// provide-ga4.ts
import {
  EnvironmentProviders,
  InjectionToken,
  makeEnvironmentProviders,
} from '@angular/core';
import { TRACKERS } from './tracker.token';
import { Ga4Tracker } from './ga4-tracker';

export const GA4_MEASUREMENT_ID = new InjectionToken<string>('GA4_MEASUREMENT_ID');

export function provideGa4(measurementId: string): EnvironmentProviders {
  return makeEnvironmentProviders([
    { provide: GA4_MEASUREMENT_ID, useValue: measurementId },
    { provide: TRACKERS, useClass: Ga4Tracker, multi: true },
  ]);
}
// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [provideGa4('G-XXXXXXX'), provideConsoleTracker()],
};

provideConsoleTracker() suit le même moule. makeEnvironmentProviders interdit l'enregistrement dans les providers d'un composant : son retour EnvironmentProviders y est refusé à la compilation. Le verrou s'arrête là : les providers d'une route lazy acceptent un EnvironmentProviders - le terrain du piège 2.

Piège 1 : la garde anti-mix n'existe qu'en dev

Un provider multi: true et un provider classique sur le même token ne cohabitent pas :

// app.config.ts
providers: [
  { provide: TRACKERS, useClass: Ga4Tracker, multi: true },
  { provide: TRACKERS, useClass: ConsoleTracker }, // multi oublié
],

En dev, Angular lève Cannot mix multi providers and regular providers dès la création de l'injecteur - au bootstrap pour app.config.ts, avant toute résolution du token. La garde a deux limites. Elle vit derrière ngDevMode : un build de production la retire (le message n'existe même plus dans un bundle prod), et le mix devient soit silencieux, soit un TypeError brut, selon l'ordre des deux lignes. Et elle ne couvre que les environment injectors : dans le providers: [] d'un composant, mêmes deux issues, garde ou pas - un seul tracker servi, ou le TypeError, selon l'ordre.

TypeScript ne voit de toute façon aucune différence entre les deux formes. D'où l'intérêt des provideX() : le multi: true est écrit une seule fois, dans la feature.

Variante voisine : tous les providers oublient multi: true. Règle standard de la DI : le dernier provider enregistré gagne. Ton Analytics reçoit une instance au lieu d'un tableau, et le premier track() explose : TypeError: this.trackers is not iterable. Bruyant, donc vite corrigé - le mix silencieux du paragraphe précédent est le vrai sournois.

Piège 2 : la hiérarchie remplace, elle n'étend pas

L'intuition voudrait qu'un provider multi ajouté dans les providers d'une route lazy s'AJOUTE à la liste du root. C'est faux :

// app.config.ts (root)
{ provide: TRACKERS, useClass: Ga4Tracker, multi: true },

// admin.routes.ts (providers de la route lazy)
{ provide: TRACKERS, useClass: AuditTracker, multi: true },

Un service instancié dans l'injecteur de la route admin qui fait inject(TRACKERS) reçoit [AuditTracker]. Pas [Ga4Tracker, AuditTracker]. Les providers d'une route lazy créent un environment injector, l'injecteur qu'Angular monte pour les providers de niveau route ou application, par opposition à celui d'un composant (article dédié sur le scope des services de route). La résolution s'arrête au premier injecteur qui connaît le token, et il répond avec sa liste. Les multi-providers s'accumulent au sein d'un même injecteur, jamais à travers la hiérarchie.

Ce n'est pas un bug, c'est cohérent avec le reste de la DI (un provider local masque toujours celui du parent), mais sur un token multi le résultat surprend : tu crois enrichir la liste, tu viens de la remplacer. Si le besoin est vraiment "les trackers du root plus un tracker local", c'est à toi de faire la couture :

// admin.routes.ts - à la place du provider multi vu plus haut, pas en plus :
{
  provide: TRACKERS,
  useFactory: () => [
    ...(inject(TRACKERS, { skipSelf: true, optional: true }) ?? []),
    inject(AuditTracker),
  ],
},

Le skipSelf va chercher la liste du parent, la factory recolle les morceaux. Et ce provider remplace la ligne useClass : les deux côte à côte sur le même token, c'est le mix multi/regular du piège 1. Verbeux, mais explicite : fusionner des listes entre niveaux d'injecteurs est une décision, pas un défaut.

Attention au sens inverse : un Analytics en providedIn: 'root' est instancié dans l'injecteur root et ne verra JAMAIS les providers d'une route lazy, même en naviguant dedans. Si des features lazy doivent enregistrer des trackers, l'orchestrateur doit être résolu dans leur injecteur, ou tout le monde s'enregistre au root via les provideX().

Piège 3 : l'instance dupliquée sans useExisting

Un tracker a parfois une deuxième casquette : un devtools panel injecte aussi ConsoleTracker directement. Si tu écris ça :

providers: [
  ConsoleTracker,
  { provide: TRACKERS, useClass: ConsoleTracker, multi: true },
],

tu as deux instances de ConsoleTracker : celle du provider direct, et celle que le useClass du token multi construit pour son propre compte. Un état gardé par la classe (buffer d'events, connexion) diverge alors en silence. La forme correcte passe par useExisting :

providers: [
  ConsoleTracker,
  { provide: TRACKERS, useExisting: ConsoleTracker, multi: true },
],

useExisting référence l'instance que l'injecteur résout déjà pour ConsoleTracker, au lieu d'en construire une nouvelle. C'est le pattern ControlValueAccessor : useExisting: forwardRef(() => MyInput), parce que le composant est l'accessor et qu'il ne faut pas en fabriquer un deuxième.

L'ordre du tableau est un contrat

L'ordre du tableau injecté suit l'ordre d'enregistrement des providers dans l'injecteur. Pour des trackers analytics, on s'en fiche. Pour une chaîne de middlewares où le premier qui échoue court-circuite les autres, cet ordre devient un contrat, écrit nulle part ailleurs que dans l'ordre des lignes de ta config. S'il est significatif, documente-le à côté des providers, ou fais comme withInterceptors : un tableau au site d'appel, transformé en providers multi dans l'ordre reçu.

Quand ne pas sortir ce pattern

Un token multi pour deux implémentations connues d'avance, c'est de l'architecture de conférence : tu payes l'indirection (token, interface, providers éclatés) sans jamais encaisser le bénéfice. Un tableau en dur dans l'orchestrateur fait le même travail et se lit en une ligne.

Le critère qui justifie le pattern, c'est la pression d'extension : des implémentations qui arrivent au fil des sprints, des builds qui n'embarquent pas tous les mêmes (white-label, feature flags), des équipes qui possèdent chacune la leur. Si tu rouvres le même service central à chaque sprint, c'est le moment. Avant, c'est de la spéculation.

Récap actionnable

  • Un orchestrateur qui inject() ses implémentations par leur nom se rouvre à chaque extension. Interface + InjectionToken + providers multi: true inversent la dépendance : les features s'enregistrent, l'orchestrateur consomme la liste.
  • Type le token comme le tableau (InjectionToken<readonly Tracker[]>) : c'est ce que l'injection te donne, et la convention d'Angular.
  • Injecte avec { optional: true } ?? [] : zéro provider est un cas nominal, pas une erreur.
  • Expose chaque enregistrement via un provideX() + makeEnvironmentProviders : le multi: true n'est écrit qu'une fois, et un composant ne peut pas l'embarquer dans ses providers.
  • Piège 1 : la garde anti-mix n'existe qu'en dev et que dans les environment injectors ; tous en regular par oubli = le dernier gagne, et le premier appel qui itère explose.
  • Piège 2 : un provider multi dans un injecteur enfant remplace la liste du parent, il ne l'étend pas. La fusion se fait à la main (useFactory + skipSelf), et un orchestrateur providedIn: 'root' ne voit jamais les providers des routes lazy.
  • Piège 3 : une classe fournie à la fois directement et via useClass dans un token multi existe en double. useExisting référence l'instance au lieu d'en créer une.
  • L'ordre du tableau = l'ordre d'enregistrement. S'il est significatif, c'est un contrat : documente-le ou repasse à un tableau explicite.
  • Pas de pression d'extension = pas de token multi. Un tableau en dur se lit mieux qu'une indirection qui ne sert à rien.

La prochaine fois que tu rouvres un service central pour y coller un if, pose la bonne question : pas "où ranger ce cas", mais "pourquoi ce service connaît-il ses implémentations par leur nom". Le jour où la réponse ne tient plus, le token multi t'attend.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.