DDDDomain-Driven DesignArchitectureSoftware Design

Revoir le Domain-Driven Design (DDD) de façon structurée

Sloth255
Sloth255
·24 min read·5,311 words

Introduction

« Je vois à peu près ce qu'est le DDD, mais concrètement, qu'est-ce qu'il faut faire pour qu'on puisse vraiment parler de DDD ? »
« Quelle est, au fond, la différence entre une Entity et un Value Object ? »
« Si je crée un Repository, est-ce que ça devient du DDD ? »

Beaucoup de personnes se posent ce genre de questions. Dans cet article, en m'appuyant sur Domain-Driven Design d'Eric Evans et Implementing Domain-Driven Design de Vaughn Vernon, je vais remettre à plat l'essentiel du DDD sous une forme réellement utilisable en pratique.

Les explications des principaux termes sont alignées autant que possible sur la DDD Reference d'Eric Evans et sur la présentation officielle de Vaughn Vernon.

Qu'est-ce que le DDD ?

Le Domain-Driven Design (DDD) est une approche de conception fondée sur l'idée suivante : lorsqu'on développe un logiciel avec une logique métier complexe,

il faut placer le domaine au centre du logiciel et écrire le code dans le langage du domaine

Le point essentiel est que le DDD n'est ni un framework ni une architecture au sens strict. C'est un ensemble d'idées et de techniques. C'est pour cela qu'on ne fait pas du DDD simplement parce qu'on a ajouté une bibliothèque estampillée DDD.

Le DDD se divise globalement en deux niveaux

On peut grossièrement séparer le DDD en deux parties.

Catégorie Autre nom Rôle
Design stratégique Strategic Design Découper correctement le domaine et tracer ses frontières
Design tactique Tactical Design Exprimer le domaine dans le code

On réduit souvent le DDD à « créer des Entities et des Value Objects », mais cela ne concerne que le design tactique. Ce qui compte le plus, c'est plutôt le design stratégique.

Design stratégique

Langage ubiquitaire (Ubiquitous Language)

C'est le point de départ du DDD et son concept le plus important.

Les experts métier, les développeurs et les parties prenantes emploient les mêmes mots

Prenons l'exemple d'un site e-commerce. Quand on parle de « commande » :

  • Marketing : « la commande existe dès qu'un produit est mis dans le panier »
  • Comptabilité : « la commande existe une fois le paiement terminé »
  • Logistique : « la commande existe une fois l'ordre d'expédition émis »

Il est très fréquent qu'un même mot change de sens selon la personne qui parle. Si on reporte cette ambiguïté telle quelle dans le code, plus personne ne sait vraiment ce que signifie la classe Order.

Le modèle doit devenir la colonne vertébrale du langage, et ce langage doit rester cohérent dans les conversations comme dans le code. C'est la base du DDD.

Contexte borné (Bounded Context)

Le langage ubiquitaire n'a pas besoin d'être unifié à l'échelle de toute l'organisation, et en pratique ce serait souvent impossible.

On trace donc une frontière du type : « dans ce périmètre, ce mot veut dire ceci ». C'est ce qu'on appelle un contexte borné (Bounded Context). Cette frontière n'est pas seulement un accord de vocabulaire. Elle se reflète aussi dans l'organisation des équipes, dans la portée du modèle dans l'application, ainsi que dans la séparation des bases de code et des stocks de données.

flowchart LR
  subgraph Sales["Contexte vente"]
    S["Order =<br/>Intention d'achat apres validation"]
  end
  subgraph Logistics["Contexte logistique"]
    L["Order =<br/>Colis avec ordre d'expedition"]
  end
  N["Le meme Order peut avoir un sens different"]

  S -.-> N
  L -.-> N

  classDef context fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef note fill:#ffffff,stroke:#94a3b8,color:#334155,stroke-dasharray: 5 5
  class S,L context
  class N note

La carte de ces frontières s'appelle la context map. C'est aussi un excellent guide lorsqu'il faut décider des frontières de microservices.

Comment extraire un domaine à partir des exigences

Tout le monde finit par se demander : « Très bien, mais comment identifier concrètement le domaine ? » Voici quelques approches réellement utiles sur le terrain.

1. Pendant les entretiens, repérer les verbes et les noms

Lorsque vous échangez avec des clients ou des responsables métier, essayez de séparer ce qui relève des noms = candidats de modèle et des verbes = candidats de comportement.

« Le commercial crée un devis, l'envoie au client et, lorsqu'il est approuvé, le transforme en commande. »

Rien qu'avec cette phrase, on peut déjà extraire :

  • Noms : commercial, devis, client, commande -> candidats pour des Entities / Value Objects
  • Verbes : créer, envoyer, approuver, transformer en commande -> candidats pour des comportements / événements de domaine

Les mots que les métiers utilisent sans y penser sont la matière première du langage ubiquitaire.

2. Utiliser EventStorming

Pour les domaines complexes, EventStorming est très efficace : on aligne les événements sur des post-it sur un tableau blanc, ou dans Miro / FigJam.

flowchart LR
  E1["🟧 Devis demande"] --> E2["🟧 Devis cree"] --> E3["🟧 Devis envoye"]
  E3 --> C2["🟦 Commande<br/>Approuver le devis"] --> E4["🟧 Devis approuve"] --> E5["🟧 Commande acceptee"]
  E3 --> C1["🟦 Commande<br/>Annuler le devis"] --> E6["🟧 Devis annule"]
  A1["🟪 Agregat<br/>Devis"] -.declenche les evenements.-> E2
  R1["🟨 Regle<br/>Seuls les devis approuves peuvent etre convertis en commande"] -.contrainte.-> E5

  classDef event fill:#fdba74,stroke:#ea580c,color:#7c2d12
  classDef command fill:#bfdbfe,stroke:#2563eb,color:#1e3a8a
  classDef aggregate fill:#ddd6fe,stroke:#7c3aed,color:#4c1d95
  classDef rule fill:#fde68a,stroke:#ca8a04,color:#713f12
  class E1,E2,E3,E4,E5,E6 event
  class C1,C2 command
  class A1 aggregate
  class R1 rule
  • 🟧 Post-it orange : événements de domaine, rédigés au passé
  • 🟦 Post-it bleus : commandes, c'est-à-dire les actions de quelqu'un
  • 🟪 Post-it violets : agrégats, les objets qui déclenchent ces événements. Exemple : Devis
  • 🟨 Post-it jaunes : règles métier / contraintes. Exemple : « Seuls les devis approuves peuvent etre convertis en commande »

