Be Software Architecture Wise
Be Wise Apps

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

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: ...
Découpage par acteur : chaque classe ne change qu’à la demande de ses propres parties prenantes, et les données partagées restent un simple enregistrement.

À 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])
Ajouter un transporteur revient à enregistrer une nouvelle classe ; le code de calcul des devis ne change jamais.

À 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 contrat de base dit qu’une remise ne rend jamais le total négatif. Un sous-type qui le rompt met en échec les appelants qui faisaient confiance au contrat.

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.
Chaque client ne dépend que du rôle dont il a besoin ; une même classe peut toujours implémenter les deux rôles.

À 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 ours
Le paquet orders définit le port dont il a besoin ; l’infrastructure l’implémente et y est branchée au démarrage.

Inverser une dépendance à travers une frontière de composant

Inversion des dépendances entre un composant Commandes et un composant Persistance, avant et aprèsAvant : le métier dépend du détailComp. CommandesOrderServiceimporte et appellela classe concrèteComposant PersistancePostgresOrderRepositoryChangement de BD propagé à CommandesAprès : le détail dépend du métierComposant Commandes (détient l’interface)OrderService«interface»OrderRepositoryimplémenteComposant PersistancePostgresOrderRepositoryLa flèche pointe vers le métier
Placer l’interface dans le composant Commandes inverse la dépendance du code source, si bien que la persistance peut changer sans toucher aux règles métier.

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

Triangle des tensions entre équivalence réutilisation/publication, fermeture commune et réutilisation communeREPRéutilisation/publicationCCPFermeture comm.CRPRéutil. comm.Trop de versionsinutilesTrop decomposants changentRéutil. ardueChaque côté indique le coûtde l’abandon du principeau sommet opposé
Privilégier deux principes de cohésion, quels qu’ils soient, entraîne le coût inscrit sur le côté qui les relie ; les architectes choisissent une position, et cette position évolue à mesure que le projet mûrit.

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.

Exemple commenté

Exemple commenté : revue d’un composant partagé dans une boutique en ligne

Une boutique en ligne a un paquet shop-core utilisé par cinq services. Il contient des règles de tarification, le calcul des taxes, la validation des adresses, le rendu des factures PDF et un utilitaire de base de données. Au cours du dernier trimestre, il a connu 40 versions. Appliquez les principes.

Acteurs (SRP, CCP)La tarification est modifiée par l’équipe commerciale, les taxes par la finance quand les taux changent, la mise en page des PDF par le marketing, l’utilitaire de base de données par l’équipe plateforme. Quatre acteurs, c’est quatre raisons indépendantes de changer : le paquet ne respecte donc pas la fermeture commune.
Utilisateurs (ISP, CRP)Le service d’entrepôt n’utilise que la validation des adresses, et pourtant chacune des 40 versions l’a obligé à se mettre à jour et à refaire ses tests. Selon CRP, environ 40 versions, moins les quelques-unes qui touchaient aux adresses, lui étaient inutiles.
Versions (REP)Les notes de version n’ont aucun thème cohérent, si bien que les consommateurs ne peuvent pas juger si une version les concerne.
Dépendances (DIP, SDP)L’utilitaire de base de données importe les modèles ORM des services, si bien qu’un paquet stable et largement utilisé dépend de code de service volatil : la flèche pointe dans le mauvais sens.
Découpage proposépricing (détenu par le domaine commercial), tax (finance), address, invoice-pdf, et l’utilitaire de base de données déplacé dans chaque service ou dans une bibliothèque de plateforme sans aucun import de domaine.
Effet attenduChaque service ne dépend que des paquets qu’il utilise. Un changement de taux de taxe ne publie qu’un seul paquet et ne touche que les services qui calculent la taxe.

Les principes n’ont pas dicté la réponse ; ils ont fourni un vocabulaire pour expliquer pourquoi le paquet actuel pose problème et ce que coûte chaque option.

Le découpage augmente le nombre de paquets à versionner et à publier : c’est le côté « trop de composants changent » du triangle. C’est acceptable ici, car les changements sont pour la plupart indépendants.

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.

  1. 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).
  2. 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.
  3. 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.
  4. 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é uniqueUn acteur par classeUn 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 interfaceDes événements, des plug-ins et des changements d’API additifs qui permettent aux autres d’étendre sans vous modifier
Substitution de LiskovLes sous-classes respectent le contrat de la classe de baseLes 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 interfacesDe petites interfaces de rôleDes API propres à chaque client (backend for frontend), des types d’événements ciblés, de petites bibliothèques partagées
Inversion des dépendancesDépendre d’abstractions que vous détenezLe 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.

Suivant · 3.6ConnascenceLa connascence de Meilir Page-Jones nomme les accords cachés entre les parties d’un système, les classe par force et les évalue selon leur localité et leur degré, des paramètres positionnels jusqu’à la synchronisation entre services.Continuer →

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 ? »