SOLID

SOLID est un acronyme rassemblant cinq principes de conception orientée objet popularisés par Robert C. Martin à la fin des années 1990. Ils ne portent pas sur la correction du code — un programme peut violer les cinq et fonctionner parfaitement — mais sur son coût de modification.

Le problème qu’ils traitent est toujours le même : dans un logiciel qui vit, un changement ne reste jamais local. On touche une classe, et trois autres cessent de compiler ; on ajoute un cas, et il faut relire un switch de deux cents lignes. Les cinq principes sont autant d’angles d’attaque sur cette propagation, et ils convergent tous vers la même idée : maîtriser le sens des dépendances.

Note

Chaque exemple est donné en TypeScript, Python et Rust. L’onglet choisi est retenu pour toute la page. La comparaison est instructive : les principes sont les mêmes, mais les langages n’offrent pas les mêmes moyens de les respecter — ni les mêmes occasions de les violer.

S — Responsabilité unique

Une classe ne doit avoir qu’une seule raison de changer.

La formulation la plus utile n’est pas « une classe ne fait qu’une chose » — trop vague, personne ne s’accorde sur la taille d’« une chose » — mais celle que Martin a retenue plus tard : un module ne doit répondre qu’à un seul acteur. Un acteur, c’est une personne ou un service qui peut demander un changement : la comptabilité, le service juridique, l’équipe base de données.

​
class Facture {
  constructor(private readonly lignes: Ligne[]) {}

  total(): number {
    return this.lignes.reduce((somme, l) => somme + l.prixHT * (1 + l.tva), 0);
  }

  enregistrer(): void {
    db.query('INSERT INTO factures ...');             // Équipe base de données
  }

  versHtml(): string {
    return `<h1>Facture</h1><p>${this.total()} €</p>`; // Équipe design
  }
}
class Facture:
    def __init__(self, lignes: list[Ligne]) -> None:
        self._lignes = lignes

    def total(self) -> float:
        return sum(l.prix_ht * (1 + l.tva) for l in self._lignes)

    def enregistrer(self) -> None:
        db.execute("INSERT INTO factures ...")              # Équipe base de données

    def vers_html(self) -> str:
        return f"<h1>Facture</h1><p>{self.total()} €</p>"    # Équipe design
struct Facture {
    lignes: Vec<Ligne>,
}

impl Facture {
    fn total(&self) -> f64 {
        self.lignes.iter().map(|l| l.prix_ht * (1.0 + l.tva)).sum()
    }

    fn enregistrer(&self, db: &Connexion) {
        db.execute("INSERT INTO factures ...");              // Équipe base de données
    }

    fn vers_html(&self) -> String {
        format!("<h1>Facture</h1><p>{} €</p>", self.total()) // Équipe design
    }
}

Trois acteurs, trois raisons de changer, et donc trois occasions de casser le travail des deux autres. Un changement de règle de TVA demandé par la comptabilité oblige à redéployer le code qui parle à la base ; un ajustement de gabarit HTML impose de retester le calcul du total.

​
class Facture {
  constructor(readonly lignes: Ligne[]) {}

  total(): number {
    return this.lignes.reduce((somme, l) => somme + l.prixHT * (1 + l.tva), 0);
  }
}

class FactureRepository {
  enregistrer(facture: Facture): void { /* ... */ }
}

class FactureHtmlPresenter {
  rendre(facture: Facture): string { /* ... */ }
}
class Facture:
    def __init__(self, lignes: list[Ligne]) -> None:
        self.lignes = lignes

    def total(self) -> float:
        return sum(l.prix_ht * (1 + l.tva) for l in self.lignes)


class FactureRepository:
    def enregistrer(self, facture: Facture) -> None: ...


class FactureHtmlPresenter:
    def rendre(self, facture: Facture) -> str: ...
struct Facture {
    lignes: Vec<Ligne>,
}

impl Facture {
    fn total(&self) -> f64 {
        self.lignes.iter().map(|l| l.prix_ht * (1.0 + l.tva)).sum()
    }
}

