Front-end angularforall.com

- Services Singleton en Angular : une seule instance partagée

Angular Singleton Providedin-Root Injection-Dependances Signals Behaviorsubject Destroyref State-Management Tree-Shaking Lazy-Modules Component-Scoped Testbed
Services Singleton en Angular : une seule instance partagée

Services singleton Angular 17+ : providedIn root, signals partages, BehaviorSubject, DestroyRef, scopes platform/any, tests TestBed et patterns reels.

Pourquoi le pattern singleton en Angular ?

Un service singleton est instancié une seule fois pour toute la durée de vie de l'application. Tous les composants qui l'injectent reçoivent la même instance — donc le même état, les mêmes méthodes, les mêmes effets de bord. C'est le pattern qui rend le partage d'état entre composants distants à la fois trivial et performant : pas de prop drilling, pas de bus d'événements, juste de l'injection de dépendances.

En Angular moderne, le singleton est la valeur par défaut. Quand vous écrivez ng generate service auth, le CLI produit une classe annotée @Injectable({ providedIn: 'root' }) — ce qui suffit pour obtenir une instance unique partagée. Vous n'avez à vous soucier du contraire (instance par composant, par module lazy) que dans des cas précis et volontaires.

Ce que cet article couvre

  • Comment l'injecteur Angular construit l'arbre des instances et choisit qui partage quoi.
  • Les quatre portées : 'root', 'platform', 'any', et providers explicites.
  • Deux façons de partager un état réactif : BehaviorSubject (RxJS) et Signal (Angular 17+).
  • Les pièges de cycle de vie (fuites mémoire, instances dupliquées) et comment les éviter.
  • Les patterns concrets — AuthService, ThemeService, NotificationService, cache HTTP.
  • Le cas inverse : quand un singleton est le mauvais choix (formulaires, composants réutilisables avec état local).
À retenir : singleton ne veut pas dire « variable globale ». L'injecteur Angular contrôle l'accès, garantit le typage TypeScript, et permet de tester le service en isolation. C'est la version moderne et propre d'une « instance partagée ».

Quand le pattern singleton fait toute la différence

Avant Angular, partager de l'état entre composants distants imposait soit du prop drilling (faire descendre la valeur de prop en prop sur 5 niveaux), soit un bus d'événements global, soit une variable au scope module mutée à la main. Toutes ces solutions présentent les mêmes défauts : couplage fort, tests difficiles, état désynchronisé entre instances. Le singleton Angular résout ces trois problèmes d'un coup en faisant de l'injecteur la source unique de vérité.

Un autre bénéfice subtil : la composition. Un singleton peut en injecter d'autres pour produire des comportements de haut niveau — un OrderService qui combine AuthService, CartService et HttpClient. Le typage TypeScript trace toutes ces dépendances, et l'IDE auto-complete suit. Vous obtenez ce que Java appelle l'inversion de contrôle, sans aucun framework lourd à l'extérieur d'Angular lui-même.

L'arbre d'injection Angular — racine, module, composant

Angular construit un arbre d'injecteurs qui suit la structure de votre application. Comprendre cet arbre, c'est comprendre quand un service est partagé et quand il ne l'est pas.

Trois niveaux d'injecteurs

  • Platform injector — créé une seule fois pour le navigateur. Partagé entre toutes les apps Angular sur la même page.
  • Root injector — créé au bootstrapApplication(). C'est lui qui héberge les services providedIn: 'root' — un par application.
  • Element injectors — un par composant. Hérite du parent. Crée une instance locale si le service est listé dans providers: [...] du décorateur.

Comment l'injection résout une dépendance

// Quand vous écrivez :
constructor(private auth: AuthService) {}
// OU
private auth = inject(AuthService);

// Angular :
// 1. Cherche AuthService dans l'element injector du composant courant
// 2. Si pas trouvé, remonte au composant parent
// 3. Si pas trouvé, remonte au module racine
// 4. Si pas trouvé, remonte au platform
// 5. Si toujours pas trouvé : NullInjectorError

