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