📧 Reste informé(e) !

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

S'inscrire gratuitement

~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://www.easyangularkit.com/easyangularkit

📧 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.