POO : principes SOLID et conception
SOLID regroupe cinq principes qui guident la conception pour obtenir un code facile à modifier et à tester. Ce sont des repères, pas des règles absolues : appliquer un principe quand le code gêne réellement (modifications difficiles, tests pénibles), pas par avance.
S : responsabilité unique
Section intitulée « S : responsabilité unique »Une classe doit avoir une seule raison de changer.
// Avant : mélange règle métier, persistance, présentationclass Facture { constructor(public lignes: { prixCentimes: number; quantite: number }[]) {} total() { return this.lignes.reduce((t, l) => t + l.prixCentimes * l.quantite, 0); } enregistrer() { /* écrit en base de données */ } versHtml() { return `<p>Total : ${this.total() / 100} €</p>`; }}// Après : chaque classe a un rôleclass Facture { constructor(readonly lignes: { prixCentimes: number; quantite: number }[]) {} get totalCentimes() { return this.lignes.reduce((t, l) => t + l.prixCentimes * l.quantite, 0); }}
interface DepotFactures { enregistrer(facture: Facture): Promise<void>;}
class RendeurHtml { rendre(facture: Facture): string { return `<p>Total : ${(facture.totalCentimes / 100).toFixed(2)} €</p>`; }}Modifier la présentation ne touche plus la règle métier ni la base de données.
O : ouvert / fermé
Section intitulée « O : ouvert / fermé »Une classe est ouverte à l’extension, fermée à la modification : ajouter un comportement sans réécrire le code existant.
// Fermé à l'extension : chaque nouveau tarif modifie la fonctionfunction prix(type: 'normal' | 'reduit' | 'enfant', base: number) { if (type === 'reduit') return base * 0.8; if (type === 'enfant') return base * 0.5; return base;}// Ouvert : un nouveau tarif = une nouvelle classeinterface Tarif { appliquer(base: number): number;}class TarifNormal implements Tarif { appliquer(b: number) { return b; }}class TarifReduit implements Tarif { appliquer(b: number) { return b * 0.8; }}class TarifEnfant implements Tarif { appliquer(b: number) { return b * 0.5; }}
const prix = (tarif: Tarif, base: number) => tarif.appliquer(base);C’est le patron « stratégie » (Composition et patrons de conception). Ne pas l’appliquer à un if simple qui ne changera pas.
L : substitution de Liskov
Section intitulée « L : substitution de Liskov »Une sous-classe doit pouvoir remplacer sa classe parente sans casser le programme.
class Rectangle { constructor( protected largeur: number, protected hauteur: number, ) {} definirLargeur(l: number) { this.largeur = l; } definirHauteur(h: number) { this.hauteur = h; } aire() { return this.largeur * this.hauteur; }}
class Carre extends Rectangle { // viole Liskov override definirLargeur(l: number) { this.largeur = l; this.hauteur = l; } override definirHauteur(h: number) { this.largeur = h; this.hauteur = h; }}
function redimensionner(r: Rectangle) { r.definirLargeur(4); r.definirHauteur(5); return r.aire(); // 20 attendu : un Carre renvoie 25}Un carré « est un » rectangle en géométrie, pas en programmation avec des objets modifiables : le comportement attendu du parent n’est pas respecté. Solutions : objets immuables, ou deux types distincts qui partagent une interface Forme.
Règles : une sous-classe ne doit pas renforcer les conditions d’entrée, affaiblir les garanties de sortie ni lever des erreurs que le parent ne lève pas. Si une sous-classe doit désactiver une méthode (throw new Error('non supporté')), l’héritage est mal choisi.
I : ségrégation des interfaces
Section intitulée « I : ségrégation des interfaces »Ne pas obliger une classe à dépendre de méthodes qu’elle n’utilise pas. Préférer plusieurs petites interfaces à une grosse.
// Interface trop largeinterface Machine { imprimer(): void; scanner(): void; faxer(): void;}class ImprimanteSimple implements Machine { imprimer() {} scanner() { throw new Error('non supporté'); } faxer() { throw new Error('non supporté'); }}// Interfaces cibléesinterface Imprimante { imprimer(): void;}interface Scanneur { scanner(): void;}
class ImprimanteSimple implements Imprimante { imprimer() {}}class Multifonction implements Imprimante, Scanneur { imprimer() {} scanner() {}}
function imprimerRapport(imprimante: Imprimante) { imprimante.imprimer();}Une fonction ne demande que ce dont elle a besoin : elle fonctionne avec toute classe qui fournit ce rôle, et se teste avec un faux minimal.
D : inversion des dépendances
Section intitulée « D : inversion des dépendances »Les modules de haut niveau (règles métier) ne dépendent pas des modules de bas niveau (base de données, courriel, fichiers) : les deux dépendent d’abstractions (interfaces).
// Haut niveau couplé au bas niveauclass ServiceCommande { private base = new ClientPostgres(); // dépendance concrète passer(c: Commande) { return this.base.inserer(c); }}// Inversion : le métier définit l'interface, l'infrastructure l'implémenteinterface DepotCommandes { enregistrer(commande: Commande): Promise<void>;}
class ServiceCommande { constructor(private readonly depot: DepotCommandes) {} passer(c: Commande) { return this.depot.enregistrer(c); }}
class DepotCommandesPostgres implements DepotCommandes { /* ... */}class DepotCommandesMemoire implements DepotCommandes { /* ... */}Le métier ne sait plus qu’une base existe ; on teste avec le dépôt en mémoire. Voir Polymorphisme et interfaces.
Cohésion et couplage
Section intitulée « Cohésion et couplage »| Notion | Définition | Objectif |
|---|---|---|
| Cohésion | Les éléments d’une classe servent un même but | Forte |
| Couplage | Dépendances d’une classe envers les autres | Faible |
Signaux d’une mauvaise cohésion : une classe Utils ou Manager qui fait tout, des méthodes qui n’utilisent pas les attributs de la classe, des noms en « et » (ChargerEtEnvoyer). Signaux de couplage excessif : une modification dans une classe en casse plusieurs autres, des chaînes d’appels a.b().c().d().
Loi de Déméter
Section intitulée « Loi de Déméter »Un objet ne devrait parler qu’à ses voisins immédiats.
// Couplé à la structure interne de plusieurs objetsconst ville = commande.client.adresse.ville;commande.client.adresse.changerVille('Lyon');
// Déléguerconst ville2 = commande.villeDeLivraison();commande.changerVilleDeLivraison('Lyon');Principe : demander à un objet de faire (« dites, ne demandez pas »), plutôt que d’extraire ses données pour décider à sa place.
Modèle « anémique » : un piège
Section intitulée « Modèle « anémique » : un piège »Une classe qui n’a que des attributs et des accesseurs, avec la logique éparpillée dans des « services », perd l’intérêt de la POO :
// Anémique : la logique est ailleursclass Compte { solde = 0;}class ServiceCompte { retirer(c: Compte, m: number) { if (c.solde >= m) c.solde -= m; }}// Riche : l'objet protège son invariantclass Compte { #solde = 0; retirer(m: number) { if (m > this.#solde) throw new Error('solde insuffisant'); this.#solde -= m; }}À l’inverse, des données transportées (réponse d’API, DTO) restent de simples objets : l’important est que les règles métier vivent là où se trouve l’état qu’elles protègent.
Quand ne pas appliquer SOLID
Section intitulée « Quand ne pas appliquer SOLID »| Situation | Conseil |
|---|---|
| Script court, prototype | Fonctions simples : pas d’abstractions |
| Une seule implémentation, aucun besoin de test isolé | Pas d’interface « au cas où » |
| Code qui ne change pas | Ne pas le restructurer |
| Équipe qui découvre la POO | Introduire un principe à la fois, à partir d’un vrai problème |
Une abstraction a un coût (indirections, fichiers supplémentaires). La conception sert à réduire le coût du changement, pas à ajouter des couches.
Un exemple complet, simple
Section intitulée « Un exemple complet, simple »interface Remise { appliquer(totalCentimes: number): number;}interface DepotCommandes { enregistrer(c: Commande): Promise<void>;}interface Notificateur { confirmer(c: Commande): Promise<void>;}
class Commande { constructor( readonly numero: string, readonly totalCentimes: number, ) {}}
class ServiceCommande { constructor( private readonly depot: DepotCommandes, private readonly notificateur: Notificateur, private readonly remise: Remise, ) {}
async passer(numero: string, brutCentimes: number): Promise<Commande> { const commande = new Commande(numero, this.remise.appliquer(brutCentimes)); await this.depot.enregistrer(commande); await this.notificateur.confirmer(commande); return commande; }}Responsabilité unique (orchestration), ouvert (nouvelle remise sans modifier le service), dépendances inversées (interfaces), interfaces ciblées : le service se teste avec trois faux d’une ligne chacun.
Dépannage
Section intitulée « Dépannage »| Symptôme | Piste |
|---|---|
| Classe de plusieurs centaines de lignes | Responsabilités multiples : extraire |
| Modifier une classe oblige à modifier beaucoup d’autres | Couplage : interfaces, injection |
Méthode throw new Error('non supporté') dans une sous-classe |
Violation de Liskov : revoir la hiérarchie, interfaces plus petites |
| Interface à plus de 5 méthodes | Ségrégation : la découper par rôle |
| Test qui demande une vraie base | Dépendance concrète : inverser par une interface |
| Abstractions que personne n’utilise | Trop de conception anticipée : les retirer |
Ubuntu 24.04 et 26.04 : différences à connaître
Section intitulée « Ubuntu 24.04 et 26.04 : différences à connaître »Aucune.