SRP & OCP : responsabilité unique et ouvert/fermé
Les deux premiers principes SOLID : une classe ne doit avoir qu'une seule raison de changer, et le code doit être ouvert à l'extension mais fermé à la modification.
Voici les deux premiers principes que Martin range sous l'acronyme SOLID. Ils ne sont pas tombés du ciel : ils répondent directement aux « odeurs » de conception décrites précédemment — la rigidité, la fragilité, l'immobilité. Le principe de responsabilité unique (Single-Responsibility Principle, SRP) attaque la fragilité à la source en réduisant le nombre de raisons qu'a une classe de changer. Le principe ouvert/fermé (Open-Closed Principle, OCP) attaque la rigidité en organisant le code pour qu'on l'enrichisse par ajout plutôt que par modification. Le livre est écrit en Java et C++ ; nous transposons ses exemples canoniques — le Modem, le Rectangle, le couple Client/Server, l'application de dessin de formes — en TypeScript idiomatique.
SRP : une seule raison de changer
L'énoncé tient en une phrase, presque tautologique tant elle paraît évidente :
Une classe ne devrait avoir qu'une seule raison de changer.
Tout le sel est dans le mot « raison ». Dans le contexte du SRP, Martin définit une responsabilité comme « une raison de changer » (a reason for change). Si vous parvenez à imaginer plus d'un motif de modifier une classe, alors cette classe porte plus d'une responsabilité. Et cela, contrairement à ce que l'évidence de l'énoncé laisse croire, est souvent difficile à voir : nous sommes habitués à regrouper les responsabilités par instinct.
La justification profonde est la notion d'axe de changement (axis of change). Chaque responsabilité est un axe le long duquel les exigences évoluent. Quand une exigence change, ce changement se manifeste par une modification de la responsabilité concernée. Si une classe assume deux responsabilités, elle a deux raisons de changer — et ces deux raisons deviennent couplées. Modifier l'une peut alors altérer ou empêcher la capacité de la classe à remplir l'autre. C'est le mécanisme exact de la fragilité : un code qui casse en des endroits inattendus quand on le touche.
Note
Le SRP est né d'une étude de cas concrète du livre : le jeu de bowling. Pendant la majeure partie de son développement, la classe Game cumulait deux responsabilités — suivre la frame courante et calculer le score. À la fin, les deux furent séparées : Game garde le suivi des frames, et un Scorer reçoit le calcul du score. Les principes émergent du code, ils ne lui sont pas plaqués d'avance.
Le calcul géométrique contre le rendu
L'exemple le plus parlant est celui du Rectangle. Imaginez une classe Rectangle dotée de deux méthodes : area(), qui calcule l'aire, et draw(), qui dessine le rectangle à l'écran. Deux applications différentes l'utilisent. La première fait de la géométrie computationnelle : elle se sert de Rectangle pour les mathématiques des formes, sans jamais rien dessiner. La seconde est graphique : elle dessine bel et bien le rectangle sur l'interface.
Géométrie Application
computationnelle ───► Rectangle ◄─── graphique
+ area()
+ draw() ───► GUI Cette conception viole le SRP. La classe Rectangle porte deux responsabilités : fournir un modèle mathématique de la géométrie d'un rectangle, et le rendre sur une interface graphique. Le couplage qui en résulte cause plusieurs ennuis concrets. D'abord, l'application de géométrie doit embarquer la bibliothèque graphique (le GUI), qu'elle n'utilise pas — temps de compilation, de liaison et empreinte mémoire gaspillés. Ensuite, si une modification de l'application graphique force Rectangle à changer, il faut recompiler, retester et redéployer l'application de géométrie, alors qu'elle n'a rien demandé. Et si l'on oublie de le faire, elle casse de façon imprévisible.
// ❌ Avant : une seule classe, deux responsabilités couplées.
class Rectangle {
constructor(
public largeur: number,
public hauteur: number,
) {}
area(): number {
return this.largeur * this.hauteur;
}
draw(gui: GUI): void {
gui.tracerRectangle(this.largeur, this.hauteur);
}
} La meilleure conception sépare les deux responsabilités dans deux classes distinctes. La partie mathématique migre vers une classe GeometricRectangle, tandis que le rendu reste ailleurs. Désormais, un changement dans la manière de dessiner les rectangles ne peut plus affecter l'application de géométrie.
// ✅ Après : la géométrie pure ne sait rien du dessin.
class GeometricRectangle {
constructor(
public largeur: number,
public hauteur: number,
) {}
area(): number {
return this.largeur * this.hauteur;
}
}
// Le rendu vit dans sa propre classe, côté graphique.
class RectangleRenderer {
draw(gui: GUI, rect: GeometricRectangle): void {
gui.tracerRectangle(rect.largeur, rect.hauteur);
}
} Qu'est-ce qu'une responsabilité ? Le cas du Modem
L'exemple du Modem montre à quel point une responsabilité peut se cacher derrière une apparence de cohésion. Considérez cette interface : la plupart d'entre nous la trouveront parfaitement raisonnable. Ses quatre fonctions appartiennent toutes, indubitablement, à un modem.
// ❌ Apparemment cohérente, mais deux responsabilités y cohabitent.
interface Modem {
dial(numero: string): void; // gestion de connexion
hangup(): void; // gestion de connexion
send(c: string): void; // communication de données
recv(): string; // communication de données
} Pourtant, deux responsabilités s'y trouvent. La première est la gestion de la connexion : dial et hangup ouvrent et ferment la liaison. La seconde est la communication de données : send et recv transmettent l'information. Ces deux axes sont bien distincts.
Faut-il les séparer ? Cela dépend de la façon dont l'application change. Si elle évolue de manière à affecter la signature des fonctions de connexion, alors la conception « sentira la rigidité » : les classes clientes qui n'appellent que send et recv devront tout de même être recompilées et redéployées plus souvent que souhaité. Dans ce cas, on sépare les deux responsabilités en deux interfaces pour empêcher les clients de coupler ce qui ne devrait pas l'être.
// ✅ Deux interfaces : un client ne dépend que de ce qu'il utilise.
interface DataChannel {
send(c: string): void;
recv(): string;
}
interface Connection {
dial(numero: string): void;
hangup(): void;
}
// L'implémentation, elle, peut rester couplée par nécessité.
class ModemImplementation implements DataChannel, Connection {
dial(numero: string): void {
/* ... */
}
hangup(): void {
/* ... */
}
send(c: string): void {
/* ... */
}
recv(): string {
return "";
}
} Si, à l'inverse, l'application ne change jamais d'une manière qui ferait évoluer les deux responsabilités à des moments différents, alors les séparer serait inutile — et cela « sentirait la complexité superflue » (needless complexity). D'où un corollaire essentiel : un axe de changement n'est un axe de changement que si les changements surviennent réellement. Il n'est pas sage d'appliquer le SRP, ni aucun autre principe, là où aucun symptôme ne se manifeste.
À retenir
Remarquez que dans la version séparée du Modem, les deux responsabilités restent couplées dans l'implémentation ModemImplementation. Ce n'est pas souhaitable, mais c'est parfois nécessaire : le matériel ou le système d'exploitation peuvent forcer ce couplage. La parade est de découpler les concepts au niveau des interfaces. Toutes les dépendances s'éloignent alors de cette classe : personne, hormis main, n'a besoin de savoir qu'elle existe. On a mis la partie laide derrière une clôture, pour que sa laideur ne pollue pas le reste de l'application.
Persistance et règles métier
Une violation très répandue du SRP mêle les règles métier et le contrôle de persistance dans une même classe. Une classe Employee qui contient à la fois calculerPaie() et enregistrer() cumule deux responsabilités qui ne devraient presque jamais cohabiter : les règles métier changent fréquemment, et bien que la persistance change moins souvent, elle change pour des raisons complètement différentes. Lier les règles métier au sous-système de persistance, c'est s'attirer des ennuis.
Le développement piloté par les tests (test-driven development, TDD) force généralement la séparation de ces deux responsabilités bien avant que la conception ne commence à sentir mauvais. Mais quand les tests n'y ont pas suffi et que les odeurs de rigidité et de fragilité deviennent fortes, on refactorise à l'aide des patrons FAÇADE (Facade) ou MANDATAIRE (Proxy) pour isoler la persistance.
Le SRP est l'un des plus simples des principes, et l'un des plus durs à appliquer correctement. Conjoindre des responsabilités est quelque chose que nous faisons naturellement ; les trouver et les séparer constitue une bonne part de ce qu'est réellement la conception logicielle. Tous les autres principes y reviennent, d'une manière ou d'une autre.
OCP : ouvert à l'extension, fermé à la modification
Comme l'a dit Ivar Jacobson, tous les systèmes changent durant leur cycle de vie ; il faut le garder à l'esprit dès lors qu'on développe un logiciel censé survivre à sa première version. Comment créer des conceptions stables face au changement ? Bertrand Meyer nous a donné la réponse dès 1988 en formulant le désormais célèbre principe ouvert/fermé.
Les entités logicielles (classes, modules, fonctions, etc.) devraient être ouvertes à l'extension, mais fermées à la modification.
Quand une seule modification d'un programme provoque une cascade de changements dans les modules dépendants, la conception « sent la rigidité ». L'OCP nous conseille de refactoriser le système pour que de futurs changements de ce type n'entraînent plus d'autres modifications. Si l'OCP est bien appliqué, ces futurs changements s'obtiennent en ajoutant du code neuf, et non en modifiant du code existant qui fonctionne déjà.
Les modules conformes à l'OCP possèdent deux attributs apparemment contradictoires :
- Ouverts à l'extension. Le comportement du module peut être enrichi. À mesure que les exigences évoluent, on étend le module avec de nouveaux comportements qui les satisfont. Autrement dit, on peut changer ce que le module fait.
- Fermés à la modification. Étendre le comportement n'entraîne aucun changement du code source ou binaire du module. Le binaire — bibliothèque liée, DLL, ou
.jarJava — reste intact.
Comment réconcilier ces deux attributs ? La façon normale d'étendre un comportement consiste justement à modifier le code source… Et un module qu'on ne peut pas changer passe pour avoir un comportement figé. La résolution de ce paradoxe tient en un mot.
Astuce
L'abstraction est la clé de l'OCP. Dans tout langage orienté objet, on peut créer des abstractions fixes — des classes de base abstraites, ou des interfaces — qui représentent pourtant un groupe non borné de comportements possibles, incarnés par toutes leurs dérivées présentes et futures. Un module qui manipule une abstraction peut être fermé à la modification, puisqu'il dépend d'une abstraction fixe ; et son comportement peut malgré tout être étendu, en créant de nouvelles dérivées de cette abstraction.
Le couple Client/Server
Prenons la conception la plus naïve : une classe Client concrète qui utilise une classe Server concrète. Si l'on veut que le client utilise un serveur différent, il faut modifier la classe Client pour qu'elle nomme la nouvelle classe de serveur. Cette conception n'est ni ouverte ni fermée.
Client ───► Server (rien n'est ouvert ni fermé) La conception conforme à l'OCP introduit une abstraction entre les deux. Le Client ne dépend plus d'un serveur concret, mais d'une interface — que Martin nomme délibérément ClientInterface et non AbstractServer. Ce choix de nom n'est pas anodin : une abstraction est plus étroitement associée à ses clients qu'aux classes qui l'implémentent. C'est le client qui définit le service dont il a besoin ; les serveurs s'y conforment.
Client ───► «interface» ClientInterface
▲
│
Server (Client est ouvert ET fermé) // L'abstraction définie du point de vue du client.
interface ClientInterface {
service(): void;
}
// Le client ne connaît que l'abstraction : il est fermé.
class Client {
constructor(private serveur: ClientInterface) {}
faireTravail(): void {
this.serveur.service(); // dispatché polymorphiquement
}
}
// On étend le comportement en ajoutant une dérivée,
// sans toucher à Client : il est ouvert.
class Server implements ClientInterface {
service(): void {
/* ... */
}
} Pour faire utiliser un serveur différent au Client, il suffit désormais de créer une nouvelle implémentation de ClientInterface. La classe Client reste inchangée. C'est la structure du patron STRATÉGIE (Strategy). Une variante équivalente place l'abstraction à l'intérieur d'une classe Policy dont les méthodes publiques décrivent un traitement en termes de méthodes abstraites, implémentées par les sous-classes : c'est le patron PATRON DE MÉTHODE (Template Method). Ces deux patrons sont les façons les plus courantes de satisfaire l'OCP, et tous deux séparent nettement la fonctionnalité générique de son implémentation détaillée.
L'application de dessin : le switch contre le polymorphisme
Voici l'exemple le plus célèbre de l'orienté objet, celui des formes. On dispose d'une application qui doit dessiner des cercles et des carrés sur une interface graphique, dans un ordre donné. En procédural, sans souci de l'OCP, on résout cela par un type discriminant et un switch.
// ❌ Avant : un switch sur un code de type, fermé à rien.
enum ShapeType {
Circle,
Square,
}
interface Shape {
type: ShapeType;
}
function drawAllShapes(liste: Shape[]): void {
for (const s of liste) {
switch (s.type) {
case ShapeType.Square:
drawSquare(s as Square);
break;
case ShapeType.Circle:
drawCircle(s as Circle);
break;
}
}
} Cette fonction drawAllShapes ne se conforme pas à l'OCP : on ne peut pas la fermer contre l'arrivée de nouvelles formes. Pour dessiner des triangles, il faut modifier la fonction. Et ce n'est que la partie émergée du problème. Dans la vraie vie, ce switch se répète encore et encore à travers toute l'application — des fonctions pour déplacer, étirer, supprimer les formes, chacune avec sa propre chaîne de switch ou de if/else. Ajouter une forme oblige à traquer chaque endroit où ces aiguillages existent, ce qui n'est pas trivial : les conditions y sont rarement aussi propres que dans notre exemple, souvent combinées par des opérateurs logiques qui « simplifient » la décision locale.
Pire encore : ajouter une valeur à l'énumération ShapeType force à recompiler tous les modules qui dépendent de cette déclaration, plus tous ceux qui dépendent de Shape. Le simple ajout d'une forme provoque une cascade de changements dans de nombreux modules sources et, par recompilation, dans encore plus de modules binaires. Cette solution est rigide (l'ajout d'un Triangle force la recompilation de Shape, Square, Circle et drawAllShapes), fragile (à cause des innombrables switch durs à trouver) et immobile (impossible de réutiliser drawAllShapes ailleurs sans traîner Square et Circle).
La solution conforme à l'OCP repose sur le polymorphisme. On écrit une abstraction Shape dotée d'une unique méthode abstraite draw. Circle et Square en sont des dérivées qui implémentent chacune leur dessin.
// ✅ Après : chaque forme sait se dessiner. drawAllShapes est fermée.
interface Shape {
draw(): void;
}
class Square implements Shape {
draw(): void {
/* dessine un carré */
}
}
class Circle implements Shape {
draw(): void {
/* dessine un cercle */
}
}
function drawAllShapes(liste: Shape[]): void {
for (const forme of liste) forme.draw();
} Pour étendre drawAllShapes à un nouveau type de forme, il suffit d'ajouter une dérivée de Shape. La fonction n'a pas besoin de changer : son comportement est étendu sans qu'on la modifie. Ajouter une classe Triangle n'a aucun effet sur le code montré ici. Une partie du système doit certes changer pour gérer le triangle — typiquement le module qui crée les instances, dans main ou dans une fabrique appelée par main — mais tout le code ci-dessus est immunisé contre le changement. La solution n'est ni rigide, ni fragile, ni immobile : elle se modifie en ajoutant du code neuf, jamais en altérant du code existant.
La fermeture stratégique : « OK, j'ai menti »
L'exemple précédent était idyllique. Que se passe-t-il si l'on décide soudain que tous les cercles doivent être dessinés avant les carrés ? La fonction drawAllShapes n'est pas fermée contre un tel changement : pour l'implémenter, il faut y entrer et parcourir la liste deux fois, d'abord pour les cercles puis pour les carrés. Cela mène à une conclusion troublante.
Attention
Aussi « fermé » que soit un module, il existera toujours un type de changement contre lequel il n'est pas fermé. Aucun modèle n'est naturel pour tous les contextes. La hiérarchie Shape avec ses dérivées Square et Circle semblait le modèle le plus naturel qui soit — mais elle ne l'est pas dans un système où l'ordre compte davantage que le type de forme.
Puisque la fermeture ne peut être totale, elle doit être stratégique. Le concepteur doit choisir les types de changements contre lesquels fermer sa conception. Il doit deviner les changements les plus probables, puis construire les abstractions qui l'en protègent. Cela demande une certaine prescience née de l'expérience : le concepteur chevronné espère connaître assez bien ses utilisateurs et son secteur pour juger de la probabilité des différents changements, puis il invoque l'OCP contre les plus probables. Ce n'est pas facile — il s'agit de paris éclairés, et on se trompe souvent.
De plus, se conformer à l'OCP coûte cher : créer les bonnes abstractions prend du temps et de l'effort, et ces abstractions augmentent la complexité de la conception. Il y a une limite à la quantité d'abstraction que l'on peut s'offrir. On veut donc limiter l'application de l'OCP aux changements qui sont vraiment susceptibles de survenir.
Ne pas mettre les « crochets » trop tôt
Au siècle dernier, on disait « mettre les crochets » (putting the hooks in) pour les changements qu'on pensait probables, croyant rendre le logiciel flexible. Mais ces crochets étaient souvent faux. Pire, ils sentaient la complexité superflue : il fallait les maintenir alors qu'ils ne servaient à rien. La bonne attitude est résumée par un adage : « Trompe-moi une fois, honte à toi ; trompe-moi deux fois, honte à moi. » On écrit d'abord le code en s'attendant à ce qu'il ne change pas. Quand le premier changement survient, on encaisse cette première balle, puis on met en place les abstractions qui nous protégeront de toutes les balles suivantes venant de cette même arme.
Piège courant
Résister à l'abstraction prématurée est aussi important que l'abstraction elle-même. On ne charge pas une conception d'abstractions inutiles « au cas où ». Conformément à l'esprit agile, on stimule le changement pour le faire arriver tôt et fréquemment : on écrit les tests d'abord (ce qui force la testabilité, une forme d'abstraction qui en protège souvent d'autres), on développe par cycles très courts — des jours plutôt que des semaines —, on privilégie les fonctionnalités sur l'infrastructure, on les montre vite aux parties prenantes, et on livre tôt et souvent.
Fermer par l'abstraction : l'ordre de dessin
Revenons à la première balle : l'utilisateur veut tous les cercles avant les carrés. Pour fermer drawAllShapes contre les changements d'ordre, il faut une abstraction d'ordonnancement : une interface abstraite à travers laquelle toute politique d'ordre possible peut s'exprimer. Une politique d'ordre revient à savoir, pour deux objets quelconques, lequel doit être dessiné en premier. On ajoute donc à Shape une méthode abstraite precedes, qui prend une autre forme et renvoie un booléen : true si la forme réceptrice doit être dessinée avant la forme passée en argument. On peut alors trier la liste avant de la dessiner.
// On enrichit l'abstraction d'une notion d'ordre.
interface Shape {
draw(): void;
precedes(autre: Shape): boolean;
}
function drawAllShapes(liste: Shape[]): void {
const ordonnee = [...liste].sort((a, b) => (a.precedes(b) ? -1 : 1));
for (const forme of ordonnee) forme.draw();
} Mais une abstraction d'ordre médiocre se cache encore ici. Si chaque forme implémente precedes en testant le type des autres, on retombe dans le piège : Circle.precedes devrait vérifier « est-ce un carré ? » pour renvoyer true. Or une telle méthode, et toutes ses sœurs dans les autres dérivées, ne se conforment pas à l'OCP : impossible de les fermer contre l'arrivée de nouvelles formes, car chaque nouvelle dérivée obligerait à modifier tous les precedes existants.
// ❌ precedes par test de type : non fermé aux nouvelles formes.
class Circle implements Shape {
draw(): void {}
precedes(autre: Shape): boolean {
return autre instanceof Square; // chaque nouvelle forme casse ceci
}
} La parade est une approche pilotée par les données (table-driven). On externalise l'ordre dans une table — un simple tableau d'identifiants de types — que la méthode precedes consulte pour déterminer la priorité, sans qu'aucune forme ne connaisse les autres.
// ✅ Pilotée par les données : l'ordre est dans une table externe.
const ordreDesTypes = ["Circle", "Square"]; // module séparé
abstract class ShapeBase implements Shape {
abstract draw(): void;
abstract typeName(): string;
precedes(autre: Shape): boolean {
const ici = ordreDesTypes.indexOf(this.typeName());
const la = ordreDesTypes.indexOf((autre as ShapeBase).typeName());
return ici < la;
}
} Ainsi, drawAllShapes est fermée contre les questions d'ordre en général, et chaque dérivée de Shape est fermée contre la création de nouvelles formes ou un changement de politique (par exemple dessiner les carrés en premier). Le seul élément non fermé contre l'ordre est la table elle-même — mais on peut l'isoler dans son propre module, de sorte que ses changements n'affectent rien d'autre.
L'OCP est, à bien des égards, au cœur de la conception orientée objet. C'est la conformité à ce principe qui produit les plus grands bénéfices revendiqués par l'orienté objet : flexibilité, réutilisabilité, maintenabilité. Mais cette conformité ne s'obtient pas simplement en utilisant un langage objet ; elle exige du concepteur qu'il dédie l'abstraction aux seules parties du programme qui changent fréquemment.
À retenir
- SRP : une classe ne doit avoir qu'une seule raison de changer. Une responsabilité est un axe de changement ; deux responsabilités dans une classe deviennent couplées et engendrent la fragilité.
- Les exemples canoniques du SRP — le Rectangle (
areacontredraw), le Modem (connexion contre données), la persistance mêlée aux règles métier — montrent qu'une responsabilité se cache souvent derrière une fausse cohésion. - N'appliquez le SRP qu'en présence d'un symptôme : un axe de changement n'en est un que si le changement survient réellement ; séparer prématurément sent la complexité superflue.
- OCP : les entités logicielles doivent être ouvertes à l'extension mais fermées à la modification. On enrichit le système en ajoutant du code, pas en modifiant le code existant.
- L'abstraction est la clé de l'OCP : dépendre d'une interface fixe (Client/Server,
Shape) permet de remplacer unswitchsur des types par du polymorphisme et d'étendre le comportement par de nouvelles dérivées. - La fermeture est stratégique : aucun modèle n'est fermé contre tous les changements. On ferme contre les changements probables, on encaisse la première balle, puis on abstrait — sans poser de « crochets » inutiles à l'avance.
- Esprit agile : stimulez le changement tôt (tests d'abord, cycles courts, livraisons fréquentes) pour découvrir les bons axes d'abstraction avant qu'il ne soit trop coûteux de les introduire.