~2 min de lecture
SOLID en Angular 20+ : mauvaise vs bonne façon
Objectif : te donner des repères concrets. Pour chaque principe, on commence par ce qu’il ne faut pas faire puis on montre la bonne approche avec Angular v20+.
📦 S — Single Responsibility Principle (Responsabilité unique)
Idée clé : un fichier = un rôle clair (UI ou logique), pas les deux.
❌ Mauvaise façon
Le composant fait tout : fetch + état + affichage.
// product-list.component.ts (anti-pattern)
import { Component, signal } from '@angular/core';
type Product = { id: number; name: string; price: number };
@Component({
selector: 'app-product-list',
template: `
<button (click)="load()">Recharger</button>
@if(loading()) { <p>Chargement…</p> }
@else {
<ul>
@for(p of products(); track p.id) {
<li>{{ p.name }} — {{ p.price | currency:'EUR' }}</li>
}
</ul>
}
`
})
export class ProductListComponent {
readonly products = signal<Product[]>([]);
readonly loading = signal(false);
async load(): void {
this.loading.set(true);
// Appel direct ici = couplage fort UI/données
const data = await fetch('/api/products').then(r => r.json());
this.products.set(data);
this.loading.set(false);
}
}
Problèmes : difficile à tester, UI couplée au transport (fetch), impossible de réutiliser la logique ailleurs.
✅ Bonne façon
Service pour la logique + composant pour l’UI.
// product.service.ts
import { Injectable, signal } from '@angular/core';
export interface Product {
id: number;
name: string;
price: number
}
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly _products = signal<Product[]>([]);
readonly products = this._products.asReadonly();
async load(): void {
const data: Product[] = await fetch('/api/products').then(r => r.json());
this._products.set(data);
}
}
// product-list.component.ts
import { Component, inject, signal } from '@angular/core';
import { ProductService } from './product.service';
@Component({
selector: 'app-product-list',
template: `
<button (click)="refresh()">Recharger</button>
@if(loading()) { <p>Chargement…</p> }
@else {
<ul>
@for(p of svc.products(); track p.id) {
<li>{{ p.name }} — {{ p.price | currency:'EUR' }}</li>
}
</ul>
}
`
})
export class ProductListComponent {
private readonly _svc = inject(ProductService);
readonly loading = signal(false);
async refresh() {
this.loading.set(true);
await this._svc.load();
this.loading.set(false);
}
}
🔒 O — Open/Closed Principle (Ouvert/Fermé)
Idée clé : ajouter une variante sans modifier le code existant.
❌ Mauvaise façon
switch répété dans le composant, à modifier à chaque nouveau moyen de paiement.
// checkout.component.ts (anti-pattern)
import { Component, input } from '@angular/core';
@Component({
selector: 'app-checkout',
template: `<button (click)="pay()">Payer</button>`
})
export class CheckoutComponent {
method = input<'stripe' | 'paypal'>('stripe');
async pay() {
const amount = 49;
if (this.method() === 'stripe') {
/* appel Stripe */
} else if (this.method() === 'paypal') {
/* appel PayPal */
}
// Ajouter ApplePay ? => on modifie ce fichier encore…
}
}
✅ Bonne façon
Stratégies sélectionnées par DI (InjectionToken). On ajoute une classe, on ne touche pas au composant.
// payment.port.ts
import { InjectionToken } from '@angular/core';
export interface PaymentStrategy {
pay(amount: number): Promise<void>;
}
export const PAYMENT = new InjectionToken<PaymentStrategy>('PAYMENT');
// stripe.strategy.ts
export class StripePayment implements PaymentStrategy {
async pay(amount: number) { /* appel Stripe */
}
}
// paypal.strategy.ts
export class PaypalPayment implements PaymentStrategy {
async pay(amount: number) { /* appel PayPal */
}
}
// checkout.component.ts
import { Component, inject } from '@angular/core';
import { PAYMENT } from './payment.port';
@Component({
selector: 'app-checkout',
// change la stratégie ici sans toucher au composant, pourrait être fait au niveau du routing
providers: [{ provide: PAYMENT, useClass: StripePayment }],
template: `<button (click)="onPay()">Payer</button>`
})
export class CheckoutComponent {
private payment = inject(PAYMENT);
async onPay() {
await this.payment.pay(49);
}
}
🔄 L — Liskov Substitution Principle (Substitution de Liskov)
Idée clé : toute implémentation peut remplacer une autre sans effet de bord.
❌ Mauvaise façon
Contrat trop large / incohérent selon l’implémentation.
// notifier.ts (anti-pattern)
export interface Notifier {
notify(message: string): Promise<void>;
// Un impl nécessite 'subject' ? L'autre l'ignore => surprise
setSubject?(subject: string): void;
}
✅ Bonne façon
Contrat minimal et cohérent. Chaque adaptateur respecte le même comportement.
// notifier.port.ts
import { InjectionToken } from '@angular/core';
export interface Notifier {
notify(message: string): Promise<void>;
}
export const NOTIFIER = new InjectionToken<Notifier>('NOTIFIER');
// email.notifier.ts
export class EmailNotifier implements Notifier {
async notify(message: string) { /* envoi email */
}
}
// sms.notifier.ts
export class SmsNotifier implements Notifier {
async notify(message: string) { /* envoi SMS */
}
}
// profile.component.ts
import { Component, inject } from '@angular/core';
import { NOTIFIER } from './notifier.port';
import { EmailNotifier } from './email.notifier';
@Component({
selector: 'app-profile',
providers: [{ provide: NOTIFIER, useClass: EmailNotifier }],
template: `<button (click)="save()">Sauvegarder</button>`
})
export class ProfileComponent {
private notifier = inject(NOTIFIER);
async save() {
await this.notifier.notify('Profil sauvegardé ✅');
}
}
🪶 I — Interface Segregation Principle (Segregation des interfaces)
Idée clé : éviter les interfaces « god object ».
❌ Mauvaise façon
Une interface force les consommateurs à implémenter tout.
// repository.ts (anti-pattern)
export interface Repository<T> {
load(): Promise<T[]>;
save(item: T): Promise<void>;
delete(id: string): Promise<void>;
// Beaucoup de composants n'ont besoin que de load()…
}
✅ Bonne façon
Des ports petits et ciblés.
export interface Loader<T> {
load(): Promise<T[]>;
}
export interface Saver<T> {
save(item: T): Promise<void>;
}
// product-editor.component.ts
import { Component, input, output, inject } from '@angular/core';
@Component({
selector: 'app-product-editor',
template: `
<h3>Éditer {{ product()?.name }}</h3>
<button (click)="onSave()">Enregistrer</button>
`
})
export class ProductEditorComponent {
product = input.required<{ id: number; name: string }>();
saved = output<{ id: number; name: string }>();
#saver = inject<ProductSaver>(ProductSaver); // DI classique
async onSave() {
await this.#saver.save(this.product()!);
this.saved.emit(this.product()!);
}
}
🔌 D — Dependency Inversion Principle (Inversion des dépendances)
Idée clé : dépendre d’abstractions (ports), pas de classes concrètes.
❌ Mauvaise façon
Le composant appelle directement une lib concrète (couplage fort).
// somewhere.component.ts (anti-pattern)
import { Component } from '@angular/core';
@Component({
selector: 'app-somewhere',
template: `<button (click)="buy()">Acheter</button>`
})
export class SomewhereComponent {
buy(): void {
// Couplage à GA4, impossible à mocker facilement
// window.gtag('event', 'purchase', { amount: 49 });
}
}
✅ Bonne façon
Port AnalyticsPort + adaptateurs interchangeables.
// analytics.port.ts
import { InjectionToken } from '@angular/core';
export interface AnalyticsPort {
track(event: string, payload: Record<string, unknown>): void;
}
export const ANALYTICS = new InjectionToken<AnalyticsPort>('ANALYTICS');
// ga4.adapter.ts
export class GA4Adapter implements AnalyticsPort {
track(e: string, payload: Record<string, unknown>) { /* gtag */
}
}
// console.adapter.ts
export class ConsoleAdapter implements AnalyticsPort {
track(e: string, payload: Record<string, unknown>) {
console.log(e, payload);
}
}
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { ANALYTICS } from './analytics.port';
import { GA4Adapter } from './ga4.adapter';
export const appConfig: ApplicationConfig = {
providers: [{ provide: ANALYTICS, useClass: GA4Adapter }]
};
// buy.component.ts
import { Component, inject } from '@angular/core';
import { ANALYTICS } from './analytics.port';
@Component({
selector: 'app-buy',
template: `<button (click)="buy()">Acheter</button>`
})
export class BuyComponent {
analytics = inject(ANALYTICS);
buy() {
this.analytics.track('purchase', { amount: 49 });
}
}
✅ Audit express — « est‑ce SOLID ? »
- S : mon composant a‑t‑il un rôle unique ?
- O : puis‑je ajouter un cas sans modifier le composant ?
- L : mes implémentations sont‑elles vraiment interchangeables ?
- I : les contrats sont‑ils petits et ciblés ?
- D : je dépends d’un InjectionToken plutôt que d’une classe concrète ?
👉🏼 Pour en apprendre davantage et mettre en pratique, viens découvrir EasyAngularKit : https://pim.ms/C31g7p2