Dernière modification : 01/02/2026

Domain-Driven Design (DDD)

Comprendre les fondamentaux et la modélisation du domaine

 

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.

 

Sommaire

  1. Avant de commencer : pourquoi le DDD existe
  2. Quand utiliser (ou éviter) le DDD
  3. Le socle du DDD : le Langage Ubiquitaire (Ubiquitous Language)
  4. Structurer le métier : Core Domain, Supporting Domain & Generic Domain
  5. Découper le problème : Bounded Context (Contexte borné)
  6. Relier les Bounded Contexts : Context Map & intégration
  7. Comprendre le métier ensemble : Event Storming
  8. La modélisation que propose le DDD
  9. Conclusion
  10. Annexe : Tableau récapitulatif des concepts du Domain-Driven Design
  11. Annexe : Pour aller plus loin

 

 

1) Avant de commencer : pourquoi le DDD existe

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 :

  • les règles sont disséminées,
  • les noms deviennent ambigus,
  • chaque évolution coûte de plus en plus cher.

Le DDD propose donc de mettre le domaine au centre et de construire le logiciel autour de lui, pas l’inverse.

Les principaux problèmes adressés par le DDD sont :

  • la perte de sens entre les besoins métier et l’implémentation,
  • les règles métier cachées dans des services techniques,
  • les effets de bord lors des évolutions,
  • la dépendance excessive à la structure de la base de données.

Le DDD permet d'avoir :

  • un modèle métier explicite,
  • un code lisible et intentionnel,
  • une meilleure communication métier / technique,
  • une base solide pour faire évoluer l’application.

Ce que le DDD n’est pas

  • une architecture imposée,
  • réservé aux micro-services,
  • forcément complexe.
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 :

  • le métier est implicite,
  • aucune règle n’est exprimée,
  • le service fait “un peu de tout”.

L'objectif du DDD est de déplacer l’intelligence métier dans le domaine.

 

2) Quand utiliser (ou éviter) le DDD

Le DDD n’est pas une solution universelle. Le DDD a du sens quand :

  • le métier contient beaucoup de règles,
  • ces règles évoluent régulièrement,
  • plusieurs personnes interprètent différemment les mêmes concepts,
  • le domaine est stratégique pour l’entreprise.

 

Exemple de domaine :

  • gestion de commandes,
  • facturation,
  • logistique,
  • assurance,
  • finance,
  • systèmes réglementés.

 

À l’inverse, le DDD est souvent surdimensionné si :

  • l’application est essentiellement du CRUD,
  • les règles sont simples,
  • le projet n'est pas amené à beaucoup évoluer,
  • le domaine est très stable.

Dans ces situations, un design simple et lisible est souvent préférable.

 

3) Le socle du DDD : le Langage Ubiquitaire (Ubiquitous Language)

Le langage ubiquitaire est l’élément le plus important du DDD. L'objectif est que chaque concept métier :

  • a un nom unique,
  • partagé par tout le monde,
  • utilisé tel quel dans le code.

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 :

  • OrderManager : Manager de quoi ? Décideur ? Orchestrateur ? Service technique ?
  • processAction : Quelle action ? Annulation ? Validation ? Mise à jour ?
  • OrderDTO : Objet technique, pas métier,
  • "SHIPPED", "CANCELLED" : chaînes magiques, pas de sens métier explicite,
  • Le code est compliqué à discuter avec le métier.

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 :

  • Order : concept métier clair,
  • cancel() : verbe métier, compréhensible sans contexte,
  • OrderStatus.SHIPPED : vocabulaire explicite,
  • La règle métier est lisible, testable et localisée.

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.

 

