Le design souple & le refactoring vers l'insight
Rendre le modèle agréable à manipuler et le laisser émerger : intentions explicites, fonctions pures, percées.
Le bon modèle ne s'obtient jamais du premier coup. Il émerge, par itérations, à mesure que l'équipe comprend mieux le domaine — et qu'elle ose remanier le code pour refléter cette compréhension. Eric Evans appelle ce mouvement le refactoring vers une compréhension plus profonde (Refactoring toward Deeper Insight) : non pas le refactoring purement technique (renommer, extraire, dédupliquer), mais celui qui rapproche le code de la réalité métier, en rendant explicites des concepts jusque-là implicites.
Mais à quoi reconnaît-on l'aboutissement de ces itérations ? À un code qu'on a plaisir à manipuler, où chaque changement « plie » le design à un endroit prévu pour cela, où l'on anticipe les conséquences d'un appel sans lire son implémentation. Evans nomme cette qualité le design souple (Supple Design). Ce chapitre couvre les deux faces d'une même pièce : le processus (comment le modèle s'approfondit) et la cible (les patrons qui rendent un design souple). Les exemples du livre sont en Java et conceptuels ; nous les traduisons en TypeScript idiomatique, dans des univers de logistique, de facturation et de banque.
Le refactoring vers une compréhension plus profonde
La plupart des transformations utiles d'un modèle prennent la même forme : reconnaître un concept qui était sous-entendu, présent de façon implicite, et lui donner un ou plusieurs objets explicites dans le modèle. Ce concept se cache souvent dans le langage des experts, dans une formule de calcul noyée dans du code, ou dans une contrainte métier non nommée.
Comment le débusquer ? Evans donne trois pistes complémentaires.
- Écouter le langage. Quand les experts emploient un terme qui n'apparaît nulle part dans le code, ou qu'ils corrigent (poliment) votre vocabulaire, c'est un indice. Si un mot résume succinctement quelque chose de compliqué, ce mot mérite peut-être un objet.
- Scruter la maladresse. Une méthode trop longue, une règle dupliquée dans plusieurs classes, une discussion qui tourne sans cesse autour d'une contrainte alors qu'elle est enfouie dans du code procédural : autant de signes d'un concept qui force la porte.
- Expérimenter sans relâche. Le concept ne « claque » presque jamais au premier essai. Il faut souvent renommer, redécouper, déplacer des responsabilités plusieurs fois avant qu'il ne se mette au point.
Note
Ce n'est pas la vieille idée « les noms sont des objets ». Entendre un mot nouveau ne crée pas mécaniquement une classe : c'est une piste à creuser avec les experts pour dégager un concept propre et utile. Le but reste le langage omniprésent (Ubiquitous Language) — un vocabulaire partagé entre le code, les diagrammes et les conversations.
Exemple : l'itinéraire qui manquait
Dans le système d'affrètement du livre, l'application de réservation planifiait le trajet d'une cargaison et écrivait chaque tronçon directement dans une table de base de données. En discutant, le développeur remarque à quel point l'expert insiste sur un mot absent du modèle : « l'itinéraire ». Toutes les données existaient déjà, le comportement était présent dans un rapport — mais le concept n'était nulle part.
// ❌ Avant : pas d'objet « itinéraire », données dispersées.
// Le moteur de routage remplit directement une table.
routingService.populateBookingTable(cargoId, legsRows);
// Le rapport, lui, recalcule les horaires à partir des voyages.
function renderItineraryReport(rows: LegRow[]): string {
return rows
.map((r) => `${r.loadLoc} -> ${r.unloadLoc}`)
.join("\n");
} Rendre l'itinéraire (Itinerary) explicite a tout débloqué : interface du service de routage plus expressive, découplage d'avec les tables de la base, dérivation unique des horaires de chargement, et un lien clair entre réservation et exploitation.
// ✅ Après : un concept nommé, point de contact unique.
class Itinerary {
constructor(private readonly legs: readonly Leg[]) {}
get originLocationCode(): string {
return this.legs[0].loadLocationCode;
}
get destinationLocationCode(): string {
return this.legs.at(-1)!.unloadLocationCode;
}
}
// Le moteur retourne désormais un objet du domaine.
const itinerary: Itinerary = routingService.route(cargo); Transformer une règle en objet : la Specification
Le cas le plus emblématique de concept implicite est la règle métier noyée dans une cascade de if. Reprenons une facture (Invoice). Un test simple comme estEnRetard() tient en une ligne. Mais la question « cette facture est-elle en souffrance ? » dépend d'un délai de grâce, du statut du client, de la politique commerciale par gamme de produits… Si l'on entasse tout cela dans Invoice, on perd la clarté de la facture comme « demande de paiement », et on l'accouple à une foule de sous-systèmes étrangers à sa responsabilité.
La tentation suivante est de pousser la règle dans la couche application — mais alors la règle quitte la couche domaine, laissant derrière elle un objet de données sans comportement. La logique métier doit rester dans le domaine ; simplement, elle n'a pas sa place dans l'objet évalué.
La spécification (Specification), que Evans a conçue avec Martin Fowler, emprunte la notion de prédicat à la programmation logique : un petit objet-valeur (Value Object) qui sait dire si un autre objet satisfait un critère.
// ❌ Avant : la règle gonfle Invoice et l'accouple à tout.
class Invoice {
constructor(
readonly amount: Money,
readonly dueDate: Date,
readonly customer: Customer,
) {}
estEnSouffrance(aujourdhui: Date): boolean {
const delaiBase = 20;
const delai = this.customer.enBonneStanding() ? 60 : delaiBase;
const limite = ajouterJours(this.dueDate, delai);
return aujourdhui.getTime() > limite.getTime();
}
} // ✅ Après : un prédicat-objet dédié, dans la couche domaine.
class FactureEnSouffranceSpecification {
constructor(
private readonly dateEvaluation: Date,
private readonly delaiBase = 20,
private readonly delaiBonClient = 60,
) {}
estSatisfaitePar(facture: Invoice): boolean {
const delai = facture.customer.enBonneStanding()
? this.delaiBonClient
: this.delaiBase;
const limite = ajouterJours(facture.dueDate, delai);
return this.dateEvaluation.getTime() > limite.getTime();
}
}
// La règle est un objet nommé, qu'on peut configurer et discuter.
const spec = new FactureEnSouffranceSpecification(aujourdhui);
const enSouffrance = factures.filter((f) => spec.estSatisfaitePar(f)); L'avantage est triple. La règle reste dans le domaine au lieu de fuir vers l'application. Comme c'est un objet à part entière, sa fabrique (Factory) peut le configurer à partir d'autres sources — politique d'entreprise, historique de paiement — sans accoupler la facture à ces sources. Enfin, la même spécification unifie des usages dispersés : validation, sélection depuis un dépôt (Repository), construction d'un objet conforme. Sans ce patron, la « même » règle réapparaît sous des formes divergentes, parfois contradictoires.
Astuce
Trois signaux indiquent qu'une contrainte déforme son objet hôte et appelle une Specification : (1) l'évaluer exige des données étrangères à la définition de l'objet ; (2) des règles voisines apparaissent dans plusieurs objets, forçant duplication ou héritage artificiel ; (3) les discussions tournent autour de la contrainte, mais le code la cache dans du procédural.
La percée
La plupart du temps, le modèle s'approfondit par petites touches : des refactorings modestes qui, accumulés, font émerger l'insight peu à peu. Mais il arrive qu'une suite de raffinements débouche soudain sur une percée (Breakthrough) — un bond où le modèle se réaligne sur une vision bien plus profonde du domaine, au prix d'un remaniement majeur.
Une percée fait peur : opportunité forte, risque fort. Le conseil d'Evans est de ne pas se laisser paralyser à la rechercher. On ne provoque pas une percée à volonté ; on prépare le terrain en cultivant le langage omniprésent et en multipliant les petits refactorings. La possibilité vient ensuite, souvent déguisée en crise : un trou béant apparaît dans ce que le modèle sait exprimer. Ce n'est pas un échec, c'est le signe que l'équipe a atteint un niveau de compréhension d'où l'ancien modèle paraît pauvre — et d'où un bien meilleur devient concevable.
À retenir
Refactorez quand : le design n'exprime plus la compréhension actuelle du domaine ; des concepts importants y restent implicites et vous voyez comment les rendre explicites ; vous apercevez l'occasion de rendre une partie cruciale plus souple. En revanche, pas la veille d'une livraison, et jamais un « modèle profond » qu'aucun expert métier n'accepterait d'utiliser.
Le design souple
Le logiciel sert d'abord ses utilisateurs — mais pour cela, il doit d'abord servir ses développeurs. Dans un processus fondé sur le refactoring, le code sera réarrangé, recombiné, étendu des années durant. Un design souple est un design où l'on a envie de travailler : ses effets sont évidents, donc les conséquences d'un changement sont faciles à anticiper ; il suit les contours du domaine, donc la plupart des changements le plient en des points flexibles.
L'écueil inverse existe : beaucoup de sur-ingénierie s'est justifiée au nom de la flexibilité, pour finir noyée sous des couches d'abstraction et d'indirection. Le design souple est simple — ce qui n'est pas la même chose que facile. Voici les patrons qu'Evans a dégagés pour y tendre.
Interfaces qui révèlent l'intention
La beauté des objets, c'est d'encapsuler les détails pour que le code client raisonne en concepts de haut niveau. Mais si l'interface ne dit pas au développeur ce qu'il doit savoir pour s'en servir, il devra plonger dans les entrailles — et toute la valeur de l'encapsulation est perdue.
Le patron interface qui révèle l'intention (Intention-Revealing Interface) tient en une phrase : nommez les classes et les opérations d'après leur effet et leur but, jamais d'après le moyen employé pour y parvenir. Le nom doit suffire à utiliser l'objet sans en lire l'implémentation, et doit appartenir au langage omniprésent.
// ❌ Avant : le nom ne dit rien ; il faut lire le code.
class Paint {
// Que fait paint(autre) ? Mystère.
paint(autre: Paint): void {
/* additionne les volumes, mélange les couleurs... */
}
} // ✅ Après : un nom qui décrit l'effet et le but.
class Paint {
// « mélanger une autre peinture dans celle-ci »
mixIn(autre: Paint): void {
/* ... */
}
} Astuce
Astuce de Kent Beck reprise par Evans : écrivez le test avant le comportement. Rédiger peinture.mixIn(bleu) du point de vue du client force votre cerveau à raisonner en intentions, pas en mécanique — et le nom juste apparaît presque tout seul.
Fonctions sans effet de bord
On peut diviser les opérations en deux familles : les requêtes (queries), qui renvoient une information sans rien changer, et les commandes (commands), qui modifient l'état du système. En informatique, un « effet de bord » désigne tout changement d'état susceptible d'affecter des opérations futures — pas seulement les conséquences involontaires.
Le problème est combinatoire. Une opération en appelle d'autres, qui en appellent d'autres encore ; dès que l'imbrication est profonde, anticiper toutes les conséquences d'un appel devient impossible. Une fonction (function), elle, renvoie un résultat sans aucun effet observable : on peut l'appeler plusieurs fois, la combiner, la tester sans crainte. Le patron fonctions sans effet de bord (Side-Effect-Free Functions) recommande donc de :
- Ségréguer strictement commandes et requêtes : les méthodes qui modifient l'état ne renvoient pas de données du domaine et restent aussi simples que possible.
- Déplacer la logique complexe dans des objets-valeurs immuables, où, par construction, toutes les opérations sont des fonctions. Plutôt que de muter un objet existant, créer et renvoyer un nouvel objet-valeur représentant le résultat.
Reprenons le mélange de peinture. La couleur est un concept central : extrayons-la dans un objet-valeur PigmentColor. Comme un objet-valeur est immuable, mélanger ne mute pas la couleur : cela en produit une nouvelle.
// ❌ Avant : le calcul mute l'objet, mêlant état et logique.
class Paint {
constructor(
public volume: number,
public red: number,
public yellow: number,
public blue: number,
) {}
mixIn(autre: Paint): void {
this.volume += autre.volume;
// Beaucoup de lignes de mélange, puis affectation de
// nouveaux red/yellow/blue sur CE Paint (mutation).
}
} // ✅ Après : la couleur est un Value Object, le mélange est pur.
class PigmentColor {
constructor(
readonly red: number,
readonly yellow: number,
readonly blue: number,
) {}
// Fonction sans effet de bord : renvoie une NOUVELLE couleur.
mixedWith(autre: PigmentColor, ratio: number): PigmentColor {
// ... logique de mélange ...
const red = this.red * (1 - ratio) + autre.red * ratio;
const yellow = this.yellow * (1 - ratio) + autre.yellow * ratio;
const blue = this.blue * (1 - ratio) + autre.blue * ratio;
return new PigmentColor(red, yellow, blue);
}
}
class Paint {
constructor(
public volume: number,
private pigmentColor: PigmentColor,
) {}
mixIn(autre: Paint): void {
this.volume += autre.volume;
const ratio = autre.volume / this.volume;
this.pigmentColor = this.pigmentColor.mixedWith(
autre.color(),
ratio,
);
}
color(): PigmentColor {
return this.pigmentColor;
}
} La logique de mélange, désormais encapsulée dans une fonction sûre, est réellement masquée : le client n'a plus besoin d'en comprendre l'implémentation. Le code de commande dans Paint est, lui, réduit au strict minimum.
Assertions
Séparer les calculs en fonctions pures réduit le problème, mais il subsiste des commandes qui produisent des effets de bord, et quiconque les emploie doit en comprendre les conséquences. Or les interfaces d'objets ne restreignent pas les effets de bord : deux sous-classes implémentant la même interface peuvent en avoir de différents. Quand les effets de bord ne sont définis qu'implicitement par l'implémentation, le seul moyen de comprendre un programme est d'en tracer l'exécution — et l'abstraction s'effondre.
Le patron assertions (Assertions), issu de la conception par contrat de Bertrand Meyer, rend ces effets explicites. On énonce des post-conditions (le résultat garanti d'une opération), des pré-conditions (ce qui doit être vrai pour que la garantie tienne) et des invariants de classe ou d'agrégat (Aggregate). Toutes décrivent des états, pas des procédures — elles sont donc faciles à analyser et à tester.
Dans l'exemple de la peinture, l'ambiguïté demeurait : que devient le volume de la peinture passée en argument à mixIn ? La post-condition honnête était bizarre (« le volume de l'autre peinture est inchangé »), parce qu'elle ne collait pas aux concepts. Cette gêne signalait un concept manquant — séparer MixedPaint (peinture mélangée) et StockPaint (peinture en stock). La plupart des langages objet ne supportent pas les assertions nativement ; on les écrit donc en tests unitaires, faciles à rédiger puisqu'ils ne portent que sur des états.
// L'assertion « le volume total est la somme des constituants »
// devient un test exécutable.
test("le volume d'un mélange est la somme des constituants", () => {
const jaune = new PigmentColor(0, 50, 0);
const bleu = new PigmentColor(0, 0, 50);
const melange = new MixedPaint();
melange.mixIn(new StockPaint(1.0, jaune));
melange.mixIn(new StockPaint(1.5, bleu));
// Post-condition garantie par mixIn.
expect(melange.getVolume()).toBeCloseTo(2.5);
}); À retenir
Le but n'est pas seulement la rigueur formelle. Cherchez des modèles dont les concepts sont cohérents au point qu'un développeur devine les assertions attendues. Les humains ne compilent pas des prédicats dans leur tête : ils extrapolent à partir du sens. Un modèle intuitif accélère l'apprentissage et réduit le risque de code contradictoire.
Contours conceptuels
Faut-il découper fin pour combiner librement, ou regrouper large pour encapsuler la complexité ? Aucune règle de cuisine ne tient. Mais il existe, au fond de tout domaine viable, une cohérence logique. Le patron contours conceptuels (Conceptual Contours) invite à découper le modèle selon les lignes naturelles du domaine, pas selon une granularité arbitraire.
Le test : à chaque décision de découpage, demandez-vous si elle reflète un simple arrangement du code actuel, ou si elle épouse un contour du domaine sous-jacent. Si une « addition » de deux objets a un sens cohérent dans le domaine, implémentez-la à ce niveau ; ne la cassez pas en deux étapes. À l'inverse, regroupez ce que personne n'a besoin de disséquer : un mélangeur de peinture ne combine jamais du pigment rouge isolé, il combine des peintures complètes.
// ❌ Avant : on force le client à recombiner des miettes.
const partiel = remise.appliquerTaux(commande, 0.1);
const final = remise.arrondir(partiel);
const ttc = taxe.ajouterTva(final);
// ✅ Après : un contour conceptuel — « prix net » est une unité.
const prixNet = tarification.prixNet(commande); C'est l'une des raisons pour lesquelles le refactoring répété mène à la souplesse : les contours conceptuels émergent à mesure que le code s'adapte aux concepts nouvellement compris. Un indicateur que le modèle « colle » : les refactorings suivants restent localisés, sans secouer plusieurs concepts à la fois. Un changement qui force un grand redécoupage est, lui, un message : la compréhension du domaine demande à être affinée.
| Symptôme | Cause probable | Remède |
|---|---|---|
| Fonctionnalité dupliquée | Concept noyé dans un bloc monolithique | Extraire selon le contour |
| Client qui assemble des miettes | Découpage trop fin, concept perdu | Regrouper en une unité de sens |
| Refactoring qui ébranle tout | Découpage à contre-courant du domaine | Approfondir le modèle |
Classes autonomes
Toute dépendance est un coût cognitif. Une association, le type de chaque argument, chaque valeur de retour : autant de classes à garder en tête en même temps. Avec une dépendance, on pense à deux classes et à leur relation ; avec deux, à trois classes et à toutes leurs relations croisées ; au-delà, l'effet boule de neige.
Le patron classes autonomes (Standalone Classes) pousse le faible couplage à son extrême : dans un sous-ensemble important du modèle, on peut réduire les dépendances à zéro, obtenant une classe entièrement compréhensible isolément, avec quelques primitives et types de base. Les concepts implicites comptent autant que les références explicites : trois entiers red, yellow, blue portaient déjà le concept de couleur — l'extraire en PigmentColor n'a pas ajouté de dépendance, il a rendu explicite ce qui existait déjà.
// La peinture dépend de la couleur, mais la couleur, où réside
// l'essentiel du calcul, ne dépend de rien : on l'étudie seule.
class PigmentColor {
constructor(
readonly red: number,
readonly yellow: number,
readonly blue: number,
) {}
mixedWith(autre: PigmentColor, ratio: number): PigmentColor {
// Aucune dépendance hors primitives : testable en isolation.
const red = this.red * (1 - ratio) + autre.red * ratio;
const yellow = this.yellow * (1 - ratio) + autre.yellow * ratio;
const blue = this.blue * (1 - ratio) + autre.blue * ratio;
return new PigmentColor(red, yellow, blue);
}
} Attention
L'objectif n'est pas d'éliminer toute dépendance, mais toute dépendance non essentielle. Et surtout, ne « bêtifiez » pas le modèle en réduisant tout à des primitives : remplacer PigmentColor par trois number nus appauvrirait l'interface et noierait le concept de couleur.
Fermeture des opérations
Comment réduire les dépendances sans appauvrir l'interface ? Le dernier patron, fermeture des opérations (Closure of Operations), emprunte son nom aux mathématiques : multiplier deux réels donne un réel — l'opération est « close » sur l'ensemble des réels, sans introduire aucun concept extérieur.
La règle : définissez, là où c'est pertinent, une opération dont le type de retour est le même que celui de ses arguments (et de son receveur, si son état entre dans le calcul). Une telle opération s'enchaîne et se combine naturellement, sans tirer aucune dépendance nouvelle. Le patron s'applique surtout aux objets-valeurs : PigmentColor.mixedWith(...) renvoie un PigmentColor, donc on peut chaîner les mélanges.
// ❌ Avant : la sélection traîne un Iterator et sa mécanique.
const basSalaires: Employe[] = [];
for (const e of employes) {
if (e.salaire() < 40000) basSalaires.push(e);
} // ✅ Après : une opération close sous le type « collection ».
// L'argument et le retour sont du même type ; on enchaîne.
const basSalaires = employes
.filter((e) => e.salaire() < 40000)
.toSorted((a, b) => a.salaire() - b.salaire()); La fermeture n'est pas toujours totale : parfois l'argument correspond au receveur mais le retour diffère, ou inversement. Ces opérations à demi closes apportent déjà beaucoup ; et quand le type supplémentaire est une primitive ou une classe de base, il libère l'esprit presque autant qu'une fermeture complète.
Note
Prolongements modernes. Evans esquisse en fin de chapitre le design déclaratif : une fois réunies des interfaces qui révèlent l'intention, des fonctions pures et des assertions, on peut combiner des Specifications avec et, ou, non — un style déclaratif très proche d'un mini-langage du domaine. Postérieurs au livre de 2003, les Domain Events comme brique tactique, CQRS (qui institutionnalise la séparation commande/requête à l'échelle de l'architecture) et l'Event Storming (atelier collaboratif pour faire émerger les concepts implicites) prolongent directement cet esprit.
À retenir
- Le modèle émerge. On le creuse en rendant explicites des concepts implicites — repérés en écoutant le langage des experts et en scrutant la maladresse du design. Transformer une règle en Specification en est l'exemple type.
- Visez le design souple, pas la flexibilité décorative : un code dont les effets sont évidents et qui plie aux bons endroits. Simple n'est pas facile.
- Révélez l'intention par les noms (l'effet et le but, jamais le moyen) ; séparez commandes et requêtes et déportez la logique dans des fonctions pures sur des objets-valeurs immuables.
- Explicitez les contrats par des assertions (post-conditions, invariants), testées même quand le langage ne les supporte pas — et préférez des concepts cohérents qui les rendent devinables.
- Découpez selon les contours conceptuels du domaine, réduisez les dépendances vers des classes autonomes, et cherchez la fermeture des opérations pour combiner sans accoupler.
- Refactorez en continu, par petites touches, en restant à l'affût de la percée : la crise où l'ancien modèle paraît soudain pauvre est l'occasion d'un modèle bien plus profond.