struct FactureRepository {
    db: Connexion,
}

impl FactureRepository {
    fn enregistrer(&self, facture: &Facture) { /* ... */ }
}

struct FactureHtmlPresenter;

impl FactureHtmlPresenter {
    fn rendre(&self, facture: &Facture) -> String { /* ... */ }
}

Chaque classe a désormais un seul demandeur. Le principe ne dit rien sur le nombre de méthodes ni sur le nombre de lignes : une classe de trois cents lignes qui ne sert qu’un acteur le respecte, une classe de dix lignes qui mélange calcul métier et accès disque le viole.

Tip

Le test pratique : nommer à voix haute qui, dans l’organisation, peut demander la modification de chaque méthode. Si la liste comporte plusieurs noms différents, la classe a plusieurs raisons de changer.

O — Ouvert / fermé

Une entité doit être ouverte à l’extension, fermée à la modification.

Ajouter un comportement ne devrait pas demander de rouvrir le code existant. Le signal d’alarme est le switch — ou la cascade de if — sur un type ou un code : chaque nouveau cas revient éditer la même fonction.

​
function fraisDeLivraison(commande: Commande): number {
  switch (commande.transporteur) {
    case 'colissimo': return 4.95;
    case 'chronopost': return 12.5 + commande.poids * 0.4;
    case 'retrait':    return 0;
    // Un nouveau transporteur ⇒ on modifie cette fonction. Et toutes les autres
    // qui font le même switch ailleurs : délai, suivi, étiquette...
  }
}
def frais_de_livraison(commande: Commande) -> float:
    match commande.transporteur:
        case "colissimo":  return 4.95
        case "chronopost": return 12.5 + commande.poids * 0.4
        case "retrait":    return 0.0
    # Un nouveau transporteur ⇒ on modifie cette fonction. Et toutes les autres
    # qui font le même match ailleurs : délai, suivi, étiquette...
fn frais_de_livraison(commande: &Commande) -> f64 {
    match commande.transporteur {
        Transporteur::Colissimo => 4.95,
        Transporteur::Chronopost => 12.5 + commande.poids * 0.4,
        Transporteur::Retrait => 0.0,
        // Un nouveau transporteur ⇒ on modifie cette fonction. Et toutes les
        // autres qui font le même match ailleurs : délai, suivi, étiquette...
    }
}

L’inversion consiste à faire porter la variabilité par les types eux-mêmes.

​
interface Transporteur {
  frais(commande: Commande): number;
  delaiEnJours(): number;
}

class Colissimo implements Transporteur {
  frais(): number { return 4.95; }
  delaiEnJours(): number { return 3; }
}

class Chronopost implements Transporteur {
  frais(commande: Commande): number { return 12.5 + commande.poids * 0.4; }
  delaiEnJours(): number { return 1; }
}
from typing import Protocol


class Transporteur(Protocol):
    def frais(self, commande: Commande) -> float: ...
    def delai_en_jours(self) -> int: ...


class Colissimo:
    def frais(self, commande: Commande) -> float: return 4.95
    def delai_en_jours(self) -> int: return 3


class Chronopost:
    def frais(self, commande: Commande) -> float:
        return 12.5 + commande.poids * 0.4

    def delai_en_jours(self) -> int: return 1
trait Transporteur {
    fn frais(&self, commande: &Commande) -> f64;
    fn delai_en_jours(&self) -> u32;
}

struct Colissimo;
struct Chronopost;

impl Transporteur for Colissimo {
    fn frais(&self, _: &Commande) -> f64 { 4.95 }
    fn delai_en_jours(&self) -> u32 { 3 }
}

impl Transporteur for Chronopost {
    fn frais(&self, commande: &Commande) -> f64 { 12.5 + commande.poids * 0.4 }
    fn delai_en_jours(&self) -> u32 { 1 }
}

Le code appelant ne connaît que Transporteur. Ajouter un point relais devient l’écriture d’une nouvelle classe, sans toucher à une ligne existante — et surtout sans risquer d’oublier un des switch dispersés dans l’application.