Par exemple, « Approuver le devis » est une commande, car c'est une instruction executee par quelqu'un. En revanche, « Devis approuve » est un evenement de domaine, car c'est un fait deja survenu.

Le faire avec les responsables métier permet de rendre visibles en une fois des connaissances implicites. C'est là que naissent naturellement des conversations du type : « Ah, donc il y a une décision ici ? » ou « En fait, dans ce cas-là, on a une exception... »

3. Toujours demander les cas exceptionnels

Dans un atelier de recueil des besoins, l'information la plus riche se cache souvent dans les flux d'exception.

Question Ce qu'elle permet de faire émerger
« Dans quels cas cela ne fonctionne-t-il pas ? » Invariants et règles métier
« Quels cas ont déjà posé problème dans le passé ? » Contraintes cachées
« Qu'est-ce qui est aujourd'hui contourné manuellement ? » Règles métier non implémentées
« Qu'est-ce qui a changé dans ce processus en cinq ans ? » Parties stables / parties qui évoluent

Si l'on n'écoute que le happy path, on ne voit jamais vraiment les contours du domaine. L'essence du domaine se cache souvent là où ça fait mal.

4. Classer en cœur, support et générique

Tous les domaines identifiés n'ont pas besoin d'être traités avec le même niveau d'investissement.

Catégorie Description Stratégie
Core Domain Source de l'avantage concurrentiel Construire en interne avec un vrai DDD
Supporting Subdomain Capacités métier qui soutiennent le cœur Implémentation légère ou externalisation
Generic Subdomain Partie commune à presque toutes les entreprises, comme l'authentification ou le paiement Utiliser du SaaS / OSS

Si l'on prend un site e-commerce :

  • Cœur : recommandation de produits, stratégie de prix, allocation de stock
  • Support : gestion du catalogue, coordination de livraison
  • Générique : authentification, paiement, envoi d'e-mails

Vouloir tout traiter avec la même intensité mène presque toujours à l'échec. Clarifier où il faut vraiment investir est la première étape d'une bonne conception des frontières.

5. Critères pour tracer les frontières de contexte

Déterminer où tracer une frontière demande de l'expérience, mais certains signaux aident beaucoup.

  • 🚩 Le même mot change de sens (comme l'exemple de Order au début)
  • 🚩 L'organisation ou le service responsable change (loi de Conway)
  • 🚩 Le cycle de vie n'est pas le même (une commande et un client n'ont pas la même durée de vie)
  • 🚩 La fréquence de changement diffère (la stratégie de prix change souvent, la gestion d'adresse beaucoup moins)
  • 🚩 Le niveau de cohérence attendu n'est pas le même (le stock demande une forte cohérence, la recommandation peut tolérer une cohérence éventuelle)

Lorsque vous ressentez l'une de ces divergences, vous tenez probablement un bon candidat de frontière.

Design tactique

À partir d'ici, on entre dans le code.

Value Object (objet valeur)

Un Value Object représente « la valeur elle-même » et possède les caractéristiques suivantes :

  • Immuable
  • Comparé par égalité de valeur plutôt que par identité
  • Sans effet de bord
// ❌ Juste une chaine
const email: string = "user@example.com";

// ✅ Value Object
class Email {
  constructor(private readonly value: string) {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      throw new Error("Adresse e-mail invalide");
    }
  }
  equals(other: Email): boolean {
    return this.value === other.value;
  }
  toString(): string {
    return this.value;
  }
}

Le point important est de faire porter la contrainte par le type : « ceci est une adresse e-mail valide ». Ainsi, la validation n'est plus dispersée partout.

Entity (entité)

Une Entity est un objet dont l'identité est déterminée par un identifiant (ID).

class User {
  constructor(
    public readonly id: UserId,
    private name: UserName,
    private email: Email,
  ) {}

  changeEmail(newEmail: Email): void {
    this.email = newEmail;
  }

  equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}

Même si le nom change ou si l'e-mail change, si l'id est le même, c'est le même User. C'est l'essence d'une Entity.

Aggregate (agrégat)

Un agrégat est une frontière de cohérence qui regroupe plusieurs Entities et Value Objects en un tout cohérent.

L'entité qui sert de point d'entrée s'appelle la racine d'agrégat (Aggregate Root), et tout accès externe doit passer par elle.

// Racine d'agregat
class Order {
  private items: OrderItem[] = [];

  constructor(
    public readonly id: OrderId,
    private readonly userId: UserId,
  ) {}

  // La racine d'agregat protege la coherence a l'interieur de l'agregat.
  addItem(product: Product, quantity: number): void {
    if (this.items.length >= 100) {
      throw new Error("La limite d'articles par commande a ete depassee");
    }
    this.items.push(new OrderItem(product.id, quantity, product.price));
  }

  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }
}

Une règle importante : un agrégat doit rester aussi petit que possible. Sinon, on finit vite par souffrir de conflits de mise à jour et de coûts de chargement trop élevés.

Repository

Le Repository est l'abstraction qui gère la persistance des agrégats. Il sert à éviter que la couche domaine dépende de l'infrastructure.

// Couche Domain : definir uniquement l'interface.
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// Couche Infrastructure : placer l'implementation a l'exterieur.
class PostgresOrderRepository implements OrderRepository {
  async findById(id: OrderId): Promise<Order | null> { /* ... */ }
  async save(order: Order): Promise<void> { /* ... */ }
}

