// Domain-Driven Design — Eric Evans · 2003

Domain-Driven
Design

Modéliser le logiciel à l’image du métier. Le code parle le même langage que l’expert, les frontières sont explicites, et la logique métier vit au cœur de l’architecture — indépendante de toute technologie.

Bounded ContextAggregateDomain EventsEric EvansTactical + Strategic
01

La métaphore fondatrice

🏙️

La Ville et ses Quartiers

Imagine que tu construis une grande ville. Chaque quartier a ses propres règles, son propre langage, ses propres habitants. Le quartier financier parle de transactions et d’actifs. Le quartier médical parle de patients et d’ordonnances. Ils ne se comprennent pas directement — il faut des traducteurs aux frontières.

C’est exactement le DDD : modéliser le logiciel à l’image du métier, avec des frontières claires entre les domaines, un langage commun à chaque quartier, et une logique métier qui dicte l’architecture — jamais l’inverse.

Le Domain-Driven Design, introduit par Eric Evans en 2003, repose sur une idée simple mais radicale : c’est le domaine métier qui doit guider chaque décision architecturale. Avant de choisir une base de données, un framework ou un pattern, on modélise la réalité du métier avec les experts — et ce modèle devient le code.

Le DDD se divise en deux niveaux : la conception stratégique, qui organise les grandes frontières du système, et la conception tactique, qui fournit les briques de construction à l’intérieur de ces frontières.

02

Vue d’ensemble — Les deux niveaux

Conception Stratégique — Comment découper le système en domaines cohérents et faire coexister plusieurs modèles.

🗺️
Bounded Context
Contexte Delimité
Frontière explicite dans laquelle un modèle de domaine a une signification précise et cohérente. Un même mot peut signifier des choses différentes dans deux Bounded Contexts distincts.
FrontièreAutonomieCohérence
« Client » dans le contexte Commande ≠ « Client » dans le contexte CRM.
🗣️
Ubiquitous Language
Langage Ubiquitaire
Vocabulaire commun entre développeurs et experts métier, utilisé partout : dans les réunions, le code, les tests, la documentation. Si le code dit passer_commande(), le métier dit aussi « passer commande ».
CommunicationAlignementCohérence
Le code est la documentation. Le vocabulaire métier EST le code.
🧭
Context Map
Carte des Contextes
Représentation des relations entre Bounded Contexts. Définit comment ils communiquent : Shared Kernel, Customer/Supplier, Anti-Corruption Layer, Published Language…
ACLShared KernelRelations

Conception Tactique — Les briques de construction à l’intérieur d’un Bounded Context.

🏰
Aggregate
Frontière de Cohérence
Grappe d’Entities et de Value Objects traitée comme une unité atomique. Un seul point d’entrée (la Racine), une seule frontière transactionnelle. Les invariants métier sont garantis à l’intérieur.
RootInvariantsTransaction
Le gardien de la cohérence métier.
🪪
Entity
Objet avec Identité
Objet défini par son identité unique, pas par ses attributs. Deux Entities avec les mêmes données sont distinctes si leurs IDs diffèrent. Mutable, avec un cycle de vie propre.
ID uniqueMutableCycle de vie
💎
Value Object
Objet sans Identité
Objet défini entièrement par ses attributs. Immuable, sans identité propre. Deux Value Objects avec les mêmes données sont égaux et interchangeables.
ImmutableÉgalité par valeurSans ID
📢
Domain Event
Événement Domaine
Quelque chose qui s’est passé dans le domaine. Nommé au passé, immuable, daté. Déclenche des réactions dans d’autres Aggregates ou Bounded Contexts sans couplage direct.
CommandeConfirméeDécouplagePassé
🔧
Domain Service
Service Domaine
Opération métier sans état qui ne se rattache naturellement à aucune Entity ou Value Object. Contient de la logique métier qui implique plusieurs Aggregates ou des calculs transverses.
Sans étatInter-entités
🗄️
Repository
Abstraction de Persistance
Interface définie dans le Domain qui donne l’illusion d’une collection en mémoire. L’implémentation SQL/NoSQL vit dans l’Infrastructure — le Domain ne la connaît jamais.
InterfaceDIPPersistance
« La DB est un détail. » — Uncle Bob
03

Architecture en couches DDD

Le DDD s’organise en quatre couches dont la règle d’or est absolue : les dépendances ne pointent que vers l’intérieur. Le Domain ne dépend de rien. L’Infrastructure dépend du Domain — jamais l’inverse.

