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.
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.
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.
functionfraisDeLivraison(commande: Commande):number {
switch (commande.transporteur) {
case'colissimo':return4.95;
case'chronopost':return12.5+commande.poids*0.4;
case'retrait':return0;
// Un nouveau transporteur ⇒ on modifie cette fonction. Et toutes les autres
// qui font le même switch ailleurs : délai, suivi, étiquette...
}
}
deffrais_de_livraison(commande: Commande) -> float:
match commande.transporteur:
case"colissimo": return4.95case"chronopost": return12.5+ commande.poids *0.4case"retrait": return0.0# Un nouveau transporteur ⇒ on modifie cette fonction. Et toutes les autres# qui font le même match ailleurs : délai, suivi, étiquette...
fnfrais_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.
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.
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.
functionagrandir(r: Rectangle):void {
r.setLargeur(10);
r.setHauteur(4);
console.assert(r.aire() ===40); // Faux pour un Carre : 16
}
defagrandir(r: Rectangle) ->None:
r.set_largeur(10)
r.set_hauteur(4)
assert r.aire() ==40# Faux pour un Carre : 16
fnagrandir<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.
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.
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.
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.
interfaceCommandeRepository {
enregistrer(commande: Commande):void;
}
interfaceNotificationClient {
confirmerCommande(commande: Commande):void;
}
classServiceCommande {
constructor(
privatereadonlyrepo: CommandeRepository,
privatereadonlynotification: 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.
classMySqlCommandeRepositoryimplementsCommandeRepository { /* ... */ }
classMailerNotificationClientimplementsNotificationClient { /* ... */ }
from typing import Protocol
# Couche métier : elle définit ses besoins, et ne dépend de rien.classCommandeRepository(Protocol):
defenregistrer(self, commande: Commande) ->None: ...classNotificationClient(Protocol):
defconfirmer_commande(self, commande: Commande) ->None: ...classServiceCommande:
def__init__(
self,
repo: CommandeRepository,
notification: NotificationClient,
) ->None:
self._repo = repo
self._notification = notification
defvalider(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.classMySqlCommandeRepository:
defenregistrer(self, commande: Commande) ->None: ...classMailerNotificationClient:
defconfirmer_commande(self, commande: Commande) ->None: ...
// Couche métier : elle définit ses besoins, et ne dépend de rien.
trait CommandeRepository {
fnenregistrer(&self, commande: &Commande);
}
trait NotificationClient {
fnconfirmer_commande(&self, commande: &Commande);
}
structServiceCommande<R: CommandeRepository, N: NotificationClient> {
repo: R,
notification: N,
}
impl<R: CommandeRepository, N: NotificationClient> ServiceCommande<R, N> {
fnvalider(&self, commande: &Commande) {
self.repo.enregistrer(commande);
self.notification.confirmer_commande(commande);
}
}
// Couche infrastructure : elle dépend du métier, et non l'inverse.
structMySqlCommandeRepository { /* ... */ }
structMailerNotificationClient { /* ... */ }
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.