Note

La version Python n’utilise pas d’héritage : un Protocol décrit la forme attendue, et n’importe quelle classe qui expose les bonnes méthodes le satisfait — c’est le duck typing, rendu vérifiable par l’analyse statique.

En Rust, l’exemple fautif n’est pas tout à fait fautif : le match sur une énumération est exhaustif, et oublier un cas est une erreur de compilation. Le risque disparaît donc, mais pas le coût : ajouter une variante impose encore de rouvrir chaque match. Le choix entre énumération et trait est exactement celui entre un ensemble de cas fermé mais vérifié, et un ensemble ouvert mais sans garantie de couverture.

Warning

Ce principe est le plus facile à sur-appliquer. Chaque point d’extension est un pari sur la nature des changements futurs : il rend gratuits ceux qu’on a prévus et plus coûteux tous les autres, puisqu’il faut désormais traverser une couche d’abstraction pour les faire. Trois switch identiques dans le code sont un fait observé qui justifie l’abstraction ; un seul cas connu ne justifie rien.

L — Substitution de Liskov

Un sous-type doit pouvoir remplacer son type de base sans que le programme change de comportement.

C’est le seul principe des cinq qui soit une contrainte formelle, énoncée par Barbara Liskov en 1987. L’héritage garantit la compatibilité des signatures ; Liskov exige la compatibilité des contrats. Un sous-type ne peut pas :

  • renforcer les préconditions — exiger plus que le parent ;
  • affaiblir les postconditions — garantir moins que le parent ;
  • lever des exceptions que le parent ne lève pas ;
  • modifier un invariant du parent.

Le contre-exemple canonique tient à ce que l’héritage de code n’est pas l’héritage de sens.

​
class Rectangle {
  constructor(protected largeur: number, protected hauteur: number) {}

  setLargeur(l: number): void { this.largeur = l; }
  setHauteur(h: number): void { this.hauteur = h; }
  aire(): number { return this.largeur * this.hauteur; }
}

class Carre extends Rectangle {
  setLargeur(l: number): void { this.largeur = l; this.hauteur = l; }
  setHauteur(h: number): void { this.largeur = h; this.hauteur = h; }
}
class Rectangle:
    def __init__(self, largeur: float, hauteur: float) -> None:
        self._largeur = largeur
        self._hauteur = hauteur

    def set_largeur(self, l: float) -> None:
        self._largeur = l

    def set_hauteur(self, h: float) -> None:
        self._hauteur = h

    def aire(self) -> float:
        return self._largeur * self._hauteur


class Carre(Rectangle):
    def set_largeur(self, l: float) -> None:
        self._largeur = self._hauteur = l

    def set_hauteur(self, h: float) -> None:
        self._largeur = self._hauteur = h
trait Redimensionnable {
    /// Contrat : `set_largeur` ne modifie **que** la largeur ; l'aire vaut
    /// toujours largeur × hauteur.
    fn set_largeur(&mut self, l: f64);
    fn set_hauteur(&mut self, h: f64);
    fn aire(&self) -> f64;
}

struct Rectangle { largeur: f64, hauteur: f64 }
struct Carre { cote: f64 }

impl Redimensionnable for Rectangle {
    fn set_largeur(&mut self, l: f64) { self.largeur = l; }
    fn set_hauteur(&mut self, h: f64) { self.hauteur = h; }
    fn aire(&self) -> f64 { self.largeur * self.hauteur }
}

impl Redimensionnable for Carre {
    fn set_largeur(&mut self, l: f64) { self.cote = l; }   // Viole le contrat
    fn set_hauteur(&mut self, h: f64) { self.cote = h; }   // Viole le contrat
    fn aire(&self) -> f64 { self.cote * self.cote }
}

Un carré est un rectangle en géométrie. Mais il ne l’est pas ici, parce que le contrat contient une garantie implicite que Carre viole : modifier une dimension laisse l’autre intacte.

