Une classe qui enregistre un utilisateur en base, lui envoie un email de bienvenue et met en forme un reçu n’est qu’à un changement de casser deux fonctionnalités qui n’ont rien à voir entre elles. Vous touchez le template de l’email et vous risquez l’écriture en base. Vous touchez la mise en page du reçu et vous risquez l’email. SOLID, ce sont cinq règles qui attaquent ce type d’enchevêtrement sous des angles différents, et elles partagent une idée : une classe doit changer pour une seule raison, et le reste du code doit dépendre de ce qu’une chose promet de faire, pas de la façon dont elle le fait.
Ces principes viennent d’un article de Robert C. Martin publié en 2000, “Design Principles and Design Patterns”. Michael Feathers les a ensuite réordonnés pour former l’acronyme SOLID. Aucun des cinq ne concerne la syntaxe ou un langage en particulier. Ils portent sur l’endroit où placer les coutures de votre code, pour qu’un changement à un endroit reste à cet endroit.
Ce que veut dire SOLID, et pourquoi cinq règles plutôt qu’une
- S, Single Responsibility : une classe n’a qu’une seule raison de changer.
- O, Open/Closed : ajoutez du comportement en ajoutant du code, pas en modifiant du code qui fonctionne déjà.
- L, Liskov Substitution : une sous-classe doit se comporter comme sa classe parente partout où celle-ci est utilisée.
- I, Interface Segregation : ne forcez pas une classe à implémenter des méthodes dont elle n’a pas besoin.
- D, Dependency Inversion : dépendez d’une abstraction, pas d’une classe concrète précise.
Ces règles se recoupent volontairement. Dependency Inversion est ce qui permet à Open/Closed de fonctionner en pratique : vous ne pouvez ajouter une nouvelle implémentation sans toucher au code existant que si ce code dépend d’une interface. Interface Segregation, c’est Single Responsibility appliqué aux interfaces plutôt qu’aux classes. Considérez les cinq comme des points de vue sur un seul objectif, pas comme cinq cases à cocher.
Single Responsibility Principle : une seule raison de changer
Voici une classe avec deux raisons de changer cachées dans un seul corps :
| |
Un changement de schéma et un changement de template d’email touchent désormais tous les deux User. Tester save() nécessite une connexion à la base (ou un mock), et tester send_welcome_email() nécessite un client SMTP, alors qu’aucun des deux tests ne se soucie de l’autre méthode. C’est aussi là que commence l’enchevêtrement décrit dans Qu’est-ce que le Spaghetti Code et Comment le Corriger : une classe continue d’absorber des tâches qui n’ont rien à voir avec elle parce qu’elle était déjà là.
Séparez les tâches :
| |
Chaque classe change désormais pour sa propre raison, et chacune peut être testée avec un fake qui couvre une tâche réduite. “Une seule raison de changer” ne veut pas dire “une seule méthode”. UserRepository pourrait grossir avec find_by_email et delete et garder une seule responsabilité, parce que chaque méthode sert la même tâche : persister des utilisateurs.
Open/Closed Principle : ajouter du comportement sans modifier du code qui fonctionne
Un calculateur qui enchaîne les if sur une étiquette de type :
| |
Ajouter un triangle veut dire modifier area() encore une fois. Pire : toute autre fonction qui teste shape["type"] de la même façon doit être repérée et modifiée pareillement. Chaque nouvelle forme est un changement dans du code déjà testé, avec un risque de casser les cas du cercle et du rectangle au passage.
Définissez le contrat une fois et ajoutez les nouvelles formes comme de nouvelles classes :
| |
Ajouter Triangle revient à créer une nouvelle classe qui implémente area(). total_area ne change jamais, ni Circle ni Rectangle non plus. C’est tout ce que veut dire “ouvert à l’extension, fermé à la modification” : le nouveau comportement arrive sous forme de nouveau code, et le code déjà couvert par des tests reste intact.
Liskov Substitution Principle : une sous-classe doit tenir les promesses de la classe parente
L’échec classique, c’est un Square construit comme sous-classe de Rectangle :
| |
| |
resize_and_check fonctionne avec n’importe quel Rectangle. Donnez-lui un Square et l’assertion échoue : fixer la hauteur a aussi changé la largeur en silence, donc l’aire vaut 16, pas 20. Square passe le contrôle de types comme Rectangle, donc mypy n’a rien à signaler, et pourtant ça casse le comportement que tout appelant de Rectangle est censé pouvoir attendre. Les violations de Liskov concernent rarement les signatures de méthodes, que les outils détectent déjà. Elles concernent une sous-classe qui change en silence ce que les appelants ont le droit de supposer.
Ne réglez pas ça avec un cas spécial pour les carrés à l’intérieur de resize_and_check. Réglez-le en ne faisant pas de Square une sous-classe de Rectangle. Un carré n’a pas deux dimensions redimensionnables indépendamment, donc il ne peut pas honorer le contrat d’un Rectangle. Les deux peuvent implémenter l’interface Shape de l’exemple Open/Closed, sans aucun héritage entre eux.
Interface Segregation Principle : ne pas imposer de méthodes inutilisées à une classe
Une interface qui regroupe des capacités qui n’ont rien à voir entre elles :
| |
Tout code qui détient un Printer doit maintenant savoir, en dehors du système de types, quelles méthodes sont sûres sur quelle instance. L’interface promet trois capacités et en livre une, plus deux mines.
Séparez l’interface par capacité plutôt que par appareil :
| |
Une fonction qui a seulement besoin d’imprimer prend un Printable, et il n’y a aucun .scan() à appeler par erreur sur un appareil qui n’en a jamais eu. Le contrat vient de ce que l’objet implémente, pas d’un commentaire qui prévient quelles méthodes vont lever une exception.
Dependency Inversion Principle : dépendre d’abstractions, pas de classes concrètes
Un service construit directement sur une bibliothèque de base de données :
| |
UserService est soudé à SQLite. Passer à Postgres, ou lui fournir un faux repository dans un test, veut dire modifier UserService lui-même plutôt que remplacer une dépendance.
| |
UserService dépend maintenant de l’abstraction UserRepository. Construire un SQLiteUserRepository(), ou un FakeUserRepository() pour un test, et le passer est une étape à part, et cette étape, c’est la dependency injection. Les deux termes sont souvent utilisés l’un pour l’autre, mais ce sont deux choses différentes : dependency inversion est la règle de conception (dépendre d’abstractions), et dependency injection est la technique qui fournit l’objet concret. Qu’est-ce que la Dependency Injection (avec Exemples) couvre le côté injection, y compris ce qu’apporte un conteneur DI quand une classe a besoin de plus d’une ou deux dépendances câblées.
Quand SOLID devient de l’over-engineering
Découper un script de vingt lignes en cinq interfaces n’apporte rien s’il existe exactement une implémentation et aucun plan pour une seconde. Interface Segregation et Dependency Inversion ajoutent chacune une couche d’indirection. Cette couche se rentabilise quand vous changez vraiment d’implémentation, comme production contre tests ou SQLite contre Postgres, ou quand vous devez isoler une classe derrière un test double comme décrit dans Tests Unitaires vs Tests d’Intégration vs Tests End-to-End. Si rien n’est jamais remplacé, l’abstraction n’est que du poids mort.
Plus d’abstractions ne veut pas dire meilleure conception. Un constructeur qui prend huit interfaces injectées n’est pas plus SOLID qu’un autre qui en prend trois. Ça veut dire que la classe derrière a encore trop de responsabilités, ce qui vous renvoie à la première lettre.
SOLID, c’est aussi du vocabulaire orienté objet. Beaucoup de bon code n’a aucune classe. Les habitudes qu’il y a derrière restent valables quand même : des petites fonctions avec une seule tâche, et des dépendances passées plutôt que récupérées dans des variables globales. Ça compte dans un style fonctionnel même quand aucune des cinq lettres ne s’applique littéralement.
Comment commencer à appliquer SOLID à un code existant
Ne passez pas tout votre code au crible d’une checklist. Appliquez d’abord Single Responsibility, et seulement là où une classe fait déjà mal à modifier : un fichier qui provoque des conflits de merge à chaque sprint, ou un test qui a besoin de trois mocks sans rapport avant de pouvoir tourner. Mettez une dépendance derrière une abstraction seulement quand une seconde implémentation ou un fake pour les tests est un besoin actuel, pas hypothétique. La plupart des problèmes d’Open/Closed et de Liskov commencent comme un problème de Single Responsibility qui a fait grossir une sous-classe, donc les trois autres principes suivent souvent une fois ces deux habitudes ancrées.