Front-end angularforall.com

- Angular @Injectable vs @Service() : DI maîtrisée

Angular Injectable Service-Decorator Dependency-Injection Injectiontoken Angular-22 Hierarchical-Injectors Http-Interceptors Testing Tree-Shaking Angular-Di Providers Singleton Standalone
Angular @Injectable vs @Service() : DI maîtrisée

Maîtrisez @Injectable() et le futur @Service() Angular 22 : hiérarchie d'injecteurs, InjectionToken, intercepteurs HTTP et stratégies de test avancées.

Le défi e-commerce : tout injecter sans tout coupler

Imaginez une boutique en ligne Angular. Le composant CartComponent doit afficher les articles du panier, calculer la TVA selon la localisation de l'utilisateur, journaliser chaque ajout vers un service d'analytics, et formater les prix selon la devise active. Sans injection de dépendances structurée, chaque service appelle l'autre directement — et le moindre changement de devise provoque une cascade de modifications.

C'est exactement le problème que résout @Injectable() en profondeur : non pas simplement "permettre l'injection", mais formaliser qui crée quoi, à quel niveau, avec quelle durée de vie. Et c'est ce que le futur @Service() d'Angular 22 cherche à simplifier pour le cas le plus courant — sans remplacer la puissance du système existant.

Dans cet article, on décortique l'ensemble du pipeline DI à travers un exemple e-commerce réel : panier, taxes, analytics, intercepteurs HTTP et tokens de configuration — avec les implications pour les tests et les performances.

Niveau : Intermédiaire à avancé. Vous devez connaître les bases d'Angular (composants, services, routing) pour tirer le maximum de cet article.

Anatomie de @Injectable() : ce que le décorateur fait vraiment

@Injectable() est souvent résumé à "ce décorateur rend la classe injectable". C'est vrai, mais incomplet. Il accomplit trois choses distinctes à la compilation :

  1. Il émet les métadonnées de constructeur via reflect-metadata, permettant au compilateur Angular de savoir quels types injecter dans le constructeur (ou via inject()).
  2. Il enregistre la factory : Angular génère une fonction ɵfac qui sait comment construire l'instance.
  3. Il déclare le scope via providedIn, déterminant dans quel injecteur l'instance sera enregistrée.

Exemple : CartService avec dépendances multiples

Notre service panier illustre ces trois aspects. Il dépend d'un service de taxes, d'un service analytics, et d'un token de configuration :

// cart.service.ts
import { Injectable, inject } from '@angular/core';
import { TaxService }        from './tax.service';
import { AnalyticsService }  from './analytics.service';
import { CURRENCY_TOKEN }    from './tokens';

@Injectable({
  providedIn: 'root' // singleton global, tree-shakeable
})
export class CartService {
  // Angular 14+ : inject() remplace le constructeur param injection
  private readonly tax       = inject(TaxService);
  private readonly analytics = inject(AnalyticsService);
  private readonly currency  = inject(CURRENCY_TOKEN); // valeur primitive

  private items: CartItem[] = [];

  addItem(product: Product, quantity: number): void {
    const item: CartItem = {
      productId : product.id,
      name      : product.name,
      quantity,
      unitPrice : product.price,
      // tax.compute() utilise la locale injectée dans TaxService
      taxRate   : this.tax.compute(product.categoryCode),
    };

    this.items.push(item);

    // analytics tracke sans que CartService connaisse l'implémentation
    this.analytics.track('cart_add', {
      productId : product.id,
      currency  : this.currency,
    });
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => {
      const subtotal = item.unitPrice * item.quantity;
      return sum + subtotal + (subtotal * item.taxRate);
    }, 0);
  }
}
Pourquoi inject() plutôt que le constructeur ? Depuis Angular 14, inject() fonctionne dans les champs de classe initialisés au moment de la construction. Il offre un typage plus précis et s'intègre mieux avec les Signals. Le constructeur reste valide mais inject() est le pattern recommandé pour les nouveaux services.

La factory générée par le compilateur