L'idée importante ici est qu'un Repository n'est pas seulement « quelque chose qui enregistre dans la base ». C'est une abstraction qui donne un accès global aux racines d'agrégat et que l'on peut manipuler comme une collection.

Domain Service (service de domaine)

Un service de domaine sert à porter des processus métier importants ou des transformations qui ne trouvent pas naturellement leur place dans une Entity ou un Value Object.

// La verification des e-mails en double ne peut pas etre decidee par User seul.
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}

  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

Entity ou Service ? Un petit guide de décision

Pendant l'implémentation, on se demande très souvent : « Cette logique doit-elle vivre dans l'Entity ou dans un Service ? » Le schéma suivant couvre la majorité des cas.

flowchart TB
  Start["La logique..."] --> Q1{"change-t-elle seulement l'etat d'un seul objet ?"}
  Q1 -- OUI --> E["Methode d'Entity"]
  Q1 -- NON --> Q2{"prend-elle une decision sur plusieurs agregats ?"}
  Q2 -- OUI --> D["Domain Service"]
  Q2 -- NON --> Q3{"a-t-elle besoin d'un repository ou d'un service externe ?"}
  Q3 -- OUI --> D
  Q3 -- NON --> Q4{"s'exprime-t-elle plus naturellement comme verbe que comme nom ?"}
  Q4 -- OUI --> D
  Q4 -- NON --> V["Entity / Value Object"]

  classDef question fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef answer fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
  class Q1,Q2,Q3,Q4 question
  class E,D,V answer
✅ Ce qui doit aller dans une Entity

La logique qui protège son propre état et ses invariants relève de l'Entity.

class Order {
  private status: OrderStatus;
  private items: OrderItem[];

  // ✅ Proteger soi-meme ses propres transitions d'etat.
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new Error("Une commande expediee ne peut pas etre annulee");
    }
    this.status = OrderStatus.CANCELLED;
  }

  // ✅ Logique qui ne fait que calculer a partir de ses propres donnees.
  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }

  // ✅ Verifier son propre invariant.
  addItem(item: OrderItem): void {
    if (this.items.length >= 100) throw new Error("La limite a ete depassee");
    this.items.push(item);
  }
}

Le critère pratique est simple : si cela semble naturel de demander « Order, peux-tu être annulée ? », alors c'est probablement une responsabilité de l'Entity.

✅ Ce qui doit aller dans un Domain Service

La logique qui ne peut pas être décidée par une seule Entity, ou qui nécessite une interaction avec l'extérieur, relève d'un Domain Service.

// ✅ Logique de virement sur deux agregats.
class TransferService {
  transfer(from: Account, to: Account, amount: Money): void {
    from.withdraw(amount);
    to.deposit(amount);
    // Le concept de "virement" n'appartient a aucun des deux comptes.
  }
}

// ✅ Verification de doublon necessitant un repository.
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}
  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

Autre test pratique : si vous demandez « User, sais-tu si cette adresse e-mail est déjà enregistrée ailleurs ? » et que la réponse naturelle est « comment pourrais-je le savoir ? », cela relève d'un Service.

❌ Antipattern : tout mettre dans un Service
// ❌ Il ne faut pas faire ca.
class OrderService {
  cancel(order: Order): void {
    if (order.status === "SHIPPED") throw new Error(/* ... */);
    order.status = "CANCELLED";  // reécrit via un setter
  }
  totalPrice(order: Order): Money { /* calculer le total en parcourant items */ }
  addItem(order: Order, item: OrderItem): void { /* ... */ }
}
class Order {
  status: string;
  items: OrderItem[];
  // uniquement getters / setters ← modele de domaine anemique
}

C'est un transaction script typique. L'Entity est devenue une simple structure de données. La règle est la suivante : si un traitement peut se formuler avec Order comme sujet, il faut l'écrire dans Order.

En résumé

Point de vue À mettre dans l'Entity À mettre dans le Domain Service
Sujet L'objet lui-même Un verbe, une action
État Modifie son propre état Sans état (stateless)
Dépendances Ne dépend pas d'un Repository Peut dépendre d'un Repository / de systèmes externes
Portée À l'intérieur d'un agrégat À travers plusieurs agrégats
Exemple order.cancel() transferService.transfer(a, b, money)

Domain Event (événement de domaine)

Un événement de domaine représente « un fait important qui s'est produit dans le domaine ». Il s'accorde très bien avec les microservices et le traitement asynchrone, ce qui explique son importance croissante.

class OrderPlaced {
  constructor(
    public readonly orderId: OrderId,
    public readonly userId: UserId,
    public readonly occurredAt: Date,
  ) {}
}

On peut ainsi découpler les effets de bord, par exemple : « commande passée -> envoyer un e-mail » ou « commande passée -> attribuer des points ».

Architecture en couches

Le DDD est souvent implémenté avec une structure similaire à celle-ci.

flowchart TB
  P["Couche Presentation<br/>UI / API"]
  A["Couche Application<br/>cas d'usage"]
  D["Couche Domain<br/>modele / regles<br/>acteur principal"]
  I["Couche Infrastructure<br/>BD / APIs"]

  P --> A --> D
  I --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class P,A,I layer
  class D domain

Le sens des dépendances pointe toujours vers l'intérieur. La couche Domain ne dépend d'aucune autre couche. Pour renforcer cette règle, on s'appuie souvent sur l'Onion Architecture ou l'Hexagonal Architecture (Ports & Adapters).

Clarifier la responsabilité de chaque couche

En pratique, le problème le plus fréquent est la logique qui déborde dans toutes les couches. Garder une phrase claire pour chaque couche aide beaucoup.