Cette résolution bottom-up est la clé de tout. Un service déclaré dans un composant intermédiaire « masque » celui de la racine pour ses descendants — c'est exactement le pattern qu'on utilise pour les services component-scoped (section 10).

providedIn: 'root' — le singleton par défaut

C'est la méthode officielle, recommandée, et la valeur par défaut du CLI depuis Angular 6. Une seule ligne d'annotation transforme une classe en service singleton.

// auth.service.ts
import { Injectable, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly http = inject(HttpClient);
  private readonly _user = signal<User | null>(null);

  readonly user = this._user.asReadonly();
  readonly isAuthenticated = computed(() => this._user() !== null);

  login(email: string, password: string) {
    return this.http.post<User>('/api/login', { email, password }).pipe(
      tap(user => this._user.set(user))
    );
  }

  logout() {
    this._user.set(null);
  }
}

Utilisation dans un composant

// header.component.ts
import { Component, inject } from '@angular/core';
import { AuthService } from './auth.service';

@Component({
  selector: 'app-header',
  standalone: true,
  template: `
    @if (auth.isAuthenticated()) {
      <span>Bonjour {{ auth.user()?.name }}</span>
      <button (click)="auth.logout()">Déconnexion</button>
    } @else {
      <a routerLink="/login">Connexion</a>
    }
  `,
})
export class HeaderComponent {
  protected readonly auth = inject(AuthService);
}
Tree-shaking gratuit : avec providedIn: 'root', si aucun composant n'injecte le service, il est supprimé du bundle final par le compilateur. C'est l'avantage majeur sur l'ancien pattern NgModule.providers qui forçait l'inclusion. Conséquence : créez tous les services en providedIn: 'root', même ceux qui ne sont utilisés que par une feature isolée.

platform, any, component — les autres portées

PortéeNombre d'instancesCas d'usage
'root'1 par applicationQuasi tous les services (recommandé)
'platform'1 par page navigateurMicro-frontends partageant le même service
'any'1 par module lazy-loadedServices scopés à une feature lazy (panier, wizard)
providers: [X] sur Component1 par instance du composantÉtat local d'un composant (FormService)
providers: [X] sur Route1 par activation de routeÉtat scopé à une route (Editor)

providedIn: 'any' — utile pour les modules lazy

// cart.service.ts — chaque feature lazy obtient sa propre instance
@Injectable({ providedIn: 'any' })
export class CartService { /* ... */ }

// Si /shop charge ShopModule lazy, ShopModule a SON CartService
// Si /b2b charge B2bModule lazy, B2bModule a son propre CartService
// Mais tous les composants de ShopModule partagent SA même instance.

providedIn: 'platform' — micro-frontends

Utilisé quand plusieurs applications Angular cohabitent sur la même page web (architecture micro-frontend). Le service vit au niveau du platform, partagé entre toutes les apps. Cas d'usage typique : un NotificationService commun, un bus d'événements global, ou un service d'auth partagé entre la shell app et plusieurs sous-apps lazy-loadées via Module Federation.

Partager un état avec BehaviorSubject (RxJS)

Avant Angular 17, c'était l'approche universelle. BehaviorSubject<T> stocke une valeur, en émet la dernière à tout nouvel abonné, et notifie tous les abonnés à chaque mise à jour. Toujours valide aujourd'hui sur les codebases existantes.

// theme.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

type Theme = 'light' | 'dark' | 'auto';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  // Source interne — privée pour éviter qu'un consommateur émette des valeurs
  private readonly state$ = new BehaviorSubject<Theme>('auto');

  // API publique — Observable en lecture seule
  readonly theme$: Observable<Theme> = this.state$.asObservable();

  // Accès synchrone si nécessaire (rare)
  get current(): Theme { return this.state$.value; }

  setTheme(theme: Theme) {
    localStorage.setItem('theme', theme);
    this.state$.next(theme);
    document.documentElement.dataset.theme = theme;
  }
}

Consommation dans un composant

