Chapitre 1219 minIntermédiaire
Les principes SOLID expliqués avec des exemples
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- énoncer les cinq principes SOLID avec un exemple ;
- repérer le principe violé dans un code ;
- découper une classe qui a trop de responsabilités ;
- expliquer couplage et cohésion.
3 exercices corrigés1 schéma
SOLID est l'acronyme de cinq principes de conception orientée objet : responsabilité unique (Single responsibility), ouvert/fermé (Open/closed), substitution de Liskov (Liskov substitution), ségrégation des interfaces (Interface segregation) et inversion des dépendances (Dependency inversion). Rassemblés par Robert C. Martin au début des années 2000 (l'acronyme est de Michael Feathers), ils servent à écrire des classes faciles à comprendre, à tester et à faire évoluer. Ce chapitre explique chaque principe avec un exemple de GestParc, avant et après, puis les notions de couplage et de cohésion qui les relient.
Pourquoi des principes de conception
Un programme qui marche n'est que la moitié du travail. L'autre moitié, c'est qu'il continue de marcher quand on le modifie : nouvelle règle de gestion, nouveau format d'export, passage d'une application console à WinForms ou à une API. À l'E6, la commission te demande précisément de faire évoluer ton application en une heure (les évolutions types de l'E6 SLAM) : une conception propre fait la différence entre une modification de dix lignes et une réécriture paniquée.
Les principes SOLID ne sont pas des règles de syntaxe. Ce sont des critères pour juger une conception : quand une classe devient difficile à modifier, l'un d'eux explique presque toujours pourquoi.
S : le principe de responsabilité unique
Une classe ne doit avoir qu'une seule raison de changer.
Voici une classe qui fait tout :
class RapportTickets
{
public void Generer(List<Ticket> tickets, string chemin)
{
// 1. Calculer les statistiques par service
// 2. Mettre en forme en CSV, avec les en-têtes
// 3. Écrire le fichier sur le disque
// 4. Envoyer le fichier par courriel au responsable
}
}
Elle a quatre raisons de changer : une nouvelle statistique, un nouveau format (PDF), un nouvel emplacement de stockage, un nouveau serveur de messagerie. Chaque modification risque de casser les trois autres fonctions, et rien ne se teste séparément. On découpe selon les responsabilités :
record LigneStat(string Service, int Nombre, int Minutes);
class StatistiquesTickets
{
public IReadOnlyList<LigneStat> ParService(IEnumerable<Ticket> tickets) =>
tickets.GroupBy(t => t.Service)
.Select(g => new LigneStat(g.Key, g.Count(), g.Sum(t => t.DureeMin)))
.OrderBy(l => l.Service)
.ToList();
}
interface IFormatRapport
{
string Formater(IReadOnlyList<LigneStat> lignes);
}
class FormatCsv : IFormatRapport
{
public string Formater(IReadOnlyList<LigneStat> lignes) =>
"service;nombre;minutes\n" + string.Concat(lignes.Select(l => $"{l.Service};{l.Nombre};{l.Minutes}\n"));
}
class FormatTexte : IFormatRapport
{
public string Formater(IReadOnlyList<LigneStat> lignes)
{
var sb = new System.Text.StringBuilder();
foreach (LigneStat l in lignes)
{
sb.AppendLine($"{l.Service} : {l.Nombre} ticket(s), {l.Minutes} min");
}
return sb.ToString();
}
}
Avec la liste tickets du chapitre 6 :
var lignes = new StatistiquesTickets().ParService(tickets);
Console.Write(new FormatTexte().Formater(lignes));
Console.Write(new FormatCsv().Formater(lignes));
Commercial : 2 ticket(s), 70 min
Comptabilité : 2 ticket(s), 65 min
Direction : 1 ticket(s), 0 min
Juridique : 1 ticket(s), 0 min
Logistique : 1 ticket(s), 90 min
service;nombre;minutes
Commercial;2;70
Comptabilité;2;65
Direction;1;0
Juridique;1;0
Logistique;1;90
Le calcul se teste sans fichier ni courriel ; le format change sans toucher au calcul. Un indice de violation : une classe dont le nom contient « Gestion », « Manager » ou « Utils » et qui grossit à chaque évolution.
O : le principe ouvert/fermé
Une classe doit être ouverte à l'extension, mais fermée à la modification.
On doit pouvoir ajouter un comportement sans modifier le code existant qui fonctionne. Au chapitre 8, le switch sur le type de matériel violait ce principe : chaque nouveau type obligeait à le modifier. La version polymorphe le respecte : un nouveau type, c'est une nouvelle classe.
L'exemple de responsabilité unique le respecte aussi : un export PDF s'ajoute en écrivant une classe FormatPdf : IFormatRapport, sans toucher à StatistiquesTickets ni aux formats existants. De même, l'échéance d'un ticket se calcule par une interface ICalculEcheance : un nouveau contrat de service, c'est une nouvelle classe (chapitre 13).
Les outils de l'ouverture/fermeture sont ceux des chapitres précédents : polymorphisme, classes abstraites, interfaces.
L : le principe de substitution de Liskov
Un objet d'une classe fille doit pouvoir remplacer un objet de la classe mère sans que le programme se comporte mal.
Formulé par Barbara Liskov en 1987, il précise ce que veut dire « est un ». Une classe fille ne doit ni refuser ce que la mère accepte, ni promettre moins qu'elle. Contre-exemple : pour réutiliser du code, une imprimante hérite de Poste.
class PosteV1(string num)
{
public string NumInventaire { get; } = num;
public virtual void Installer(string logiciel) => Console.WriteLine($"{logiciel} installé sur {NumInventaire}");
}
class ImprimanteV1(string num) : PosteV1(num)
{
public override void Installer(string logiciel) =>
throw new NotSupportedException("on n'installe pas de logiciel sur une imprimante");
}
Tout code qui installe un logiciel sur une liste de PosteV1 plante dès qu'une imprimante s'y trouve : il faudrait tester le type avant chaque appel, ce qui détruit le polymorphisme. La fille ne respecte pas le contrat de la mère.
La correction : l'imprimante n'est pas un poste. Elle hérite de Materiel, et seule la capacité d'installation est partagée par une interface que seuls les postes implémentent.
interface IInstallable
{
void Installer(string logiciel);
}
class PosteFixe(string num) : IInstallable
{
public string NumInventaire { get; } = num;
public void Installer(string logiciel) => Console.WriteLine($"{logiciel} installé sur {NumInventaire}");
}
class Portable(string num) : PosteFixe(num);
L'exemple classique des manuels est le carré qui hérite du rectangle : si on change la largeur d'un carré, sa hauteur change aussi, ce qu'aucun code écrit pour un rectangle n'attend. Mathématiquement, un carré est un rectangle ; en programmation, un carré modifiable n'en est pas un.
I : le principe de ségrégation des interfaces
Aucune classe ne doit dépendre de méthodes qu'elle n'utilise pas.
Une interface trop large oblige ses implémentations à écrire des méthodes absurdes :
interface IMateriel
{
decimal ValeurNette(DateOnly date);
void Installer(string logiciel);
void Imprimer(string document);
int NiveauEncre();
}
Un poste doit implémenter Imprimer et NiveauEncre, une imprimante Installer : on retombe sur les NotSupportedException du principe précédent. On découpe en interfaces centrées sur un usage, que chaque classe combine selon ses capacités :
interface IAmortissable { decimal ValeurNette(DateOnly date); }
interface IInstallable { void Installer(string logiciel); }
interface IImprimante { void Imprimer(string document); int NiveauEncre(); }
// class Poste : Materiel, IAmortissable, IInstallable
// class Imprimante : Materiel, IAmortissable, IImprimante
Le code qui calcule la valeur du parc ne dépend que d'IAmortissable ; il ne voit rien de l'impression.
D : le principe 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.
Le haut niveau, ce sont les règles métier : ouvrir un ticket, prévenir l'astreinte s'il est critique. Le bas niveau, ce sont les détails techniques : MySQL, l'envoi de courriels, l'horloge du système. Si TicketService crée lui-même un TicketRepositoryMySql et un EnvoiSmtp, la règle métier est soudée à ces techniques : impossible de la tester sans base ni serveur de messagerie, impossible de changer de base sans la modifier.
// Avant : le métier dépend directement de la technique
class TicketService
{
private readonly TicketRepositoryMySql depot = new TicketRepositoryMySql("server=…");
private readonly EnvoiSmtp courriel = new EnvoiSmtp("smtp.sorbier.example");
// …
}
L'inversion consiste à faire dépendre le métier d'interfaces qu'il définit selon ses besoins, et à laisser les classes techniques les implémenter. La flèche de dépendance, qui allait du métier vers la technique, s'inverse : c'est la technique qui dépend du contrat fixé par le métier.
// Après : le métier dépend d'abstractions, reçues de l'extérieur
public class TicketService
{
private readonly ITicketRepository depot;
private readonly INotificateur notificateur;
public TicketService(ITicketRepository depot, INotificateur notificateur)
{
this.depot = depot;
this.notificateur = notificateur;
}
}
Qui fournit alors les objets concrets ? Le code de démarrage de l'application, par injection de dépendances : c'est le sujet du chapitre 14. En test, on fournit une fausse implémentation en mémoire (chapitre 15).
Couplage et cohésion
Les cinq principes poursuivent deux objectifs, que tu peux citer à l'oral :
| Notion | Définition | Objectif |
|---|---|---|
| Couplage | le degré de dépendance entre classes : combien une classe doit connaître des autres | faible : modifier une classe ne casse pas les autres |
| Cohésion | le degré d'unité d'une classe : ses membres servent-ils un même but ? | forte : une classe fait une chose, entièrement |
La responsabilité unique augmente la cohésion ; l'inversion des dépendances et la ségrégation des interfaces diminuent le couplage ; ouvert/fermé et Liskov rendent l'extension sûre.
D'autres principes à connaître
| Principe | Idée | Exemple |
|---|---|---|
| DRY (Don't Repeat Yourself) | chaque règle est écrite à un seul endroit | la validation d'un numéro d'inventaire dans un record, pas dans chaque formulaire |
| KISS (Keep It Simple, Stupid) | la solution la plus simple qui répond au besoin | une méthode de dix lignes plutôt qu'un patron de conception superflu |
| YAGNI (You Aren't Gonna Need It) | ne pas coder ce dont on n'a pas besoin aujourd'hui | pas d'export PDF « au cas où » |
| Loi de Déméter | ne parle qu'à tes voisins immédiats | ticket.NomDuDemandeur() plutôt que ticket.Demandeur.Service.Responsable.Nom dispersé partout |
| Composition plutôt qu'héritage | assembler des objets plutôt que d'empiler des niveaux d'héritage | chapitre 7 |
SOLID sans dogmatisme
Appliquer SOLID à la lettre sur un programme de cent lignes produit dix interfaces et autant de fichiers pour rien : c'est l'inverse de KISS. Les principes servent quand le code grandit et change. Une bonne pratique : écris d'abord simplement ; quand une modification te fait souffrir (le même switch à changer trois fois, une classe impossible à tester), identifie le principe violé et corrige à ce moment-là. À l'oral de l'E6, savoir dire « j'ai extrait cette interface pour pouvoir tester le service sans la base » vaut mieux que réciter les cinq définitions.
Exercices
Exercice 1 sur 3 Quel principe est violé ? Réussi À revoir
Pour chaque extrait, nomme le principe SOLID violé.
- Une classe
Facturecalcule ses totaux, se sauvegarde dans MySQL et se met en page en PDF. class LogicielLibre : LogicielredéfinitAcheter(int nbLicences)en levantNotSupportedException.class Rapport { private readonly ExportExcel export = new ExportExcel(); … }- Une interface
IUtilisateuravecSeConnecter(),ValiderNoteDeFrais(),GererLesDroits()etConsulterPlanning(), implémentée par les visiteurs, les comptables et les administrateurs. - Pour ajouter un type de contrat, il faut ajouter un
casedans quatreswitchrépartis dans l'application.
Voir la réponseMasquer la réponse de l'exercice 1
- S, responsabilité unique : trois raisons de changer (calcul, stockage, présentation).
- L, substitution de Liskov : un
LogicielLibrene peut pas remplacer unLogicieldans le code qui achète des licences. - D, inversion des dépendances :
Rapportcrée lui-même une classe technique concrète ; il devrait recevoir une abstraction (IExport). - I, ségrégation des interfaces : un visiteur doit implémenter
GererLesDroits(), qu'il n'utilise pas. - O, ouvert/fermé : chaque extension oblige à modifier du code existant ; le polymorphisme (une classe par type de contrat) règle le problème.
Tu avais trouvé ?
Exercice 2 sur 3 Découper une classe Réussi À revoir
Cette classe viole la responsabilité unique. Propose un découpage : noms des classes ou interfaces, et rôle de chacune.
class GestionImport
{
public void Importer(string chemin)
{
// lit le fichier CSV ligne par ligne
// vérifie chaque ligne : numéro au format PC-0000, prix positif
// crée les objets Poste
// les enregistre dans la base MySQL
// écrit un compte rendu dans un fichier journal
}
}
Voir la réponseMasquer la réponse de l'exercice 2
Un découpage possible :
| Classe ou interface | Responsabilité |
|---|---|
LecteurCsv | lire le fichier et produire des lignes brutes (string[] ou un record LigneImport) |
NumeroInventaire (record) et le constructeur de Poste | valider les données : le format du numéro, le prix positif |
MaterielFactory | créer le bon objet à partir d'une ligne (chapitre 13) |
IPosteRepository (implémentée par PosteRepositoryMySql) | enregistrer les postes |
IJournal | écrire le compte rendu |
ImportService | orchestrer : lire, créer, enregistrer, journaliser, en recevant les objets précédents par son constructeur |
ImportService garde une seule raison de changer : l'enchaînement des étapes. Chaque étape se teste seule, et passer de MySQL à une API, ou du fichier journal à la console, ne touche qu'une classe.
Tu avais trouvé ?
Exercice 3 sur 3 Inverser une dépendance Réussi À revoir
Réécris cette classe pour qu'elle respecte l'inversion des dépendances, et qu'un test puisse vérifier le message journalisé à une heure fixée.
class ExportCompta
{
public void Exporter(IEnumerable<Facture> factures)
{
decimal total = factures.Sum(f => f.Montant);
File.AppendAllText("export.log", $"{DateTime.Now:dd/MM/yyyy HH:mm} export de {factures.Count()} factures, {total:N2} €\n");
}
}
Avec deux factures de 1 200 € et 349,90 € exportées le 2 novembre 2026 à 18 h 30, quel message est journalisé ?
Voir la réponseMasquer la réponse de l'exercice 3
Deux dépendances techniques sont cachées : le fichier (File) et l'horloge (DateTime.Now). On les remplace par deux interfaces, reçues par le constructeur :
interface IJournal { void Ecrire(string message); }
interface IHorloge { DateTime Maintenant { get; } }
class ExportCompta(IJournal journal, IHorloge horloge)
{
public void Exporter(IEnumerable<Facture> factures)
{
decimal total = factures.Sum(f => f.Montant);
journal.Ecrire($"{horloge.Maintenant:dd/MM/yyyy HH:mm} export de {factures.Count()} factures, {total:N2} €");
}
}
En production, on injecte un JournalFichier et une horloge système ; en test, un JournalMemoire qui garde les lignes dans une liste et une HorlogeFixe. Le message journalisé est « 02/11/2026 18:30 export de 2 factures, 1 549,90 € ». .NET fournit déjà une abstraction de l'horloge, la classe TimeProvider, utilisée au chapitre 14.
Tu avais trouvé ?