Couche Responsabilité Ce qu'elle ne doit pas faire
Presentation Recevoir les requêtes et afficher le résultat Écrire les règles métier
Application Coordonner les cas d'usage Écrire les règles métier
Domain Porter les règles métier elles-mêmes Dépendre d'un framework ou de la base de données
Infrastructure Implémenter la persistance et les communications externes Prendre des décisions métier

Le point clé est simple : les règles métier ne doivent vivre que dans la couche Domain. En respectant cela, la logique métier survit aux changements de framework.

Le rôle de la couche Presentation

Elle reçoit les entrées des utilisateurs ou d'autres systèmes, les transmet à la couche Application, puis retourne le résultat. La règle d'or est : la garder aussi fine que possible.

// ✅ Bon controller : il se contente de relayer.
@Controller("orders")
class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  @Post()
  async create(@Body() body: PlaceOrderRequest): Promise<OrderResponse> {
    // 1. Mettre la requete en forme (conversion en DTO).
    const command = new PlaceOrderCommand(
      body.userId,
      body.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
    );

    // 2. Deleguer au cas d'usage.
    const result = await this.placeOrder.execute(command);

    // 3. Mettre la reponse en forme et la renvoyer.
    return OrderResponse.from(result);
  }
}

Ce que la couche Presentation doit faire :

  • Parser la requête et valider sa forme (champs obligatoires, type, longueur, etc.)
  • Gérer l'entrée de l'authentification et de l'autorisation
  • Convertir le résultat au bon format de réponse
  • Choisir les codes de statut HTTP

En revanche, elle ne doit pas porter de décisions métier du type « s'il n'y a plus de stock, retourner 400 ». Cela relève de la couche Domain. Presentation se contente de traduire le résultat du domaine dans le langage de HTTP.

Le rôle de la couche Application

C'est probablement la couche la plus mal comprise en DDD. Un Application Service est un coordinateur de cas d'usage. Il ne contient pas la logique métier.

// ✅ Bon Application Service : il ne fait que coordonner le flux.
class PlaceOrderUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly productRepository: ProductRepository,
    private readonly orderRepository: OrderRepository,
    private readonly inventoryService: InventoryService,  // Domain Service
    private readonly transactionManager: TransactionManager,
    private readonly eventPublisher: EventPublisher,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderResult> {
    return this.transactionManager.run(async () => {
      // 1. Charger les agregats necessaires.
      const user = await this.userRepository.findById(command.userId);
      if (!user) throw new UserNotFoundError(command.userId);

      const products = await this.productRepository.findByIds(
        command.items.map(i => i.productId),
      );

      // 2. Deleguer le traitement au domaine (les regles metier restent dans Domain).
      const order = Order.place(user, products, command.items);

      // 3. Appeler le Domain Service (decision sur plusieurs agregats).
      await this.inventoryService.reserve(order);

      // 4. Persister.
      await this.orderRepository.save(order);

      // 5. Publier les Domain Events.
      await this.eventPublisher.publishAll(order.pullEvents());

      return OrderResult.from(order);
    });
  }
}

Les responsabilités d'un Application Service se résument ainsi :

  1. Charger les agrégats (en appelant les Repository)
  2. Déléguer au domaine (laisser Entities et Domain Services appliquer les règles)
  3. Gérer les frontières transactionnelles
  4. Orchestrer la persistance (en sauvegardant via les Repository)
  5. Publier les événements de domaine

À l'inverse, voici le type de code qui ne doit pas se retrouver dans un Application Service :

// ❌ Anti-pattern : les regles metier fuient dans la couche Application.
class PlaceOrderUseCase {
  async execute(command: PlaceOrderCommand) {
    // ❌ Verifier le stock ici fait fuiter la connaissance metier.
    if (product.stock < command.quantity) {
      throw new Error("Rupture de stock");
    }
    // ❌ Calculer le total ici est de la responsabilite d'Order.
    const total = command.items.reduce((s, i) => s + i.price * i.quantity, 0);
    // ❌ Decider de l'etat ici est de la responsabilite d'Order.
    if (user.status === "BLOCKED") throw new Error("Compte suspendu");

    await this.orderRepository.save({ /* ... */ });
  }
}

Un bon test consiste à se demander : « Si je montre ce code à quelqu'un du métier, voit-on les règles métier dedans ? » Si des décisions métier sont prises ici à coups de if, c'est un signe qu'elles doivent remonter dans la couche Domain.

Le parcours d'une requête à travers les couches

Suivons maintenant un cas concret : l'utilisateur clique sur le bouton de commande.

sequenceDiagram
  participant Browser
  participant Presentation as Presentation / OrderController
  participant Application as Application / PlaceOrderUseCase
  participant Domain as Domain / Order.place(...)
  participant Infrastructure as Infrastructure / Implementation du repository

  Browser->>Presentation: POST /orders
  Presentation->>Application: Transmettre PlaceOrderCommand
  Note over Application: Demarrer la transaction
  Application->>Infrastructure: userRepository.findById()
  Application->>Infrastructure: productRepository.findByIds()
  Infrastructure-->>Application: Agregats User / Product
  Application->>Domain: Order.place(user, products, items)
  activate Domain
  Domain->>Domain: Verifier les regles metier
  Domain->>Domain: Verifier les invariants
  Domain->>Domain: Generer l'evenement OrderPlaced
  Domain-->>Application: Agregat Order et OrderPlaced
  deactivate Domain
  Application->>Domain: inventoryService.reserve(order)
  Domain-->>Application: Resultat de la reservation de stock
  Application->>Infrastructure: orderRepository.save(order)
  Application->>Infrastructure: eventPublisher.publishAll(...)
  Infrastructure-->>Application: Persistance terminee
  Note over Application: Valider la transaction
  Application-->>Presentation: OrderResult
  Presentation-->>Browser: 201 Created

Ce flux montre bien que les décisions métier n'ont lieu qu'à l'intérieur de la couche Domain. Les autres couches ne font que préparer le terrain et effectuer les opérations de support.

La frontière entre DTO et objets du domaine