Lors du build, Angular génère une propriété statique ɵfac sur la classe. C'est elle que l'injecteur appelle lors de la première résolution :

// Code simplifié de ce qu'Angular génère (ne jamais écrire manuellement)
CartService.ɵfac = function CartService_Factory(t) {
  return new (t || CartService)();
  // inject() est résolu via le contexte d'injection actif au moment de la construction
};

CartService.ɵprov = ɵɵdefineInjectable({
  token  : CartService,
  factory: CartService.ɵfac,
  providedIn: 'root', // l'injecteur root sera propriétaire
});
Implication pratique : Si vous appelez inject() en dehors du contexte d'injection (hors constructeur, hors champ initialisé, après la construction), Angular lève une erreur NG0203: inject() must be called from an injection context. Utilisez runInInjectionContext(injector, fn) pour les cas hors-contexte.

La hiérarchie des injecteurs Angular

Angular ne dispose pas d'un seul injecteur global. Il en maintient plusieurs organisés en arbre, ce qui permet des instances locales indépendantes sans perturber les singletons globaux.

Niveau Déclaration Durée de vie Cas d'usage
Platform providedIn: 'platform' Toute la session navigateur Config partagée entre micro-frontends
Root providedIn: 'root' Durée de vie de l'application CartService, AuthService, ApiService
Module / Route providers: [] dans NgModule ou route Durée de vie du module chargé Services propres à un lazy module
Element (Component) providers: [] dans @Component Durée de vie du composant État local, FormService, DragService

Exemple : TaxService local au checkout

Le service de taxes doit avoir sa propre instance dans le tunnel de commande pour ne pas affecter les calculs affichés ailleurs (liste produits, en-tête panier). On le fourni au niveau du composant :

// checkout.component.ts
import { Component, inject } from '@angular/core';
import { TaxService }        from '../services/tax.service';

@Component({
  selector  : 'app-checkout',
  standalone: true,
  // TaxService est instancié UNE FOIS PAR instance de CheckoutComponent
  providers : [TaxService],
  template  : `
    <div>TVA appliquée : {{ taxRate() | percent }}</div>
    <app-tax-breakdown />
  `
})
export class CheckoutComponent {
  private readonly tax = inject(TaxService);

  protected taxRate = computed(() => this.tax.currentRate());
}
// tax-breakdown.component.ts — enfant de CheckoutComponent
import { Component, inject } from '@angular/core';
import { TaxService }        from '../services/tax.service';

@Component({ selector: 'app-tax-breakdown', standalone: true, template: '...' })
export class TaxBreakdownComponent {
  // Angular remonte l'arbre des injecteurs et trouve l'instance de CheckoutComponent
  // PAS le singleton root — l'instance locale est partagée avec le parent
  private readonly tax = inject(TaxService);
}
Angular résout les dépendances en remontant l'arbre d'injecteurs de l'enfant vers le parent. Le premier injecteur qui possède le token gagne. Cette remontée s'arrête au root par défaut, sauf si vous utilisez @SkipSelf() ou @Host().

@SkipSelf() pour forcer le niveau supérieur

// logger-child.service.ts
import { Injectable, inject, SkipSelf } from '@angular/core';
import { LoggerService } from './logger.service';

@Injectable()
export class LoggerChildService {
  // Ignore l'injecteur du composant courant,
  // force la résolution dans le parent (ou root)
  private readonly parentLogger = inject(LoggerService, { skipSelf: true });

  log(message: string): void {
    // délègue toujours au logger parent — pas de boucle infinie
    this.parentLogger.log(`[child] ${message}`);
  }
}

InjectionToken : injecter des configs, pas des classes

Notre CartService injecte CURRENCY_TOKEN — une chaîne, pas une classe. Angular ne peut pas utiliser le type seul comme identifiant (les types disparaissent à la compilation JavaScript). InjectionToken résout ce problème en créant un objet unique qui sert de clé dans le registre de l'injecteur.

Déclaration des tokens de configuration e-commerce

// tokens.ts
import { InjectionToken } from '@angular/core';