​
function agrandir(r: Rectangle): void {
  r.setLargeur(10);
  r.setHauteur(4);
  console.assert(r.aire() === 40);   // Faux pour un Carre : 16
}
def agrandir(r: Rectangle) -> None:
    r.set_largeur(10)
    r.set_hauteur(4)
    assert r.aire() == 40      # Faux pour un Carre : 16
fn agrandir<F: Redimensionnable>(f: &mut F) {
    f.set_largeur(10.0);
    f.set_hauteur(4.0);
    assert_eq!(f.aire(), 40.0);   // Faux pour un Carre : 16
}

Cette fonction est juste, la hiérarchie est fautive. Aucune correction locale n’existe : il faut renoncer à l’héritage — deux types distincts implémentant une interface Forme en lecture seule — ou rendre les formes immuables, auquel cas le problème disparaît avec les setters.

Note

Rust n’a pas d’héritage d’implémentation, ce qui semble mettre le problème hors de portée. Il n’en est rien : le contrat se déplace simplement du parent vers le trait, où il vit dans la documentation et non dans le système de types. Le compilateur vérifie les signatures, personne ne vérifie la promesse — la substituabilité reste une discipline, dans les trois langages.

Note

La violation la plus fréquente en pratique est plus discrète : une implémentation qui lève NotImplementedError sur une méthode héritée, ou qui retourne null là où le parent promet une valeur. Le compilateur est satisfait, l’appelant écrit du code correct, et il casse à l’exécution.

I — Ségrégation des interfaces

Aucun client ne doit dépendre de méthodes qu’il n’utilise pas.

Une interface trop large impose à ses implémentations des méthodes dont elles n’ont que faire, et à ses clients une recompilation à chaque évolution d’une partie qui ne les concerne pas.

​
interface Peripherique {
  imprimer(doc: Document): void;
  scanner(): Document;
  envoyerFax(numero: string, doc: Document): void;
}

class ImprimanteLaser implements Peripherique {
  imprimer(doc: Document): void { /* ... */ }
  scanner(): Document { throw new Error('Non supporté'); }        // Menteur
  envoyerFax(): void { throw new Error('Non supporté'); }         // Menteur
}
from abc import ABC, abstractmethod


class Peripherique(ABC):
    @abstractmethod
    def imprimer(self, doc: Document) -> None: ...
    @abstractmethod
    def scanner(self) -> Document: ...
    @abstractmethod
    def envoyer_fax(self, numero: str, doc: Document) -> None: ...


class ImprimanteLaser(Peripherique):
    def imprimer(self, doc: Document) -> None: ...

    def scanner(self) -> Document:
        raise NotImplementedError("Non supporté")               # Menteur

    def envoyer_fax(self, numero: str, doc: Document) -> None:
        raise NotImplementedError("Non supporté")               # Menteur
trait Peripherique {
    fn imprimer(&self, doc: &Document);
    fn scanner(&self) -> Document;
    fn envoyer_fax(&self, numero: &str, doc: &Document);
}

struct ImprimanteLaser;

impl Peripherique for ImprimanteLaser {
    fn imprimer(&self, doc: &Document) { /* ... */ }
    fn scanner(&self) -> Document { unimplemented!("Non supporté") }        // Menteur
    fn envoyer_fax(&self, _: &str, _: &Document) { unimplemented!() }       // Menteur
}

Le symptôme est reconnaissable : des méthodes qui n’existent que pour lever une exception. Ce sont d’ailleurs des violations de Liskov, ce qui illustre à quel point les principes se recouvrent.

​
interface Imprimante { imprimer(doc: Document): void; }
interface Scanner    { scanner(): Document; }
interface Fax        { envoyerFax(numero: string, doc: Document): void; }

class ImprimanteLaser implements Imprimante { /* ... */ }

class Multifonction implements Imprimante, Scanner, Fax { /* ... */ }

function archiver(scanner: Scanner): void { /* n'a besoin que de scanner */ }
from typing import Protocol


