SOLID revient dans tous les entretiens et dans toutes les revues de code. Beaucoup de gens savent réciter les cinq lettres. Beaucoup moins savent repérer le moment où un principe s'applique vraiment et encore moins le moment où l'appliquer serait une erreur.
Cet article détaille les cinq principes avec des exemples courts. Il élargit ensuite aux autres bonnes pratiques qui servent au quotidien et il se termine par ce que SOLID ne dit pas.
D'où ça vient
L'acronyme a été proposé par Michael Feathers au début des années 2000. Il reprend des principes formulés par Robert C. Martin, tous répondent à la même question : comment écrire du code qu'on pourra modifier dans six mois sans tout casser ?
Un mot important avant de commencer. Ce sont des principes, pas des règles. Ils donnent une direction, ils ne fixent pas de seuil. Un code qui respecte SOLID à la lettre mais qui demande quarante classes pour envoyer un email n'est pas un bon code.
S - Single Responsibility Principle (Principe de responsabilité unique)
Une classe ne doit avoir qu'une seule raison de changer.
La formulation exacte de Robert C. Martin est plus fine que celle qu'on répète. Une classe doit répondre à un seul acteur. Un seul groupe de gens capable de demander une modification.
Le problème
class InvoiceService
{
public function calculateTotal(Invoice $invoice): float
{
// Règle métier décidée par la comptabilité
}
public function saveToDatabase(Invoice $invoice): void
{
// Persistance décidée par la technique
}
public function renderPdf(Invoice $invoice): string
{
// Mise en forme décidée par le marketing
}
}
Trois raisons de changer. Trois acteurs différents. Un simple changement de charte graphique oblige à toucher une classe qui contient aussi le calcul de la TVA. Le risque de casse est direct.
La correction
class InvoiceCalculator
{
public function calculateTotal(Invoice $invoice): float { }
}
class InvoiceRepository
{
public function save(Invoice $invoice): void { }
}
class InvoicePdfRenderer
{
public function render(Invoice $invoice): string { }
}
Le piège
C'est le principe le plus mal appliqué des cinq. Poussé à fond, il produit des classes avec une seule méthode de trois lignes et une architecture où suivre un traitement demande d'ouvrir quinze fichiers.
Le bon indicateur n'est pas le nombre de méthodes. Pose-toi plutôt cette question : est-ce que deux personnes différentes pourraient demander de modifier cette classe ? Si oui, découpe. Sinon, laisse tranquille.
O - Open/Closed Principle (Principe ouvert/fermé)
Ouvert à l'extension, fermé à la modification.
Traduction simple. Ajouter un comportement ne devrait pas obliger à toucher au code déjà écrit.
Le problème
public class ShippingCalculator {
public BigDecimal calculate(String method) {
if ("standard".equals(method)) {
return BigDecimal.valueOf(5.90);
}
if ("express".equals(method)) {
return BigDecimal.valueOf(12.50);
}
throw new IllegalArgumentException("Mode inconnu");
}
}
Chaque nouveau mode de livraison oblige à rouvrir cette méthode. Elle grossit et chaque modification met en danger les modes qui marchaient déjà.
La correction
public interface ShippingMethod {
String code();
BigDecimal cost();
}
@Component
public class ExpressShipping implements ShippingMethod {
@Override
public String code() {
return "express";
}
@Override
public BigDecimal cost() {
return BigDecimal.valueOf(12.50);
}
}
Spring injecte ensuite toutes les implémentations disponibles dans le calculateur. Ajouter un mode revient à créer une classe. Aucun fichier existant n'est touché.
Quand ne pas l'appliquer
Trois conditions qui ne bougeront jamais ne justifient pas une interface et trois classes. Le principe devient rentable quand tu remarques que tu reviens sans arrêt modifier la même condition la deuxième ou la troisième fois, pas avant.
L - Liskov Substitution Principle (Principe de substitution de Liskov)
Une sous-classe doit pouvoir remplacer sa classe parente sans rien casser.
Le principe vient de Barbara Liskov en 1987. C'est le plus abstrait des cinq et le plus souvent mal compris.
L'exemple classique
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int area() {
return width * height;
}
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}
}
En maths, un carré est un rectangle. En objet, non.
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
// On attend 20. On obtient 16.
System.out.println(r.area());
Le code appelant respecte le contrat de Rectangle et obtient un résultat faux. La sous-classe a cassé la promesse du parent.
Comment le repérer
Trois signaux qui ne trompent pas.
Une méthode redéfinie qui lève UnsupportedOperationException.
Une sous-classe qui refuse des valeurs que le parent acceptait.
Du code appelant qui teste le type réel avec instanceof avant d'agir. Celui-là est le plus parlant. Si tu dois savoir quelle sous-classe tu manipules, c'est que le polymorphisme ne marche pas.
La correction
Souvent, l'héritage était juste le mauvais outil. Deux classes distinctes derrière une interface commune règlent le problème.
public interface Shape {
int area();
}
public record Rectangle(int width, int height) implements Shape {
@Override
public int area() {
return width * height;
}
}
public record Square(int side) implements Shape {
@Override
public int area() {
return side * side;
}
}
Les objets deviennent immuables. La question ne se pose plus.
I - Interface Segregation Principle (Principe de ségrégation des interfaces)
Personne ne doit dépendre de méthodes qu'il n'utilise pas.
Le problème
interface Employee
{
public function work(): void;
public function submitTimesheet(): void;
public function attendMeeting(): void;
}
Un prestataire externe n'a pas de feuille de temps interne à remplir. Il devra quand même implémenter la méthode avec un corps vide ou une exception, ce qui viole aussi Liskov au passage. Les principes se recoupent souvent.
La correction
interface Worker
{
public function work(): void;
}
interface TimeTracked
{
public function submitTimesheet(): void;
}
class Contractor implements Worker
{
public function work(): void { }
}
class Employee implements Worker, TimeTracked
{
public function work(): void { }
public function submitTimesheet(): void { }
}
Une interface avec une seule méthode n'a rien de choquant. C'est même souvent le signe d'une abstraction bien ciblée.
D - Dependency Inversion Principle (Principe d'inversion des dépendances)
Le code métier ne doit pas dépendre du code technique. Les deux dépendent d'abstractions.
C'est le principe le plus structurant et celui qui explique pourquoi Spring et Laravel marchent comme ils marchent.
Le problème
class OrderService
{
private MySqlOrderRepository $repository;
public function __construct()
{
$this->repository = new MySqlOrderRepository();
}
}
Ta règle métier dépend de MySQL. Tu ne peux pas la tester sans base. Tu ne peux pas changer de stockage sans la modifier.
La correction
interface OrderRepository
{
public function findById(int $id): ? Order;
public function save(Order $order): void;
}
class OrderService
{
public function __construct(
private readonly OrderRepository $repository
) {
}
}
Il y a un détail que beaucoup d'articles ratent. Le sens de la dépendance compte. L'interface OrderRepository appartient à la couche métier, pas à la couche technique. C'est le métier qui dit ce dont il a besoin. L'infrastructure s'y conforme. Sans ça, tu as juste ajouté une interface sans rien inverser du tout.
En Laravel, la liaison se fait dans un fournisseur de services.
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
En Spring, elle est déduite toute seule dès qu'une seule implémentation existe.
Les autres principes qui comptent
SOLID ne couvre pas tout. Ces quatre-là servent au moins autant au quotidien.
DRY (Don't Repeat Yourself)
Ne te répète pas. Une même connaissance ne doit exister qu'à un seul endroit.
Attention au contresens. DRY parle de connaissance, pas de code qui se ressemble. Deux fonctions presque identiques aujourd'hui mais qui répondent à deux besoins différents vont finir par diverger. Les fusionner crée un couplage artificiel et tu le paieras plus tard avec une fonction bourrée de conditions.
La règle pratique : attends la troisième occurrence. Deux fois, ça peut être un hasard. Trois fois, c'est un motif.
KISS (Keep It Simple, Stupid)
Fais simple. Entre deux solutions qui marchent, la plus lisible gagne. Même si elle est moins maligne.
// Malin, mais il faut relire deux fois
$total = array_reduce($items, fn($c, $i) => $c + ($i->qty * $i->price), 0);
// Bête et immédiat
$total = 0;
foreach ($items as $item) {
$total += $item->qty * $item->price;
}
Le second n'est pas moins bon. Il est juste plus long et plus rapide à comprendre pour celui qui te relira.
YAGNI (You Ain't Gonna Need It)
Tu n'en auras pas besoin. N'écris pas de code pour un besoin imaginaire.
C'est le contrepoids du principe ouvert et fermé. Une interface avec une seule implémentation, créée au cas où, c'est du poids mort. Elle sera d'ailleurs mal conçue puisque tu l'as pensée sans connaître le deuxième cas d'usage.
La loi de Déméter
Ne parle qu'à tes voisins directs.
// Trois niveaux de connaissance sur des objets qui ne te regardent pas
$city = $order->getCustomer()->getAddress()->getCity();
// Le contrat est clair
$city = $order->getShippingCity();
Chaque point en plus est une dépendance en plus. Si Address change, la première ligne casse alors que rien ne le laissait deviner.
Les signes d'un code qui se dégrade
Quelques symptômes concrets à surveiller en revue.
La classe qui n'arrête pas de grossir. Au-delà de trois cents lignes, pose-toi la question. Ce n'est pas une limite absolue. C'est un seuil d'attention.
La méthode à plus de trois paramètres. Souvent le signe qu'un objet manque.
// Difficile à appeler sans se tromper d'ordre
public void createUser(String name, String email, String street, String city) { }
// L'intention est claire
public void createUser(UserIdentity identity, Address address) { }
Le paramètre booléen. save($user, true) ne dit rien au lecteur. Deux méthodes bien nommées valent mieux qu'un drapeau.
Les conditions imbriquées. Trois niveaux se remplacent presque toujours par des clauses de garde.
// Avant
public function process(Order $order): void
{
if ($order->isValid()) {
if ($order->hasStock()) {
if ($order->isPaid()) {
$this->ship($order);
}
}
}
}
// Après
public function process(Order $order): void
{
if (!$order->isValid()) {
return;
}
if (!$order->hasStock()) {
return;
}
if (!$order->isPaid()) {
return;
}
$this->ship($order);
}
Le commentaire qui répète le code. Un commentaire doit dire pourquoi pas comment. Si ton code a besoin d'un commentaire pour être compris, commence par renommer les variables.
// Inutile, le code le dit déjà
// Incrémente le compteur
$count++;
// Utile, le code ne peut pas le dire
// Le fournisseur renvoie parfois deux fois la même ligne,
// on déduplique avant de facturer
$lines = collect($lines)->unique('reference');
Le nom qui ne dit rien. data, info, manager, helper, process. Un bon nom permet de comprendre sans ouvrir la méthode.
Ce que SOLID ne dit pas
Ces principes sont discutés et les critiques sont justes.
Ils datent d'une époque où l'héritage était partout et où les langages étaient plus rigides. Certains s'appliquent mal à la programmation fonctionnelle où les fonctions pures rendent plusieurs de ces questions inutiles.
Ils décrivent le résultat voulu, sans dire quand agir. La responsabilité unique ne définit même pas ce qu'est une responsabilité. C'est pourtant toute la difficulté.
Le vrai risque reste la sur-ingénierie. Une petite application noyée sous les interfaces, les fabriques et les couches d'abstraction est plus dure à maintenir qu'une application directe. La complexité n'a pas disparu elle a juste changé de place.
La bonne posture tient en une phrase. Écris le code le plus direct qui résout le problème d'aujourd'hui. Quand une modification devient pénible, demande-toi lequel de ces principes aurait évité la douleur. Refactorise à ce moment-là. Un refactoring guidé par une vraie gêne vaut toujours mieux qu'une abstraction préventive.
Pour aller plus loin
- Clean Code de Robert C. Martin. À lire avec du recul, certains conseils ont vieilli. Les chapitres sur les noms et les fonctions restent excellents
- Refactoring de Martin Fowler. Le catalogue des transformations avec le symptôme qui déclenche chacune
- Le catalogue en ligne de Fowler
- Les PSR pour les conventions PHP
- Effective Java de Joshua Bloch pour la partie Java
Un exercice pour finir. Prends une classe de ton projet qui dépasse deux cents lignes, liste ses méthodes, note en face de chacune qui pourrait demander sa modification. Si tu trouves plus de deux acteurs différents alors tu tiens ton premier découpage.
Commentaires (0)
Laisser un commentaire
Vous pouvez commenter en renseignant votre nom et votre email. Votre message sera publié après modération. Avec un compte, il apparaît immédiatement et reste modifiable.
Aucun commentaire pour le moment
Soyez le premier à commenter cet article !