Présentation
Presentation Layer
Reçoit les requêtes, délègue à l’Application Layer. Ne contient aucune logique métier.
REST ControllersGraphQL ResolversCLI AdaptersDTOs d'entrée
Application
Application Layer — Use Cases
Orchestre sans logique métier. Coordonne les appels aux Repositories, dispatche les Domain Events. Définit les transactions.
PasserCommandeUseCaseCommand HandlersQuery HandlersCQRS
Domain ★
Domain Layer — Le Cœur Métier
Indépendant de tout framework. Contient toute l’intelligence métier. Ne dépend de RIEN d’autre. C’est la règle d’or.
EntitiesValue ObjectsAggregatesDomain EventsDomain ServicesRepository (interfaces)Specifications
Infrastructure
Infrastructure Layer
Implémentations techniques. Dépend du Domain via inversion de dépendance (DIP). Remplaçable sans toucher au Domain.
PostgresCommandeRepoRabbitMQ EventBusStripeServiceSendGridMailer
La Règle d’Or des dépendances :
Présentation → Application → Domain ← Infrastructure
Le Domain est le seul à ne dépendre de rien. Si votre Domain importe quoi que ce soit de NestJS, TypeORM ou Express — la règle est violée.
04

Structure de fichiers recommandée

src/ ├── commande/ ← Bounded Context │ ├── domain/ ← Noyau métier (0 dépendance externe) │ │ ├── Commande.ts ← Aggregate Root │ │ ├── LigneCommande.ts ← Entity interne │ │ ├── Montant.ts ← Value Object │ │ ├── Adresse.ts ← Value Object │ │ ├── CommandeId.ts ← Value Object (ID typé) │ │ ├── CommandeConfirmeeEvent.ts ← Domain Event │ │ ├── CommandeRepository.ts ← Interface (port de sortie) │ │ └── PrixCalculateurService.ts← Domain Service │ ├── application/ ← Orchestration, pas de logique métier │ │ ├── PasserCommandeUseCase.ts │ │ ├── AnnulerCommandeUseCase.ts │ │ └── GetCommandeQuery.ts │ ├── infrastructure/ ← Implémentations techniques │ │ ├── PostgresCommandeRepository.ts ← implements CommandeRepository │ │ └── CommandeMapper.ts ← SQL row ↔ Domain object │ └── presentation/ ← HTTP, CLI, GraphQL │ └── CommandeController.ts ├── paiement/ ← Autre Bounded Context └── livraison/ ← Autre Bounded Context
05

L’Aggregate — Le cœur battant du DDD

🏰

La Métaphore du Château Fort

Un Aggregate, c’est un château fort médiéval. Il y a une porte principale (la Racine — Aggregate Root), des remparts qui définissent la frontière de cohérence, et à l’intérieur vivent des Entities et des Value Objects. Pour parler au seigneur, aux chevaliers ou aux serviteurs — tu passes obligatoirement par la porte principale. Jamais par les remparts. Et une seule règle absolue : une transaction ne franchit pas les murs d’un seul château.

AGGREGATE : Commande — Frontière de cohérence
RootCommandeid: CommandeId · statut: StatutCommande
EntityLigneCommande
EntityRemise
VOMontant
VOAdresse
VOQuantite

Les 5 règles d’or de l’Aggregate