class Imprimante(Protocol):
    def imprimer(self, doc: Document) -> None: ...


class Scanner(Protocol):
    def scanner(self) -> Document: ...


class Fax(Protocol):
    def envoyer_fax(self, numero: str, doc: Document) -> None: ...


class ImprimanteLaser:
    def imprimer(self, doc: Document) -> None: ...


class Multifonction:
    def imprimer(self, doc: Document) -> None: ...
    def scanner(self) -> Document: ...
    def envoyer_fax(self, numero: str, doc: Document) -> None: ...


def archiver(scanner: Scanner) -> None: ...   # N'a besoin que de scanner
trait Imprimante { fn imprimer(&self, doc: &Document); }
trait Scanner    { fn scanner(&self) -> Document; }
trait Fax        { fn envoyer_fax(&self, numero: &str, doc: &Document); }

struct ImprimanteLaser;
struct Multifonction;

impl Imprimante for ImprimanteLaser { /* ... */ }

impl Imprimante for Multifonction { /* ... */ }
impl Scanner    for Multifonction { /* ... */ }
impl Fax        for Multifonction { /* ... */ }

fn archiver(scanner: &impl Scanner) { /* n'a besoin que de scanner */ }

Chaque implémentation n’annonce que ce qu’elle sait faire. Surtout, un service qui ne fait qu’imprimer déclare Imprimante en paramètre : sa signature dit exactement de quoi il a besoin, et il devient trivial de le tester avec un double d’une seule méthode.

Tip

Le découpage se dicte par les usages, pas par les capacités techniques. Une interface Repository exposant lire, écrire et supprimer mérite d’être scindée dès lors qu’une partie du code ne fait que lire : la séparation rend l’intention lisible dans le type, et le mode lecture seule vérifiable à la compilation.

D — Inversion des dépendances

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

C’est le principe qui structure les architectures en couches — hexagonale, clean architecture, ports et adaptateurs. Dans le sens naturel des dépendances, la logique métier appelle l’infrastructure, et s’y trouve donc attachée.

​
class MySqlCommandeRepository { /* ... */ }
class SmtpMailer { /* ... */ }

class ServiceCommande {
  private readonly repo = new MySqlCommandeRepository();  // Métier → MySQL
  private readonly mailer = new SmtpMailer();             // Métier → SMTP

  valider(commande: Commande): void { /* ... */ }
}
class ServiceCommande:
    def __init__(self) -> None:
        self._repo = MySqlCommandeRepository()      # Métier → MySQL
        self._mailer = SmtpMailer()                 # Métier → SMTP

    def valider(self, commande: Commande) -> None: ...
struct ServiceCommande {
    repo: MySqlCommandeRepository,   // Métier → MySQL
    mailer: SmtpMailer,              // Métier → SMTP
}

impl ServiceCommande {
    fn valider(&self, commande: &Commande) { /* ... */ }
}

Rien ne fonctionne sans une base MySQL et un serveur SMTP joignables — pas même un test unitaire. Le métier, qui est la partie stable et précieuse, est prisonnier de choix techniques qui sont la partie volatile.

L’inversion introduit une interface, et la subtilité tient à son propriétaire : elle appartient au module de haut niveau, exprimée dans son vocabulaire.

​
// Couche métier : elle définit ses besoins, et ne dépend de rien.
interface CommandeRepository {
  enregistrer(commande: Commande): void;
}

interface NotificationClient {
  confirmerCommande(commande: Commande): void;
}

class ServiceCommande {
  constructor(
    private readonly repo: CommandeRepository,
    private readonly notification: NotificationClient,
  ) {}

  valider(commande: Commande): void {
    this.repo.enregistrer(commande);
    this.notification.confirmerCommande(commande);
  }
}

// Couche infrastructure : elle dépend du métier, et non l'inverse.
class MySqlCommandeRepository implements CommandeRepository { /* ... */ }
class MailerNotificationClient implements NotificationClient { /* ... */ }
from typing import Protocol