4) Structurer le métier : Core Domain, Supporting Domain & Generic Domain

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 :

  • D’abord, le Core Domain, qui correspond à la partie la plus importante et différenciante : c’est là que se trouve la logique métier la plus complexe, celle qui fait réellement la valeur du produit et qui évolue souvent. C’est aussi la zone où le DDD est le plus rentable : on veut un langage extrêmement précis, des règles très explicites, et un modèle de domaine riche, parce que c’est ce qui doit rester robuste face au changement.
  • Ensuite, on trouve le Supporting Domain, qui contient du métier réel, mais plutôt “de support”. Ce sont des éléments utiles, parfois incontournables, mais qui ne donnent pas un avantage concurrentiel particulier. Dans ces zones-là, il faut évidemment rester cohérent, lisible et propre, mais on peut se permettre une modélisation plus simple.
  • Enfin, il existe le Generic Domain, qui représente les fonctionnalités standard qu’on retrouve dans presque tous les systèmes : authentification, envoi d’emails, notifications, reporting simple, etc. Ces briques sont rarement stratégiques, et l’intérêt n’est pas de les modéliser “profondément”, mais de les réutiliser via des solutions existantes, des services externes ou des librairies éprouvées. C’est typiquement là où faire du DDD complet serait disproportionné.

 

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 Core Domain représente ce qui différencie vraiment l’entreprise : par exemple un moteur de Pricing (promotions, remises, règles de calcul) complexe et évolutif. C’est là qu’on investit le plus dans un modèle DDD riche.
  • le Supporting Domain correspond à du métier important mais moins différenciant, comme la gestion des retours. Il doit rester cohérent et propre, sans forcément être extrêmement sophistiqué.
  • le Generic Domain regroupe les briques standard, comme l’authentification ou la gestion des comptes, souvent externalisables.

 

5) Découper le problème : Bounded Context (Contexte borné)

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.

  • Pour le service commercial, c'est une intention d’achat, un engagement client,
  • Pour la facturation, c'est une base de calcul, des montants, de la TVA,
  • Pour la logistique, c'est quelque chose à préparer, emballer, expédier.

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).

 

Comment identifier les Bounded Contexts

Plusieurs signaux permettent de détecter les différents bounded contexts :

  1. Les équipes : quand différentes équipes parlent du “même” concept mais ne se comprennent pas.

  2. 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.

  3. 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.

 

6) Relier les Bounded Contexts : Context Map & intégration

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.
 

Exemple de Context Map (e-commerce)

Imaginons trois contextes :

  • Ordering : gestion des commandes,
  • Billing : gestion de la facturation et du paiement,
  • Shipping : gestion des expéditions.

Relations typiques :

  • Ordering → Billing (la facturation dépend de la commande),
  • Billing → Shipping (l’expédition dépend du paiement).

Ordering ne doit pas dépendre de Shipping.

 

6.1) Problème classique : la propagation du modèle

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 :

  • Conformist : le consommateur adopte tel quel le modèle en amont, sans chercher à le traduire ni à le protéger,
  • ACL (Anti-Corruption Layer) : une couche de traduction qui protège le domaine,
  • Published Language / Open Host Service : une API ou des événements stables et versionnés,
  • Shared Kernel (rarement) : uniquement des éléments très simples, jamais le métier.

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.

 

6.2) Le pattern Conformist

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 contexte consommateur a peu d’importance stratégique,
  • le contexte amont est imposé (legacy, solution externe, équipe dominante),
  • le coût de mise en place d’une Anti-Corruption Layer est jugé trop élevé,
  • la capacité d’influence sur le modèle en amont est quasi inexistante.

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.

 

6.3) Anti-Corruption Layer (ACL) - Côté consommateur (downstream)

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 :

  • Ordering n’importe pas le status Payment tel quel,
  • si Payment ajoute un statut demain (PENDING_REVIEW), Ordering ne casse pas,
  • la traduction est localisée dans l’ACL.

 

6.4) Open Host Service (Côté fournisseur (upstream)) / Published Language

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.

Exemple : Dans un e-commerce, le contexte Billing est responsable du paiement. Quand un paiement est validé, Billing publie un événement métier comme :

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 :

  • la commande passe de PENDING_PAYMENT → PAID
  • puis elle peut déclencher la suite (préparation, expédition, etc.)

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 :

  • Billing reste maître de son domaine et de ses règles,
  • Ordering reçoit juste l’information dont il a besoin, au bon moment,
  • chaque équipe peut faire évoluer son modèle sans casser les autres.

Cependant, pour que cela fonctionne, le contrat doit être :

  • stable : Éviter de changer la structure régulièrement,
  • versionné : si cela évolue, on introduit une nouvelle version,
  • indépendant du modèle interne : Ne pas “publier” une entité ou un objet métier interne tel quel.

 

