Architecture logicielle
Domain-Driven Design : langage omniprésent, agrégats et bounded contexts
Avancé11 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Le domain-driven design consiste à laisser la forme du métier dicter la forme du code, et fournit un vocabulaire pour négocier les frontières entre équipes. Ce guide couvre le volet stratégique (bounded contexts, context maps, sous-domaines) et le volet tactique avec un modèle Java complet, puis montre comment l'architecture hexagonale préserve le domaine de tout framework.
Ce que vous allez apprendre
- Construire un langage omniprésent avec les experts métier et le garder visible dans le code
- Modéliser entités, value objects et agrégats avec des invariants respectés à chaque commit
- Tracer les bounded contexts le long des frontières de langage et documenter leurs relations dans une context map
- Publier et consommer des événements de domaine sans exposer le modèle interne
- Appliquer l'architecture hexagonale pour que le domaine ne dépende d'aucun framework
La plupart des logiciels qui deviennent impossibles à maintenir le deviennent pour la même raison : le code a cessé de décrire le métier. Les noms de classes s'éloignent de ce que les gens disent réellement en réunion, les règles atterrissent dans le service qui se trouvait ouvert à ce moment-là, et au bout de deux ans, le seul moyen fiable de savoir « que se passe-t-il quand une commande est annulée après expédition ? » consiste à lire quatre mille lignes. Le domain-driven design est un ensemble de pratiques pour éviter cela, fondé sur une affirmation qui paraît évidente et qui est très difficile à tenir dans la durée : le modèle dans le code et le modèle dans la tête des experts métier doivent être le même modèle.
Le DDD a deux moitiés, généralement enseignées ensemble et adoptées séparément. La moitié stratégique traite des frontières : quelles parties du métier ont droit à leur propre modèle, comment ces modèles s'articulent, et où investir un effort de conception qui reste rare. La moitié tactique est un ensemble de briques pour exprimer un modèle dans le code. C'est le DDD stratégique qui apporte l'essentiel de la valeur, et c'est la partie que les équipes sautent.
Le langage omniprésent
Le langage omniprésent (ubiquitous language) est un vocabulaire partagé par les développeurs et les experts métier, employé à l'identique dans la conversation, dans la documentation et dans le code. Pas un glossaire rangé dans un wiki que personne n'ouvre : un langage de travail, qui évolue quand la compréhension de l'équipe évolue.
Le test pratique est simple. Asseyez-vous avec un expert métier et décrivez ce que fait le code en n'utilisant que les mots du code. Si vous vous surprenez à traduire (« ce que nous appelons un TransactionRecord, c'est ce que vous appelez une instruction de règlement, sauf quand c'est une contrepassation »), cette traduction est une taxe permanente et une source permanente de défauts, car chaque traduction est une occasion de mal traduire.
Construire ce langage, c'est surtout être à l'écoute des distinctions. Les experts disent rarement « nous avons deux sortes de clients » ; ils disent « pour une entreprise, il nous faut le mandat avant de pouvoir prélever ». Cette phrase contient une distinction (entreprise ou particulier), un concept (le mandat) et une règle (pas de prélèvement sans mandat), et tous trois ont leur place dans le modèle. Des techniques comme l'event storming, qui consiste à afficher au mur tous les événements du domaine dans l'ordre chronologique avec toute l'équipe, existent précisément pour faire émerger rapidement ce genre de phrases.
Une fois le langage établi, défendez-le. Quand un développeur propose un OrderProcessor, demandez comment le métier appelle cette activité ; si personne ne sait répondre, la classe est probablement un fourre-tout de comportements sans rapport. Quand un expert emploie un mot absent du code, c'est une lacune de modélisation, pas un problème de nommage.
Les briques tactiques
Les patterns tactiques donnent au langage un endroit où vivre.
Value object. Entièrement défini par ses attributs, immuable, sans identité : Money, IBAN, DateRange, Quantity. Deux value objects aux attributs égaux sont interchangeables. C'est là que la plupart des règles du domaine peuvent vivre à moindre coût : un montant qui ne peut pas être négatif, deux devises qui doivent correspondre avant une addition, un intervalle dont la fin ne peut pas précéder le début. Les rendre immuables élimine toute une catégorie de bugs d'aliasing, et leur donner du comportement garde ce comportement hors des services.
Entité. Possède une identité qui persiste à travers les changements d'état. Deux commandes au contenu identique restent deux commandes. L'égalité se fait sur l'identifiant, jamais sur les valeurs des champs.
Agrégat. Un groupe d'entités et de value objects dont une entité est désignée comme racine. Tout ce qui est extérieur ne référence que la racine, et la racine est garante des invariants sur l'ensemble du groupe. L'agrégat est l'unité de chargement, de sauvegarde et de verrouillage, ce qui en fait la frontière de cohérence transactionnelle, et donc l'une des décisions les plus lourdes de conséquences du modèle.
Repository. Une interface à la manière d'une collection pour récupérer et persister des racines d'agrégat, exprimée dans les termes du domaine : orders.findAwaitingDispatch(), et non select * from orders where status = 3. L'interface appartient au domaine ; l'implémentation appartient à l'infrastructure.
Service de domaine. Un comportement qui relève du domaine sans appartenir naturellement à une entité particulière : un virement entre deux comptes, un calcul de prix portant sur plusieurs agrégats. À utiliser avec parcimonie ; un domaine rempli de services et vide de comportement, c'est le modèle anémique.
Factory. Encapsule la création quand construire un agrégat valide n'a rien de trivial. Souvent une méthode statique sur la racine elle-même.
Événement de domaine. La trace d'un fait significatif, nommée au passé : OrderDispatched, PaymentDeclined, MandateRevoked. Les événements permettent à un agrégat de déclencher du travail dans un autre sans y être couplé.
Un modèle complet
Les règles : une commande ne peut être confirmée que si elle comporte au moins une ligne et une adresse de livraison valide ; aucune ligne ne peut être ajoutée après confirmation ; le total est la somme des lignes et ne doit pas dépasser la limite de crédit du client, vérifiée en dehors de l'agrégat.
// Value object: immutable, self-validating, behaviour included.
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.scale() > currency.getDefaultFractionDigits())
throw new IllegalArgumentException("too many decimals for " + currency);
}
public Money plus(Money other) {
if (!currency.equals(other.currency))
throw new CurrencyMismatch(currency, other.currency);
return new Money(amount.add(other.amount), currency);
}
public boolean exceeds(Money limit) { return amount.compareTo(limit.amount) > 0; }
}
// Aggregate root: the only thing outside code may hold a reference to.
public class Order {
private final OrderId id;
private final CustomerId customer;
private final List<OrderLine> lines = new ArrayList<>();
private DeliveryAddress address;
private OrderStatus status = OrderStatus.DRAFT;
private final List<DomainEvent> events = new ArrayList<>();
public void addLine(Sku sku, int quantity, Money unitPrice) {
if (status != OrderStatus.DRAFT)
throw new OrderAlreadyConfirmed(id); // invariant, enforced here and nowhere else
lines.stream().filter(l -> l.sku().equals(sku)).findFirst()
.ifPresentOrElse(l -> l.increaseBy(quantity),
() -> lines.add(new OrderLine(sku, quantity, unitPrice)));
}
public void confirm(Clock clock) {
if (lines.isEmpty()) throw new EmptyOrder(id);
if (address == null) throw new MissingDeliveryAddress(id);
status = OrderStatus.CONFIRMED;
events.add(new OrderConfirmed(id, customer, total(), clock.instant()));
}
public Money total() {
return lines.stream().map(OrderLine::lineTotal)
.reduce(Money::plus).orElseThrow();
}
public List<DomainEvent> pullEvents() { // drained by the application layer after save
List<DomainEvent> drained = List.copyOf(events);
events.clear();
return drained;
}
}
// Repository interface: domain vocabulary, no persistence technology.
public interface Orders {
Optional<Order> byId(OrderId id);
List<Order> awaitingDispatch(DeliveryZone zone);
void save(Order order);
}
Trois points méritent l'attention. Toutes les règles d'une commande vivent dans Order : il y a un seul endroit à lire et un seul endroit à modifier. Rien d'extérieur ne peut atteindre directement une OrderLine, donc aucun appelant ne peut contourner addLine. Et il n'y a pas une seule annotation de framework dans le domaine : le mapping de persistance vit dans un adaptateur d'infrastructure, ce qui est le sujet de la section suivante.
La vérification de la limite de crédit est volontairement absente de l'agrégat. Elle a besoin de l'agrégat client, et c'est en traversant les agrégats au sein d'une même transaction que ceux-ci grossissent jusqu'à ce que toutes les commandes du système se disputent la même ligne client. À la place, soit la couche application charge les deux, soit la vérification a lieu de façon asynchrone en réaction à OrderConfirmed, avec une annulation compensatoire en cas d'échec : la forme de saga présentée dans transactions distribuées et cohérence.
Les bounded contexts
Un modèle unique couvrant toute une entreprise s'effondre sous son propre poids, parce qu'un même mot signifie des choses différentes selon les départements. Pour les commerciaux, un « client » est un prospect à une certaine étape du pipeline. Pour la facturation, c'est une entité juridique avec un identifiant fiscal et un moyen de paiement. Pour le support, c'est la personne au bout du fil. Obliger une seule classe Customer à servir les trois produit un type à soixante champs, pour la plupart null, avec des règles qui se contredisent.
Un bounded context (contexte délimité) est une frontière explicite à l'intérieur de laquelle un modèle et un langage s'appliquent de façon cohérente. À l'extérieur, les mêmes mots peuvent signifier autre chose, et c'est acceptable tant que la frontière est explicite et que la traduction s'y fait. Un contexte a une équipe, une base de code, son propre stockage et son propre vocabulaire.
Les bounded contexts sont donc le meilleur guide disponible pour tracer les frontières de service. Une frontière de contexte marque l'endroit où le langage change, c'est-à-dire exactement là où doit se trouver une interface ; contextes et services se correspondent donc généralement un à un, même si un contexte peut être implémenté par plusieurs services et que, dans un monolithe modulaire, il s'agit simplement d'un module aux frontières strictement appliquées.
S'y rattache la distinction entre types de sous-domaines, qui indique où placer l'effort :
| Sous-domaine | Ce que c'est | Investissement |
|---|---|---|
| Cœur (core) | Ce qui différencie l'entreprise | Les meilleurs profils, la modélisation la plus poussée, développé en interne |
| De soutien (supporting) | Nécessaire, propre à vous, mais non différenciant | Construire simplement, rester sobre |
| Générique (generic) | Problèmes résolus : identité, comptabilité, e-mail | Acheter ou utiliser un service existant |
Appliquer tout l'arsenal tactique du DDD à un sous-domaine générique est une erreur courante et coûteuse. Personne n'a jamais rien gagné à modéliser à la main une table de conversion de devises.
Les context maps
Dès que vous avez plusieurs contextes, vous avez des relations, et le type de relation détermine combien chaque changement fait souffrir. Les nommer rend explicite un rapport de force jusque-là implicite.
- Partnership (partenariat) : deux équipes réussissent ou échouent ensemble et coordonnent délibérément leurs livraisons.
- Shared kernel (noyau partagé) : un petit modèle délibérément partagé par deux contextes, modifiable uniquement d'un commun accord. Gardez-le minuscule ; il couple les cycles de livraison.
- Customer/supplier (client/fournisseur) : l'équipe en aval est un client dont les besoins entrent dans le backlog de l'équipe en amont.
- Conformist (conformiste) : l'aval adopte tel quel le modèle de l'amont, faute d'avoir la moindre influence. Peu coûteux, et cela importe en bloc les choix de conception de l'amont.
- Anti-corruption layer (ACL) (couche anticorruption) : l'aval construit une couche de traduction pour que le modèle de l'amont ne pénètre jamais le sien. Le pattern le plus précieux pour s'intégrer à un système legacy ou à un fournisseur externe.
- Open host service (service hôte ouvert) : l'amont publie un protocole délibérément conçu pour de nombreux consommateurs plutôt que des intégrations sur mesure.
- Published language (langage publié) : un format d'échange partagé et bien documenté, souvent associé à un open host service.
- Separate ways (chemins séparés) : les contextes ne s'intègrent pas du tout, parce que le coût dépasse le bénéfice.
L'anti-corruption layer mérite qu'on insiste. Quand un service consomme l'API d'un partenaire ou un système de référence dont le modèle ne ressemble en rien au vôtre, traduire en bordure (un adaptateur qui convertit leur CUST_REC_TYPE_3 en votre Customer) empêche le modèle étranger de se répandre dans votre code. Sans elle, dans un an, les noms de champs du partenaire seront dans votre base de données.
L'architecture hexagonale
Les patterns tactiques ne survivent que si l'infrastructure ne peut pas s'infiltrer dans le domaine, et c'est une question de sens des dépendances. L'architecture hexagonale (autrement dit ports et adaptateurs, ou encore les architectures onion et clean) énonce la règle sans détour : les dépendances pointent vers l'intérieur, vers le domaine, et le domaine ne dépend de rien.
- Domaine : entités, value objects, agrégats, services de domaine, événements de domaine, interfaces de repository. Aucun import de framework, pas de SQL, pas de HTTP, pas de JSON.
- Application : les cas d'utilisation qui orchestrent le domaine : charger un agrégat, appeler une méthode, sauvegarder, publier les événements, gérer la frontière transactionnelle. Mince par conception.
- Adaptateurs : le monde extérieur, dans les deux sens. Les adaptateurs primaires (contrôleurs REST, listeners de messages, tâches planifiées) appellent l'application ; les adaptateurs secondaires (repositories JPA, clients HTTP, producteurs vers le broker) implémentent des interfaces déclarées par le domaine.
// Application layer: one use case, transaction boundary, no business rules of its own.
@Service
class ConfirmOrder {
private final Orders orders; // domain port
private final DomainEventPublisher publisher;
@Transactional
public void handle(OrderId id) {
Order order = orders.byId(id).orElseThrow(() -> new UnknownOrder(id));
order.confirm(Clock.systemUTC()); // the rule lives in the aggregate
orders.save(order);
publisher.publishAll(order.pullEvents());
}
}
Le bénéfice concret, c'est la testabilité. Le domaine n'a aucun conteneur à démarrer ni aucune base à alimenter : ses tests s'exécutent en quelques millisecondes et se lisent comme des spécifications des règles métier. Ces idées de découpage en couches rejoignent directement l'architecture en couches et n-tiers, et l'inversion de dépendances qui rend tout cela possible est le même mécanisme que celui décrit dans le conteneur IoC de Spring.
Une remarque pragmatique sur la persistance. Les puristes tiennent les annotations JPA hors du domaine en maintenant des entités de persistance séparées et un mapping entre les deux ; c'est réellement plus propre, et réellement plus de code. Annoter directement les classes du domaine est un compromis défendable pour une équipe qui, sinon, ne maintiendrait pas ce mapping, à condition que la conception du modèle soit guidée par le domaine et non par ce qui se mappe commodément. Ce qui n'est pas défendable, c'est de laisser le lazy loading, les réglages de cascade ou les identifiants générés décider des frontières de vos agrégats.
Erreurs fréquentes
Des patterns tactiques sans conception stratégique. Coller des @ValueObject partout dans un unique modèle partagé gigantesque produit du cérémonial sans bénéfice. Les contextes d'abord.
L'agrégat divin. Un agrégat Customer contenant commandes, factures et tickets signifie que chaque opération verrouille le client. Découpez selon les invariants, reliez par des événements.
Des repositories qui renvoient des lignes. Un repository qui expose un langage de requête, des projections et de la pagination sur des champs arbitraires n'est qu'un DAO au nom à la mode. Les besoins de requêtes qui ne rentrent pas dans le modèle sont légitimes : servez-les avec un modèle de lecture séparé plutôt qu'en déformant l'agrégat.
Publier les événements internes comme contrat public. Chaque consommateur se retrouve couplé à vos noms de champs. Traduisez les événements de domaine en événements d'intégration délibérément conçus, à la frontière.
Modéliser en vase clos. Un modèle construit par des développeurs à partir d'un document de spécification, sans expert métier dans la pièce, encode les ambiguïtés de la spécification. Le langage se construit dans la conversation, ou il ne se construit pas.
Points clés à retenir
- Le langage omniprésent est la pratique centrale : un seul vocabulaire dans la conversation, la documentation et le code, révisé dès que la compréhension évolue.
- Les value objects portent les règles à moindre coût, les entités portent l'identité et les agrégats définissent la frontière de cohérence : gardez-les petits et n'en modifiez qu'un par transaction.
- Les bounded contexts rendent légitime qu'un même mot signifie des choses différentes selon les parties du métier, et ils sont le guide le plus fiable pour tracer les frontières de service.
- Une context map nomme chaque relation (conformist, customer/supplier, anti-corruption layer) et rend le couplage et le rapport de force visibles avant qu'ils ne fassent mal.
- Concentrez l'effort de modélisation sur le sous-domaine cœur ; achetez le générique et gardez les sous-domaines de soutien sobres.
- L'architecture hexagonale tient les frameworks hors du domaine, ce qui rend le modèle testable en quelques millisecondes et durable face aux changements de technologie.
Sources et lectures complémentaires
Questions fréquentes
- Le DDD impose-t-il les microservices ?
- Pas du tout. Le DDD est antérieur aux microservices et s'applique parfaitement au sein d'un même livrable, où les bounded contexts deviennent des modules aux frontières strictement appliquées. La relation fonctionne dans l'autre sens : si vous découpez un système en services, les bounded contexts sont le meilleur guide disponible pour placer les coutures, car une frontière de contexte est précisément l'endroit où le langage et le modèle changent.
- Quelle taille doit faire un agrégat ?
- Aussi petite que les invariants le permettent. Un agrégat est une frontière de cohérence chargée et sauvegardée d'un bloc ; un gros agrégat implique donc de grosses transactions et de la contention sur la racine. Si une règle exige réellement que deux entités changent ensemble de façon atomique, elles appartiennent au même agrégat ; si elle tolère un court délai, séparez-les et reliez les morceaux par un événement de domaine.
- Un modèle de domaine anémique est-il toujours une erreur ?
- C'est une erreur quand le domaine comporte des règles intéressantes et qu'elles finissent éparpillées dans des services et des contrôleurs au lieu de vivre avec les données qu'elles contraignent. Pour du CRUD vraiment simple (données de référence, écrans de configuration), un modèle léger accompagné d'une couche de services convient, et le cérémonial du DDD serait du gaspillage. Appliquez les patterns tactiques au cœur complexe, pas uniformément.
- Quelle différence entre un événement de domaine et un événement d'intégration ?
- Un événement de domaine décrit quelque chose qui s'est produit à l'intérieur d'un bounded context, emploie le langage de ce contexte et peut transporter des identifiants internes. Un événement d'intégration est un message public conçu délibérément pour les autres contextes, avec un schéma stable et un vocabulaire qui a du sens à l'extérieur. Publier vos événements de domaine tels quels sur le broker partagé couple chaque consommateur à votre modèle interne : traduisez à la frontière.