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.
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 :
- Il émet les métadonnées de constructeur via
reflect-metadata, permettant au compilateur Angular de savoir quels types injecter dans le constructeur (ou viainject()). - Il enregistre la factory : Angular génère une fonction
ɵfacqui sait comment construire l'instance. - 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);
}
}
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
});
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);
}
@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;
}
}
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
],
});
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()
@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);
});
});
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 |
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
- CartService —
providedIn: 'root', inject() pour TaxService, AnalyticsService et CURRENCY_TOKEN - TaxService —
@Injectable()sans providedIn, fourni dans CheckoutComponent pour l'isoler - ECOMMERCE_CONFIG —
InjectionTokenfourni dans bootstrapApplication avecuseValue - 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.