// Token typé : Angular connaît le type attendu à l'injection
export const CURRENCY_TOKEN = new InjectionToken<string>(
  'currency',
  { providedIn: 'root', factory: () => 'EUR' } // valeur par défaut
);

export const API_BASE_URL = new InjectionToken<string>(
  'api-base-url'
  // Pas de factory : DOIT être fourni explicitement
);

export interface EcommerceConfig {
  maxCartItems  : number;
  taxByDefault  : number;
  supportedLocales: string[];
}

export const ECOMMERCE_CONFIG = new InjectionToken<EcommerceConfig>(
  'ecommerce-config'
);

Fourniture des tokens dans bootstrapApplication

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent }         from './app/app.component';
import { API_BASE_URL, ECOMMERCE_CONFIG } from './app/tokens';
import { environment }          from './environments/environment';

bootstrapApplication(AppComponent, {
  providers: [
    // Valeur statique depuis l'environnement de build
    { provide: API_BASE_URL, useValue: environment.apiUrl },

    // Objet de configuration structuré
    {
      provide : ECOMMERCE_CONFIG,
      useValue: {
        maxCartItems     : 50,
        taxByDefault     : 0.20, // 20% TVA France
        supportedLocales : ['fr-FR', 'en-US', 'de-DE'],
      } satisfies EcommerceConfig,
    },
  ],
});

Injection dans un service : useFactory pour la logique

// tax.service.ts
import { Injectable, inject } from '@angular/core';
import { LOCALE_ID }          from '@angular/core';
import { ECOMMERCE_CONFIG }   from './tokens';

@Injectable({ providedIn: 'root' })
export class TaxService {
  private readonly config = inject(ECOMMERCE_CONFIG);
  private readonly locale = inject(LOCALE_ID); // 'fr-FR', 'en-US', etc.

  // Taux par catégorie de produit selon la locale
  private readonly taxRates: Record<string, Record<string, number>> = {
    'fr-FR': { food: 0.055, electronics: 0.20, books: 0.055 },
    'en-US': { food: 0.00,  electronics: 0.08, books: 0.00  },
    'de-DE': { food: 0.07,  electronics: 0.19, books: 0.07  },
  };

  compute(categoryCode: string): number {
    // Remonte sur la locale de base ('fr-FR' → 'fr-FR') puis sur le défaut config
    const rates = this.taxRates[this.locale]
                ?? this.taxRates['fr-FR'];
    return rates[categoryCode] ?? this.config.taxByDefault;
  }

  get currentRate(): number {
    return this.config.taxByDefault;
  }
}
Tree-shaking des tokens : Un InjectionToken avec factory et providedIn: 'root' est tree-shaken si aucun code ne l'injecte. Préférez toujours la factory inline pour les tokens optionnels rarement utilisés.

Intercepteurs HTTP comme services avancés

Les intercepteurs HTTP sont des services particuliers : ils implémentent HttpInterceptorFn (Angular 15+) ou HttpInterceptor (approche classe). Notre boutique e-commerce a besoin de deux intercepteurs : ajout automatique du token JWT, et retry avec backoff exponentiel en cas d'erreur réseau.

Intercepteur JWT — approche fonctionnelle (Angular 15+)

// auth.interceptor.ts
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { inject }    from '@angular/core';
import { AuthStore } from '../stores/auth.store';

