Clean code et refactoring - 08 - SOLID en pratique avec TypeScript

Les cinq principes SOLID illustres avec du TypeScript concret, et quand ils sont de trop

  1. 01 Clean code et refactoring - 00 - Pourquoi le clean code est un investissement, pas un luxe
  2. 02 Clean code et refactoring - 01 - Nommage : la competence la plus sous-estimee
  3. 03 Clean code et refactoring - 02 - Fonctions : courtes, claires, responsables
  4. 04 Clean code et refactoring - 03 - Conditions et lisibilité : sortir de la pyramide
  5. 05 Clean code et refactoring - 04 - Commentaires et documentation : quand le code ne suffit pas
  6. 06 Clean code et refactoring - 05 - Immutabilite et effets de bord : moins de surprises, moins de bugs
  7. 07 Clean code et refactoring - 06 - Gestion des erreurs propre : fail fast, fail loud
  8. 08 Clean code et refactoring - 07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur
  9. 09 Clean code et refactoring - 08 - SOLID en pratique avec TypeScript
  10. 10 Clean code et refactoring - 09 - DRY, KISS, YAGNI
  11. 11 Clean code et refactoring - 10 - Couplage et cohesion
  12. 12 Clean code et refactoring - 11 - Complexite cyclomatique
  13. 13 Clean code et refactoring - 12 - Abstractions prematurees vs tardives
  14. 14 Clean code et refactoring - 13 - Code smells
  15. 15 Clean code et refactoring - 14 - Techniques de refactoring
  16. 16 Clean code et refactoring - 15 - Refactoring legacy sans tout casser
  17. 17 Clean code et refactoring - 16 - Tests comme filet de sécurité pour le refactoring
  18. 18 Clean code et refactoring - 17 - Structurer un projet — feature-based vs layer-based
  19. 19 Clean code et refactoring - 18 - Constantes, configuration et magic numbers
  20. 20 Clean code et refactoring - 19 - Linting et formatting — ESLint, Biome, automatiser la qualité
  21. 21 Clean code et refactoring - 20 - Conventions d'équipe et ADR
  22. 22 Clean code et refactoring - 21 - Dette technique — quand elle est acceptable, quand elle tue le projet
  23. 23 Clean code et refactoring - 22 - Code review — donner et recevoir du feedback
  24. 24 Clean code et refactoring - 23 - Glossaire — tous les termes de la serie

08 - SOLID en pratique avec TypeScript

Ce que tu vas apprendre

  • Les cinq principes SOLID avec des exemples TypeScript concrets
  • Comment appliquer chaque principe sans tomber dans la sur-ingenierie
  • Les erreurs frequentes d'interprétation de chaque lettre
  • Quand SOLID est excessif pour ton contexte

Prerequisites

Cet article fait suite a 07 - Programmation defensive vs offensive. Une connaissance de base de TypeScript et de la programmation orientee objet est attendue.


J'ai passe six mois dans une équipe ou chaque classe avait une interface, chaque interface avait un seul consumer, et chaque module avait trois couches d'abstraction. Le code etait "SOLID". Il etait aussi incomprehensible. SOLID n'est pas un dogme religieux. C'est un ensemble d'heuristiques qui, bien comprises, rendent le code plus simple a faire évoluer. Mal comprises, elles produisent exactement l'inverse.

Robert C. Martin a formule ces principes dans les annees 2000. Ils s'appliquent a la POO, mais les idees sous-jacentes sont universelles. Regardons chaque lettre avec du vrai code.

S - Single Responsibility Principle

Une classe a une seule raison de changer. Ca ne veut pas dire "une seule méthode". Ca veut dire un seul axe de modification.

typescript// Avant : deux raisons de changer (format ET persistance)
class UserReport {
  generateReport(user: User): string {
    return `Nom: ${user.name}, Email: ${user.email}`;
  }

  saveToFile(content: string, path: string): void {
    fs.writeFileSync(path, content);
  }
}

// Apres : chaque classe a un seul axe de changement
class UserReportFormatter {
  format(user: User): string {
    return `Nom: ${user.name}, Email: ${user.email}`;
  }
}

class FileWriter {
  write(content: string, path: string): void {
    fs.writeFileSync(path, content);
  }
}

L'erreur classique est de créer une classe par méthode. Si generateReport et formatHeader changent toujours ensemble, elles appartiennent a la meme classe. Le critère est la raison du changement, pas le nombre de lignes.

O - Open/Closed Principle

Ouvert a l'extension, ferme a la modification. En pratique, ca veut dire qu'on ajoute du comportement sans toucher au code existant.

typescript// Le pattern Strategy est l'incarnation du O
interface PricingStrategy {
  calculate(basePrice: number): number;
}

class RegularPricing implements PricingStrategy {
  calculate(basePrice: number): number {
    return basePrice;
  }
}

