Dans cet article, nous allons explorer les principes fondamentaux du Domain-Driven Design (DDD) afin de comprendre comment concevoir des applications centrées sur le métier, lisibles et évolutives.
Cet article ne se veut pas exhaustif : il a pour objectif de proposer une initiation au DDD, en donnant les clés essentielles pour comprendre ses concepts, son vocabulaire et sa philosophie.
Le Domain-Driven Design (DDD) est né d’un constat simple : Les applications les plus difficiles à maintenir sont celles où le code ne reflète pas le métier.
Quand le métier devient complexe, le code “technique” seul ne suffit plus :
Le DDD propose donc de mettre le domaine au centre et de construire le logiciel autour de lui, pas l’inverse.
public class OrderService {
public void createOrder(Long customerId, BigDecimal amount) {
OrderEntity entity = new OrderEntity();
entity.setCustomerId(customerId);
entity.setAmount(amount);
entity.setStatus("CREATED");
orderRepository.save(entity);
}
}
Ce code est fonctionnel, mais :
L'objectif du DDD est de déplacer l’intelligence métier dans le domaine.
Le DDD n’est pas une solution universelle. Le DDD a du sens quand :
Exemple de domaine :
À l’inverse, le DDD est souvent surdimensionné si :
Dans ces situations, un design simple et lisible est souvent préférable.
Le langage ubiquitaire est l’élément le plus important du DDD. L'objectif est que chaque concept métier :
Le vocabulaire devient une source de vérité, comme exemple (domaine du e-commerce) : "Une commande expédiée ne peut pas être annulée". Une règle métier doit pouvoir être formulée par le métier, comprise par l’équipe technique, et se retrouver explicitement dans le code.
Exemple de mauvaise traduction (implicite)
public class OrderManager {
public void processAction(OrderDTO dto) {
if ("SHIPPED".equals(dto.getStatus())) {
throw new RuntimeException("Invalid state");
}
dto.setStatus("CANCELLED");
}
}
Problèmes :
Exemple de bonne traduction :
public class Order {
private OrderStatus status;
public void cancel() {
if (OrderStatus.SHIPPED.equals(status)) {
throw new IllegalStateException("A shipped order cannot be cancelled");
}
this.status = OrderStatus.CANCELLED;
}
}
Ce code présente plusieurs améliorations par rapport au précédent :
La règle devient suffisamment explicite pour être discutée et validée avec le métier.
Utiliser un langage clair et compris par tous est important. Nous avons moins de malentendus, moins de bugs liés à l’interprétation et un code plus stable dans le temps.
Avant de découper un système en Bounded Contexts, il est utile de prendre un pas de recul et de regarder le métier “par zones”. En DDD, on part du principe que tout ce qui compose le domaine n’a pas la même valeur stratégique. Certaines parties sont au cœur du produit et justifient un travail de modélisation fin, tandis que d’autres sont nécessaires au fonctionnement de l’application mais ne méritent pas qu’on y investisse le même autant d’effort.
Pour représenter cela, le DDD parle de subdomains (sous-domaines), c’est-à-dire des "grandes zones fonctionnelles du métier". L’idée est ensuite de distinguer trois grandes catégories :
Un point important : un subdomain n’est pas la même chose qu’un bounded context. Le subdomain décrit une zone du problème métier, alors que le bounded context est une frontière de modélisation et de vocabulaire dans la solution. Autrement dit, le subdomain répond à la question “de quoi parle le métier ?”, tandis que le bounded context répond plutôt à “où le langage et le modèle restent cohérents ?”.
Dans la pratique, un subdomain peut être couvert par un ou plusieurs bounded contexts, et l’inverse est aussi possible.
Cette distinction est très utile, car elle permet de prendre de meilleures décisions : où mettre le plus d’attention, où investir du temps de conception, et où au contraire rester simple. Elle évite surtout l’erreur classique de faire du DDD “partout” de manière uniforme, alors que le cœur du métier mérite beaucoup plus d’effort que les parties génériques.
Pour rendre ces notions plus concrètes, voici un exemple volontairement simple dans un e-commerce.
Le Bounded Context est l’un des concepts structurants du DDD. C’est aussi celui qui est le plus souvent mal compris ou sous-estimé.
Dans un système réel, le métier n’est jamais homogène, même si tout le monde utilise les mêmes mots, le sens change selon le contexte.
Exemple : Une commande.
Le même mot a trois sens différents. Ici, le Bounded Context sert à empêcher que ces sens se mélangent.
Mais qu'est-ce que ce Bounded Context (BC) ? C'est une frontière explicite dans laquelle le langage est cohérent, les règles métier sont homogènes, le modèle est stable et compréhensible.
Donc, le Bounded Context regroupe un modèle de domaine, un langage ubiquitaire, des règles métier et a souvent ses propres cas d'usage, persistance et parfois sa propre équipe. Il s’agit avant tout d’une frontière sémantique (et non d’une séparation technique).
Plusieurs signaux permettent de détecter les différents bounded contexts :
Les équipes : quand différentes équipes parlent du “même” concept mais ne se comprennent pas.
Les règles contradictoires : exemple : “Une commande validée est immuable” (vente) / “Une commande peut être ajustée après validation” (facturation) -> Deux contextes différents.
Les responsabilités trop larges : si une classe ou un module gère la création / le paiement / la livraison etc.
Chaque bounded context a son propre vocabulaire et ses propres définitions. Un même mot peut donc exister dans plusieurs contextes, mais ne pas vouloir dire la même chose à chaque fois. Le fait d'avoir un bounded context permet donc de protéger le métier des autres contextes, domaines etc.
Définir des Bounded Contexts est une première étape. Dans un système réel, il est ensuite nécessaire d’organiser leur collaboration. C’est ce que formalise la Context Map : qui parle à qui, comment, et avec quelles règles.
Une Context Map n’est pas un "grand" diagramme figé : c’est un outil de décision, parfois un simple schéma suffit.
Sans elle, les dépendances se créent “par hasard” et les modèles finissent par se mélanger, de même que les vocabulaires et les notions également.
Imaginons trois contextes :
Relations typiques :
Ordering ne doit pas dépendre de Shipping.
Sans protection, il est courant de voir cela :
public class Order {
private BigDecimal total;
// Payment concepts
private String paymentId;
private String paymentStatus; // CAPTURED / AUTHORIZED / FAILED
// Shipping concepts
private String carrier;
private String trackingNumber;
private LocalDate shippedAt;
}
Ici, le modèle de commande connaît le paiement et la livraison et donc devient instable à chaque changement. C’est ce que la Context Map cherche à éviter.
Pour intégrer proprement, on s’appuie sur quelques patterns clés :
Note : Dans une relation, il y a toujours un upstream (fournit) et un downstream (consomme). Le downstream s’adapte, il n’impose pas le modèle.
Dans certains cas, un Bounded Context ne cherche pas à protéger son propre modèle métier, mais choisit volontairement de se conformer au modèle d’un autre contexte. Ce type de relation est appelé Conformist dans la Context Map.
Ici, le contexte consommateur reprend tel quel le langage, les concepts et les règles du contexte, sans mettre en place de couche d’anticorruption. Il accepte donc les décisions de modélisation de l’autre contexte, même si celles-ci ne sont pas idéales pour son propre domaine.
Ce pattern apparaît généralement lorsque :
Le Conformist est un choix pragmatique, mais non sans conséquences : il introduit un fort couplage entre les Bounded Contexts et rend le contexte dépendant et donc plus sensible aux évolutions du modèle amont.
C’est le pattern le plus important. Un contexte dépend d’un autre, cependant les modèles sont différents, le vocabulaire diverge et le contexte consommateur veut rester propre. Par conséquent nous avons recours à une couche de traduction.
Voici un exemple entre le payment et l'ordering :
Côté Payment (API externe) : L’API Payment renvoie un modèle qui lui appartient :
public record PaymentStatusResponse(
String transactionId,
String status, // AUTHORIZED, CAPTURED, FAILED
long amountInCents,
String currency
) {}
Côté Ordering (domaine interne propre)
Ordering ne veut pas dépendre de AUTHORIZED/CAPTURED ni de “transactionId”.
public enum OrderPaymentState {
UNPAID, PAID, PAYMENT_FAILED
}
public class Order {
private OrderPaymentState paymentState = OrderPaymentState.UNPAID;
public void markPaid() {
this.paymentState = OrderPaymentState.PAID;
}
public void markPaymentFailed() {
this.paymentState = OrderPaymentState.PAYMENT_FAILED;
}
}
ACL = traduction + règles + protection
public class PaymentAcl {
public Optional<OrderPaymentState> toOrderPaymentState(PaymentStatusResponse res) {
return switch (res.status()) {
case "CAPTURED" -> Optional.of(OrderPaymentState.PAID);
case "FAILED" -> Optional.of(OrderPaymentState.PAYMENT_FAILED);
case "AUTHORIZED" -> Optional.of(OrderPaymentState.UNPAID);
default -> Optional.empty(); // Unknown value => protected boundary
};
}
}
Ici on voit bien l’intérêt de l'ACL :
Parfois, un bounded context décide d’ouvrir une porte “officielle” vers l’extérieur. Il expose volontairement une interface claire et stable, pour que les autres contextes puissent l’utiliser sans venir toucher à son modèle interne.
C’est l’idée du Open Host Service : un point d’entrée propre (API, événements, messages) et du Published Language : un langage partagé, simple et explicite, utilisé dans ce contrat (exposé via une API ou par des évènements).
L’objectif est de permettre l’intégration sans créer de dépendance directe sur le domaine.
public record PaymentConfirmed(
String orderReference,
Instant occurredAt
) {}
De son côté, Ordering n’appelle pas ici Billing pour lui demander “alors, c’est payé ?”. En effet, Ordering écoute plutôt l’événement, et met à jour son état :
Ce qui est important ici, c’est que Ordering ne connaît pas la structure interne de Billing car il ne dépend que d’un contrat public et volontairement exposé.
C’est une pratique courante, car avec ce modèle :
Cependant, pour que cela fonctionne, le contrat doit être :
Le pattern Shared Kernel est utilisé lorsqu’une partie d’un sous-domaine est implémentée dans plusieurs bounded contexts, ou quand on choisit de partager du code entre plusieurs sous-domaines, par exemple pour éviter la duplication. Mais attention, car plus le Shared Kernel grossit, plus le couplage grandit également.
Shared Kernel = couplage assumé + gouvernance commune
En pratique, on retrouve fréquemment OHS et ACL utilisés en même temps, chacun de son côté, dans une relation fournisseur / consommateur.
Dans notre exemple :
Avant de modéliser le code, il faut comprendre ce qui se passe vraiment dans le métier. L’Event Storming sert à reconstruire le fonctionnement réel d’un processus, ensemble, sans jargon technique.
C’est une méthode qui se pratique généralement sous forme d’atelier collaboratif, réunissant des personnes du métier et de la technique autour d’un même support (mur, tableau, Miro).
Le principe consiste à décrire le métier à travers des événements (des faits au passé), par exemple :
CommandeCréée → PaiementConfirmé → CommandeExpédiée.
Ensuite on identifie :
Cela permet d'avoir :
Une fois le vocabulaire clarifié, découpé en bounded contexts et aligné tout le monde via l’event storming, on a enfin une vision claire du métier. Il s’agit ensuite de traduire cette compréhension dans le code, de manière cohérente et maintenable.
Le but n’est pas de placer toutes les briques “par principe”. L’idée, c’est plutôt de mettre chaque règle au bon endroit, pour éviter que le code soit "fragile" et difficilement maintenable. Pour ce faire, le DDD fournit un ensemble de briques pour transformer cette connaissance en code.
Un Value Object, c’est une valeur métier :
Exemple : un montant d’argent qui ne peut pas être négatif :
public record Money(BigDecimal amount) {
public Money {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("Amount must be >= 0");
}
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount));
}
public Money multiply(int factor) {
if (factor < 0) throw new IllegalArgumentException("Factor must be >= 0");
return new Money(this.amount.multiply(BigDecimal.valueOf(factor)));
}
public static Money zero() {
return new Money(BigDecimal.ZERO);
}
}
On notera ici que Money est immuable : toute opération retourne une nouvelle instance.
Exemple d'autres Value Objects qui n'ont pas besoin d'identifiant et qui peuvent se comparer par leurs valeurs :
Une Entity, c’est un objet métier avec une identité stable, qui change au fil du temps.
Une commande reste la même commande, même si son état évolue (brouillon → validée → expédiée).
Ce qui compte ici, c’est que les règles soient à l’intérieur de l’objet, pas dans du code autour.
public class OrderLine {
private final ProductId productId;
private final Money unitPrice;
private int quantity;
OrderLine(ProductId productId, Money unitPrice, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be > 0");
}
if (unitPrice == null) {
throw new IllegalArgumentException("Unit price must not be null");
}
this.productId = productId;
this.unitPrice = unitPrice;
this.quantity = quantity;
}
void changeQuantity(int newQuantity) {
if (newQuantity <= 0) {
throw new IllegalArgumentException("Quantity must be > 0");
}
this.quantity = newQuantity;
}
public Money price() {
return unitPrice.multiply(quantity);
}
public ProductId productId() { return productId; }
public int quantity() { return quantity; }
public Money unitPrice() { return unitPrice; }
}
Ici, l’état n’est pas modifié directement depuis l’extérieur : les changements passent par des méthodes métier explicites. Une entité n’est pas immuable : son état peut évoluer, mais uniquement via des opérations métier qui garantissent ses invariants.
Un invariant c’est une règle métier qui doit toujours être vraie, à tout instant où l’objet (ou l’agrégat) est considéré comme valide.
Quand un modèle devient riche, il faut décider ce qui doit rester cohérent “d’un seul coup”. C’est le rôle de l’Aggregate : il définit une frontière de cohérence.
Par convention, toutes les modifications d’un agrégat passent par sa racine (Aggregate Root), afin de garantir les invariants.
Exemple : Order est la racine, et OrderLine ne se modifie pas directement depuis l’extérieur.
public class Order {
private final OrderId id;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status = OrderStatus.DRAFT;
public Order(OrderId id) {
this.id = id;
}
public void addLine(ProductId productId, Money unitPrice, int quantity) {
ensureDraft();
lines.add(new OrderLine(productId, unitPrice, quantity));
}
public void changeQuantity(ProductId productId, int quantity) {
ensureDraft();
OrderLine line = lines.stream()
.filter(l -> l.productId().equals(productId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown product in order"));
line.changeQuantity(quantity);
}
public void validate() {
ensureDraft();
if (lines.isEmpty()) throw new IllegalStateException("Empty order");
status = OrderStatus.VALIDATED;
}
public List<OrderLine> lines() {
// expose read-only view to avoid breaking invariants from outside
return List.copyOf(lines);
}
private void ensureDraft() {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("Only draft can be modified");
}
}
Note :
Il arrive souvent qu’une règle soit métier mais qu’elle ne “tombe” pas naturellement dans une entité précise. Ce n’est pas la responsabilité d’un objet en particulier, mais plutôt une logique qui traverse plusieurs éléments du domaine.
Dans ce cas, on utilise un Domain Service. C’est une classe qui porte une logique métier, avec un nom qui parle au métier mais sans se transformer en “service fourre-tout”.
Exemple : Calcul du total d’une commande. Ce calcul dépend des lignes, des prix, parfois des remises, mais ce n’est pas forcément le rôle de Order de tout connaître en détail.
public class OrderPricingService {
public Money calculateTotal(Order order, PricingPolicy policy) {
Money subtotal = order.lines().stream()
.map(OrderLine::price)
.reduce(Money.zero(), Money::add);
return policy.apply(subtotal, order);
}
}
Un Domain Service ne stocke pas d’état, reste dans un vocabulaire métier et ne dépend pas de détails techniques (base de données, REST, etc.).
Un Repository, c’est la manière “propre” de récupérer et sauvegarder des agrégats. Il joue le rôle de collection métier : on cherche une commande, on la charge, on la sauvegarde… sans se soucier de comment cela est stocké.
Le repository masque la base de données, la techno utilisée (JPA, SQL, Mongo…) et les détails de persistance. Le Repository reste métier sans dépendances annexes.
Côté domaine, on garde une interface simple :
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
Pour y arriver, on peut s’appuyer sur l'architecture hexagonale. Dans ce modèle, l’interface du repository reste côté métier, mais son implémentation concrète est généralement placée dans la couche infra.
Cette partie-là est volontairement en dehors du domaine : elle contient tout ce qui dépend de la technique, comme la base de données, les choix de persistance, ou encore les frameworks utilisés. Ainsi, le métier reste propre et ne se retrouve pas couplé à des détails d’implémentation.
Créer un objet “à la main” avec un simple new suffit parfois… mais pas toujours.
Dès que la construction demande plusieurs étapes, des règles, ou un état initial précis, une Factory devient très utile.
Par exemple, si une commande doit toujours démarrer en mode brouillon, avec certaines valeurs par défaut, on préfère centraliser cela.
| Type de logique | Où la placer |
|---|---|
| Validation locale | Value Object |
| Invariant de cycle de vie | Entity |
| Cohérence transactionnelle | Aggregate |
| Règle transverse | Domain Service |
| Création complexe | Factory |
| Accès aux données | Repository |
Quelques cas anti-patterns à éviter :
Le Domain-Driven Design est avant tout une approche de conception centrée sur le métier : il ne s’agit pas d’empiler des patterns, mais de construire un logiciel qui reflète fidèlement les concepts, les règles et le langage du domaine. En posant un langage ubiquitaire, en découpant le système en Bounded Contexts, puis en organisant la collaboration via une Context Map, on évite naturellement les modèles confus et les dépendances.
Avec des pratiques comme l’Event Storming, le DDD aide aussi à mieux comprendre le métier avant de développer, et à faire émerger une modélisation robuste (Value Objects, Entities, Aggregates…). L’idée clé est de mettre l’intelligence métier dans le domaine, et utiliser le reste (application/infrastructure) comme des couches de support.
Enfin, il est important de garder en tête que le DDD n’est pas une fin en soi : c’est un ensemble d’outils à utiliser quand le domaine le justifie. Bien appliqué, il rend le code plus lisible, testable et évolutif, et permet aux équipes de faire évoluer un produit complexe plus facilement.
Un prochain article abordera l'architecture hexagonale, et des notions de DDD avancé comme le Domain Events, CQRS/ES.
| Concept | Catégorie | Définition courte | Responsabilité principale | Exemple typique |
|---|---|---|---|---|
| Domaine | Fondamental | Ensemble des règles et connaissances métier | Porter le sens métier | Commande, Facturation |
| Ubiquitous Language | Fondamental | Langage commun partagé par métier et technique | Réduire les ambiguïtés | Order, Validate, Invoice |
| Bounded Context | Stratégique | Frontière où un modèle et un langage sont cohérents | Protéger le modèle | Ordering / Billing |
| Context Map | Stratégique | Carte des relations entre bounded contexts | Maîtriser les dépendances | Ordering → Billing |
| Anti-Corruption Layer (ACL) | Stratégique | Couche de traduction entre contextes | Isoler un modèle | Translator DTO → Domain |
| Shared Kernel | Stratégique | Petit sous-modèle partagé volontairement | Mutualiser avec fort couplage | OrderId |
| Event Storming | Stratégique | Atelier de découverte du métier par les événements | Comprendre les flux métier | OrderValidated |
| Event métier | Stratégique / Tactique | Fait métier déjà survenu | Représenter un changement | PaymentReceived |
| Value Object | Tactique | Valeur immuable sans identité | Encapsuler une règle locale | Money, Email |
| Entity | Tactique | Objet métier avec identité et cycle de vie | Gérer l’état métier | Order |
| Aggregate | Tactique | Graphe d’objets cohérents transactionnellement | Garantir les invariants | Order + OrderLines |
| Aggregate Root | Tactique | Point d’entrée unique d’un aggregate | Protéger la cohérence | Order |
| Invariant | Tactique | Règle métier toujours vraie | Empêcher les états invalides | “Commande non vide” |
| Domain Service | Tactique | Règle métier transverse sans état | Porter une logique métier | Calcul de prix |
| Factory | Tactique | Objet dédié à la création complexe | Garantir les invariants initiaux | OrderFactory |
| Repository (port) | Tactique | Accès abstrait aux agrégats | Isoler la persistance | OrderRepository |
| Application Layer | Architecture | Orchestration des cas d’usage | Coordonner le métier | ValidateOrderUseCase |
| Use Case | Architecture | Scénario métier exécutable | Encapsuler une intention | Valider une commande |
| Command | Architecture | Intention d’exécution | Transporter une demande | ValidateOrderCommand |
| Ports & Adapters (Hexagonal) | Architecture | Inversion des dépendances | Protéger le cœur métier | Repo, Publisher |
| Infrastructure | Architecture | Détails techniques (DB, messaging…) | Implémenter les ports | JPA, Kafka |
| Domain Event | Avancé | Événement interne au domaine | Réagir au métier | OrderValidatedEvent |
| Integration Event | Avancé | Événement publié vers l’extérieur | Intégrer les contextes | Event Kafka |
| CQRS | Avancé | Séparation écriture / lecture | Scalabilité, lisibilité | Commands / Queries |
| Event Sourcing | Avancé | Persistance par événements | Audit, historique | Flux d’événements |
| Anemic Domain Model | Anti-pattern | Domaine sans comportement | Signal de mauvais design | Entités vides |
| Subdomain (Sous-domaine) | Stratégique | Partie fonctionnelle du métier à modéliser | Découper le métier en zones | Pricing, Retour, Catalogue |
| Core Domain (Domaine cœur) | Stratégique | Sous-domaine le plus différenciant et complexe | Concentrer l’effort DDD là où ça crée de la valeur | Moteur de pricing, Scoring, Stock temps réel |
| Supporting Domain (Domaine de support) | Stratégique | Sous-domaine métier utile mais non différenciant | Modéliser proprement sans surinvestir | Retours, SAV, Back-office |
| Generic Domain (Domaine générique) | Stratégique | Fonctionnalité standard souvent externalisable | Réutiliser/externaliser plutôt que réinventer | Auth, Emails, Notifications |
| Strategic Design | Stratégique | Partie du DDD qui structure les frontières et la collaboration | Définir où et comment modéliser | Subdomains + BC + Context Map |
| Tactical Design | Tactique | Partie du DDD qui décrit les briques de modélisation | Implémenter le modèle métier en code | Entities, VOs, Aggregates |
À noter que cet article a été en partie rédigé par une IA et reformulé, complété et vérifié par un humain.