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 noteLa 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
Orderau 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 domainLe 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 :
- Charger les agrégats (en appelant les Repository)
- Déléguer au domaine (laisser Entities et Domain Services appliquer les règles)
- Gérer les frontières transactionnelles
- Orchestrer la persistance (en sauvegardant via les Repository)
- 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 CreatedCe 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.
- Écrire le cas d'usage en une phrase : « un utilisateur commande un produit »
- Extraire les termes qui apparaissent : utilisateur / produit / commande / stock
- Définir les agrégats et leurs frontières : que contient l'agrégat
Order? - Commencer par la couche Domain : Entity, Value Object, Domain Service
- Écrire les tests du domaine (ils devraient tourner sans base de données)
- Écrire l'Application Service : utiliser les Repository comme interfaces
- Implémenter l'Infrastructure : vraies implémentations de Repository, configuration ORM
- Implémenter la Presentation : contrôleurs, types de requête et de réponse
- 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.
- Les frontières de contexte deviennent visibles physiquement. Une conception qui reste contenue dans
ordering/émerge naturellement. - La découpe en microservices devient plus simple. On peut quasiment extraire
contexts/ordering/tel quel. - 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 domainLes 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 outputUn 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.
- Tracer clairement les frontières de contexte - même dans les zones non-DDD, la notion de frontière reste indispensable
- Ne pas économiser sur le cœur métier - c'est là que les raccourcis coûtent le plus cher plus tard
- Écrire simplement les zones CRUD - ne pas forcer une modélisation DDD inutile
- Autoriser une voie séparée pour la lecture - utiliser des Read Models ou un CQRS léger
- 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.
- Recueillir le langage métier - construire un glossaire avec les experts métier
- Prendre conscience des frontières - délimiter ce qui relève du même contexte
- Commencer par les Value Objects - remplacer les types primitifs par des types porteurs de sens
- Identifier les agrégats - penser en termes de frontières transactionnelles
- 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"