6.5) Shared Kernel

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

 

6.6) Ce qui est le plus souvent utilisé

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 :

  • Le supplier (fournisseur) expose volontairement son contrat via un Open Host Service, souvent sous forme d’API REST.
  • Le customer (consommateur), lui, ne réutilise pas directement ce modèle. Il place une Anti-Corruption Layer (ACL) qui reçoit les requêtes, messages ou commandes, puis les traduit dans un langage et un modèle que son propre contexte comprend.

 

7) Comprendre le métier ensemble : Event Storming

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 :

  • les commandes (les intentions) : ValiderCommande, EncaisserPaiement…
  • les règles métier (les décisions) : pas de validation sans article, pas de paiement en double…
  • les acteurs / systèmes externes : client, paiement, transporteur…

Cela permet d'avoir :

  • une vision claire du flux,
  • des zones floues révélées vite,
  • des règles implicites rendues explicites.

 

8) La modélisation que propose le DDD

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.

 

8.1 Value Objects - Exprimer les règles locales

Un Value Object, c’est une valeur métier :

  • sans identité propre (un montant reste un montant),
  • immuable,
  • comparée par sa valeur.

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 :

  1. Email : une adresse email valide (format + normalisation éventuelle),
  2. Numéro de téléphone : un numéro au format standard + validation,
  3. Money (Montant + devise) : un montant monétaire (ex: 19.99 EUR) avec des règles (devise obligatoire, arrondi, opérations),
  4. Adresse postale : rue, code postal, ville, pays (validation, formatage, éventuellement normalisation),
  5. DateRange (Période) : une période avec date de début et date de fin et l’invariant start <= end,
  6. Pourcentage / Taux : une valeur entre 0 et 100% (ou 0..1) utilisée pour TVA, remise, scoring,
  7. Quantité (valeur + unité) : une quantité (ex: 2 kg, 12 unités, 3 h) avec règles (>= 0, unité autorisée),
  8. Coordonnées GPS : latitude + longitude avec bornes (-90..90 / -180..180),
  9. IBAN : un identifiant bancaire au bon format (validation + checksum).

 

8.2 Entities - Gérer le cycle de vie

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.

 

8.3 Aggregate & Aggregate Root - maîtriser la cohérence

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 :

  • Entity = identité
  • Agrégat = groupe d’objets cohérents (un ensemble d’objets qu’on modifie comme un bloc cohérent)
  • Aggregate Root = l’Entity qui protège l’agrégat (le chef de cet ensemble : tout passe par lui)

 

8.4 Domain Services - quand une règle ne rentre pas dans une seule entité

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.).

 

8.5 Repositories - accéder aux agrégats sans exposer la technique

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.

 

8.6 Factories - sécuriser la création d’objets

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.

 

8.7 Où va la logique métier ?

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

 

8.8. Anti-patterns (à éviter)

Quelques cas anti-patterns à éviter :

  • Entités anémiques (tout dans les services) : Le domaine devient un simple "sac de données", et toute la logique métier se retrouve ailleurs. Résultat : code peu lisible et difficile à maintenir.
  • Agrégats gigantesques : Un seul agrégat qui “contient tout” finit par devenir ingérable, lent à charger, et impossible à faire évoluer sans casser des invariants.
  • Repositories trop riches : Quand le repository se transforme en mini “service SQL” avec plein de méthodes spécifiques, on mélange métier et technique et on perd l’intention.
  • Domain Services techniques : Un Domain Service ne doit pas faire du REST, du mapping JSON ou gérer des transactions : sinon il sort du domaine et se transforme en couche applicative déguisée.
  • Domaine dépendant de JPA / Spring : Si le domaine a des annotations JPA ou dépend de Spring, il devient prisonnier du framework et perd sa portabilité.

 

9) Conclusion

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.

 

 

Annexe : Tableau récapitulatif des concepts du Domain-Driven Design

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

 

Annexe : Pour aller plus loin

 

À noter que cet article a été en partie rédigé par une IA et reformulé, complété et vérifié par un humain.