class BlackFridayPricing implements PricingStrategy {
  calculate(basePrice: number): number {
    return basePrice * 0.7;
  }
}

class VipPricing implements PricingStrategy {
  calculate(basePrice: number): number {
    return basePrice * 0.85;
  }
}

// Le checkout ne change jamais quand on ajoute une strategie
class Checkout {
  constructor(private pricing: PricingStrategy) {}

  total(items: { price: number }[]): number {
    return items.reduce(
      (sum, item) => sum + this.pricing.calculate(item.price),
      0
    );
  }
}

Pour ajouter un tarif etudiant, je créé StudentPricing. Je ne touche pas a Checkout. Ca, c'est le O en action.

L - Liskov Substitution Principle

Un sous-type doit pouvoir remplacer son type parent sans casser le programme. L'exemple classique est le carre et le rectangle.

typescript// Violation du L : le carre casse le contrat du rectangle
class Rectangle {
  constructor(protected width: number, protected height: number) {}

  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }
  area(): number { return this.width * this.height; }
}

class Square extends Rectangle {
  setWidth(w: number): void {
    this.width = w;
    this.height = w; // Surprise !
  }
  setHeight(h: number): void {
    this.width = h;
    this.height = h;
  }
}

// Ce code marche avec Rectangle, pas avec Square
function doubleWidth(rect: Rectangle): number {
  rect.setWidth(rect.area() / rect.area() * 10); // simplifie
  rect.setHeight(5);
  rect.setWidth(10);
  return rect.area(); // attend 50, Square retourne 100
}

La solution est souvent de ne pas utiliser l'héritage du tout. Deux types distincts Rectangle et Square avec une interface commune Shape qui expose seulement area() et perimeter().

I - Interface Segregation Principle

Les clients ne doivent pas dépendre d'interfaces qu'ils n'utilisent pas.

typescript// Avant : interface trop large
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  attendMeeting(): void;
}

// Un robot ne mange pas et ne dort pas
class Robot implements Worker {
  work(): void { /* ... */ }
  eat(): void { throw new Error("Robots don't eat"); }
  sleep(): void { throw new Error("Robots don't sleep"); }
  attendMeeting(): void { /* ... */ }
}

// Apres : interfaces decoupees
interface Workable {
  work(): void;
}

interface Feedable {
  eat(): void;
}

interface Restable {
  sleep(): void;
}

class HumanWorker implements Workable, Feedable, Restable {
  work(): void { /* ... */ }
  eat(): void { /* ... */ }
  sleep(): void { /* ... */ }
}

class RobotWorker implements Workable {
  work(): void { /* ... */ }
}

En TypeScript, le I est naturel grace au duck typing et aux types structurels. Tu n'as meme pas besoin de implements pour que ca marche.

D - Dependency Inversion Principle

Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions.

typescript// Avant : couplage direct
class OrderService {
  private db = new PostgresDatabase();
  private mailer = new SendgridMailer();

  async createOrder(order: Order): Promise<void> {
    await this.db.insert("orders", order);
    await this.mailer.send(order.userEmail, "Commande confirmee");
  }
}

// Apres : injection de dependances
interface OrderRepository {
  save(order: Order): Promise<void>;
}

interface NotificationService {
  notify(email: string, message: string): Promise<void>;
}

class OrderService {
  constructor(
    private repository: OrderRepository,
    private notifications: NotificationService
  ) {}

  async createOrder(order: Order): Promise<void> {
    await this.repository.save(order);
    await this.notifications.notify(order.userEmail, "Commande confirmee");
  }
}

Le D rend le code testable. En test, je passe un InMemoryOrderRepository. En prod, un PostgresOrderRepository. Le service ne sait pas et ne veut pas savoir.

Quand SOLID est de trop

Un script de 50 lignes qui tourne une fois par mois n'a pas besoin de cinq interfaces et trois couches d'abstraction. Un prototype qu'on va jeter dans deux semaines merite du code jetable.

J'applique SOLID quand le code va vivre longtemps, etre lu par d'autres, ou changer souvent. Pour un one-shot, je m'en passe sans culpabilite.

Le vrai danger n'est pas de violer SOLID. C'est de l'appliquer mecaniquement sans comprendre le problème que chaque principe resout. Retrouve d'autres reflexions sur les compromis en architecture sur paltemps.fr.

Résumé

  • S : une classe a une seule raison de changer, pas une seule méthode
  • O : ajouter du comportement sans modifier le code existant (strategy, plugins)
  • L : un sous-type remplace son parent sans surprise
  • I : des interfaces petites et ciblees plutot qu'une grosse interface
  • D : dépendre d'abstractions, pas d'implementations concrètes
  • SOLID est un guide, pas un dogme -- le contexte décidé

Article précédent : 07 - Programmation defensive vs offensive

Article suivant : 09 - DRY, KISS, YAGNI

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.