export const authInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
) => {
  // inject() fonctionne car l'intercepteur est appelé dans un contexte d'injection
  const auth  = inject(AuthStore);
  const token = auth.accessToken();

  if (!token) {
    // Pas de token : on laisse passer sans modifier la requête
    return next(req);
  }

  // clone() est immuable — on ne modifie jamais la requête originale
  const authedReq = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` }
  });

  return next(authedReq);
};

Intercepteur retry avec backoff exponentiel

// retry.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject }  from '@angular/core';
import { retry, timer, mergeMap, throwError } from 'rxjs';
import { ECOMMERCE_CONFIG } from '../tokens';

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  // Limite configurable via le token central
  const config   = inject(ECOMMERCE_CONFIG);
  const maxRetry = 3;

  return next(req).pipe(
    retry({
      count: maxRetry,
      // Backoff exponentiel : 1s, 2s, 4s
      delay: (error: HttpErrorResponse, retryIndex: number) => {
        // Ne retente que les erreurs réseau (pas les 4xx)
        if (error.status >= 400 && error.status < 500) {
          return throwError(() => error);
        }
        const waitMs = Math.pow(2, retryIndex - 1) * 1000;
        return timer(waitMs);
      }
    })
  );
};

Enregistrement des intercepteurs dans bootstrapApplication

// main.ts (suite)
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor }  from './app/interceptors/auth.interceptor';
import { retryInterceptor } from './app/interceptors/retry.interceptor';

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      // L'ordre compte : auth s'exécute AVANT retry dans la chaîne sortante
      withInterceptors([authInterceptor, retryInterceptor])
    ),
    // ... autres providers
  ],
});
Intercepteurs et inject() : Les intercepteurs fonctionnels peuvent utiliser inject() car Angular les appelle dans un contexte d'injection. Chaque appel HTTP crée un nouveau contexte d'exécution — l'injecteur actif est celui de l'injecteur root, sauf si vous utilisez HttpClient depuis un composant qui a son propre injecteur.

@Service() Angular 22 : ce que ça change vraiment

La PR #68195 propose un nouveau décorateur @Service() pour Angular 22. L'idée centrale : 95% des services sont des singletons root sans configuration spéciale. Pourquoi imposer providedIn: 'root' à chaque fois ?

Avant (Angular actuel)

// analytics.service.ts — AVANT @Service()
import { Injectable, inject } from '@angular/core';
import { HttpClient }         from '@angular/common/http';
import { API_BASE_URL }       from './tokens';

@Injectable({
  providedIn: 'root' // obligatoire pour le tree-shaking
})
export class AnalyticsService {
  private readonly http    = inject(HttpClient);
  private readonly baseUrl = inject(API_BASE_URL);

  track(event: string, payload: Record<string, unknown>): void {
    this.http.post(`${this.baseUrl}/analytics`, { event, payload, ts: Date.now() })
      .subscribe(); // fire-and-forget
  }
}

Après (Angular 22 avec @Service())

// analytics.service.ts — APRÈS @Service()
import { Service, inject } from '@angular/core'; // @Service vient du même package
import { HttpClient }      from '@angular/common/http';
import { API_BASE_URL }    from './tokens';

@Service() // root + tree-shakeable + inject() obligatoire — c'est tout
export class AnalyticsService {
  private readonly http    = inject(HttpClient);
  private readonly baseUrl = inject(API_BASE_URL);

  track(event: string, payload: Record<string, unknown>): void {
    this.http.post(`${this.baseUrl}/analytics`, { event, payload, ts: Date.now() })
      .subscribe();
  }
}

Les trois différences structurelles

Aspect @Injectable() @Service()
Scope par défaut Aucun (erreur si oublié sans providers) Root, toujours
Configuration useFactory, useExisting, multi Aucune option — simplifié volontairement
Constructeur Autorisé avec paramètres Constructeur vide obligatoire, inject() imposé
Tree-shaking Oui si providedIn: 'root' Oui, toujours
Interception, multi-providers Oui via providers: [] Non — utiliser @Injectable() dans ce cas

Quand utiliser l'un ou l'autre ?

  • @Service() — service métier simple, singleton root, inject() uniquement, pas de config d'injection particulière (AnalyticsService, NotificationService, CacheService)
  • @Injectable({ providedIn: 'root' }) — même scope root, mais vous avez besoin de useFactory, d'un constructeur paramétré, ou d'une compatibilité inter-versions
  • @Injectable() sans providedIn — service fourni manuellement au niveau composant ou module (ex. TaxService local au checkout)
  • Intercepteurs HTTP — fonction pure ou classe, jamais @Service()
Disponibilité : @Service() est en Developer Preview dans la PR #68195. Ne l'utilisez pas en production avant la stabilisation officielle Angular 22. @Injectable() restera supporté indéfiniment — les deux coexistent.

Tests avancés : TestBed, SpyObj et hiérarchie

Un des avantages majeurs de l'injection de dépendances est la testabilité. Angular fournit TestBed pour configurer un injecteur de test sans bootstrapper l'application entière. Voici comment tester CartService avec toutes ses dépendances mockées.

Test unitaire de CartService

// cart.service.spec.ts
import { TestBed }       from '@angular/core/testing';
import { CartService }   from './cart.service';
import { TaxService }    from './tax.service';
import { AnalyticsService } from './analytics.service';
import { CURRENCY_TOKEN }   from './tokens';

describe('CartService', () => {
  let service     : CartService;
  let taxSpy      : jasmine.SpyObj<TaxService>;
  let analyticsSpy: jasmine.SpyObj<AnalyticsService>;

  beforeEach(() => {
    // Crée des spies : objet avec toutes les méthodes remplacées par des espions
    taxSpy       = jasmine.createSpyObj('TaxService',       ['compute']);
    analyticsSpy = jasmine.createSpyObj('AnalyticsService', ['track']);

    TestBed.configureTestingModule({
      providers: [
        CartService,
        // Remplace les vraies implémentations par les spies
        { provide: TaxService,       useValue: taxSpy       },
        { provide: AnalyticsService, useValue: analyticsSpy },
        // Fournit le token avec une valeur de test
        { provide: CURRENCY_TOKEN,   useValue: 'USD'        },
      ],
    });

    service = TestBed.inject(CartService);
  });

  it('calcule le total avec TVA', () => {
    // Arrange : le mock retourne un taux de 20%
    taxSpy.compute.and.returnValue(0.20);

    const product: Product = { id: '1', name: 'Laptop', price: 1000, categoryCode: 'electronics' };

    // Act
    service.addItem(product, 2);
    const total = service.getTotal();

    // Assert : 2 × 1000 × (1 + 0.20) = 2400
    expect(total).toBe(2400);
    expect(taxSpy.compute).toHaveBeenCalledWith('electronics');
    expect(analyticsSpy.track).toHaveBeenCalledWith('cart_add', {
      productId: '1',
      currency : 'USD',
    });
  });

  it('retourne 0 pour un panier vide', () => {
    expect(service.getTotal()).toBe(0);
  });
});

Test d'un service avec hiérarchie d'injecteurs

// tax.service.spec.ts
import { TestBed }   from '@angular/core/testing';
import { TaxService } from './tax.service';
import { LOCALE_ID, ECOMMERCE_CONFIG } from '@angular/core';

describe('TaxService — locale', () => {
  function setup(locale: string) {
    TestBed.configureTestingModule({
      providers: [
        TaxService,
        { provide: LOCALE_ID,        useValue: locale },
        { provide: ECOMMERCE_CONFIG, useValue: { taxByDefault: 0.15, maxCartItems: 10, supportedLocales: [] } },
      ],
    });
    return TestBed.inject(TaxService);
  }

  it('applique le taux français pour fr-FR', () => {
    const svc = setup('fr-FR');
    expect(svc.compute('electronics')).toBe(0.20); // 20% France
    expect(svc.compute('food')).toBe(0.055);       // 5.5% alimentaire
  });

  it('applique 0% nourriture pour en-US', () => {
    const svc = setup('en-US');
    expect(svc.compute('food')).toBe(0.00);
  });

  it('tombe sur le défaut config pour locale inconnue', () => {
    const svc = setup('ja-JP');
    // ja-JP inconnue → défaut config → 15%
    expect(svc.compute('electronics')).toBe(0.15);
  });
});
Réinitialiser TestBed entre les tests : Angular réinitialise automatiquement TestBed après chaque it() si vous utilisez TestBed.configureTestingModule() dans beforeEach(). Utilisez TestBed.resetTestingModule() explicitement si vous testez des comportements de singleton entre plusieurs tests dans le même describe().

Tree-shaking et performance avec providedIn

Le choix de providedIn a un impact direct sur la taille du bundle. Un service déclaré dans providers: [] d'un NgModule sera toujours inclus, même si aucune page ne l'utilise. providedIn: 'root' active le tree-shaking : si aucun inject() ne référence le service, le bundler l'élimine.

Comparaison des stratégies

// ❌ Inclus dans le bundle même si inutilisé
@NgModule({
  providers: [ReportService] // toujours bundlé
})
export class ReportModule {}

// ✅ Tree-shakeable : éliminé si inject(ReportService) n'existe nulle part
@Injectable({ providedIn: 'root' })
export class ReportService {}

// ✅ Tree-shakeable via token avec factory
export const REPORT_CONFIG = new InjectionToken<ReportConfig>('report-config', {
  providedIn: 'root',
  factory   : () => ({ format: 'pdf', pageSize: 'A4' })
});

providedIn: 'any' — une instance par lazy module

// preview.service.ts
@Injectable({
  // 'any' : une instance dans le root injector ET une instance
  // dans chaque lazy module qui l'injecte.
  // Utile pour isoler un état de prévisualisation par module chargé.
  providedIn: 'any'
})
export class PreviewService {
  private draft: CartSnapshot | null = null;

  setDraft(snapshot: CartSnapshot): void {
    this.draft = snapshot;
  }

  getDraft(): CartSnapshot | null {
    return this.draft;
  }
}

Benchmark : impact sur le bundle

Stratégie Bundle si non utilisé Bundle si utilisé Recommandé pour
providers: [] NgModule Inclus (pas de tree-shaking) Inclus Legacy, compatibilité
providedIn: 'root' Éliminé ✅ Dans le main chunk Services globaux (95% des cas)
providedIn: 'any' Éliminé ✅ Dans chaque lazy chunk État isolé par module
@Service() (v22) Éliminé ✅ Dans le main chunk Singletons simples
Mesurer le tree-shaking : Lancez ng build --stats-json puis analysez dist/stats.json avec webpack-bundle-analyzer ou source-map-explorer. Les services inutilisés n'apparaissent pas dans l'arbre de dépendances si providedIn: 'root' est bien configuré.

Checklist et recommandations

La DI Angular est un écosystème complet — pas juste une façon de partager des objets entre composants. Maîtriser la hiérarchie des injecteurs, les InjectionToken, les intercepteurs et les stratégies de tree-shaking différencie un développeur Angular junior d'un senior.

Récapitulatif de l'exemple e-commerce

  • CartServiceprovidedIn: 'root', inject() pour TaxService, AnalyticsService et CURRENCY_TOKEN
  • TaxService@Injectable() sans providedIn, fourni dans CheckoutComponent pour l'isoler
  • ECOMMERCE_CONFIGInjectionToken fourni dans bootstrapApplication avec useValue
  • authInterceptor — fonction avec inject(AuthStore), enregistrée via withInterceptors()
  • retryInterceptor — backoff exponentiel, ne retente pas les 4xx
  • Tests — SpyObj pour toutes les dépendances, LOCALE_ID mocké pour TaxService

Décision @Injectable() vs @Service()

  • Service métier simple, singleton, pas de config ? → @Service() (dès Angular 22 stable)
  • Besoin de useFactory, useExisting, ou multi-provider ? → @Injectable() obligatoire
  • Service local à un composant ? → @Injectable() + fourni dans providers: [] du composant
  • Intercepteur HTTP ? → fonction HttpInterceptorFn ou classe @Injectable()
  • Valeur de config (string, number, objet) ? → InjectionToken avec useValue

Ce qu'Angular 22 ne change pas

@Service() est une syntaxe de confort pour le cas le plus courant. La hiérarchie des injecteurs, les InjectionToken, les intercepteurs et les fournisseurs avancés (useFactory, useExisting, multi) restent l'apanage de @Injectable() — et ce n'est pas prêt de changer. Comprendre les deux systèmes est indispensable pour toute application Angular sérieuse.

Partager