La plupart des bugs métier ne viennent pas d’une erreur de syntaxe, mais d’un malentendu : le code dit une chose, l’expert métier en pensait une autre. Le Domain-Driven Design, formalisé par Eric Evans en 2003, attaque ce problème à la racine en faisant du modèle métier le centre de gravité du logiciel. Dans ce tutoriel, vous allez modéliser le cœur d’une plateforme de gestion de commandes — l’objet commande et ses règles — en TypeScript, de façon à ce que le code soit aussi précis que le vocabulaire d’un gérant de boutique.
🎯 Ce que vous allez apprendre
- Découper un domaine en contextes bornés et établir un langage partagé avec le métier.
- Distinguer une entité d’un objet-valeur, et savoir quand utiliser l’un ou l’autre.
- Concevoir un agrégat qui protège ses propres règles d’intégrité.
- Émettre des événements de domaine sans coupler le métier à l’infrastructure.
- Définir un dépôt comme une interface, prête à recevoir n’importe quelle base de données.
🛠️ Ce que vous allez construire
Un module « Commandes » entièrement en mémoire, sans base de données ni framework web, qui sait créer une commande, y ajouter des lignes, en calculer le total, la confirmer et refuser tout état incohérent. À la fin, un test rejouable prouvera que les règles métier tiennent. C’est la fondation que les tutoriels suivants viendront brancher sur le monde extérieur.
Prérequis
- Node.js 20 ou plus récent et un éditeur TypeScript.
- TypeScript installé localement :
npm i -D typescript tsx. - Niveau intermédiaire. Test express : si vous savez écrire une classe TypeScript avec un constructeur privé et une méthode statique, vous êtes prêt. Sinon, révisez les classes ES2015 d’abord.
- ⏱️ Temps estimé : ~45 minutes.
Étape 1 — Cartographier les contextes bornés et le langage
Avant la moindre ligne de code, le DDD demande de répondre à une question : de quoi parle-t-on, exactement ? Dans une boutique, le mot « produit » n’a pas le même sens partout. Pour le catalogue, un produit a un nom, une description, des photos. Pour le stock, il n’a qu’une quantité et un emplacement. Pour la commande, il a un prix figé au moment de l’achat. Vouloir un seul objet « Produit » qui satisfait ces trois usages aboutit à une classe obèse que personne ne comprend.
La réponse du DDD est le contexte borné : on découpe le domaine en zones, chacune avec son modèle. Nous travaillerons ici dans le seul contexte « Commandes ». À l’intérieur, on fixe un langage omniprésent : une commande possède des lignes, chaque ligne référence un produit par son identifiant et fige un prix unitaire, et une commande passe par des statuts (brouillon, confirmée, expédiée, annulée). Ce vocabulaire, négocié avec le métier, doit se retrouver tel quel dans le code : pas de data, item ou obj vagues, mais Order, OrderLine, confirm().
Il est utile, à ce stade, de distinguer deux faces du DDD. Le DDD stratégique s’occupe des grandes frontières : combien de contextes bornés, comment ils communiquent, lesquels sont au cœur du métier et lesquels sont accessoires. C’est un travail de carte, souvent mené sur tableau blanc avec les experts. Le DDD tactique, lui, fournit les briques de code à l’intérieur d’un contexte : entités, objets-valeurs, agrégats, dépôts. Ce tutoriel est surtout tactique, mais il prend racine dans une décision stratégique — isoler « Commandes » du reste — sans laquelle aucune brique n’aurait de sens. Garder cette distinction en tête évite l’erreur courante qui consiste à appliquer les patrons tactiques partout, y compris dans des contextes secondaires où une simple manipulation de données suffirait.
Concrètement, une carte des contextes de notre boutique ressemblerait à ceci : « Catalogue » publie les produits et leurs prix de référence ; « Commandes » consomme ces informations mais fige son propre prix au moment de l’achat ; « Stock » suit les quantités ; « Livraison » planifie les expéditions. Chaque flèche entre deux contextes est un point d’attention : c’est là que se nouent les dépendances et que naissent, plus tard, les contrats entre services. Reconnaître ces frontières dès la modélisation, c’est se donner le découpage en microservices presque gratuitement le jour où il deviendra nécessaire.
✅ Point d’étape — Vous devez pouvoir lister, sur une feuille, les noms exacts des concepts du contexte et leurs relations. Si un terme vous semble ambigu, c’est qu’une discussion métier manque encore. N’écrivez pas de code tant que le vocabulaire n’est pas stable.
Étape 2 — Modéliser les objets-valeurs
Un objet-valeur est défini par ce qu’il contient, pas par une identité. Deux montants de 1500 centimes en euros sont interchangeables : ils n’ont pas d’« identifiant ». Les objets-valeurs sont immuables et portent leur propre logique. Commençons par le plus universel : l’argent. Manipuler des montants avec des nombres à virgule flottante est une source classique d’erreurs d’arrondi ; on travaille donc en centimes, sous forme d’entiers.
// money.ts
export class Money {
private constructor(
public readonly amount: number, // en centimes, toujours un entier
public readonly currency: string
) {}
static of(amount: number, currency = 'EUR'): Money {
if (!Number.isInteger(amount)) {
throw new Error('Le montant doit être un entier (centimes)');
}
if (amount < 0) {
throw new Error('Un montant ne peut pas être négatif');
}
return new Money(amount, currency);
}
add(other: Money): Money {
if (other.currency !== this.currency) {
throw new Error('Devises incompatibles');
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(Math.round(this.amount * factor), this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
Remarquez le constructeur privé doublé d’une fabrique of() : impossible de créer un Money invalide, car toute construction passe par les vérifications. C’est le premier réflexe DDD — un objet-valeur ne doit jamais pouvoir exister dans un état incohérent. La méthode add refuse d’additionner deux devises différentes : la règle métier vit dans l’objet, pas dans un service externe qui pourrait l’oublier.
Étape 3 — Construire l’entité et la ligne de commande
Contrairement à un objet-valeur, une entité possède une identité qui persiste dans le temps même si ses attributs changent. Une commande reste « la commande 4072 » qu’elle soit en brouillon ou expédiée. Définissons d’abord la ligne de commande, qui combine un produit, un prix unitaire figé et une quantité.
// order-line.ts
import { Money } from './money';
export class OrderLine {
constructor(
public readonly productId: string,
public readonly unitPrice: Money,
public readonly quantity: number
) {
if (quantity <= 0) {
throw new Error('La quantité doit être strictement positive');
}
}
get subtotal(): Money {
return this.unitPrice.multiply(this.quantity);
}
}
La ligne calcule elle-même son sous-total : la connaissance « sous-total = prix × quantité » appartient au domaine, pas à un composant d’affichage. Si demain la règle change (remise par palier, par exemple), un seul endroit est à modifier. C’est la cohésion forte évoquée dans le guide principal, appliquée à l’échelle d’un objet.
Étape 4 — L’agrégat racine et la protection des invariants
Voici le concept central. Un agrégat est une grappe d’objets qu’on traite comme une unité de cohérence, avec une racine par laquelle tout passe. La commande est la racine ; ses lignes ne sont accessibles qu’à travers elle. Cette discipline garantit qu’aucun code externe ne peut bidouiller une ligne en contournant les règles. La racine devient la gardienne des invariants — les vérités qui doivent toujours rester vraies, comme « une commande confirmée ne peut plus être modifiée » ou « on ne confirme pas une commande vide ».
// order.ts
import { Money } from './money';
import { OrderLine } from './order-line';
import { DomainEvent, OrderConfirmed } from './events';
type OrderStatus = 'DRAFT' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';
export class Order {
private _lines: OrderLine[] = [];
private _status: OrderStatus = 'DRAFT';
private _events: DomainEvent[] = [];
private constructor(
public readonly id: string,
public readonly customerId: string
) {}
static create(id: string, customerId: string): Order {
return new Order(id, customerId);
}
addLine(productId: string, unitPrice: Money, quantity: number): void {
if (this._status !== 'DRAFT') {
throw new Error('Seule une commande en brouillon peut être modifiée');
}
const existing = this._lines.find(l => l.productId === productId);
const newQuantity = existing ? existing.quantity + quantity : quantity;
this._lines = this._lines.filter(l => l.productId !== productId);
this._lines.push(new OrderLine(productId, unitPrice, newQuantity));
}
confirm(): void {
if (this._lines.length === 0) {
throw new Error('Impossible de confirmer une commande sans ligne');
}
if (this._status !== 'DRAFT') {
throw new Error('La commande est déjà confirmée');
}
this._status = 'CONFIRMED';
this._events.push(new OrderConfirmed(this.id, this.total));
}
get total(): Money {
return this._lines.reduce(
(acc, line) => acc.add(line.subtotal),
Money.of(0, 'EUR')
);
}
get status(): OrderStatus { return this._status; }
get lines(): readonly OrderLine[] { return this._lines; }
pullEvents(): DomainEvent[] {
const out = [...this._events];
this._events = [];
return out;
}
}
Tout l’art est dans les mots-clés private. Les listes de lignes et le statut sont inaccessibles de l’extérieur : on ne peut agir que par les méthodes addLine et confirm, qui vérifient les règles avant d’agir. Le getter lines renvoie un tableau en lecture seule (readonly), pour que personne ne modifie la collection dans le dos de l’agrégat. Ainsi, il est structurellement impossible d’obtenir une commande confirmée vide : la règle n’est pas une convention, c’est une garantie du type.
Une question revient sans cesse : jusqu’où étendre un agrégat ? La tentation est d’y inclure tout ce qui semble lié — le client, ses adresses, l’historique de ses commandes. C’est une erreur. La règle, popularisée par Vaughn Vernon, tient en trois mots : concevez de petits agrégats. Un agrégat ne devrait contenir que ce qui doit rester cohérent dans la même transaction. Tout le reste se référence par identifiant. Notre commande référence ainsi le client par customerId et les produits par productId — de simples chaînes — au lieu de charger des objets entiers. Deux agrégats différents (une commande et le stock d’un produit) ne se mettent jamais à jour dans la même transaction : ils se coordonnent par événements, en acceptant une cohérence à terme. Cette règle a une conséquence directe et heureuse : de petits agrégats donnent des transactions courtes, moins de contention en base, et un découpage en services bien plus naturel.
✅ Point d’étape — Demandez-vous : « depuis l’extérieur de cette classe, puis-je mettre la commande dans un état interdit ? » Si la réponse est non pour chaque invariant listé à l’étape 1, l’agrégat est correct. Si oui, c’est qu’une propriété est exposée ou qu’une vérification manque.
Étape 5 — Émettre des événements de domaine
Quand un fait métier important se produit — une commande est confirmée — le reste du système doit pouvoir réagir : décrémenter le stock, préparer la facture. Mais l’agrégat ne doit surtout pas appeler directement ces services : ce serait recréer le couplage que toute cette architecture cherche à éviter. La solution est l’événement de domaine : la commande enregistre simplement « je viens d’être confirmée », et un mécanisme externe se chargera de publier ce fait.
// events.ts
import { Money } from './money';
export interface DomainEvent {
readonly occurredAt: Date;
}
export class OrderConfirmed implements DomainEvent {
readonly occurredAt = new Date();
constructor(
public readonly orderId: string,
public readonly total: Money
) {}
}
L’agrégat accumule ses événements dans _events et les expose via pullEvents(), qui les retourne et vide la liste. Ce détail compte : la couche applicative récupérera ces événements juste après avoir sauvegardé la commande, puis les publiera sur une messagerie. Le domaine, lui, ne connaît ni RabbitMQ ni HTTP — il produit des faits, point. Ce découplage est précisément ce qu’exploitent les tutoriels sur l’architecture orientée événements et le CQRS avec Event Sourcing.
Étape 6 — Le dépôt comme interface
Reste à ranger et retrouver les commandes. Le DDD introduit le dépôt (repository) : une abstraction qui se comporte comme une collection d’agrégats, sans révéler s’ils sont stockés en mémoire, dans PostgreSQL ou ailleurs. On le déclare comme une simple interface, dans le domaine.
// order-repository.ts
import { Order } from './order';
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
Le domaine dépend de cette interface, jamais d’une implémentation concrète. C’est exactement le principe d’inversion des dépendances : le métier définit le contrat, l’infrastructure s’y plie. La mise en œuvre concrète de ce contrat — avec une vraie base de données branchée par un adaptateur — fait l’objet du tutoriel sur l’architecture hexagonale, qui prolonge directement ce que nous venons de poser.
Étape 7 — Vérification : prouver que les règles tiennent
Le grand avantage d’un domaine sans dépendance technique, c’est qu’il se teste instantanément, sans base ni serveur. Écrivons un petit scénario exécutable qui rejoue le parcours d’une commande et vérifie les invariants.
// check.ts — exécuter avec : npx tsx check.ts
import { Order } from './order';
import { Money } from './money';
import { strict as assert } from 'node:assert';
const order = Order.create('ord-1', 'cust-42');
// Une commande vide ne se confirme pas
assert.throws(() => order.confirm(), /sans ligne/);
// Ajout de deux lignes, dont une qui cumule la même référence
order.addLine('prod-A', Money.of(1500), 2);
order.addLine('prod-A', Money.of(1500), 1); // doit cumuler à 3
order.addLine('prod-B', Money.of(800), 1);
assert.equal(order.total.amount, 1500 * 3 + 800); // 5300
order.confirm();
assert.equal(order.status, 'CONFIRMED');
// Une commande confirmée ne se modifie plus
assert.throws(() => order.addLine('prod-C', Money.of(100), 1), /brouillon/);
// L'événement de confirmation est disponible une seule fois
assert.equal(order.pullEvents().length, 1);
assert.equal(order.pullEvents().length, 0);
console.log('Tous les invariants sont respectés ✓');
En lançant npx tsx check.ts, vous devez voir s’afficher Tous les invariants sont respectés ✓. Si une assertion échoue, le script s’arrête avec le détail. Ce test ne mesure pas une couverture de code ; il vérifie des règles métier en langage du domaine, et c’est ce qui le rend précieux. Vous pourriez le confier à un expert non-développeur : il comprendrait chaque ligne.
🐞 Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Totaux faux de quelques centimes | Montants stockés en flottants (19.99) |
Travailler en centimes entiers ; ne convertir qu’à l’affichage |
| Une commande confirmée se laisse modifier | Propriétés publiques ou tableau mutable exposé | Tout en private, getters en readonly, agir uniquement par méthodes |
| Le domaine importe une classe de base de données | Dépendance dans le mauvais sens | Déclarer un dépôt en interface ; l’implémentation vit hors du domaine |
| Un « God object » Produit partout | Refus de découper en contextes bornés | Un modèle par contexte ; le même mot peut avoir deux définitions |
| Les événements sont rejoués deux fois | pullEvents ne vide pas la liste |
Retourner une copie et réinitialiser, comme ci-dessus |
✅ Récapitulatif
Vous avez modélisé un domaine sans écrire une ligne d’infrastructure. Vous savez désormais fixer un langage omniprésent dans un contexte borné, distinguer entités et objets-valeurs, concevoir un agrégat qui protège ses invariants par construction, produire des événements de domaine découplés, et déclarer un dépôt sous forme d’interface. Ce cœur métier est volontairement ennuyeux du point de vue technique — et c’est sa plus grande qualité : il survivra au changement de framework, de base ou de protocole.
🧾 Aide-mémoire
| Brique DDD | Rôle |
|---|---|
| Objet-valeur | Défini par ses attributs, immuable (ex. Money) |
| Entité | Identité stable dans le temps (ex. Order) |
| Agrégat | Grappe cohérente avec une racine gardienne des invariants |
| Événement de domaine | Fait métier publié, sans appel direct aux autres services |
| Dépôt | Interface de persistance, indépendante de la base réelle |
| Contexte borné | Zone avec son propre modèle et son propre langage |
💪 À vous de jouer
Ajoutez une règle métier : une commande ne peut pas dépasser 50 articles au total, toutes lignes confondues. Faites échouer addLine proprement si la limite est franchie, et complétez le test de vérification.
Voir une solution
// dans addLine, avant d'ajouter la ligne :
const currentCount = this._lines.reduce((n, l) => n + l.quantity, 0);
if (currentCount + quantity > 50) {
throw new Error('Une commande ne peut pas dépasser 50 articles');
}
Puis dans check.ts : ajouter une ligne de 49, puis tenter d’en ajouter 2, et vérifier avec assert.throws(..., /50 articles/).
Tutoriels liés
- Architecture hexagonale avec NestJS — brancher ce domaine sur une vraie base et une API, sans le polluer.
- CQRS et Event Sourcing pas à pas — stocker la suite des événements de la commande plutôt que son état.
Pour aller plus loin
- 🔝 Revenir à la vue d’ensemble : Architecture logicielle moderne.
- Eric Evans, Domain-Driven Design (2003), et Vaughn Vernon, Implementing Domain-Driven Design (2013), pour approfondir les agrégats.
Questions fréquentes
Un objet-valeur peut-il contenir un autre objet-valeur ?
Oui, et c’est courant. Une adresse (objet-valeur) peut contenir un code postal lui-même validé comme objet-valeur. L’immutabilité se propage naturellement.
Faut-il un agrégat par table de base de données ?
Non. L’agrégat est une frontière de cohérence métier, pas un reflet du schéma SQL. Un agrégat peut couvrir plusieurs tables, et toutes les tables ne sont pas des agrégats.
Où placer la logique qui concerne plusieurs agrégats ?
Dans un service de domaine : une classe sans état qui orchestre plusieurs agrégats lorsque la règle ne tient dans aucun d’eux. Mais cherchez toujours d’abord à loger la règle dans un agrégat existant.
Mots-clés : domain-driven design, contexte borné, agrégat, objet-valeur, entité, événement de domaine, dépôt, TypeScript.