// theme-toggle.component.ts
@Component({
  imports: [AsyncPipe],
  template: `
    <select (change)="onChange($event)" [value]="(theme.theme$ | async) ?? 'auto'">
      <option value="light">Clair</option>
      <option value="dark">Sombre</option>
      <option value="auto">Auto</option>
    </select>
  `,
})
export class ThemeToggleComponent {
  protected readonly theme = inject(ThemeService);

  onChange(e: Event) {
    this.theme.setTheme((e.target as HTMLSelectElement).value as 'light');
  }
}

L'async pipe gère automatiquement la souscription et la désouscription quand le composant est détruit. Vous n'avez jamais besoin d'appeler subscribe() manuellement dans un composant qui consomme un état partagé.

Partager un état avec Signals (Angular 17+)

Les Signals remplacent BehaviorSubject dans 80 % des cas avec moins de code, plus de performance, et une intégration native au change detection. C'est l'approche recommandée pour tout nouveau service en 2026.

// theme.service.ts — version Signals
import { Injectable, signal, computed, effect, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  // Source interne privée
  private readonly _theme = signal<Theme>(this.loadInitial());

  // API publique — readonly empêche la mutation depuis l'extérieur
  readonly theme = this._theme.asReadonly();

  // Dérivation — recalculée automatiquement
  readonly isDark = computed(() =>
    this._theme() === 'dark' ||
    (this._theme() === 'auto' && this.systemPrefersDark())
  );

  constructor() {
    // Side-effect synchronisé avec le state
    effect(() => {
      if (!this.isBrowser) return;
      document.documentElement.dataset.theme = this._theme();
      localStorage.setItem('theme', this._theme());
    });
  }

  setTheme(theme: Theme) { this._theme.set(theme); }
  toggle()              { this._theme.update(t => t === 'dark' ? 'light' : 'dark'); }

  private loadInitial(): Theme {
    if (!this.isBrowser) return 'auto';
    return (localStorage.getItem('theme') as Theme) ?? 'auto';
  }

  private systemPrefersDark(): boolean {
    return this.isBrowser && matchMedia('(prefers-color-scheme: dark)').matches;
  }
}

Consommation directe sans async pipe

@Component({
  template: `
    <button (click)="theme.toggle()">
      Thème : {{ theme.theme() }} {{ theme.isDark() ? '🌙' : '☀️' }}
    </button>
  `,
})
export class ThemeToggleComponent {
  protected readonly theme = inject(ThemeService);
}

Aucune souscription, aucun unsubscribe, aucun async pipe. Le change detection d'Angular sait quand un signal change et met à jour le DOM minimalement. C'est ce qui rend la combinaison singleton + Signals imbattable pour les nouveaux projets.

Cycle de vie : DestroyRef et fuites mémoire

Un service singleton vit aussi longtemps que l'application. Cela signifie : pas de ngOnDestroy automatique, pas de cleanup magique. Toute souscription RxJS, tout listener DOM, tout timer ouvert dans le service reste actif jusqu'à la fin de l'app. C'est la cause #1 des fuites mémoire en Angular.

Pattern 1 — pas de subscription longue durée dans le service

// ❌ Mauvais — la subscription vit éternellement
@Injectable({ providedIn: 'root' })
export class BadService {
  constructor() {
    interval(1000).subscribe(() => console.log('tick')); // jamais nettoyé
  }
}

// ✅ Bon — utiliser DestroyRef ou laisser le composant s'abonner
@Injectable({ providedIn: 'root' })
export class GoodService {
  readonly tick$ = interval(1000); // l'Observable est créé, mais pas démarré
  // Chaque composant qui s'abonne nettoie via async pipe ou takeUntilDestroyed
}

Pattern 2 — takeUntilDestroyed côté composant

// theme.service.ts expose un Observable
readonly themeChanged$ = this._theme.asObservable();

// Dans le composant — auto-cleanup à la destruction
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

constructor() {
  this.theme.themeChanged$
    .pipe(takeUntilDestroyed())
    .subscribe(t => this.applyTheme(t));
}