Règle 1
Accès par la Racine uniquement
Pour modifier quoi que ce soit dans l’Aggregate, on passe obligatoirement par l’Aggregate Root. Aucun accès direct aux Entities ou Value Objects internes depuis l’extérieur.
Règle 2
1 transaction = 1 seul Aggregate
Une transaction ne modifie qu’un seul Aggregate à la fois. Si deux Aggregates doivent changer, on utilise des Domain Events et la cohérence éventuelle.
Règle 3
Référence externe = ID uniquement
Un Aggregate ne référence jamais un autre Aggregate par objet. Seulement par son ID (Value Object typé). Cela garantit l’indépendance et la performance.
Règle 4
Les invariants sont garantis par la Racine
C’est la Racine qui vérifie toutes les règles métier avant chaque mutation. Si un invariant est violé, la Racine lève une exception — l’état reste cohérent.
Règle 5
Préférer les petits Aggregates
Plus l’Aggregate est petit, plus il est performant (moins de données chargées) et moins il génère de conflits de concurrence. Un Aggregate idéal a 1 à 3 Entities.
Règle 6
Communication inter-Aggregates via Events
Deux Aggregates ne s’appellent jamais directement. L’Aggregate A émet un Domain Event, un handler réagit et modifie l’Aggregate B dans une transaction séparée.
Factory
création
crée
EN_ATTENTE
new
ajouterProduit()
EN_ATTENTE
avec lignes
confirmer()
CONFIRMEE
↳ event émis
expedier()
EXPEDIEE
terminal
annuler()
ANNULEE
terminal
domain/Commande.ts — Aggregate Root (OOP)
type StatutCommande = 'EN_ATTENTE' | 'CONFIRMEE' | 'EXPEDIEE' | 'ANNULEE'; class Commande { private readonly _lignes: LigneCommande[] = []; private _statut: StatutCommande = 'EN_ATTENTE'; private readonly _evenements: DomainEvent[] = []; private constructor( readonly id: CommandeId, private readonly _clientId: string, ) {} // Factory method — seul point de création static creer(clientId: string): Commande { if (!clientId) throw new Error('Client obligatoire'); return new Commande(CommandeId.nouveau(), clientId); } // Comportement métier — invariants vérifiés ici ajouterProduit(produitId: ProduitId, quantite: number, prix: Montant): void { this.verifierEtat('EN_ATTENTE', 'ajouterProduit'); if (this._lignes.length >= 10) throw new Error('Maximum 10 produits par commande'); if (this._lignes.some(l => l.produitId.estEgalA(produitId))) throw new Error('Produit déjà présent'); this._lignes.push(LigneCommande.creer(produitId, quantite, prix)); this._evenements.push(new ProduitAjouteEvent(this.id, produitId)); } confirmer(): void { this.verifierEtat('EN_ATTENTE', 'confirmer'); if (this._lignes.length === 0) throw new Error('Commande vide'); this._statut = 'CONFIRMEE'; this._evenements.push( new CommandeConfirmeeEvent(this.id, this._clientId, this.calculerTotal()) ); } calculerTotal(): Montant { return this._lignes.reduce( (acc, ligne) => acc.ajouter(ligne.sousTotal), Montant.de(0) ); } // Le Use Case collecte et dispatche les events APRÈS persistance collecterEvenements(): DomainEvent[] { const evts = [...this._evenements]; this._evenements.length = 0; return evts; } private verifierEtat(attendu: StatutCommande, op: string): void { if (this._statut !== attendu) throw new Error(`"${op}" impossible — statut: ${this._statut}`); } }
domain/commande.fp.ts — Approche Fonctionnelle (immutabilité totale)
// Types algébriques — l'état est un type pur, sans classe type Commande = Readonly<{ id: string; clientId: string; lignes: readonly LigneCommande[]; statut: StatutCommande; }>; // Résultat : nouvel état + événements émis — aucun effet de bord type CommandeResult = { commande: Commande; events: DomainEvent[] }; const ajouterProduit = ( commande: Commande, produitId: string, quantite: number, prix: number ): CommandeResult => { if (commande.statut !== 'EN_ATTENTE') throw new Error('Non modifiable'); if (commande.lignes.length >= 10) throw new Error('Max 10 lignes'); return { // Spread = nouvel objet, l'original est intact commande: { ...commande, lignes: [...commande.lignes, { produitId, quantite, prix }] }, events: [{ type: 'PRODUIT_AJOUTE', commandeId: commande.id, produitId }], }; }; const confirmerCommande = (commande: Commande): CommandeResult => { if (commande.lignes.length === 0) throw new Error('Commande vide'); return { commande: { ...commande, statut: 'CONFIRMEE' }, events: [{ type: 'COMMANDE_CONFIRMEE', commandeId: commande.id }], }; };
06

Entity vs Value Object

💰

La Métaphore des Billets de Banque

Entity → Pense à une personne. Si deux jumeaux ont exactement le même nom, la même apparence, la même date de naissance — ce sont quand même deux personnes distinctes. Ce qui compte, c’est leur identité, pas leurs attributs.

Value Object → Pense à un billet de 50€. Si tu échanges ton billet contre un autre billet de 50€ — tu t’en fous. Ils sont interchangeables. Ce qui compte, c’est la valeur, pas quel billet physique tu tiens.

💎 Value Object
Égalité par valeur — deux VO avec les mêmes données sont égaux
Immuable — on ne modifie pas un VO, on en crée un nouveau
Pas d’identité propre — aucun ID
Pas de cycle de vie — il naît et meurt avec son Entity
Partage sécurisé — on peut le passer partout sans risque
🪪 Entity
Égalité par identité — seul l’ID compte pour la comparaison
Mutable — ses attributs changent au cours du temps
ID unique obligatoire — c’est sa raison d’être
Cycle de vie propre — création, modification, archivage
Partage avec précaution — risque de mutation extérieure
domain/Montant.ts — Value Object
// Immuable. Égalité par valeur. Pas d'identité. class Montant { private constructor( private readonly _valeur: number, private readonly _devise: string = 'EUR', ) { if (_valeur < 0) throw new Error('Montant négatif interdit'); } // Factory method — seul point de création static de(valeur: number, devise = 'EUR'): Montant { return new Montant(valeur, devise); } // Opérations immuables — retournent un NOUVEAU Montant ajouter(autre: Montant): Montant { if (this._devise !== autre._devise) throw new Error('Devises différentes'); return Montant.de(this._valeur + autre._valeur, this._devise); } // Égalité par VALEUR — pas par référence estEgalA(autre: Montant): boolean { return this._valeur === autre._valeur && this._devise === autre._devise; } } // Test : deux Montant identiques sont égaux const a = Montant.de(50, 'EUR'); const b = Montant.de(50, 'EUR'); a.estEgalA(b); // true → même VALEUR = égaux a === b; // false → références différentes, mais on s'en fiche
domain/Client.ts — Entity
// Identité unique. Mutable. Cycle de vie propre. class Client { private _email: Email; // ← Value Object private _adresse: Adresse; // ← Value Object constructor( readonly id: ClientId, // ← Value Object (ID typé) email: Email, adresse: Adresse, ) { this._email = email; this._adresse = adresse; } // Comportement métier : on change l'email changerEmail(nouvelEmail: Email): void { if (this._email.estEgalA(nouvelEmail)) throw new Error('Même email'); this._email = nouvelEmail; // ← nouveau VO, pas de mutation du VO } // Égalité par IDENTITÉ — seul l'ID compte estEgalA(autre: Client): boolean { return this.id.estEgalA(autre.id); } } // Test : deux Clients avec le même ID sont le même Client const c1 = new Client(id, email1, adresse1); const c2 = new Client(id, email2, adresse2); c1.estEgalA(c2); // true → même ID = même Entity
Le cas piège — L’Adresse : VO ou Entity ? Un même concept peut être Value Object dans un contexte et Entity dans un autre. L’Adresse de livraison d’une commande est un VO : elle est capturée au moment de la commande et ne doit pas changer si le client déménage. L’Adresse d’un entrepôt logistique est une Entity : on veut tracer ses modifications au fil du temps et la retrouver par son identité. C’est le contexte métier qui décide, jamais la technique.
1Se soucie-t-on de QUELLE instance c’est ?Non → VOOui → Entity
2Si je remplace cet objet par un autre avec les mêmes données, est-ce que ça change quelque chose ?Non → VO (billet)Oui → Entity (passeport)
3Cet objet a-t-il une vie propre — on le crée, on le suit, on l’archive ?Non → VOOui → Entity
4L’objet change-t-il au cours du temps tout en restant le même objet ?Non → VOOui → Entity
5A-t-on besoin de le retrouver par son identité en base de données ?Non → VOOui → Entity
La règle pratique : La grande majorité des attributs d’une Entity devraient être des Value Objects. Client est une Entity — mais son email, son adresse, son telephone sont des Value Objects. L’Entity est le squelette — les Value Objects sont la chair.
07

Domain Events — Le découplage par les faits

Un Domain Event représente quelque chose qui s’est passé dans le domaine. Nommé au passé, immuable, daté. Il permet à un Aggregate de déclencher des réactions dans d’autres Aggregates — ou d’autres Bounded Contexts — sans couplage direct.

❌ Sans Domain Events — Couplage fort
Le Use Case appelle directement le service de stock, le mailer, le CRM…
Ajouter un effet de bord = modifier le Use Case existant
Impossible de tester la logique métier isolément
Un échec dans le mailer fait échouer toute la commande
✓ Avec Domain Events — Découplage
L’Aggregate émet un event — il ne sait pas qui écoute
Ajouter un effet de bord = ajouter un handler, sans toucher au code existant
Chaque handler est testable indépendamment
Un échec dans le mailer ne bloque pas la confirmation
domain/events/CommandeConfirmeeEvent.ts
// Interface commune à tous les Domain Events interface DomainEvent { readonly occurredOn: Date; readonly eventType: string; } // Event concret — immuable, nommé au passé class CommandeConfirmeeEvent implements DomainEvent { readonly occurredOn = new Date(); readonly eventType = 'COMMANDE_CONFIRMEE'; constructor( readonly commandeId: CommandeId, readonly clientId: string, readonly montantTotal: Montant, ) {} } // Handlers — chacun réagit indépendamment class ReduireStockHandler { async handle(event: CommandeConfirmeeEvent): Promise<void> { // Réduire le stock pour chaque produit de la commande await this.stockService.reduire(event.commandeId); } } class EnvoyerConfirmationHandler { async handle(event: CommandeConfirmeeEvent): Promise<void> { // Envoyer un email au client — si ça échoue, la commande reste confirmée await this.mailer.envoyer(event.clientId, 'Votre commande est confirmée'); } }
08

Repository & Application Layer

Le Repository est une abstraction qui donne au domaine l’illusion d’une collection en mémoire. L’interface vit dans le Domain, l’implémentation SQL dans l’Infrastructure. L’Application Layer orchestre — sans jamais contenir de logique métier.

domain/CommandeRepository.ts — Interface (Domain)
// Aucune dépendance technique — pur Domain interface CommandeRepository { findById(id: CommandeId): Promise<Commande | null>; save(commande: Commande): Promise<void>; findByClientId(clientId: string): Promise<Commande[]>; }
application/PasserCommandeUseCase.ts — Orchestration (Application)
// Application Layer — orchestre, ne décide RIEN du métier class PasserCommandeUseCase { constructor( private readonly commandeRepo: CommandeRepository, // ← interface private readonly eventBus: EventBus, // ← interface ) {} async executer(dto: PasserCommandeDTO): Promise<void> { // 1. Créer l'Aggregate via sa Factory const commande = Commande.creer(dto.clientId); // 2. Déléguer le comportement au Domain for (const ligne of dto.lignes) { commande.ajouterProduit( new ProduitId(ligne.produitId), ligne.quantite, Montant.de(ligne.prix), ); } commande.confirmer(); // 3. Persister via le Repository (interface) await this.commandeRepo.save(commande); // 4. Dispatcher les Domain Events APRÈS persistance for (const event of commande.collecterEvenements()) { await this.eventBus.publish(event); } } }
09

Les pièges classiques à éviter

🐘
Piège 1
L’Aggregate trop gros
Un Aggregate « Commande » qui contient le Client, les Produits, l’Historique, les Factures… Trop d’Entities = trop de conflits de concurrence, trop de données chargées, trop de couplage.
PerformanceConcurrenceDécoupage
🔗
Piège 2
Référence directe entre Aggregates
Stocker une référence objet vers un autre Aggregate au lieu de son ID. Résultat : couplage fort, problèmes de lazy loading, transactions qui débordent.
ID uniquementDécouplagePerformance
🧠
Piège 3
Logique métier dans le Use Case
Le Use Case contient des if métier, des calculs de prix, des validations complexes. Il devrait seulement orchestrer : créer, déléguer, persister, dispatcher.
OrchestrationDélégationSéparation
🚪
Piège 4
Exposer les collections internes
Retourner un tableau mutable depuis l’Aggregate. L’extérieur peut alors modifier la collection sans passer par la Racine — les invariants sont contournés.
EncapsulationInvariantsReadonly
📭
Piège 5
Oublier les Domain Events
Appeler directement les services depuis le Use Case au lieu d’émettre des events. Résultat : couplage temporel, impossible d’ajouter un handler sans modifier le code existant.
Open/ClosedDécouplageExtensibilité
🏗️
Piège 6
Confondre Entity TypeORM et Entity Domain
L’Entity TypeORM est un modèle de persistance avec décorateurs @Entity @Column. L’Entity Domain est un objet pur sans dépendance technique. Ce sont deux choses complètement différentes.
MappingSéparationDomain pur
Signal d’alerte universelle : Si votre test d’un use case nécessite de lancer une base de données, de démarrer NestJS ou d’appeler Stripe — la Dependency Rule est violée quelque part. Le domaine doit être testable sans aucune infrastructure.
10

Récapitulatif — Les 7 piliers

ConceptRôleRègle clé
Ubiquitous LanguageLangage commun dévs ↔ expertsLe code utilise les mêmes termes que le métier
Bounded ContextFrontière explicite du modèleUn mot = une définition dans un contexte
EntityObjet avec identité uniqueÉgalité par ID, cycle de vie propre
Value ObjectObjet sans identité, immuableÉgalité par valeur, interchangeable
AggregateFrontière de cohérence transactionnelleAccès par la Racine, 1 tx = 1 Aggregate
Domain EventFait passé dans le domaineImmuable, daté, déclenche des réactions
RepositoryAbstraction de persistanceInterface dans Domain, impl dans Infra
📖

La synthèse de Robert C. Martin

Le Domain, c’est le cœur de la maison — il ne dépend pas des murs, de la plomberie ou de l’électricité. Si vous changez de plombier (base de données), le cœur ne bouge pas. C’est le principe de Clean Architecture appliqué au DDD : l’infrastructure est un détail remplaçable. Le métier, lui, est éternel.

Aggregate / Root
Entity
Value Object
Domain Event
Repository / Interface
Infrastructure
Application / Use Cases