Les principes SOLID
Cinq principes pour un code orienté objet compréhensible, flexible et maintenable : SRP, OCP, LSP, ISP et DIP.
Maintenant que les principes de conception fondamentaux sont posés, place à cinq d'entre eux qui forment un acronyme célèbre : SOLID. Robert C. Martin les a popularisés dans son livre Agile Software Development, Principles, Patterns, and Practices. C'est un moyen mnémotechnique pour cinq principes de conception orientée objet destinés à rendre nos logiciels plus compréhensibles, plus flexibles et plus maintenables.
Une mise en garde, néanmoins, avant de plonger. Comme tout dans la vie, appliquer ces principes sans discernement peut faire plus de mal que de bien. Les intégrer dans l'architecture d'un programme a un coût : celui de le rendre plus compliqué qu'il ne devrait l'être. Il est douteux qu'un seul produit logiciel à succès applique les cinq principes en même temps et partout. Tendre vers eux est une bonne chose, mais restez toujours pragmatique et ne prenez rien de ce qui suit pour un dogme. Nous illustrons chaque principe par un exemple TypeScript avant (la violation) puis après (le respect).
Attention
SOLID n'est pas une liste de cases à cocher. Sur un programme de 200 lignes, inventer une architecture sophistiquée est une perte de temps : quelques méthodes bien écrites suffisent. Les principes deviennent précieux quand le code grandit et change sans cesse.
(S) Responsabilité unique
Le principe de responsabilité unique (Single Responsibility Principle, ou SRP) tient en une phrase : une classe ne devrait avoir qu'une seule raison de changer.
Essayez de rendre chaque classe responsable d'une seule partie de la fonctionnalité offerte par le logiciel, et faites en sorte que cette responsabilité soit entièrement encapsulée dans la classe. L'objectif principal de ce principe est de réduire la complexité.
Les vrais problèmes émergent quand le programme grossit. À un certain point, les classes deviennent si grosses qu'on n'en retient plus les détails ; la navigation ralentit, et il faut parcourir des classes entières pour trouver une chose précise. Pire : si une classe fait trop de choses, vous devez la modifier chaque fois que l'une de ces choses change — au risque de casser les autres parties que vous n'aviez pas l'intention de toucher.
Le problème
La classe Employe ci-dessous a plusieurs raisons de changer. La première relève de son métier principal : gérer les données de l'employé. Mais il en existe une autre : le format du rapport de feuille de temps peut évoluer, ce qui vous obligera à modifier le code de la classe.
// ❌ AVANT : la classe mélange deux responsabilités.
class Employe {
constructor(
public nom: string,
private heures: number[],
) {}
// Responsabilité 1 : gestion des données employé.
enregistrer(): void {
/* persistance en base */
}
// Responsabilité 2 : mise en forme du rapport horaire.
imprimerRapportHoraire(): string {
const total = this.heures.reduce((a, b) => a + b, 0);
return `=== ${this.nom} ===\nTotal : ${total} h`;
}
} La solution
Résolvez le problème en déplaçant le comportement lié à l'impression du rapport horaire dans une classe à part. Ce changement vous permet d'y déplacer au passage tout le reste du code lié aux rapports.
// ✅ APRÈS : chaque responsabilité dans sa propre classe.
class Employe {
constructor(
public nom: string,
public heures: number[],
) {}
enregistrer(): void {
/* persistance en base */
}
}
class ImprimeurDeRapportHoraire {
imprimer(employe: Employe): string {
const total = employe.heures.reduce((a, b) => a + b, 0);
return `=== ${employe.nom} ===\nTotal : ${total} h`;
}
} Désormais, une évolution du format du rapport ne touche plus la classe Employe, et inversement. Chaque classe a une seule raison de changer.
Quand l'appliquer : dès qu'il devient difficile de vous concentrer sur un aspect du programme à la fois, ou qu'une classe est modifiée pour des motifs sans rapport entre eux. C'est le signal qu'il est temps de la diviser.
(O) Ouvert/fermé
Le principe ouvert/fermé (Open/Closed Principle, ou OCP) énonce qu'une classe doit être ouverte à l'extension mais fermée à la modification. L'idée maîtresse : empêcher le code existant de casser lorsque vous ajoutez de nouvelles fonctionnalités.
Une classe est ouverte si vous pouvez l'étendre — produire une sous-classe, ajouter des méthodes ou des champs, surcharger un comportement. Elle est fermée (ou « complète ») si elle est prête à 100 % à être utilisée : son interface est clairement définie et ne changera plus. Les mots paraissent contradictoires, mais une classe peut bien être à la fois ouverte (à l'extension) et fermée (à la modification).
Note
Ce principe n'est pas censé s'appliquer à tous les changements. Si vous savez qu'il y a un bug dans une classe, corrigez-le directement : ne créez pas une sous-classe pour ça. Une classe enfant ne doit pas porter la responsabilité des défauts de son parent.
Le problème
Vous avez une application e-commerce avec une classe Commande qui calcule les frais d'expédition. Toutes les méthodes d'expédition y sont codées en dur. Pour en ajouter une nouvelle, vous devez modifier le code de Commande et risquez de le casser.
// ❌ AVANT : ajouter un transporteur force à modifier Commande.
class Commande {
constructor(
public articles: { poids: number }[],
public transport: "standard" | "express",
) {}
fraisDeExpedition(): number {
const poids = this.articles.reduce((a, x) => a + x.poids, 0);
switch (this.transport) {
case "standard":
return poids * 1.5;
case "express":
return poids * 3 + 5;
}
}
} La solution
Appliquez le patron stratégie (Strategy). Commencez par extraire les méthodes d'expédition dans des classes distinctes partageant une interface commune.
// ✅ APRÈS : chaque transporteur est une stratégie autonome.
interface Expedition {
cout(poids: number): number;
}
class ExpeditionStandard implements Expedition {
cout(poids: number): number {
return poids * 1.5;
}
}
class ExpeditionExpress implements Expedition {
cout(poids: number): number {
return poids * 3 + 5;
}
}
class Commande {
constructor(
public articles: { poids: number }[],
private expedition: Expedition,
) {}
fraisDeExpedition(): number {
const poids = this.articles.reduce((a, x) => a + x.poids, 0);
return this.expedition.cout(poids);
}
} Pour implémenter un nouveau mode d'expédition, vous dérivez une nouvelle classe de l'interface Expedition sans toucher au code de Commande. En prime, cette solution permet de déplacer le calcul du délai de livraison dans des classes plus pertinentes, conformément au principe de responsabilité unique.
Quand l'appliquer : quand une classe a déjà été développée, testée, relue et intégrée à un framework ou une application. Y toucher est risqué ; mieux vaut étendre son comportement par l'abstraction et le polymorphisme.
(L) Substitution de Liskov
Le principe de substitution de Liskov (Liskov Substitution Principle, ou LSP), nommé d'après Barbara Liskov, dit ceci : en étendant une classe, vous devez pouvoir passer des objets de la sous-classe à la place d'objets de la classe parente sans casser le code client.
La sous-classe doit rester compatible avec le comportement de la superclasse. Quand vous surchargez une méthode, étendez le comportement de base plutôt que de le remplacer par tout autre chose. Contrairement aux autres principes, largement ouverts à l'interprétation, Liskov impose une série d'exigences formelles sur les sous-classes et leurs méthodes.
La checklist formelle
- Les types de paramètres d'une méthode de la sous-classe doivent correspondre ou être plus abstraits que ceux de la superclasse. Si une méthode
nourrir(c: Chat)est surchargée ennourrir(c: Animal), tout va bien — la sous-classe nourrit n'importe quel animal, donc aussi tout chat. À l'inverse, la restreindre ànourrir(c: ChatBengal)casse le client, qui lui passe des chats génériques. - Le type de retour d'une méthode de la sous-classe doit correspondre ou être un sous-type de celui de la superclasse (exigence inverse de celle des paramètres).
acheterChat(): ChatBengalest acceptable ;acheterChat(): Animalcasse le client, qui attend un chat. - Les exceptions lancées ne doivent pas être de types que la méthode de base ne lance pas. Sinon, une exception inattendue franchit les lignes défensives du client et fait planter l'application.
- Pas de pré-conditions renforcées. Si la méthode de base accepte n'importe quel entier et que la sous-classe exige une valeur positive, le client qui passait jusque-là des nombres négatifs casse.
- Pas de post-conditions affaiblies. Si la méthode de base ferme toujours les connexions à la base de données, une sous-classe qui les laisse ouvertes pour les réutiliser pollue le système de connexions fantômes.
- Les invariants de la superclasse doivent être préservés. Les invariants d'un chat : quatre pattes, une queue, miauler. La façon la plus sûre d'étendre une classe est d'ajouter des champs et méthodes sans toucher aux membres existants.
À retenir
Dans la plupart des langages à typage statique (Java, C#…), une partie de ces règles est vérifiée par le compilateur : un programme qui les viole ne compile tout simplement pas. En TypeScript, le typage structurel détecte aussi les écarts de paramètres et de retour.
Le problème
Voici une hiérarchie de documents qui viole le principe. La méthode enregistrer() n'a pas de sens pour un document en lecture seule ; la sous-classe tente de régler ça en réinitialisant le comportement de base — en lançant une exception.
// ❌ AVANT : la sous-classe casse le contrat de la base.
class Document {
constructor(public donnees: string) {}
enregistrer(): void {
/* écrit sur le disque */
}
}
class DocumentLectureSeule extends Document {
enregistrer(): void {
throw new Error("Lecture seule : impossible d'enregistrer.");
}
} Le code client qui itère sur une liste de Document et appelle enregistrer() plantera dès qu'il croisera un DocumentLectureSeule. Pire, il devient dépendant des classes concrètes (il doit tester le type avant d'enregistrer), ce qui viole aussi le principe ouvert/fermé.
La solution
Repensez la hiérarchie : une sous-classe doit étendre le comportement de sa base. Faites donc du document en lecture seule la classe de base, et du document modifiable une sous-classe qui ajoute le comportement d'enregistrement.
// ✅ APRÈS : la base ne promet que ce que tous savent faire.
class Document {
constructor(public donnees: string) {}
// Pas de enregistrer() ici : tous les documents se lisent.
}
class DocumentModifiable extends Document {
enregistrer(): void {
/* écrit sur le disque */
}
} Tout client manipulant un DocumentModifiable sait qu'il peut l'enregistrer ; aucun client ne se voit plus promettre un comportement qui pourrait échouer.
(I) Ségrégation des interfaces
Le principe de ségrégation des interfaces (Interface Segregation Principle, ou ISP) tient en une phrase : les clients ne devraient pas être forcés de dépendre de méthodes qu'ils n'utilisent pas.
Rendez vos interfaces assez étroites pour que les classes clientes n'aient pas à implémenter des comportements dont elles n'ont pas besoin. Découpez les interfaces « grasses » en interfaces plus granulaires et spécifiques. Sinon, une modification d'une interface fourre-tout casserait jusqu'aux clients qui n'utilisent pas les méthodes modifiées. L'héritage limite une classe à une seule superclasse, mais pas le nombre d'interfaces qu'elle implémente : nul besoin d'entasser des méthodes sans rapport dans une seule interface.
Le problème
Imaginez une bibliothèque qui simplifie l'intégration avec divers fournisseurs de cloud. Sa première version ne gérait qu'Amazon, et couvrait l'ensemble complet de ses services. Vous aviez supposé que tous les fournisseurs offrent le même éventail de fonctionnalités. Mais en implémentant le support d'un autre fournisseur, il s'avère que la plupart des interfaces sont trop larges : certaines méthodes décrivent des fonctions que les autres clouds n'ont tout simplement pas.
// ❌ AVANT : une interface grasse que tous ne peuvent honorer.
interface FournisseurCloud {
stockerFichier(chemin: string): void;
obtenirFichier(chemin: string): Buffer;
envoyerCourriel(a: string, corps: string): void; // pas partout !
creerServeur(region: string): string; // pas partout !
}
class DropboxCloud implements FournisseurCloud {
stockerFichier(chemin: string): void {
/* ... */
}
obtenirFichier(chemin: string): Buffer {
return Buffer.from("");
}
// Méthodes inutiles, réduites à des bouchons gênants.
envoyerCourriel(): void {
throw new Error("Non supporté.");
}
creerServeur(): string {
throw new Error("Non supporté.");
}
} Vous pourriez remplir ces méthodes de bouchons (stubs), mais ce ne serait pas une jolie solution.
La solution
La meilleure approche est de découper l'interface. Les classes capables de tout implémenter implémentent simplement plusieurs interfaces raffinées ; les autres n'implémentent que celles dont les méthodes ont un sens pour elles.
// ✅ APRÈS : des interfaces ciblées et combinables.
interface StockageCloud {
stockerFichier(chemin: string): void;
obtenirFichier(chemin: string): Buffer;
}
interface CourrielCloud {
envoyerCourriel(a: string, corps: string): void;
}
interface CalculCloud {
creerServeur(region: string): string;
}
// Dropbox n'implémente que ce qu'il sait vraiment faire.
class DropboxCloud implements StockageCloud {
stockerFichier(chemin: string): void {
/* ... */
}
obtenirFichier(chemin: string): Buffer {
return Buffer.from("");
}
}
// Amazon, lui, peut tout implémenter d'un coup.
class AmazonCloud implements StockageCloud, CourrielCloud, CalculCloud {
stockerFichier(chemin: string): void {
/* ... */
}
obtenirFichier(chemin: string): Buffer {
return Buffer.from("");
}
envoyerCourriel(a: string, corps: string): void {
/* ... */
}
creerServeur(region: string): string {
return "i-12345";
}
} Piège courant
Comme pour les autres, on peut aller trop loin. Ne divisez pas une interface déjà bien spécifique : plus vous créez d'interfaces, plus votre code se complexifie. Gardez l'équilibre.
(D) Inversion des dépendances
Le principe d'inversion des dépendances (Dependency Inversion Principle, ou DIP) énonce que les classes de haut niveau ne doivent pas dépendre des classes de bas niveau : les deux doivent dépendre d'abstractions. Et les abstractions ne doivent pas dépendre des détails ; ce sont les détails qui doivent dépendre des abstractions.
On distingue généralement deux niveaux de classes. Les classes de bas niveau réalisent des opérations basiques : travailler avec le disque, transférer des données sur le réseau, se connecter à une base. Les classes de haut niveau contiennent la logique métier complexe qui pilote les classes de bas niveau. Quand on conçoit le bas niveau en premier (fréquent sur un prototype), la logique métier finit par dépendre de classes primitives. Le principe propose d'inverser le sens de cette dépendance.
La démarche tient en trois temps : (1) décrivez des interfaces pour les opérations de bas niveau dont le haut niveau a besoin, formulées en termes métier — la logique appellera ouvrirRapport(fichier) plutôt que ouvrirFichier, lireOctets, fermerFichier ; (2) faites dépendre les classes de haut niveau de ces interfaces, et non plus des classes concrètes ; (3) les classes de bas niveau implémentent ces interfaces et deviennent ainsi dépendantes du niveau métier — la dépendance est inversée.
Le problème
Une classe de reporting budgétaire de haut niveau utilise directement une classe de base de données de bas niveau pour lire et persister ses données. Tout changement dans la classe de bas niveau (nouvelle version du serveur, par exemple) risque d'affecter la classe de haut niveau, qui n'est pourtant pas censée se soucier des détails de stockage.
// ❌ AVANT : le haut niveau dépend du bas niveau concret.
class BaseMySQL {
insererLigne(table: string, donnees: object): void {
/* SQL spécifique à MySQL */
}
lireLignes(table: string): object[] {
return [];
}
}
class RapportBudget {
constructor(private db: BaseMySQL) {} // dépendance concrète !
ouvrir(annee: number): object[] {
return this.db.lireLignes(`budget_${annee}`);
}
} La solution
Créez une interface de haut niveau décrivant les opérations de lecture/écriture, formulée en termes métier, et faites dépendre la classe de reporting de cette interface. La classe de bas niveau implémente alors l'interface déclarée par la logique métier.
// ✅ APRÈS : tout dépend d'une abstraction définie par le métier.
interface DepotBudget {
charger(annee: number): object[];
sauvegarder(annee: number, lignes: object[]): void;
}
// Le bas niveau implémente le contrat du haut niveau.
class DepotBudgetMySQL implements DepotBudget {
charger(annee: number): object[] {
return [];
}
sauvegarder(annee: number, lignes: object[]): void {
/* SQL spécifique à MySQL */
}
}
class RapportBudget {
constructor(private depot: DepotBudget) {} // dépend de l'abstraction
ouvrir(annee: number): object[] {
return this.depot.charger(annee);
}
} Le sens de la dépendance d'origine est inversé : les classes de bas niveau dépendent désormais d'abstractions de haut niveau. Vous pouvez remplacer MySQL par PostgreSQL, ou par un dépôt en mémoire pour les tests, sans toucher à RapportBudget.
Astuce
Le principe d'inversion des dépendances accompagne souvent le principe ouvert/fermé : vous pouvez étendre les classes de bas niveau pour les utiliser avec différentes logiques métier sans casser les classes existantes. C'est aussi la clé de l'injection de dépendances et des tests par doublures.
Tableau récapitulatif
| Principe | Intitulé | En une phrase |
|---|---|---|
| S | Responsabilité unique (SRP) | Une classe ne devrait avoir qu'une seule raison de changer. |
| O | Ouvert/fermé (OCP) | Ouverte à l'extension, fermée à la modification : ajouter sans toucher à l'existant. |
| L | Substitution de Liskov (LSP) | Une sous-classe doit pouvoir remplacer sa base sans casser le code client. |
| I | Ségrégation des interfaces (ISP) | Ne forcez pas un client à dépendre de méthodes qu'il n'utilise pas. |
| D | Inversion des dépendances (DIP) | Haut et bas niveau dépendent d'abstractions, pas l'un de l'autre. |
À retenir
- SOLID vise un code compréhensible, flexible et maintenable, mais reste un guide, pas un dogme : appliqué aveuglément, il mène à la sur-ingénierie.
- SRP : une seule raison de changer par classe. Extrayez les responsabilités annexes (comme l'impression d'un rapport hors d'une classe
Employe). - OCP et DIP vont de pair : programmez contre des abstractions (interfaces) pour étendre par le polymorphisme, sans modifier ni coupler le code existant aux détails de bas niveau.
- LSP impose une checklist formelle (paramètres plus abstraits, retours plus spécifiques, pas d'exceptions surprises, invariants préservés) : une sous-classe étend, elle ne contredit jamais sa base.
- ISP : préférez plusieurs interfaces étroites et combinables à une interface grasse — sans pour autant émietter une interface déjà spécifique.
- Le vrai bénéfice de SOLID se révèle quand le programme grandit et change ; sur un petit script, restez simple.