Quand on traverse les couches, la règle habituelle est de ne pas exposer directement les objets du domaine à l'extérieur.

// Couche Domain
class Order { /* Agregat avec logique metier */ }

// Couche Application : DTO d'entree / sortie
class PlaceOrderCommand { /* DTO de requete */ }
class OrderResult { /* DTO de reponse */ }

// Couche Presentation : types HTTP
class PlaceOrderRequest { /* Schema JSON */ }
class OrderResponse { /* JSON de reponse */ }

Cela peut sembler fastidieux, mais les avantages sont réels :

  • Le Domain reste intact même si la forme de l'API change
  • Les contraintes de sérialisation ne salissent pas le domaine
  • Le passage à GraphQL ou gRPC reste localisé

En particulier, il ne faut jamais annoter un objet du domaine avec des décorateurs ORM comme @Column ou de sérialisation comme @Expose. À partir de ce moment-là, le domaine dépend du framework.

Ordre recommandé pour implémenter

Pour finir, voici un ordre de travail recommandé lorsqu'on implémente une nouvelle fonctionnalité en DDD.

  1. Écrire le cas d'usage en une phrase : « un utilisateur commande un produit »
  2. Extraire les termes qui apparaissent : utilisateur / produit / commande / stock
  3. Définir les agrégats et leurs frontières : que contient l'agrégat Order ?
  4. Commencer par la couche Domain : Entity, Value Object, Domain Service
  5. Écrire les tests du domaine (ils devraient tourner sans base de données)
  6. Écrire l'Application Service : utiliser les Repository comme interfaces
  7. Implémenter l'Infrastructure : vraies implémentations de Repository, configuration ORM
  8. Implémenter la Presentation : contrôleurs, types de requête et de réponse
  9. Tests E2E

Le point clé est de coder de l'intérieur vers l'extérieur. Commencer par la conception de tables de base de données va à l'encontre de l'esprit du DDD. Le modèle de domaine d'abord, les tables ensuite.

Bonnes pratiques pour la structure de dossiers

La première question qui revient souvent avec le DDD est la suivante :

Faut-il découper par couche ou par domaine / fonctionnalité ?

La réponse la plus robuste est : d'abord par domaine, puis par couche à l'intérieur.