Pattern 3 — DestroyRef pour les services injectés dans un composant

Pour les services component-scoped (voir section 10), DestroyRef est bien plus utile car il se déclenche à la destruction du composant — vraiment souvent, pas une fois par session. Combiné à takeUntilDestroyed() dans le service lui-même, on obtient un nettoyage propre des subscriptions, des timers et des listeners DOM.

Si un service doit vraiment être destroyé en même temps qu'un composant racine (cas rare, comme un service de WebSocket lié à la session), inject DestroyRef et enregistrez un callback.

@Injectable({ providedIn: 'root' })
export class SocketService {
  private readonly destroyRef = inject(DestroyRef);
  private socket?: WebSocket;

  connect() {
    this.socket = new WebSocket('wss://api.example.com');
    this.destroyRef.onDestroy(() => this.socket?.close());
    // Ne se déclenchera qu'à la fin de l'app pour un service providedIn root
  }
}

Dépendances entre services et inject()

Un service peut en injecter un autre — c'est même très courant (un UserService qui consomme HttpClient et AuthService). Depuis Angular 14, la fonction inject() remplace le constructor pour la majorité des cas — plus court, plus testable, et utilisable dans les champs de classe.

// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class UserService {
  // inject() dans les champs — pas besoin de constructor du tout
  private readonly http = inject(HttpClient);
  private readonly auth = inject(AuthService);

  getProfile() {
    const userId = this.auth.user()?.id;
    if (!userId) throw new Error('Not authenticated');
    return this.http.get<User>(`/api/users/${userId}`);
  }
}

Le pattern inject() brille particulièrement dans les classes abstraites héritées et les fonctions utilitaires. Vous pouvez même factoriser une logique d'injection commune dans une fonction injectAuth() qui regroupe plusieurs inject() liés à l'authentification — réutilisable depuis n'importe quel service, guard ou intercepteur. C'est devenu un pattern courant dans les codebases Angular modernes.

Injection circulaire — à éviter

Si ServiceA injecte ServiceB et ServiceB injecte ServiceA, Angular jette une erreur de cycle. Solution : extraire l'état partagé dans un troisième service plus bas dans la hiérarchie (le shared store), ou utiliser un Observable/Signal partagé qu'un service écrit et l'autre lit.

Tokens d'injection — au-delà des classes

// On peut injecter autre chose qu'une classe
import { InjectionToken } from '@angular/core';

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

// Dans app.config.ts
providers: [
  { provide: API_BASE_URL, useValue: 'https://api.example.com' }
]

// Dans un service
private readonly baseUrl = inject(API_BASE_URL);

Tester un service singleton avec TestBed

Chaque test crée un nouveau TestBed donc un nouvel injecteur — vous obtenez une nouvelle instance de service par test, sans pollution croisée. C'est le bénéfice clé du pattern : vos services sont testables en isolation.

// theme.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { ThemeService } from './theme.service';

describe('ThemeService', () => {
  let service: ThemeService;

  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [ThemeService] });
    service = TestBed.inject(ThemeService);
  });

  it('a la valeur auto par défaut', () => {
    expect(service.theme()).toBe('auto');
  });

  it('setTheme met à jour le signal', () => {
    service.setTheme('dark');
    expect(service.theme()).toBe('dark');
  });

  it('deux injections retournent la MÊME instance', () => {
    const a = TestBed.inject(ThemeService);
    const b = TestBed.inject(ThemeService);
    expect(a).toBe(b); // singleton confirmé
  });
});

Services component-scoped : quand briser le singleton

Le singleton n'est pas toujours souhaitable. Pour les composants réutilisables avec état local, on veut au contraire qu'une instance par composant. Exemple typique : un StepperService qui suit l'étape courante d'un wizard — si on a deux wizards sur la même page, chacun doit avoir son propre service indépendant.

