Comprendre les principes SOLID à travers des exemples de code concrets
Ce guide explique en détail les cinq principes SOLID à l’aide d’exemples de code concrets, montrant comment ils s’appliquent dans des projets réels et des applications React.
Lorsque vous développez du logiciel, faire en sorte que le code fonctionne correctement n’est qu’une partie du travail.
Lorsqu’une application commence à se développer, sa base de code devient souvent plus difficile à lire, plus difficile à modifier et plus difficile à maintenir en bon état de fonctionnement. Une petite modification dans une partie du système peut perturber silencieusement des éléments sans rapport.
C’est précisément ce type de problème que les principes SOLID ont été conçus pour résoudre.
SOLID est un ensemble de cinq principes de conception issus de la programmation orientée objet qui visent à créer un code qui soit :
- Moins complexe à maintenir
- Moins complexe à tester
- Moins complexe à étendre
- Plus peu couplé
- Moins difficile à comprendre pour l’équipe
L’acronyme se décompose comme suit :
S — Principe de responsabilité unique
O — Principe ouvert/fermé
L — Principe de substitution de Liskov
I — Principe de ségrégation des interfaces
D — Principe d’inversion des dépendances
Examinons chacun d’eux à l’aide d’exemples simples.
1. S — Principe de la responsabilité unique
« Une classe ne doit avoir qu’une seule raison de changer. »
En termes simples, chaque classe ou module doit être conçue autour d’une seule tâche.
Prenons par exemple une classe User chargée de :
- Gérer les données des utilisateurs
- Interagir avec la base de données
- Envoyer des e-mails
- Créer des rapports
C’est beaucoup trop pour une seule classe.
class User {
createUser() {
// create user
}
saveToDatabase() {
// save user
}
sendEmail() {
// send email
}
generateReport() {
// generate report
}
}
Si la logique d’envoi d’e-mails doit être modifiée, vous devez modifier la classe User.
Si les méthodes de gestion de la base de données changent, c’est encore cette même classe qui est modifiée.
Une approche plus propre consiste à séparer ces tâches.
class User {
createUser() {
// create user
}
}
class UserRepository {
saveToDatabase() {
// database logic
}
}
class EmailService {
sendEmail() {
// email logic
}
}
class ReportService {
generateReport() {
// report logic
}
}
Avec cette séparation, chaque classe ne gère plus qu’une seule responsabilité.
Pourquoi est-ce utile ?
Lorsqu’une exigence change, on sait immédiatement quelle partie du code modifier.
Une seule tâche par classe signifie une seule raison pour que cette classe change.
2. O — Principe ouvert/fermé
« Les entités logicielles doivent être ouvertes à l’extension mais fermées à la modification. »
Cette formulation semble abstraite, mais le concept qui la sous-tend ne l’est pas.
L’objectif est de introduire de nouvelles fonctionnalités sans avoir à réécrire en permanence du code qui fonctionne déjà.
Prenons l’exemple du traitement des paiements :
function processPayment(type, amount) {
if (type === "card") {
// card payment
} else if (type === "upi") {
// UPI payment
} else if (type === "paypal") {
// PayPal payment
}
}
Supposons maintenant que vous ayez besoin de prendre en charge :
- Stripe
- Razorpay
- Apple Pay
- Google Pay
Chaque nouvelle option rend cette fonction plus volumineuse et plus complexe.
Une stratégie plus efficace consiste à attribuer à chaque méthode de paiement sa propre classe.
class CardPayment {
pay(amount) {
console.log(`Card payment: ${amount}`);
}
}
class UpiPayment {
pay(amount) {
console.log(`UPI payment: ${amount}`);
}
}
class PaypalPayment {
pay(amount) {
console.log(`PayPal payment: ${amount}`);
}
}
Avec cette structure en place, ajouter une nouvelle option de paiement ne nécessite pas de modifier les classes déjà écrites.
class StripePayment {
pay(amount) {
console.log(`Stripe payment: ${amount}`);
}
}
Les implémentations originales restent exactement telles qu’elles étaient.
L’idée
Développez le système en ajoutant de nouveaux codes, et non en modifiant à répétition des codes déjà stables.
3. L — Principe de substitution de Liskov
« Les sous-types doivent être substituables à leurs types de base. »
En substance, ce principe stipule :
Si
Best un sous-type deA, il doit être possible de remplacerBpartout oùAest utilisé, et l’application doit continuer de fonctionner correctement.
Un exemple classique concerne les oiseaux.
Disons que vous définissez :
class Bird {
fly() {
console.log("Flying");
}
}
Puis on l’étend :
class Sparrow extends Bird {
fly() {
console.log("Sparrow is flying");
}
}
Jusqu’ici, tout va bien.
Mais que se passe-t-il avec un penguin ?
class Penguin extends Bird {
fly() {
throw new Error("Penguins cannot fly");
}
}
Cela révèle une faille dans la conception.
Si d’autres parties du code supposent que tout objet de type Bird peut voler, lui fournir une instance de Penguin viendra contredire cette attente.
Une conception plus judicieuse isole le comportement de vol dans une fonction distincte.
class Bird {
eat() {
console.log("Eating");
}
}
class FlyingBird extends Bird {
fly() {
console.log("Flying");
}
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}
De cette façon, les pingouins ne sont plus contraints de supporter des comportements qui ne s’appliquent pas à eux.
La leçon
Évitez de créer des hiérarchies d’héritage qui ne sont pas logiquement cohérentes.
Une sous-classe doit fonctionner correctement partout où l’on s’attend à ce que sa classe parente fonctionne.
4. I — Principe de ségrégation des interfaces
« Les clients ne doivent pas être contraints de dépendre de méthodes qu’ils n’utilisent pas. »
Imaginez une interface conçue de cette manière :
print()
scan()
fax()
copy()
Imaginez maintenant une imprimante de base qui ne fait que, eh bien, imprimer.
Pourquoi cette imprimante devrait-elle également implémenter les fonctions scan(), fax() et copy() ?
Il n’y a aucune raison valable à cela.
Une meilleure approche consiste à diviser l’interface en fonction de ce que chaque capacité fait réellement.
Par exemple :
class Printer {
print() {
console.log("Printing...");
}
}
class Scanner {
scan() {
console.log("Scanning...");
}
}
class FaxMachine {
fax() {
console.log("Faxing...");
}
}
Dans JavaScript moderne
JavaScript ne dispose pas d’interfaces formelles comme Java ou C#, mais l’idée de base reste valable.
Vous pouvez la mettre en pratique grâce à :
- De petits modules
- De petites API
- La composition
- Des services distincts
- Des composants React ciblés
Au lieu de regrouper tout cela dans un seul service géant :
userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();
Séparer les responsabilités :
userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();
La leçon
N’obligez jamais un composant, une classe ou un module à dépendre d’une fonctionnalité dont il n’a pas besoin.
5. D — Principe d’inversion des dépendances
« Les modules de haut niveau ne doivent pas dépendre directement des modules de bas niveau. Tous deux doivent dépendre d’abstractions. »
Ce principe existe pour réduire les couplages forts.
Prenons cet exemple :
class MongoDB {
save(data) {
console.log("Saving to MongoDB");
}
}
class UserService {
constructor() {
this.database = new MongoDB();
}
saveUser(user) {
this.database.save(user);
}
}
Le problème ici est que UserService est directement connecté à MongoDB.
Passer à PostgreSQL plus tard signifierait devoir modifier UserService lui-même.
Une meilleure approche consiste à injecter la dépendance plutôt que de la lier directement.
class UserService {
constructor(database) {
this.database = database;
}
saveUser(user) {
this.database.save(user);
}
}
Désormais, différentes implémentations de bases de données peuvent être passées librement.
const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);
Et par la suite, le remplacement devient trivial :
const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);
UserService n’a jamais besoin de savoir quel est le database qui se trouve derrière lui.
Pourquoi est-ce utile ?
Cela vous permet d’avoir un code qui est :
- Moins difficile à tester
- Moins difficile à remplacer
SOLID dans un projet réel
Respecter les principes SOLID ne signifie pas créer une classe dédiée pour chaque élément.
Cette distinction est très importante.
SOLID vise à prendre des décisions de conception solides, et non à accumuler des abstractions pour leur propre compte.
Dans une application React, par exemple, ces principes se manifestent naturellement lorsque l’on sépare :
Components
↓
Hooks
↓
Services
↓
API Layer
↓
Database
La tâche principale d’un composant est de gérer l’interface utilisateur.
Un hook personnalisé peut gérer une logique d’état réutilisable.
Un service API peut s’occuper de la communication HTTP.
Le backend gère la logique métier.
La couche de base de données gère la persistance.
Ce découpage permet de maintenir l’application gérable à mesure qu’elle grandit.
SOLID et React
Même si SOLID est issu du design orienté objet, plusieurs de ses principes s’appliquent bien au développement avec React.
Responsabilité unique
Au lieu de créer un composant énorme qui tente de faire tout le travail :
Dashboard.jsx
il vaut mieux le diviser en plusieurs composants :
Dashboard
UserProfile
Statistics
RecentOrders
Notifications
Chaque partie a ainsi une fonction bien plus claire.
Ouvert/Fermé
Concevez des composants réutilisables qui acquièrent de nouveaux comportements grâce aux props, plutôt que de devoir réécrire leur logique interne à chaque fois qu’une nouvelle variation est nécessaire.
<Button variant="primary">
Save
</Button>
<Button variant="danger">
Delete
</Button>
Inversion des dépendances
Au lieu de lier un composant directement à une méthode spécifique de récupération des données, conservez la logique API à l’intérieur d’un service ou d’un hook.
const users = await userService.getUsers();
Le composant lui-même n’a pas besoin de savoir comment cette requête est traitée en interne.
Pourquoi SOLID est important
Les véritables avantages de SOLID ont peu à voir avec l’élégance du code.
Il s’agit plutôt de rendre les modifications futures moins difficiles.
Imaginez un projet comprenant :
10 développeurs → 100 fonctionnalités → des milliers de fichiers → des modifications constantes
Sans une conception réfléchie, une petite exigence peut déclencher une série de dysfonctionnements non liés.
Avec une séparation claire et un couplage faible, les modifications deviennent bien plus prévisibles.
SOLID peut vous aider à :
- Réduire la duplication de code
- Diminuer le couplage étroit
- Mieux assurer la testabilité
- Rendre les fonctionnalités plus faciles à étendre
- Simplifier le débogage
- Mieux favoriser la collaboration au sein d’une équipe
- Permettre de maintenir les grandes applications
SOLID ne signifie pas sur-ingénierie
Ce pourrait être la leçon la plus importante à retenir.
N’appliquez pas SOLID comme une liste de contrôle rigide.
Prenons une fonction simple comme :
function add(a, b) {
return a + b;
}
elle n’a pas besoin de cinq classes, trois interfaces et un conteneur d’injection de dépendances pour fonctionner.
L’objectif n’a jamais été de rendre le code plus complexe.
L’objectif est de rendre le code véritablement complexe plus facile à gérer.
Recourez à SOLID uniquement lorsque la complexité du système justifie réellement cette structure supplémentaire.
Résumé rapide
S — Responsabilité unique : une classe ou un module doit avoir une seule responsabilité principale.
O — Ouvert/Clôturé : étendez le comportement plutôt que d’éditer à plusieurs reprises du code qui fonctionne déjà.
L — Substitution de Liskov : les sous-classes doivent fonctionner correctement partout où la classe parente est attendue.
I — Ségrégation des interfaces : évitez que les clients dépendent de fonctionnalités dont ils n’ont pas besoin.
D — Inversion des dépendances : privilégiez les abstractions plutôt que l’utilisation directe d’implémentations concrètes.
Pensées finales
SOLID n’a jamais été conçu pour être cinq définitions à mémoriser en vue d’un entretien.
C’est une manière de raisonner sur la conception logicielle.
Lors de l’écriture du code, il est utile de s’arrêter et de se demander :
Ce module tente-t-il de faire trop de choses en même temps ?
L’ajout d’une nouvelle fonctionnalité obligera-t-il à réécrire du code existant ?
Sont-elles introduites ici des dépendances inutiles ?
Ce code peut-il être testé sans difficulté ?
Est-ce que quelque chose est forcé de supporter un comportement dont il n’a pas réellement besoin ?
Se poser ces questions a tendance à être plus important que de savoir réciter ce que représente chaque lettre de SOLID.
Un bon logiciel n’est pas simplement un logiciel qui fonctionne pour l’instant.
Un bon logiciel est un logiciel qui continue de évoluer gracieusement plutôt que de se transformer en cauchemar.
Lectures complémentaires
- Lorsque l’IA écrit votre application React mais ignore les principes du code propre — Découvrez sept habitudes de codage propre — DRY, responsabilité unique, clauses de protection, etc. — que le code React généré par l’IA viole souvent et comment les corriger.