# Couche métier : elle définit ses besoins, et ne dépend de rien.


class CommandeRepository(Protocol):
    def enregistrer(self, commande: Commande) -> None: ...


class NotificationClient(Protocol):
    def confirmer_commande(self, commande: Commande) -> None: ...


class ServiceCommande:
    def __init__(
        self,
        repo: CommandeRepository,
        notification: NotificationClient,
    ) -> None:
        self._repo = repo
        self._notification = notification

    def valider(self, commande: Commande) -> None:
        self._repo.enregistrer(commande)
        self._notification.confirmer_commande(commande)


# Couche infrastructure : elle dépend du métier, et non l'inverse.


class MySqlCommandeRepository:
    def enregistrer(self, commande: Commande) -> None: ...


class MailerNotificationClient:
    def confirmer_commande(self, commande: Commande) -> None: ...
// Couche métier : elle définit ses besoins, et ne dépend de rien.
trait CommandeRepository {
    fn enregistrer(&self, commande: &Commande);
}

trait NotificationClient {
    fn confirmer_commande(&self, commande: &Commande);
}

struct ServiceCommande<R: CommandeRepository, N: NotificationClient> {
    repo: R,
    notification: N,
}

impl<R: CommandeRepository, N: NotificationClient> ServiceCommande<R, N> {
    fn valider(&self, commande: &Commande) {
        self.repo.enregistrer(commande);
        self.notification.confirmer_commande(commande);
    }
}

// Couche infrastructure : elle dépend du métier, et non l'inverse.
struct MySqlCommandeRepository { /* ... */ }
struct MailerNotificationClient { /* ... */ }

impl CommandeRepository for MySqlCommandeRepository { /* ... */ }
impl NotificationClient for MailerNotificationClient { /* ... */ }

La flèche de dépendance a été retournée : c’est désormais l’infrastructure qui se conforme au métier. Le nom des interfaces le trahit — NotificationClient et non Mailer : on décrit une intention métier, pas une technologie. Un remplacement de MySQL par PostgreSQL, ou du courriel par une notification push, ne touche que la couche externe.

Note

La version Rust résout les dépendances à la compilation par généricité, sans coût à l’appel ; Box<dyn CommandeRepository> donne la variante à résolution tardive, utile quand l’implémentation dépend de la configuration. Le principe est identique, seul le moment du choix change.

Note

C’est exactement ce que fournissent les conteneurs d’injection de dépendances, Spring comme Angular : la classe déclare ce dont elle a besoin, et un tiers décide de l’implémentation concrète à l’exécution. L’injection est l’outil, l’inversion est le principe — injecter une classe concrète respecte le premier sans rien inverser du tout.

Ce que SOLID ne dit pas

Les cinq principes décrivent une direction, pas une cible. Appliqués sans discernement, ils produisent un code qu’ils étaient censés éviter : une interface par classe, une classe par méthode, et une indirection à traverser pour lire la moindre règle métier.

Trois garde-fous utiles :

  • L’abstraction se paie. Chaque interface ajoute un niveau d’indirection à suivre et un fichier à ouvrir. Elle est justifiée par un besoin de variation constaté, pas supposé.
  • La duplication est un meilleur premier réflexe qu’une mauvaise abstraction. Une abstraction prématurée fige la mauvaise ligne de découpe, et il est nettement plus difficile de défaire une abstraction que de factoriser trois copies.
  • Les principes se recouvrent. Une interface trop large viole souvent Liskov et la responsabilité unique ; l’inversion des dépendances rend l’ouverture à l’extension presque gratuite. Il n’y a pas cinq problèmes indépendants, mais un seul — le couplage — vu sous cinq angles.

SOLID s’exprime à l’échelle de la classe et du module. Les questions de découpage en sous-domaines, de langage partagé et de frontières entre contextes relèvent d’un autre niveau, traité par le Domain Driven Design. Les solutions récurrentes que ces principes conduisent à réinventer sont, elles, cataloguées dans les design patterns.

Références