Read in English · Leer en español · Auf Deutsch lesen
Section 3.5 sur 3.8 · Intermédiaire · 26 min de lecture
Les principes SOLID appliqués aux composants
Leçon vidéo
5 min · narré en anglais, avec sous-titresÉcouter seulement (audio)
Lire la transcription
SOLID est l’ensemble de principes de conception le plus connu, et l’un des plus débattus. Dans cette leçon, vous apprendrez ce que chacun des cinq signifie vraiment, comment chacun s’applique aux composants et aux services, et quand s’en méfier.
Pensez à un groupe de restaurants bien géré. Chaque cuisine rend compte à un seul chef, si bien que deux patrons ne lui donnent jamais d’ordres contradictoires. On ajoute de nouveaux plats à la carte sans reconstruire la cuisine. Un chef remplaçant doit préparer chaque plat exactement comme le chef habituel, sinon les clients s’en aperçoivent. Chaque fournisseur ne reçoit que le bon de commande dont il a besoin, pas tout le manuel de l’entreprise. Et le siège fixe les normes, tandis que les fournisseurs s’y adaptent. SOLID, ce sont ces cinq habitudes, transposées au logiciel. Le premier principe de Robert C. Martin a rassemblé les principes, et Michael Feathers a remarqué que leurs initiales pouvaient être réarrangées pour former SOLID.
S, c’est le principe de responsabilité unique, le plus mal compris des cinq. Il ne dit pas qu’un module ne doit faire qu’une seule chose. Il dit qu’un module doit être responsable devant un seul acteur : un groupe de personnes qui veulent que le système change de la même manière. Imaginez un service Restaurant qui détient les cartes, les règles de commission et le classement des recherches. Les restaurateurs, la finance et l’équipe croissance le modifient chacun, pour des raisons sans rapport. Découpez-le selon ces acteurs. Notez que ce n’est pas une règle pour faire de petits services.
O, c’est le principe ouvert-fermé, énoncé pour la première fois par Bertrand Meyer : ouvert à l’extension, fermé à la modification. Quand un type de variation est attendu, la variante suivante doit être du nouveau code, pas une branche de plus dans l’ancien code. Un service des commandes qui publie un événement OrderPlaced est ouvert à l’extension. L’équipe fidélité peut ajouter un consommateur qui attribue des points sans que le service des commandes change en rien. Mais n’ajoutez des points d’extension que pour les changements qui surviennent réellement. L, c’est le principe de substitution de Liskov, dû à Barbara Liskov. Le code qui fonctionne avec un type doit continuer à fonctionner avec n’importe quel sous-type, sans savoir lequel il a reçu. Les bons noms de méthodes ne suffisent pas ; le comportement doit être compatible. Un substitut ne doit pas exiger davantage, promettre moins, ni rompre les invariants. C’est exactement le critère d’une version d’API compatible : pas de nouveau champ obligatoire, pas de champ de réponse supprimé.
I, c’est la ségrégation des interfaces : aucun client ne doit être forcé de dépendre d’opérations qu’il n’utilise pas. Une interface trop large couple chaque client à chaque opération. Entre services, cela plaide pour des API propres à chaque client et des types d’événements ciblés. Une bibliothèque commune à toute l’entreprise pose le même problème : un correctif dans son code PDF force de nouvelles versions de services qui ne produisent jamais de PDF. D, c’est l’inversion des dépendances. Les règles métier ne doivent pas dépendre de détails comme les bases de données. Les deux doivent dépendre d’abstractions. Et le point souvent négligé est la propriété, comme le montre le schéma suivant.
Avant, le composant Commandes importe et appelle un dépôt PostgreSQL concret situé dans le composant de persistance. Ainsi, un changement de base de données se répercute sur les règles métier. Après, le composant Commandes détient une interface de dépôt de commandes, écrite dans ses propres termes. Le composant de persistance l’implémente, si bien que la flèche du code source pointe désormais vers les règles métier. Si l’interface était restée dans le paquet de persistance, rien n’aurait été inversé.
Martin ajoute trois principes pour décider quelles classes vont ensemble dans un même composant. Équivalence réutilisation-publication : l’unité de réutilisation est l’unité de publication, donc les classes d’un composant doivent avoir un sens à être publiées ensemble. Fermeture commune : regroupez les classes qui changent pour les mêmes raisons et aux mêmes moments. C’est la responsabilité unique appliquée aux composants. Réutilisation commune : les classes utilisées ensemble vont ensemble, car dépendre d’un composant, c’est dépendre de tout son contenu. Ils tirent dans des sens opposés, et chaque côté indique le prix à payer quand on abandonne le sommet opposé. Au début, les équipes privilégient la fermeture commune. À mesure que d’autres en viennent à dépendre de leurs parties, l’équilibre se déplace vers la réutilisation.
Appliquons-les. Le paquet shop core d’une boutique en ligne est utilisé par cinq services. Il contient la tarification, les taxes, la validation des adresses, les factures PDF et un utilitaire de base de données, et a connu quarante versions au dernier trimestre. Quatre équipes différentes en demandent les modifications. Quatre acteurs, c’est quatre raisons indépendantes de changer. Le service d’entrepôt n’utilise que la validation des adresses, et pourtant chacune de ces quarante versions l’a obligé à se mettre à jour et à refaire ses tests. Et l’utilitaire de base de données importe les propres modèles des services, si bien qu’un paquet stable et largement utilisé dépend de code volatil. La flèche pointe dans le mauvais sens. Le découper signifie qu’un changement de taux de taxe ne publie plus qu’un seul paquet, et ne touche que les services qui calculent la taxe. Le coût, c’est davantage de paquets à versionner. Remarquez que les principes n’ont pas dicté la réponse. Ils ont fourni un vocabulaire pour expliquer pourquoi cela faisait mal. Appliqué de façon dogmatique, SOLID produit une interface pour chaque classe et des points d’extension que personne n’utilise. Servez-vous-en pour diagnostiquer de vrais problèmes.
La section complète montre chaque principe en Python, et expose les critiques en détail. Ensuite, dans la section trois point six, nous découvrirons la connascence, une mesure plus fine du couplage. À très vite.
Ce qu’il faut savoir
- SOLID désigne cinq principes rassemblés par Robert C. Martin : responsabilité unique, ouvert-fermé, substitution de Liskov, ségrégation des interfaces et inversion des dépendances ; chacun s’applique aux classes, aux composants et aux services.
- Responsabilité unique signifie un acteur par module : le code modifié à la demande de groupes de personnes différents doit être séparé, et cela ne signifie pas qu’un module ne peut faire qu’une seule chose.
- La substitution de Liskov exige une compatibilité comportementale : pas de préconditions plus fortes, pas de postconditions plus faibles et des invariants préservés, ce qui est exactement le critère d’une version d’API rétrocompatible.
- La ségrégation des interfaces plaide pour de petites interfaces de rôle dans le code et, entre services, pour des API propres à chaque client, des événements ciblés et de petites bibliothèques.
- L’inversion des dépendances fait dépendre les détails d’abstractions détenues par le côté métier ; savoir qui détient l’interface compte plus que le fait qu’elle existe.
- REP, CCP et CRP décident de ce qui entre dans un composant et tirent dans des sens opposés ; le bon équilibre se déplace de CCP vers REP et CRP à mesure qu’un système mûrit.
- Appliqué de façon dogmatique, SOLID produit des abstractions superficielles et spéculatives ; il fonctionne mieux comme vocabulaire pour diagnostiquer les conceptions qui causent de vrais problèmes.
Cinq principes et leur histoire
SOLID est un moyen mnémotechnique désignant cinq principes de conception que Robert C. Martin a rassemblés et affinés à la fin des années 1990, de la façon la plus complète dans son article de 2000 Design Principles and Design Patterns. Michael Feathers a remarqué que leurs initiales pouvaient être réarrangées pour former le mot SOLID, et l’acronyme est resté. Les principes ont été écrits pour les classes orientées objet, mais le livre ultérieur de Martin, Clean Architecture (2017), soutient que chacun s’applique aussi aux composants, et les praticiens les appliquent couramment aux services et aux API.
C’est pourquoi ils ont leur place dans un cours d’architecture. Chaque principe est en réalité un énoncé sur l’endroit où le changement doit atterrir et le sens dans lequel les dépendances doivent pointer, les deux préoccupations des sections précédentes sur le couplage et la cohésion et la gestion des dépendances. Cette section passe en revue chaque principe, le montre en quelques lignes de Python, puis montre ce qu’il signifie lorsque la « classe » est un composant déployable ou un service détenu par une autre équipe. Elle se termine par les principes de cohésion au niveau des composants et par les critiques que suscite SOLID, qui sont substantielles et méritent d’être connues.
S : responsabilité unique signifie un seul acteur
Le principe de responsabilité unique est le plus cité et le plus mal compris des cinq. Il ne dit pas qu’un module ne doit faire qu’une seule chose ; c’est une bonne règle pour les fonctions, mais c’est une règle différente. La formulation de Martin lui-même a évolué : il disait qu’une classe ne devrait avoir qu’une seule raison de changer, puis, dans Clean Architecture, qu’un module devrait être responsable devant un, et un seul, acteur. Un acteur est un groupe de personnes (utilisateurs ou parties prenantes) qui veulent que le système change de la même manière. Le principe porte sur les personnes et sur la direction des changements futurs.
L’illustration classique est une classe Employee dotée de trois méthodes : calculate_pay, spécifiée par la finance ; report_hours, spécifiée par les opérations ; et save, spécifiée par les administrateurs de bases de données. Si le calcul de la paie et le rapport d’heures partagent une fonction utilitaire pour calculer les heures normales, une modification demandée par la finance altère silencieusement le rapport sur lequel s’appuient les opérations. Personne n’a été négligent ; le code mélangeait simplement les préoccupations de deux acteurs, si bien qu’un changement demandé par l’un pouvait nuire à l’autre.
from dataclasses import dataclass
@dataclass
class EmployeeRecord: # shared data, no behaviour
id: str
hourly_rate: float
hours: list[float]
class PayCalculator: # actor: finance
def pay(self, e: EmployeeRecord) -> float:
regular = sum(min(h, 8) for h in e.hours)
overtime = sum(max(h - 8, 0) for h in e.hours)
return (regular + 1.5 * overtime) * e.hourly_rate
class HoursReporter: # actor: operations
def weekly_report(self, e: EmployeeRecord) -> str:
return f"{e.id}: {sum(e.hours):.1f} hours"
class EmployeeRepository: # actor: database administrators
def save(self, e: EmployeeRecord) -> None: ...À l’échelle des composants et des services, le principe devient un test pour les frontières : qui demande les changements de ce composant ? Dans une plateforme de livraison de repas, un service « Restaurant » unique qui détient les cartes (modifiées à la demande des restaurateurs), les règles de commission (modifiées par l’équipe financière) et le classement des résultats de recherche (modifié par l’équipe croissance) sera modifié pour trois raisons sans rapport, par trois groupes de personnes, à trois rythmes de mise en production. Chaque mise en production fait courir un risque aux deux autres préoccupations, et l’équipe propriétaire devient une file d’attente. Découper selon les acteurs, par exemple en un service de carte, un composant de commissions au sein du domaine financier et un service de classement, aligne chaque frontière sur une seule source de changement. C’est aussi l’idée qui sous-tend l’alignement des services sur les capacités métier et, en conception pilotée par le domaine, sur les contextes délimités (traités au chapitre 5).
Notez que le principe ne dit pas « faites des services petits ». Un gros service qui sert bien un seul acteur ne pose pas de problème ; un tout petit service que deux départements ne cessent de modifier dans des directions contradictoires en pose un.
O : ouvert à l’extension, fermé à la modification
Le principe ouvert-fermé a été énoncé pour la première fois par Bertrand Meyer dans Object-Oriented Software Construction (1988) : un module doit être ouvert à l’extension (on peut ajouter un nouveau comportement) mais fermé à la modification (son code source existant et testé n’a pas besoin d’être modifié pour cela). Meyer pensait à l’héritage ; Martin l’a réinterprété en termes d’interfaces et de polymorphisme. Sa forme pratique est la suivante : quand un type de variation est attendu, donnez-lui un point d’extension, afin que la variante suivante soit du nouveau code plutôt qu’une branche de plus dans l’ancien code.
from typing import Protocol
class Carrier(Protocol):
def quote(self, weight_kg: float) -> int: ... # price in pence
class RoyalMail:
def quote(self, weight_kg: float) -> int:
return 350 + int(weight_kg * 120)
class Courier:
def quote(self, weight_kg: float) -> int:
return 600 + int(weight_kg * 80)
CARRIERS: dict[str, Carrier] = {"royal_mail": RoyalMail(), "courier": Courier()}
def cheapest(weight_kg: float) -> tuple[str, int]:
return min(((name, c.quote(weight_kg)) for name, c in CARRIERS.items()),
key=lambda pair: pair[1])À l’échelle de l’architecture, la même idée apparaît sous la forme de points d’extension que d’autres équipes peuvent utiliser sans vous demander de modifier votre code. Un service des commandes qui publie un événement OrderPlaced est ouvert à l’extension : l’équipe fidélité peut ajouter un nouveau consommateur qui attribue des points, et le service des commandes n’est ni modifié, ni retesté, ni redéployé. Une architecture à plug-ins (le style micronoyau) fait la même promesse pour les fonctionnalités. Pour les API, le principe se traduit par une évolution additive : de nouveaux champs facultatifs, de nouveaux endpoints et de nouveaux types d’événements étendent un contrat sans modifier ce sur quoi s’appuient les clients existants.
Le piège, c’est la prédiction. Un point d’extension n’a de valeur que pour les types de changement qui surviennent réellement. Un registre de transporteurs vaut la peine si la boutique ajoute des transporteurs tous les quelques mois ; un « moteur de règles de tarification » générique construit parce que la tarification pourrait un jour varier est une machinerie spéculative. Les professionnels ajoutent le point d’extension la deuxième ou la troisième fois qu’une variation apparaît, pas avant.
L : substitution de Liskov et contrats comportementaux
Le principe de substitution de Liskov vient de l’exposé principal de Barbara Liskov, Data Abstraction and Hierarchy, lors de la conférence OOPSLA de 1987, et a été précisé par Liskov et Jeannette Wing dans A Behavioral Notion of Subtyping (ACM Transactions on Programming Languages and Systems, 1994). De manière informelle : si du code fonctionne avec des objets de type T, il doit continuer à fonctionner lorsqu’on lui donne un objet de n’importe quel sous-type S, sans savoir lequel il a reçu. Avoir les bons noms de méthodes ne suffit pas ; le comportement doit être compatible.
Les règles du sous-typage comportemental se rattachent directement à la conception par contrat (section 3.3). Un sous-type ne peut pas renforcer les préconditions (exiger davantage de l’appelant), ne peut pas affaiblir les postconditions (promettre moins en retour) et doit préserver les invariants (les faits toujours vrais pour le type). Liskov et Wing ont ajouté une contrainte d’historique : un sous-type ne doit pas permettre des changements d’état que le supertype interdit, ce qui explique pourquoi un point mutable ne peut pas se faire passer pour un point immuable.
class Discount:
"""Postcondition: 0 <= result <= total."""
def apply(self, total: int) -> int:
return total
class PercentOff(Discount):
def __init__(self, pct: int):
self.pct = pct
def apply(self, total: int) -> int:
return total - total * self.pct // 100 # keeps the contract
class FixedVoucher(Discount):
def __init__(self, pence: int):
self.pence = pence
def apply(self, total: int) -> int:
return total - self.pence # can go below zero!
# FixedVoucher(500).apply(300) == -200: the checkout now issues a refund.
# Fix: return max(total - self.pence, 0), honouring the base postcondition.Le symptôme d’une violation du LSP est la vérification de type chez les appelants : if isinstance(d, FixedVoucher): .... Chaque vérification de ce genre signifie qu’une abstraction n’est plus digne de confiance, et la connaissance de ses exceptions se répand dans toute la base de code.
À l’échelle des services, la substituabilité est la question qui se cache derrière chaque changement d’API compatible. Si la version 2.3 d’une API de paiement doit remplacer la 2.2 sans que les clients s’en aperçoivent, elle est un sous-type comportemental de la 2.2 et les mêmes règles s’appliquent. Elle ne doit pas renforcer les préconditions : pas de nouveau champ obligatoire dans la requête, pas de validation plus stricte des champs existants, pas de limite de débit plus basse. Elle ne doit pas affaiblir les postconditions : pas de champs de réponse supprimés, pas de temps de réponse garanti plus lent, pas de perte d’idempotence. Elle doit préserver les invariants : un montant est toujours exprimé en unités mineures, un identifiant de commande, comme avant, ne change jamais. Ajouter une nouvelle valeur à une énumération dans une réponse est un cas subtil : cela élargit ce que le client peut recevoir, ce qui casse les clients qui supposaient l’ancien ensemble complet. Le même raisonnement s’applique lorsqu’une implémentation d’un service en remplace une autre derrière un équilibreur de charge, ou lorsque vous remplacez l’adaptateur d’un prestataire de paiement par celui d’un autre. Les tests de contrats pilotés par le consommateur sont, en pratique, des vérifications de Liskov automatisées, et la loi de Hyrum (section 3.3) rappelle que les clients peuvent s’appuyer sur un comportement que vous n’avez jamais promis.
I : ségrégation des interfaces et API propres à chaque client
Le principe de ségrégation des interfaces dit qu’aucun client ne doit être forcé de dépendre d’opérations qu’il n’utilise pas. Une interface trop large couple chaque client à chaque opération : quand une opération change, tous les clients sont touchés, ne serait-ce que parce qu’ils doivent être recompilés, retestés ou redéployés, même ceux qui ne l’appellent jamais. Dans les langages compilés, ce couplage est littéral (une modification d’un fichier d’en-tête ou d’un JAR partagé oblige à reconstruire tout ce qui en dépend) ; en Python, il se manifeste par des doublures de test qui doivent implémenter vingt méthodes pour tester du code qui en appelle deux.
from typing import Protocol
class OrderReader(Protocol):
def get(self, order_id: str) -> dict: ...
def list_for_customer(self, customer_id: str) -> list[dict]: ...
class OrderWriter(Protocol):
def save(self, order: dict) -> None: ...
def cancel(self, order_id: str) -> None: ...
def monthly_statement(orders: OrderReader, customer_id: str) -> int:
return sum(o["total"] for o in orders.list_for_customer(customer_id))
# The reporting job cannot cancel an order, and its tests
# need a fake with two read methods, not the whole repository.À l’échelle des services, la ségrégation des interfaces est l’argument en faveur des API propres à chaque client. Une API unique et généraliste qui sert l’application mobile, la boutique en ligne, le système d’entrepôt et les intégrations des partenaires tend à devenir une conception au plus petit dénominateur commun : chaque client reçoit des champs qu’il ignore, et chaque changement doit être négocié avec tous. Le patron backend for frontend (nommé par Phil Calçado chez SoundCloud et décrit par Sam Newman) donne à chaque type de client sa propre couche d’API légère, détenue par l’équipe qui construit ce client, et qui compose les appels aux services sous-jacents. GraphQL s’attaque au même problème autrement, en laissant chaque client sélectionner exactement les champs dont il a besoin. Pour les événements, le principe suggère de publier plusieurs types d’événements ciblés (OrderPlaced, OrderShipped) plutôt qu’un seul événement OrderChanged contenant tous les champs, qui force chaque consommateur à tout analyser et filtrer.
La version architecturale s’applique aussi aux bibliothèques partagées. Une bibliothèque common commune à toute l’entreprise, qui contient la journalisation, la gestion des montants, la génération de PDF et des clients HTTP, couple chaque service à chacune de ses parties : un correctif de sécurité dans le code PDF force une nouvelle version de services qui ne génèrent jamais de PDF. La découper en petites bibliothèques que les clients adoptent indépendamment, c’est appliquer la ségrégation des interfaces au niveau des paquets, et cela anticipe le principe de réutilisation commune présenté plus bas.
D : l’inversion des dépendances et qui détient l’interface
Le principe d’inversion des dépendances comporte deux parties : les modules de haut niveau (les règles métier) ne doivent pas dépendre des modules de bas niveau (les détails comme les bases de données, les courtiers de messages et les frameworks web) ; les deux doivent dépendre d’abstractions. Et les abstractions ne doivent pas dépendre des détails. Le mot « inversion » désigne le sens de la dépendance dans le code source : dans une conception naïve, les règles métier appellent le détail et donc l’importent ; après l’inversion, le détail importe une interface que les règles métier définissent, si bien que la flèche du code source pointe dans le sens opposé au flux de contrôle.
Le point crucial, et souvent négligé, est la propriété. L’interface appartient au composant de haut niveau et s’exprime dans ses termes (OrderRepository.save(order), et non PostgresClient.execute(sql)). Si l’interface se trouve dans le paquet de persistance, vous avez ajouté une abstraction mais vous n’avez rien inversé : les règles métier dépendent toujours du paquet de persistance.
# orders/ports.py (high-level policy owns this)
from typing import Protocol
class PaymentPort(Protocol):
def charge(self, order_id: str, pence: int) -> str: ... # returns payment id
# orders/service.py
class Checkout:
def __init__(self, payments: PaymentPort):
self.payments = payments
def pay(self, order_id: str, pence: int) -> str:
return self.payments.charge(order_id, pence)
# infrastructure/stripe_adapter.py (detail depends on policy)
from orders.ports import PaymentPort
class StripeAdapter(PaymentPort):
def charge(self, order_id: str, pence: int) -> str:
... # call the provider's API, map its errors to oursInverser une dépendance à travers une frontière de composant
L’inversion des dépendances n’est pas la même chose que l’injection de dépendances, même si elles vont souvent de pair. L’injection est une technique pour fournir à un module ses collaborateurs depuis l’extérieur (l’argument de constructeur payments ci-dessus) ; l’inversion est une décision sur le sens dans lequel pointent les dépendances du code source. Vous pouvez injecter une classe concrète sans rien inverser.
À l’échelle de l’architecture, l’inversion des dépendances est la règle qui organise les architectures hexagonale, en oignon et Clean Architecture : le domaine est au centre et définit des ports ; les adaptateurs pour les bases de données, les files d’attente et les interfaces utilisateur sont à l’extérieur et dépendent vers l’intérieur. Entre services, la même question se pose sous la forme « qui détient le contrat ? ». Si un service central de facturation définit un schéma BillingEvent et que chaque équipe produit doit s’y adapter, la facturation incarne les règles stables et les produits en dépendent, ce qui est souvent juste. Si au contraire la facturation lit les tables internes de chaque produit, la dépendance va du stable vers le volatil, et chaque modification de schéma d’un produit casse la facturation. La section 3.4 a montré comment quantifier cela avec le principe des dépendances stables.
Les principes de cohésion des composants : REP, CCP et CRP
SOLID vous indique comment façonner les classes et leurs dépendances. Martin l’associe à trois principes pour décider quelles classes vont ensemble dans un même composant, un composant étant une unité de publication et de déploiement comme un JAR, un paquet Python ou un assembly .NET.
Le principe d’équivalence réutilisation/publication (REP) dit que l’unité de réutilisation est l’unité de publication : si d’autres doivent réutiliser un composant, il lui faut des numéros de version, des notes de version et un thème cohérent, donc les classes qu’il contient doivent avoir un sens à être publiées ensemble. Le principe de fermeture commune (CCP) est le principe de responsabilité unique appliqué aux composants : regroupez dans un même composant les classes qui changent pour les mêmes raisons et aux mêmes moments, afin qu’un changement typique ne touche qu’un composant et que seul celui-ci doive être publié. Le principe de réutilisation commune (CRP) est le principe de ségrégation des interfaces appliqué aux composants : les classes utilisées ensemble vont ensemble, et celles qui ne le sont pas ne doivent pas être empaquetées ensemble, car dépendre d’un composant, c’est dépendre de tout son contenu.
Les trois tirent dans des sens opposés. REP et CCP sont inclusifs (ils font grossir les composants) ; CRP est exclusif (il les fait rapetisser). Martin les représente par un triangle dont chaque côté indique le prix à payer quand on abandonne le principe situé au sommet opposé.
Le triangle des tensions
Martin observe que la position d’un projet dans le triangle devrait évoluer dans le temps. Au début, la facilité de développement compte le plus, donc les équipes privilégient CCP : regrouper par changement, en acceptant que les composants soient difficiles à réutiliser. À mesure qu’un système mûrit et que d’autres équipes commencent à dépendre de ses parties, l’équilibre se déplace vers REP et CRP. La même tension apparaît entre les services. Fusionner deux services qui changent toujours ensemble (CCP) réduit les mises en production coordonnées mais rend le résultat moins réutilisable ; découper un service pour que les consommateurs ne dépendent que de ce qu’ils utilisent (CRP) augmente le nombre d’unités déployables que touche un seul changement métier.
Critiques et limites
SOLID est largement enseigné et largement critiqué, et les deux réactions ont leurs mérites. Voici les critiques les plus sérieuses.
Les principes sont assez vagues pour justifier presque n’importe quoi. Dan North a défendu cette thèse dans des conférences et dans son essai de 2022 présentant CUPID (composable, Unix philosophy, predictable, idiomatic, domain-based). Il soutient que « une seule raison de changer » est arbitraire, puisque l’on peut dire de presque n’importe quel code qu’il change pour de nombreuses raisons, et que l’appliquer avec zèle crée des coutures artificielles entre du code qui change en réalité ensemble, comme le contenu d’un rapport et son format. Il propose des propriétés à viser plutôt que des règles à suivre. Martin a répondu à cette ligne de critique dans un billet de 2020 sur la pertinence de SOLID, en soutenant que les principes restent valables parce que les problèmes sous-jacents de gestion des dépendances n’ont pas changé, et que dire aux gens de « faire simple » sans les guider produit un fouillis.
L’application dogmatique produit une sur-abstraction. Une interface pour chaque classe, une fabrique pour chaque interface et un point d’extension pour chaque variation imaginable mènent à du code où suivre un seul comportement oblige à ouvrir dix fichiers. La distinction de John Ousterhout entre modules profonds et modules superficiels (section 3.3) offre un contrepoids utile : une abstraction devrait cacher beaucoup derrière une petite interface, et beaucoup de classes inspirées de SOLID sont superficielles. Les points d’extension spéculatifs violent YAGNI (section 3.8) et ont un coût réel.
Les principes ont été écrits pour un seul paradigme. Dans les langages dotés de fonctions de première classe et d’un typage structurel, de nombreux patrons SOLID se réduisent à passer une fonction ou à s’appuyer sur le duck typing. Les idées sous-jacentes (séparer par acteur, dépendre de contrats stables, garder les interfaces petites) restent valables ; le cérémonial, souvent, non.
Le point de vue professionnel équilibré consiste à traiter SOLID comme un vocabulaire de diagnostic : quand une conception pose problème, les principes aident à expliquer pourquoi et suggèrent une direction. Ce n’est pas une liste de contrôle que chaque classe doit satisfaire, et « viole l’OCP » n’est pas en soi un argument ; « chaque nouveau transporteur a exigé de modifier cette fonction et a causé deux incidents » en est un.
Guide de décision
Guide de décision : dois-je ajouter une abstraction ici ?
Utilisez-le quand un principe semble réclamer une nouvelle interface, un point d’extension ou un découpage.
- Ce type de variation s’est-il réellement produit au moins deux fois, ou est-il certain (par exemple, un contrat avec un second prestataire est signé) ?OuiUn point d’extension est probablement justifié ; continuez.NonGardez le code concret, faites en sorte qu’il reste facile à refactoriser, et attendez des preuves réelles (YAGNI).
- Les parties que vous voulez séparer sont-elles modifiées par des acteurs différents ou selon des calendriers différents ?OuiSéparez-les (SRP, CCP) ; la frontière réduira la coordination.NonGardez-les ensemble ; découper ajouterait des coutures que chaque changement devrait franchir.
- Des règles métier de haut niveau importent-elles actuellement un détail volatil, comme une base de données, le SDK d’un fournisseur ou un framework ?OuiDéfinissez une interface détenue par le côté métier et implémentez-la dans le détail (DIP).NonAucune inversion n’est nécessaire ; une dépendance directe envers un module stable ne pose pas de problème.
- La nouvelle interface cachera-t-elle une complexité importante derrière quelques opérations ?OuiC’est un module profond ; il mérite d’exister.NonC’est un simple relais superficiel ; reconsidérez-le, ou fusionnez-le avec son appelant.
SOLID à trois échelles
| Principe | Échelle de la classe | Échelle du composant ou du service |
|---|---|---|
| Responsabilité unique | Un acteur par classe | Un service ou un composant aligné sur une capacité métier et une équipe propriétaire |
| Ouvert-fermé | Un nouveau comportement grâce à de nouvelles implémentations d’une interface | Des événements, des plug-ins et des changements d’API additifs qui permettent aux autres d’étendre sans vous modifier |
| Substitution de Liskov | Les sous-classes respectent le contrat de la classe de base | Les nouvelles versions d’API et les implémentations de remplacement respectent l’ancien contrat : pas de nouvelles entrées obligatoires, pas de sorties supprimées |
| Ségrégation des interfaces | De petites interfaces de rôle | Des API propres à chaque client (backend for frontend), des types d’événements ciblés, de petites bibliothèques partagées |
| Inversion des dépendances | Dépendre d’abstractions que vous détenez | Le domaine définit des ports ; les adaptateurs et l’infrastructure dépendent vers l’intérieur ; les propriétaires stables définissent les contrats |
Vérifiez votre compréhension
Une classe met en forme une facture et calcule aussi sa taxe. Cela viole-t-il automatiquement le principe de responsabilité unique ?
Pas automatiquement. C’est le cas si la mise en forme et les règles fiscales sont modifiées par des acteurs différents (par exemple le marketing et la finance), car alors les changements demandés par l’un peuvent casser l’autre ; si un seul groupe détient les deux, la classe peut très bien convenir.
Pourquoi l’ajout d’un nouveau champ obligatoire à une requête d’API rompt-il la substituabilité ?
Il renforce la précondition : les appelants qui respectaient l’ancien contrat échouent désormais, si bien que la nouvelle version ne peut plus remplacer l’ancienne.
Quelle est la différence entre l’inversion des dépendances et l’injection de dépendances ?
L’inversion concerne le sens des dépendances du code source, le côté métier détenant l’abstraction ; l’injection est une technique pour fournir des collaborateurs depuis l’extérieur. Vous pouvez injecter une classe concrète sans rien inverser.
Une équipe regroupe les classes strictement selon ce qui change ensemble, et d’autres équipes se plaignent que les composants sont impossibles à réutiliser. Sur quel côté du triangle des tensions se trouve-t-elle ?
Le côté CCP-CRP, qui porte la mention difficile à réutiliser : elle a négligé le principe d’équivalence réutilisation/publication.
Sources et lectures complémentaires
Une question sur cette section ? ArchitectBot, l’assistant d’étude IA du cours, répond d’abord à partir du cours.
Demander : « Notre bibliothèque partagée « common » est utilisée par douze microservices et chaque nouvelle version les oblige tous à se mettre à jour. Comment la découper, et quels principes doivent guider ce découpage ? »