Domain-Driven Design
Modéliser la complexité métier, pas la complexité technique
Le DDD, formalisé par Eric Evans dans son livre de 2003, ne parle presque jamais de couches ou de dépendances. Il s'attaque à un autre problème : comment construire un modèle logiciel qui reflète fidèlement un métier complexe, en collaboration étroite avec les experts de ce métier.
Contexte Ventes
Client
- · historique d'achats
- · limite de crédit
- · segment tarifaire
Anticorruption Layer entre les deux modèles
Contexte Support
Client
- · tickets ouverts
- · niveau de satisfaction
- · SLA applicable
Le problème que ça résout
Il existe deux sortes de complexité dans un logiciel. La complexité accidentelle : celle qu'on s'inflige nous-mêmes (mauvaise architecture, dette technique, outils mal choisis). Et la complexité essentielle : celle qui vient du métier lui-même - les règles de tarification d'un assureur, les contraintes réglementaires d'une banque, la logistique d'un transporteur.
L'architecture hexagonale et Clean Architecture s'attaquent surtout à la première. Le DDD s'attaque à la seconde. Sa thèse : dans un logiciel qui échoue, le problème vient rarement de la technique choisie, mais d'un modèle qui ne correspond pas à la réalité du métier - ou pire, d'une absence totale de modèle explicite.
La réponse d'Evans tient en une idée forte : le code doit être écrit main dans la main avec les experts du domaine, au point que le vocabulaire du code et celui des experts métier deviennent littéralement le même.
Le langage omniprésent (Ubiquitous Language)
Le langage omniprésent est un vocabulaire précis, partagé par les développeurs et les experts métier, utilisé sans traduction ni approximation - dans les discussions, la documentation, et directement dans le nom des classes et des méthodes.
Si les experts métier parlent de "résilier un contrat" et que le code contient une méthode deleteContract(), il y a un problème : soit le code ment sur ce qu'il fait réellement, soit la conversation avec les experts n'a jamais eu lieu. Le bon signal, c'est une méthode terminate() sur une entité Contract, qui applique les règles métier réelles de résiliation (préavis, pénalités, etc.).
Ce langage n'est pas figé : il évolue au fil des conversations, et le code évolue avec lui. C'est un des points les plus sous-estimés du DDD, car il ne demande aucun pattern technique - juste de la discipline dans les mots choisis.
Bounded Context : un modèle n'est valide que dans une frontière explicite
Un mot comme "Client" ne signifie pas la même chose partout dans une entreprise. Dans le contexte des ventes, un client a un historique d'achats et une limite de crédit. Dans le contexte du support, ce même client a des tickets ouverts et un niveau de satisfaction. Vouloir une seule classe Client qui satisfasse ces deux usages mène presque toujours à un modèle bancal, chargé de champs optionnels et de cas particuliers.
Le Bounded Context (contexte borné) est la réponse : on accepte qu'il existe plusieurs modèles du "client", chacun valide et cohérent à l'intérieur de sa propre frontière (le contexte Ventes, le contexte Support), et on gère explicitement la traduction entre ces frontières quand c'est nécessaire.
C'est probablement le concept le plus mal compris du DDD : on l'associe souvent aux microservices, alors qu'un contexte borné peut très bien exister comme un simple module au sein d'un même monolithe. La frontière est avant tout conceptuelle, pas nécessairement une frontière de déploiement.
Context Mapping : organiser les relations entre contextes
Une fois plusieurs contextes bornés identifiés, il faut décrire comment ils communiquent. Evans propose plusieurs patterns de relation, dont voici les plus courants :
- Shared Kernel : deux équipes partagent délibérément une petite portion de modèle commun, avec un coût de coordination assumé.
- Customer / Supplier : un contexte amont (fournisseur) fournit des données ou services à un contexte aval (client), qui peut influencer sa feuille de route.
- Conformist : le contexte aval accepte tel quel le modèle du contexte amont, sans négociation possible (API d'un fournisseur externe, par exemple).
- Anticorruption Layer (ACL) : le contexte aval construit une couche de traduction pour se protéger d'un modèle externe qu'il ne veut pas laisser polluer son propre langage.
- Open Host Service : un contexte expose un protocole public bien défini, pensé pour être consommé par plusieurs autres contextes sans coordination individuelle.
Entités et Value Objects
Une Entité est définie par son identité, qui persiste dans le temps même si ses attributs changent. Une Commande reste la même commande qu'on lui ajoute une ligne ou qu'on change son statut - c'est son identifiant qui compte, pas son état à un instant donné.
Un Value Object (objet-valeur) est défini uniquement par ses attributs, sans identité propre. Deux instances de Money représentant "10 EUR" sont interchangeables et parfaitement égales. Les Value Objects sont immuables par construction : on n'en modifie pas un, on en crée un nouveau.
Cette distinction simple évite un piège courant : représenter une adresse, un prix ou une plage de dates comme une simple chaîne ou un nombre, en dispersant ensuite la logique de validation et de comparaison un peu partout dans le code.
// Value Object : "data class" donne l'égalité par valeur gratuitement
data class Money private constructor(val amount: Int, val currency: String) {
companion object {
fun of(amount: Int, currency: String): Money {
if (amount < 0) throw NegativeAmountError()
return Money(amount, currency)
}
}
fun add(other: Money): Money {
assertSameCurrency(other)
return of(amount + other.amount, currency)
}
private fun assertSameCurrency(other: Money) {
if (currency != other.currency) throw CurrencyMismatchError()
}
}Agrégats et racine d'agrégat
Un Agrégat regroupe une ou plusieurs entités et value objects qui doivent rester cohérents ensemble, à tout instant. Une Commande et ses lignes de commande forment un agrégat naturel : on ne peut pas valider une ligne de commande sans passer par les règles de la commande entière (montant minimum, stock disponible, etc.).
L'agrégat a une Racine d'agrégat (Aggregate Root) : la seule entité accessible depuis l'extérieur. Tout accès aux objets internes de l'agrégat passe obligatoirement par la racine, qui garantit ainsi que les invariants sont toujours respectés.
Règle pratique très utile : une transaction ne devrait modifier qu'un seul agrégat à la fois. Si une opération semble devoir toucher deux agrégats en même temps de façon strictement synchrone, c'est souvent le signe que la frontière de l'agrégat est mal tracée - ou qu'un événement de domaine, traité de façon asynchrone, serait plus approprié.
Domain Events, Repositories, Services, Factories
- Domain Event : un fait qui s'est produit dans le domaine, nommé au passé (OrderPlaced, SubscriptionCancelled). Il permet à d'autres parties du système de réagir sans coupler directement l'agrégat émetteur à ses conséquences.
- Repository : donne l'illusion d'une collection en mémoire pour un agrégat donné (save, findById). Un repository correspond toujours à une racine d'agrégat, jamais à une entité interne.
- Domain Service : porte une opération métier qui n'appartient naturellement à aucune entité précise - par exemple, transférer un montant entre deux comptes distincts.
- Factory : encapsule une logique de création complexe ou soumise à des règles, quand un simple constructeur ne suffit plus à garantir un objet valide dès sa naissance.
Comment ça s'articule avec l'hexagonale et Clean Architecture
DDD, architecture hexagonale et Clean Architecture ne sont pas des concurrents : ils répondent à des questions différentes. Le DDD répond à "quel modèle mettre dans le cœur du système, et comment le découper en contextes cohérents ?". L'hexagonale et Clean Architecture répondent à "comment protéger ce cœur de tout ce qui l'entoure ?".
En pratique, les entités, value objects et agrégats du DDD sont exactement ce qui vit dans le cercle "Entités" de Clean Architecture, ou dans le noyau de l'architecture hexagonale. Les repositories définis par le domaine sont des ports sortants au sens hexagonal. Les deux approches s'emboîtent naturellement, sans friction.
Pièges fréquents
Le DDD souffre d'une réputation de complexité, en grande partie à cause de ces erreurs récurrentes :
- Appliquer les patterns tactiques (agrégats, repositories, value objects) à un domaine réellement simple, sans complexité métier à modéliser : le DDD tactique a un coût, qui ne se justifie que face à une vraie complexité essentielle.
- Sauter la partie stratégique (langage omniprésent, bounded context) - la plus utile et la moins coûteuse - pour ne retenir que les patterns tactiques, réduisant le DDD à "mettre des entités et des repositories partout".
- Construire un modèle anémique : des entités qui ne sont que des sacs de données avec des getters et setters, toute la logique métier étant déportée dans des services externes. Ce n'est plus du DDD, même si le vocabulaire (Entity, Repository) y ressemble encore.
- Vouloir un seul contexte borné pour toute l'entreprise : c'est l'inverse de l'idée de départ, et cela recrée exactement le problème que le bounded context est censé résoudre.