Introduction
Les principes SOLID sont cinq règles de conception orientée objet pour écrire du code propre et maintenable. Savoir écrire des classes et instancier des objets, c'est la base de la programmation orientée objet. Mais comment s'assurer que le code reste propre et maintenable quand le projet grossit et que l'équipe s'agrandit ? C'est là que les principes SOLID entrent en jeu. On nous les enseigne pendant nos études, mais peu de développeurs savent vraiment les appliquer au quotidien.
Dans cet article, nous allons passer en revue les cinq principes SOLID, avec pour chacun une explication et un exemple en Java.
Qu'est-ce que SOLID ?
SOLID est un acronyme qui regroupe cinq principes de conception orientée objet. Leur objectif : produire des logiciels plus faciles à comprendre, à faire évoluer et à maintenir.
| Lettre | Principe | En une phrase |
|---|---|---|
| S | Single Responsibility Principle (SRP), responsabilité unique | Une classe ne doit avoir qu'une seule raison de changer |
| O | Open/Closed Principle (OCP), ouvert/fermé | Ouverte à l'extension, fermée à la modification |
| L | Liskov Substitution Principle (LSP), substitution de Liskov | Une classe fille doit pouvoir remplacer sa classe mère sans casser le programme |
| I | Interface Segregation Principle (ISP), ségrégation des interfaces | Plusieurs petites interfaces valent mieux qu'une grosse |
| D | Dependency Inversion Principle (DIP), inversion des dépendances | On dépend d'abstractions, pas d'implémentations |
Ces principes ont été formalisés par Robert C. Martin ("Uncle Bob"), notamment dans son article Design Principles and Design Patterns publié en 2000 [1]. L'acronyme, lui, a été proposé vers 2004 par Michael Feathers [2]. Bien qu'ils soient pensés pour l'orienté objet, ils inspirent aussi des approches comme l'agilité, qui privilégient un code capable de s'adapter au changement [3].
Pourquoi SOLID ?
Vous avez déjà ouvert une classe de 2000 lignes que personne n'ose toucher ? Ou modifié une fonctionnalité en cassant trois autres endroits du code sans comprendre pourquoi ? C'est exactement le genre de problèmes que SOLID aide à éviter. En travaillant en équipe, un code clair et facile à modifier devient vite indispensable.
Attention : SOLID n'est pas une loi, mais un ensemble de bonnes pratiques. Il existe des situations où s'en écarter est plus judicieux, par exemple pour ne pas multiplier les interfaces et les abstractions dans une petite application. L'important est de comprendre ce que chaque principe apporte, pour savoir quand l'appliquer.
S : Responsabilité unique (Single Responsibility)
Une classe ne doit avoir qu'une seule raison de changer.
Une classe qui fait trop de choses est fragile : modifier une partie risque de casser les autres. Chaque classe doit se concentrer sur une seule responsabilité.
❌ Avant : une classe qui fait tout
public class Facture {
private double montant;
public double calculerTotalAvecTVA() {
return montant * 1.20;
}
public void enregistrerEnBase() {
// Code SQL pour sauvegarder la facture
}
public void imprimer() {
// Code de mise en forme et d'impression
}
}
Ici, Facture change si la règle de TVA évolue, si la base de données change, ou si le format d'impression change. Trois raisons de changer, donc trois responsabilités.
✅ Après : une classe, une responsabilité
public class Facture {
private double montant;
public double calculerTotalAvecTVA() {
return montant * 1.20;
}
}
public class FactureRepository {
public void enregistrer(Facture facture) {
// Code SQL pour sauvegarder la facture
}
}
public class FactureImprimante {
public void imprimer(Facture facture) {
// Code de mise en forme et d'impression
}
}
Chaque classe est plus simple à comprendre, à tester et à modifier indépendamment des autres.
O : Ouvert/fermé (Open/Closed)
Une classe doit être ouverte à l'extension, mais fermée à la modification.
Ajouter une fonctionnalité ne devrait pas obliger à modifier du code existant (et déjà testé). On étend le comportement en ajoutant du code, pas en réécrivant l'ancien.
❌ Avant : un if à modifier à chaque nouveau cas
public class CalculateurRemise {
public double calculer(String typeClient, double montant) {
if (typeClient.equals("STANDARD")) {
return montant * 0.05;
} else if (typeClient.equals("PREMIUM")) {
return montant * 0.10;
}
return 0;
}
}
Chaque nouveau type de client oblige à rouvrir cette méthode, avec le risque de casser les cas existants.
✅ Après : on étend sans modifier
public interface Remise {
double calculer(double montant);
}
public class RemiseStandard implements Remise {
public double calculer(double montant) {
return montant * 0.05;
}
}
public class RemisePremium implements Remise {
public double calculer(double montant) {
return montant * 0.10;
}
}
public class CalculateurRemise {
public double calculer(Remise remise, double montant) {
return remise.calculer(montant);
}
}
Pour ajouter une remise "VIP", il suffit de créer une nouvelle classe RemiseVip. Le code existant ne bouge pas.
L : Substitution de Liskov (Liskov Substitution)
Une classe fille doit pouvoir remplacer sa classe mère sans casser le programme.
Si le code fonctionne avec un objet de type A, il doit continuer à fonctionner avec n'importe quelle sous-classe de A. Une sous-classe qui lance une exception inattendue ou change le comportement attendu viole ce principe.
❌ Avant : un pingouin qui ne sait pas voler
public class Oiseau {
public void voler() {
System.out.println("Je vole !");
}
}
public class Pingouin extends Oiseau {
@Override
public void voler() {
throw new UnsupportedOperationException("Je ne sais pas voler !");
}
}
public void faireVoler(Oiseau oiseau) {
oiseau.voler(); // Plante si on lui passe un Pingouin
}
Le problème vient de la hiérarchie : tous les oiseaux ne volent pas, donc voler() n'a pas sa place dans la classe Oiseau.
✅ Après : une hiérarchie qui reflète la réalité
public abstract class Oiseau {
public abstract void seDeplacer();
}
public interface Volant {
void voler();
}
public class Moineau extends Oiseau implements Volant {
public void seDeplacer() { voler(); }
public void voler() { System.out.println("Je vole !"); }
}
public class Pingouin extends Oiseau {
public void seDeplacer() { System.out.println("Je nage !"); }
}
public void faireVoler(Volant volant) {
volant.voler(); // Fonctionne avec tout ce qui sait voler
}
Désormais, on ne peut plus passer un Pingouin à faireVoler() : l'erreur est détectée à la compilation, pas en production.
I : Ségrégation des interfaces (Interface Segregation)
Aucune classe ne doit être forcée d'implémenter des méthodes dont elle n'a pas besoin.
Mieux vaut plusieurs petites interfaces spécifiques qu'une grosse interface généraliste.
❌ Avant : une interface trop large
public interface Employe {
void travailler();
void manger();
}
public class Robot implements Employe {
public void travailler() {
System.out.println("Je travaille.");
}
public void manger() {
// Un robot ne mange pas... que mettre ici ?
throw new UnsupportedOperationException();
}
}
Robot est obligé d'implémenter une méthode qui n'a aucun sens pour lui.
✅ Après : des interfaces plus petites
public interface Travailleur {
void travailler();
}
public interface Mangeur {
void manger();
}
public class Humain implements Travailleur, Mangeur {
public void travailler() { System.out.println("Je travaille."); }
public void manger() { System.out.println("Je mange."); }
}
public class Robot implements Travailleur {
public void travailler() { System.out.println("Je travaille."); }
}
Chaque classe n'implémente que ce qui la concerne.
D : Inversion des dépendances (Dependency Inversion)
Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions.
Une classe métier ne devrait pas connaître les détails techniques (base de données, API externe, système de fichiers). Elle dépend d'une interface, et on lui "injecte" l'implémentation concrète.
❌ Avant : dépendance directe à une implémentation
public class ServiceCommande {
private final MySQLDatabase database = new MySQLDatabase();
public void passerCommande(Commande commande) {
database.sauvegarder(commande);
}
}
ServiceCommande est collé à MySQL : impossible de changer de base de données ou de le tester sans une vraie base.
✅ Après : dépendance à une abstraction
public interface CommandeRepository {
void sauvegarder(Commande commande);
}
public class MySQLCommandeRepository implements CommandeRepository {
public void sauvegarder(Commande commande) {
// Sauvegarde dans MySQL
}
}
public class ServiceCommande {
private final CommandeRepository repository;
// L'implémentation est injectée de l'extérieur
public ServiceCommande(CommandeRepository repository) {
this.repository = repository;
}
public void passerCommande(Commande commande) {
repository.sauvegarder(commande);
}
}
On peut maintenant passer un MySQLCommandeRepository en production, et un faux repository en mémoire dans les tests. C'est aussi le principe sur lequel reposent les frameworks d'injection de dépendances comme Spring.
Pour aller plus loin : les patrons de conception
Vous l'avez peut-être remarqué en lisant les exemples : certaines solutions reviennent souvent. Dans le principe O, on a remplacé un enchaînement de if par une interface et plusieurs implémentations interchangeables. Dans le principe D, on a injecté une dépendance plutôt que de l'instancier soi-même. Ces solutions ont un nom : ce sont des patrons de conception (design patterns).
Un patron de conception est une solution classique à un problème de conception récurrent. SOLID donne les principes (le "pourquoi"), les patrons proposent des recettes (le "comment"). Les deux sont complémentaires :
| Patron | Principe(s) SOLID qu'il aide à respecter | Idée |
|---|---|---|
| Strategy | O, D | Rendre un algorithme interchangeable (notre exemple des remises) |
| Decorator | O, S | Ajouter un comportement à un objet sans modifier sa classe |
| Factory | O, D | Centraliser la création d'objets pour ne pas dépendre des classes concrètes |
| Adapter | D, I | Faire cohabiter une interface existante avec celle qu'on attend |
| Observer | O, D | Notifier plusieurs objets d'un changement sans les coupler |
| Repository | S, D | Isoler l'accès aux données de la logique métier |
Ces patrons ont été popularisés par le livre Design Patterns : Elements of Reusable Object-Oriented Software (1994) du "Gang of Four", qui en décrit 23 [4]. Inutile de tous les connaître par cœur : commencez par Strategy, Factory et Observer, vous les croiserez très vite dans du code réel. Un prochain article leur sera consacré.

Commentaires
Connectez-vous en un clic pour rejoindre la discussion.