// stepper.service.ts — NOT providedIn: 'root'
@Injectable() // pas de providedIn — on le scope manuellement
export class StepperService {
  private readonly _step = signal(0);
  readonly step = this._step.asReadonly();
  next()  { this._step.update(s => s + 1); }
  prev()  { this._step.update(s => Math.max(0, s - 1)); }
  reset() { this._step.set(0); }
}

// stepper.component.ts — fournit le service lui-même
@Component({
  selector: 'app-stepper',
  standalone: true,
  providers: [StepperService], // ← chaque <app-stepper> a SON instance
  template: `
    <p>Étape {{ stepper.step() }}</p>
    <button (click)="stepper.prev()">Précédent</button>
    <button (click)="stepper.next()">Suivant</button>
    <ng-content></ng-content>
  `,
})
export class StepperComponent {
  protected readonly stepper = inject(StepperService);
}

Quand briser le singleton est la bonne décision

Repérez les services qui demandent un scope plus étroit : un WizardService qui suit l'étape d'un assistant, un FormStateService qui mémorise les valeurs d'un formulaire spécifique, un ChartConfigService qui configure un graphique particulier. Tous ces services ont en commun de stocker un état qui n'a aucun sens à l'extérieur d'un composant donné. Les déclarer en providedIn: 'root' entraînerait des collisions d'état entre deux instances du même composant — bugs silencieux et difficiles à reproduire.

Avantage : les enfants partagent l'instance du parent

N'importe quel composant enfant placé dans <app-stepper> qui injecte StepperService obtient automatiquement l'instance du parent — c'est la résolution bottom-up vue en section 2. Deux <app-stepper> côte à côte ne partagent jamais leur état. C'est le pattern « parent-child via DI » très utilisé par FormGroupDirective et FormControlName dans les Reactive Forms.

Patterns réels : Auth, Theme, Notification, Cache

AuthService — déjà vu (section 3)

État utilisateur + login/logout. Toujours providedIn: 'root'. C'est le service le plus critique d'une app — toute la logique d'autorisation et de session passe par lui, et chaque guard, chaque intercepteur HTTP et chaque header de navigation l'injecte.

ThemeService — déjà vu en détail (sections 5 et 6)

État de thème synchronisé entre tous les composants de l'app + persistance localStorage + détection des préférences système. Singleton classique, idéal en Signals.

NotificationService — toast global accessible partout

@Injectable({ providedIn: 'root' })
export class NotificationService {
  private readonly _items = signal<Notif[]>([]);
  readonly items = this._items.asReadonly();

  push(message: string, type: 'success' | 'error' | 'info' = 'info') {
    const id = crypto.randomUUID();
    this._items.update(arr => [...arr, { id, message, type }]);
    setTimeout(() => this.dismiss(id), 4000);
  }

  dismiss(id: string) {
    this._items.update(arr => arr.filter(n => n.id !== id));
  }
}

À placer dans un composant racine via <ng-container> + @for (n of notif.items(); track n.id). Tout le reste de l'app appelle notif.push('Sauvegardé !', 'success') sans jamais s'occuper du rendu visuel.

CacheService — éviter les appels HTTP redondants en mémoire

@Injectable({ providedIn: 'root' })
export class CacheService {
  private readonly cache = new Map<string, { data: unknown; expires: number }>();

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry || entry.expires < Date.now()) {
      this.cache.delete(key);
      return null;
    }
    return entry.data as T;
  }

  set<T>(key: string, data: T, ttlSeconds = 300) {
    this.cache.set(key, { data, expires: Date.now() + ttlSeconds * 1000 });
  }

  invalidate(prefix?: string) {
    if (!prefix) return this.cache.clear();
    for (const key of this.cache.keys()) {
      if (key.startsWith(prefix)) this.cache.delete(key);
    }
  }
}

Combiné à un intercepteur HTTP qui consulte le cache avant d'effectuer une requête, vous obtenez une couche de cache transparente sans toucher aux composants. C'est un pattern qui fait gagner 30 à 60 % de bande passante sur les écrans très visités (homepages, dashboards).