❌ Structure orientée couches (proche de l'antipattern)

src/
├── controllers/
│   ├── user.controller.ts
│   ├── order.controller.ts
│   └── product.controller.ts
├── services/
│   ├── user.service.ts
│   ├── order.service.ts
│   └── product.service.ts
├── repositories/
│   ├── user.repository.ts
│   ├── order.repository.ts
│   └── product.repository.ts
└── entities/
    ├── user.entity.ts
    ├── order.entity.ts
    └── product.entity.ts

Au début, cela paraît simple à lire, mais dès que le système grossit :

  • modifier un seul cas d'usage impose de naviguer dans quatre dossiers
  • les frontières du domaine deviennent invisibles
  • extraire ensuite des microservices devient difficile

✅ Structure orientée domaine (recommandée)

src/
├── contexts/                      ← Organise par Bounded Context
│   ├── ordering/                  ← Contexte commande
│   │   ├── domain/
│   │   │   ├── order.ts           (Entity / Aggregate Root)
│   │   │   ├── order-item.ts
│   │   │   ├── order-id.ts        (Value Object)
│   │   │   ├── order-status.ts
│   │   │   ├── order.repository.ts(interface uniquement)
│   │   │   └── events/
│   │   │       └── order-placed.ts
│   │   ├── application/
│   │   │   ├── place-order.usecase.ts
│   │   │   └── cancel-order.usecase.ts
│   │   ├── infrastructure/
│   │   │   ├── postgres-order.repository.ts
│   │   │   └── stripe-payment.gateway.ts
│   │   └── presentation/
│   │       ├── order.controller.ts
│   │       └── dto/
│   │           ├── place-order.request.ts
│   │           └── order.response.ts
│   ├── catalog/                   ← Contexte catalogue produit
│   │   └── (meme structure)
│   └── shipping/                  ← Contexte livraison
│       └── (meme structure)
├── shared-kernel/                 ← Value Objects partages par tous les contextes
│   ├── money.ts
│   ├── email.ts
│   └── user-id.ts
└── shared/                        ← Base technique transverse
    ├── logger.ts
    ├── transaction-manager.ts
    └── event-bus.ts

Cette disposition offre trois grands avantages.

  1. Les frontières de contexte deviennent visibles physiquement. Une conception qui reste contenue dans ordering/ émerge naturellement.
  2. La découpe en microservices devient plus simple. On peut quasiment extraire contexts/ordering/ tel quel.
  3. Les revues de code sont plus ciblées. On voit immédiatement si un changement reste dans ordering.

Organiser l'intérieur de la couche Domain

Quand domain/ commence à grossir, il est recommandé de découper par agrégat.

ordering/domain/
├── order/
│   ├── order.ts                   (racine d'agregat)
│   ├── order-item.ts
│   ├── order-id.ts
│   ├── order-status.ts
│   ├── order.repository.ts
│   └── events/
│       ├── order-placed.ts
│       └── order-cancelled.ts
├── customer/
│   ├── customer.ts
│   └── customer.repository.ts
└── services/
    └── pricing.service.ts          (Domain Service)

Quand un agrégat est organisé comme s'il possédait son propre petit monde, ses dépendances deviennent elles aussi plus faciles à suivre.

Dans un monorepo

Si plusieurs services sont gérés dans un même dépôt, on obtient généralement une structure comme celle-ci.

monorepo/
├── apps/
│   ├── web/                       (frontend Next.js)
│   ├── api/                       (API backend)
│   └── admin/                     (ecran d'administration)
├── packages/
│   ├── ordering-context/          ← Bounded Context package
│   ├── catalog-context/
│   ├── shipping-context/
│   └── shared-kernel/
└── package.json

L'idée clé est que chaque Bounded Context devient un package npm indépendant. apps/api les importe simplement, ce qui permet d'imposer les dépendances entre contextes au niveau des packages.

Quelques repères de nommage

Avoir une convention cohérente pour les noms de fichiers et de classes est très utile. En pratique, les règles suivantes fonctionnent bien.

Élément Exemple de nommage
Entity / Aggregate Root Order (nom commun au singulier)
Value Object Email, Money, OrderId
Repository (interface) OrderRepository
Repository (implémentation) PostgresOrderRepository
Domain Service PricingService, TransferService
Application Service PlaceOrderUseCase, CancelOrderUseCase
Domain Event OrderPlaced, OrderCancelled (au passé)
Command (DTO) PlaceOrderCommand

En particulier, mettre les Domain Events au passé est important. Cela permet d'indiquer clairement : « c'est quelque chose qui s'est produit », pas « quelque chose qu'on s'apprête à faire ».

Concevoir les couches dans un système avec UI

Jusqu'ici, nous avons surtout parlé du backend. Mais que se passe-t-il lorsqu'on ajoute une UI (frontend web) ?

Si on transpose tel quel les 4 couches du backend dans le frontend...

La réponse est simple : mieux vaut éviter de le faire de manière mécanique. Le frontend a ses propres préoccupations, et lui imposer la même structure que le serveur devient vite artificiel.

[Preoccupations propres au frontend]
- Routage
- Etat d'ecran (chargement, erreur, element selectionne, etc.)
- Validation de formulaire (pour l'UX)
- Animation
- Rerendu reactif

Si l'on met tout cela dans la couche Domain, le Domain finit par dépendre de React, Vue ou d'un autre framework. Ce serait exactement l'inverse de l'objectif recherché.

Un modèle en couches plus adapté au frontend

Une séparation plus pratique ressemble à ceci.

flowchart TB
  R["Couche Pages / Routes<br/>Next.js app/, pages/, etc."]
  C["Couche Components<br/>UI / responsabilite visuelle"]
  V["Couche ViewModel / Hooks<br/>etat et appels aux cas d'usage"]
  A["Couche Application<br/>cas d'usage / clients API"]
  D["Couche Domain<br/>modele frontend minimal"]

  R --> C --> V --> A --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class R,C,V,A layer
  class D domain

Les responsabilités deviennent alors :

Couche Responsabilité Exemple
Pages / Routes Associer URL et page page /orders/new
Components Apparence et interaction <OrderForm />, <Button />
ViewModel / Hooks Gérer l'état d'écran, déclencher les cas d'usage useOrderForm()
Application Appeler les API, mettre les données en forme placeOrderUseCase()
Domain Règles métier minimales côté frontend Order.canCancel()

Exemple en React + TypeScript

// Couche Domain : garder une version legere des regles du backend.
class Order {
  constructor(
    public readonly id: string,
    public readonly status: OrderStatus,
    public readonly items: OrderItem[],
  ) {}

  // La decision UI "peut-on cliquer sur Annuler ?" est centralisee ici.
  canCancel(): boolean {
    return this.status === "PENDING" || this.status === "CONFIRMED";
  }

  totalPrice(): number {
    return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
  }
}

// Couche Application : cas d'usage qui appelle l'API.
class PlaceOrderUseCase {
  constructor(private readonly api: OrderApiClient) {}

  async execute(items: CartItem[]): Promise<Order> {
    const response = await this.api.placeOrder({ items });
    return Order.fromResponse(response);
  }
}

// Couche ViewModel / Hooks : gerer l'etat de l'ecran.
function useOrderForm() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const placeOrder = useMemo(() => new PlaceOrderUseCase(orderApi), []);

  const submit = async () => {
    setIsSubmitting(true);
    try {
      await placeOrder.execute(items);
    } catch (e) {
      setError(formatError(e));
    } finally {
      setIsSubmitting(false);
    }
  };

  return { items, setItems, isSubmitting, error, submit };
}

// Couche Components : se concentrer uniquement sur la presentation.
function OrderForm() {
  const { items, isSubmitting, error, submit } = useOrderForm();
  return (
    <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
      {/* JSX */}
    </form>
  );
}

Le point important est que la Component peut se concentrer sur l'affichage. L'état d'écran va dans le Hook, les règles métier comme canCancel() dans le Domain, et les appels API dans la couche Application.

Jusqu'où faut-il pousser la couche Domain côté frontend ?

Cela dépend de la nature du projet.

Nature du projet Couche Domain côté frontend
Principalement des écrans CRUD simples Presque inutile, on peut utiliser directement les réponses API
Calculs ou décisions complexes nécessaires dans l'UI Utile, par exemple pour recalculer les prix ou vérifier ce qui est sélectionnable
Support offline / mobile Beaucoup plus utile (voir plus bas)

Autre point important : il n'est pas nécessaire de recopier le modèle de domaine du backend à l'identique. Côté frontend, il suffit de ne garder que les règles utiles à l'interaction utilisateur.

Le BFF comme option

Si « la forme des données du backend ne correspond pas aux besoins du frontend » ou si « une seule page doit combiner plusieurs API », alors intercaler un BFF peut être une très bonne solution.

flowchart TB
  Browser["Navigateur"]
  BFF["BFF<br/>Next.js API Routes / Hono, etc.<br/>Endpoint optimise pour l'ecran"]
  Ordering["Service commande"]
  Catalog["Service catalogue"]
  Inventory["Service stock"]
  Response["Reponse faconnee pour l'ecran"]

  Browser --> BFF
  BFF --> Ordering
  BFF --> Catalog
  BFF --> Inventory
  Ordering --> BFF
  Catalog --> BFF
  Inventory --> BFF
  BFF --> Response --> Browser

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef service fill:#e0e7ff,stroke:#4f46e5,color:#312e81
  classDef output fill:#ffffff,stroke:#94a3b8,color:#334155
  class Browser,BFF layer
  class Ordering,Catalog,Inventory service
  class Response output

Un BFF est une couche mince, uniquement au service de l'écran, sans domaine propre. Il peut récupérer en une fois des données réparties sur plusieurs agrégats ou agréger les résultats de plusieurs contextes dans un JSON adapté à l'interface. Il est aussi tout à fait naturel de l'utiliser comme côté Query dans une approche CQRS.

Le DDD peut-il s'appliquer aux applications mobiles ?

« Le DDD, ce n'est pas surtout un sujet backend ? »

C'est une impression fréquente, mais le DDD peut tout à fait s'appliquer aux applications mobiles. Dès qu'il y a du support offline ou de la synchronisation, les bénéfices d'un vrai modèle de domaine deviennent même très visibles.

Spécificités des applications mobiles

Spécificité Impact
Support offline Une base locale devient nécessaire -> le Repository prend tout son sens
Notifications push / traitement en arrière-plan Très bonne affinité avec les Domain Events
Capteurs / caméra du terminal À traiter comme de l'Infrastructure
Cycle de vie de l'application Rend la persistance d'état plus complexe
Compatibilité des versions Nécessite un support des anciens appareils

Si tout cela est codé directement dans Activity, Fragment ou ViewController, on peut vite se retrouver avec une application que la moindre mise à jour de l'OS fragilise complètement.

Exemple de structure en couches en natif (Swift / Kotlin)

App/
├── Domain/
│   ├── Entities/
│   │   ├── Recipe.swift
│   │   └── Ingredient.swift
│   ├── ValueObjects/
│   │   └── CookingTime.swift
│   ├── Repositories/
│   │   └── RecipeRepository.swift   (protocol / interface)
├── Application/
│   └── UseCases/
│       └── SearchRecipeUseCase.swift
├── Infrastructure/
│   ├── Repositories/
│   │   ├── CoreDataRecipeRepository.swift  (base locale)
│   │   └── ApiRecipeRepository.swift       (API distante)
│   ├── Storage/
│   │   └── KeyValueStore.swift
│   └── Sync/
│       └── RecipeSyncService.swift
└── Presentation/
    ├── ViewModels/
    │   └── RecipeListViewModel.swift
    └── Views/
        └── RecipeListView.swift            (SwiftUI)

Comme dans les sections précédentes, le Use Case est placé dans la couche Application et la couche Domain reste concentrée sur le modèle et ses règles.

Un exemple où le Repository brille en mode offline

// Couche Domain : abstraire plusieurs cibles de persistance.
protocol RecipeRepository {
    func find(id: RecipeId) async throws -> Recipe?
    func save(_ recipe: Recipe) async throws
}

// Couche Infrastructure : composer local + distant.
class HybridRecipeRepository: RecipeRepository {
    let local: CoreDataRecipeRepository
    let remote: ApiRecipeRepository

    func find(id: RecipeId) async throws -> Recipe? {
        // Lisible depuis le stockage local meme hors ligne.
        if let cached = try await local.find(id: id) {
            return cached
        }
        let remote = try await remote.find(id: id)
        if let recipe = remote {
          try await local.save(recipe)  // rafraichir le cache
        }
        return remote
    }
}

Comme la logique offline est enfermée dans l'implémentation du Repository, le ViewModel et le domaine n'ont qu'à dire « récupère-moi cela ». C'est précisément là que le DDD devient puissant.

Cas React Native / Flutter

La logique est la même en cross-platform. L'un des grands avantages est même de pouvoir partager une couche Domain commune entre le Web et le mobile.

shared/
└── domain/                        ← Partage entre Web et Mobile
    ├── recipe.ts
    └── recipe.repository.ts
apps/
├── web/
│   └── infrastructure/
│       └── api-recipe.repository.ts
└── mobile/
    └── infrastructure/
        └── sqlite-recipe.repository.ts  (offline first)

Cette structure n'est possible que parce que le Domain ne dépend de rien d'autre. C'est l'une des plus belles démonstrations du DDD.

Mais ne pas oublier de rester léger

Apporter tout l'arsenal DDD du backend dans une application mobile est souvent excessif. Dans une app mobile :

  • les frontières d'agrégat n'ont pas besoin d'être aussi strictes que côté serveur, puisque la cohérence finale reste garantie par le serveur
  • les Domain Events peuvent souvent se limiter à des notifications d'événements locales
  • aller jusqu'à CQRS n'est pas toujours nécessaire

Un bon compromis consiste à garder une version cliente légère du modèle de domaine côté serveur.

Faire coexister CRUD, écrans d'administration et DDD

À ce stade, beaucoup pensent probablement : « La majeure partie de notre application, ce sont des écrans de réglages ou d'administration. Est-ce encore du DDD ? » Ce malaise est justifié.

Le DDD est fait pour les domaines complexes. Là où il n'y a pas de complexité métier, il ne fait souvent qu'alourdir le code inutilement.

Évaluer le "poids" du domaine

Même dans un seul projet, toutes les fonctionnalités n'ont pas le même niveau de complexité.

Fonction Nature de la complexite Approche d'implementation
Traitement des commandes allocation de stock, calcul du prix, transitions d'etat complexes Investir avec DDD
Gestion du stock reservation, remise en stock, maintien de la coherence Investir avec DDD
Referentiel produit principalement CRUD Une approche legere suffit
Parametres utilisateur simple stockage de valeurs CRUD suffit
Consultation des logs admin seulement requeter et afficher CRUD suffit

Le point important, c'est que la distinction entre cœur, support et générique se reflète directement dans le style d'implémentation.

Type de domaine Style d'implémentation Exemple
Core Domain DDD complet prise de commande, stock, stratégie tarifaire
Supporting Subdomain DDD léger ou transaction script master produit, configuration de livraison
Generic Subdomain CRUD ou SaaS authentification, e-mail, écrans de configuration

Comment écrire les parties CRUD

Pour les zones légères, un transaction script simple est tout à fait acceptable.

// ✅ Pour mettre a jour l'ecran de reglages, cela suffit.
class UpdateSiteSettingsHandler {
  constructor(private readonly db: Database) {}

  async execute(input: UpdateSiteSettingsInput): Promise<void> {
    await this.db.siteSettings.update({
      where: { id: 1 },
      data: input,
    });
  }
}

Si l'on force ici Entities, Repository et Domain Services, on augmente seulement le volume de code sans rien gagner.

Mélanger les deux dans un même projet

En pratique, la solution réaliste consiste à changer de style d'implémentation selon le contexte.

src/contexts/
├── ordering/                ← DDD complet
│   ├── domain/
│   ├── application/
│   ├── infrastructure/
│   └── presentation/
├── inventory/               ← DDD complet
│   └── (comme ci-dessus)
├── catalog/                 ← DDD leger (garder les Entities, minimiser les Services)
│   └── (version simplifiee)
└── admin/                   ← Base sur CRUD
  ├── handlers/            (handlers fins seulement)
    └── controllers/

Tant que chaque contexte reste cohérent en interne, cela fonctionne très bien. Il n'est pas nécessaire d'imposer une seule manière de faire à tout le projet.

Pour les API d'administration en lecture seule, CQRS est pratique

Un besoin fréquent dans les écrans d'administration est : « je veux afficher une liste en combinant plusieurs agrégats ». Cela revient souvent à faire de la lecture transversale sur plusieurs agrégats, ce qui s'accorde mal avec les règles strictes des agrégats DDD.

Dans ce cas, une adoption légère de CQRS est très utile : l'écriture (Command) reste stricte via les agrégats, la lecture (Query) peut passer par du SQL brut.

// Les ecritures passent par les agregats (strict).
class CancelOrderUseCase {
  async execute(orderId: OrderId): Promise<void> {
    const order = await this.orderRepository.findById(orderId);
    order.cancel();                    // regle metier
    await this.orderRepository.save(order);
  }
}

// Les requetes en lecture seule peuvent utiliser du SQL brut (optimise pour l'ecran).
class OrderListQuery {
  async findForAdmin(filter: AdminFilter): Promise<OrderListView[]> {
    return this.db.raw(`
      SELECT o.id, o.status, u.name AS user_name, p.name AS product_name, ...
      FROM orders o
      JOIN users u ON ...
      JOIN order_items oi ON ...
      WHERE ...
    `);
  }
}

Il faut éviter de charger les agrégats un par un pour les reconstituer ensuite en mémoire. Pour la lecture, il faut utiliser des outils de lecture. C'est l'approche pragmatique.

Principes pour faire coexister CRUD et DDD

Pour finir, voici les règles à garder en tête quand les deux approches cohabitent.

  1. Tracer clairement les frontières de contexte - même dans les zones non-DDD, la notion de frontière reste indispensable
  2. Ne pas économiser sur le cœur métier - c'est là que les raccourcis coûtent le plus cher plus tard
  3. Écrire simplement les zones CRUD - ne pas forcer une modélisation DDD inutile
  4. Autoriser une voie séparée pour la lecture - utiliser des Read Models ou un CQRS léger
  5. Faire évoluer plus tard si la complexité apparaît - ce qui commence en CRUD peut devenir du DDD ensuite

Le DDD n'est pas une religion, c'est un outil. L'investissement doit être proportionné à la complexité du domaine.

Pièges fréquents

1. Le modèle de domaine anémique

C'est la situation où l'Entity n'est plus qu'un tas de getters / setters et où toute la logique fuit vers des Services. Gardez cette phrase en tête : un objet sans comportement n'est pas vraiment un objet.

2. Appliquer le DDD partout

Écrans de configuration, écrans d'administration, CRUD simples. Si l'on apporte le DDD dans tous ces endroits, la vitesse de développement baisse presque à coup sûr. Il faut l'utiliser uniquement là où il existe une vraie complexité métier. La section précédente sur la coexistence entre CRUD, administration et DDD va dans le même sens.

3. Ne retenir que les patterns tactiques

« J'ai fait des Entities et des Repository, donc je fais du DDD » : non. Sans travail sur le langage ubiquitaire et les contextes bornés, les patterns tactiques perdent une grande partie de leur impact.

Comment démarrer avec le DDD

Si vous voulez introduire le DDD dans un contexte réel, mieux vaut avancer progressivement plutôt que de déployer d'emblée toute la panoplie.

  1. Recueillir le langage métier - construire un glossaire avec les experts métier
  2. Prendre conscience des frontières - délimiter ce qui relève du même contexte
  3. Commencer par les Value Objects - remplacer les types primitifs par des types porteurs de sens
  4. Identifier les agrégats - penser en termes de frontières transactionnelles
  5. Découpler avec des événements - introduire des Domain Events lorsque c'est pertinent

Conclusion

À mes yeux, l'essence du DDD consiste à faire face directement, dans le code, à la complexité du domaine.

Si l'on transforme l'apprentissage des patterns en objectif en soi, le code devient souvent plus complexe au lieu de s'éclaircir. Faire grandir le langage ubiquitaire au sein de l'équipe est à la fois le point de départ et, d'une certaine manière, le but du DDD.

Pourquoi ne pas commencer dès la prochaine review par une simple question : « Ce nom de classe parlerait-il aussi aux personnes du métier ? »

Références

  • Eric Evans "Domain-Driven Design: Tackling Complexity in the Heart of Software"
  • Eric Evans "DDD Reference: Definitions and Pattern Summaries" https://www.domainlanguage.com/ddd/reference/
  • Vaughn Vernon "Implementing Domain-Driven Design"
  • Masanobu Naruse "Introduction au Domain-Driven Design"
  • Toru Masuda "Principes de conception de systemes utiles sur le terrain"
  • Martin Fowler "Patterns of Enterprise Application Architecture"
  • Robert C. Martin "Clean Architecture"
  • Alberto Brandolini "Introducing EventStorming"