ConfigService — environnement et feature flags chargés au démarrage

@Injectable({ providedIn: 'root' })
export class ConfigService {
  private readonly _config = signal<AppConfig | null>(null);
  readonly config = this._config.asReadonly();

  readonly featureFlags = computed(() => this._config()?.flags ?? {});

  async load() {
    const config = await fetch('/api/config').then(r => r.json());
    this._config.set(config);
  }

  isFeatureEnabled(name: string): boolean {
    return this.featureFlags()[name] === true;
  }
}

Chargé une seule fois au démarrage via APP_INITIALIZER (ou la nouvelle API provideAppInitializer en Angular 19+), le service met sa config à disposition de tous les composants. Idéal pour les feature flags, les URLs d'API par environnement, et toutes les valeurs qui changent entre dev/staging/prod sans rebuilder l'app.

Pièges classiques et bonnes pratiques

À faire
  • Toujours utiliser providedIn: 'root' par défaut — tree-shakable.
  • Préférer Signals à BehaviorSubject sur les nouveaux services Angular 17+.
  • Exposer l'état en readonly (asReadonly()) pour empêcher les mutations externes.
  • Utiliser computed() pour les dérivations — recalcul automatique et mémoïsé.
  • Injecter via inject() dans les champs de classe — plus court, plus testable.
  • Tester chaque service en isolation via TestBed.
À éviter
  • Subscribe RxJS dans le constructeur d'un service singleton — fuite mémoire éternelle.
  • Mettre un service dans providers: [] d'un module ET providedIn: 'root' — comportement imprévisible.
  • Exposer le BehaviorSubject/Signal en écriture — perte de contrôle sur l'état.
  • Stocker des références à des éléments DOM dans un service singleton — empêche le GC du composant.
  • Créer un singleton pour un état strictement local d'un composant — utilisez providers de Component.
  • Confondre singleton et variable globale — l'injecteur Angular est ce qui fait la différence.

Conclusion

Le pattern singleton via providedIn: 'root' est l'épine dorsale d'une application Angular bien architecturée. Combiné aux Signals (Angular 17+) pour l'état réactif et à inject() pour les dépendances, il offre une API minimaliste, tree-shakable, testable, et compatible zoneless. Les services deviennent la couche de logique métier centrale ; les composants se contentent de les consommer et d'afficher.

Le seul piège à connaître : ne jamais subscribe une stream longue durée dans le constructeur d'un singleton sans nettoyage. Pour le reste, Angular fait le travail à votre place : une instance, gardée vivante, partagée entre tous les consommateurs, supprimée du bundle si jamais injectée, testable en isolation via TestBed. C'est la meilleure mise en œuvre du pattern singleton qu'on puisse trouver dans un framework JavaScript moderne.

Le chemin pratique pour appliquer cet article sur un projet existant : commencez par identifier les composants qui partagent de l'état via @Input/@Output sur plus de deux niveaux de profondeur. Chacun est un candidat naturel à l'extraction en singleton. Migrer un tel cas prend trente minutes et améliore la lisibilité, les tests, et la maintenabilité de toute la branche concernée. Répétez sur quelques sprints et vous obtenez une architecture qui scale.

Récapitulatif des bonnes pratiques :
  • Créer tous les services en @Injectable({ providedIn: 'root' }) par défaut
  • Préférer Signals + computed() à BehaviorSubject + combineLatest
  • Exposer l'état en asReadonly() pour préserver l'encapsulation
  • Injecter via inject() dans les champs de classe — plus court et plus testable
  • Éviter toute souscription longue durée dans le constructeur du service
  • Utiliser 'any' pour les services scopés à un module lazy (panier, wizard d'une feature)
  • Utiliser providers: [Service] sur un composant pour les états strictement locaux
  • Tester chaque service avec TestBed et un nouvel injecteur par test
  • Éviter les dépendances circulaires entre services — extraire un troisième service partagé
  • Utiliser InjectionToken pour injecter de la config ou des valeurs primitives

Partager