Domain-Driven Design
Chapitre 10 / 10 · 12 min de lecture

Domain Events, CQRS & Event Sourcing

Le DDD tactique moderne : capturer ce qui se passe dans le domaine, séparer lecture et écriture, rejouer l'histoire.

Le livre d'Eric Evans de 2003 nous a donné les briques tactiques fondamentales : entités, objets-valeurs, agrégats, dépôts, services. Mais il s'arrête à la lisière d'une idée qui allait transformer la pratique du DDD dans la décennie suivante : et si, au lieu de ne stocker que l'état de nos objets, nous capturions aussi les faits métier qui les ont fait changer ? C'est tout l'objet de ce chapitre.

Trois patrons dominent ce DDD tactique « moderne » : l'événement de domaine (Domain Event), la séparation commande/requête (CQRS) et le stockage par événements (Event Sourcing). Ils ne sont pas dans le livre de 2003 — Evans n'y mentionne ni CQRS, ni Event Sourcing, ni l'Event Storming. Ils ont été popularisés ensuite par Greg Young, Udi Dahan, Alberto Brandolini et surtout Vaughn Vernon, dont les ouvrages Implementing DDD (2013) et DDD Distilled (2016) servent de fil conducteur ici. Nous les présentons comme un prolongement du DDD d'Evans, fidèle à sa philosophie, traduit en TypeScript idiomatique dans un univers logistique, e-commerce et bancaire.

Note

Evans a ajouté un événement de domaine dans une réédition tardive de son glossaire, mais la brique tactique telle qu'on l'utilise aujourd'hui — un objet métier publié et consommé — est une contribution de la communauté postérieure à 2003.

L'événement de domaine

Un événement de domaine (Domain Event) est l'enregistrement d'un fait métier significatif qui s'est produit dans un contexte délimité (Bounded Context). Le temps employé n'est pas anodin : un événement décrit le passé, quelque chose d'accompli et indéniable. On ne peut pas refuser un fait : il a eu lieu.

Cette nuance grammaticale a une conséquence de conception majeure. Une commande (Command) — la forme objet d'une requête d'action — peut être rejetée : on refuse PasserCommande si le stock manque ou si la carte est déclinée. Un événement, lui, est une part d'histoire et ne peut logiquement pas être nié. D'où la règle : événements au passé, commandes à l'impératif.

AspectCommande (Command)Événement (Domain Event)
Temps grammaticalImpératif présentParticipe passé
ExemplePasserCommandeCommandePassée
Peut être refusé ?Oui (validation, stock…)Non, c'est un fait
IntentionDemander un changementNotifier un changement advenu
DestinataireUn agrégat précisTout abonné intéressé

Nommer dans le langage omniprésent

Les noms d'événements forment un pont entre ce qui se passe dans votre modèle et le monde extérieur. Ils doivent donc puiser dans le langage omniprésent (Ubiquitous Language) du domaine, et énoncer clairement et concisément ce qui s'est produit.

// ✅ Des verbes au passé, dans le langage du métier.
// CommandePassée, PaiementRefusé, StockÉpuisé,
// ColisExpédié, AdresseModifiée, RemboursementÉmis

Attention

Méfiez-vous des noms trop vagues comme CommandeMiseÀJour. Que s'est-il vraiment passé ? L'abonné devrait comparer l'ancien et le nouvel état pour le deviner. Préférez des événements précis — AdresseLivraisonModifiée, CommandeAnnulée — qui portent leur sens dans leur nom.

Définir un événement de domaine en TypeScript

Vernon recommande une interface minimale que tout événement implémente, ne serait-ce que pour véhiculer l'instant où le fait s'est produit (occurredOn). Voici une base réaliste pour un domaine de paiement :

interface DomainEvent {
	readonly occurredOn: Date;
}

// L'événement porte les données du fait, et rien de plus.
class PaiementRefusé implements DomainEvent {
	readonly occurredOn = new Date();

	constructor(
		readonly commandeId: string,
		readonly montant: Money,
		readonly motif: "fonds_insuffisants" | "carte_expirée",
	) {}
}

Quelles propriétés mettre dans un événement ? La réponse de Vernon est limpide : celles de la commande qui l'a causé. Si PasserCommande portait un clientId, un panier et une adresse, alors CommandePassée doit les contenir pour informer pleinement et fidèlement ses abonnés.

Astuce

On peut enrichir un événement de quelques données supplémentaires pour épargner aux consommateurs une requête vers votre contexte. Mais n'y déversez pas tout l'état de l'agrégat : un événement gavé de données perd son sens, et l'on ne sait plus ce qui s'est passé.

Un agrégat qui émet des événements

C'est l'agrégat (Aggregate) qui produit les événements : il exécute une commande, change d'état, puis enregistre le fait correspondant. L'agrégat ne publie pas lui-même sur un bus — il accumule ses événements et les expose ; c'est l'application qui les publiera après avoir confirmé la transaction.

// ❌ Avant : l'agrégat change d'état en silence.
class Commande {
	private statut: Statut = "en_attente";

	confirmerPaiement(): void {
		this.statut = "payée";
		// ... mais personne au dehors ne sait que c'est arrivé.
	}
}
// ✅ Après : l'agrégat enregistre ce qui s'est produit.
class Commande {
	private statut: Statut = "en_attente";
	private événements: DomainEvent[] = [];

	confirmerPaiement(transactionId: string): void {
		if (this.statut !== "en_attente") {
			throw new Error("Commande déjà traitée");
		}
		this.statut = "payée";
		this.enregistrer(
			new PaiementConfirmé(this.id, transactionId),
		);
	}

	private enregistrer(événement: DomainEvent): void {
		this.événements.push(événement);
	}

	tirerÉvénements(): DomainEvent[] {
		const sortants = [...this.événements];
		this.événements = [];
		return sortants;
	}
}

Sauver l'agrégat et l'événement ensemble

Point crucial souligné par Vernon : l'agrégat modifié et ses événements doivent être persistés dans la même transaction. Sinon, on risque de modifier l'état sans jamais notifier les abonnés (ou l'inverse). Avec un ORM classique, on écrit l'agrégat dans sa table et l'événement dans une table d'événements, puis on valide une seule transaction.

async function exécuterPaiement(
	commandeId: string,
	transactionId: string,
): Promise<void> {
	await uow.transaction(async (session) => {
		const commande = await dépôt.parId(commandeId, session);
		commande.confirmerPaiement(transactionId);

		await dépôt.sauver(commande, session); // état
		await eventStore.ajouter( // faits
			commande.tirerÉvénements(),
			session,
		);
	});
	// La publication vers les abonnés vient APRÈS le commit.
}

Découpler les agrégats et les contextes

Pourquoi se donner cette peine ? Parce que les événements sont le moyen privilégié de découpler les agrégats entre eux, et les contextes délimités les uns des autres.

Evans posait déjà une règle d'or : une transaction ne modifie qu'un seul agrégat. Lorsqu'un fait dans un agrégat doit en faire réagir un autre, on ne les modifie pas ensemble dans la même transaction — on publie un événement, et l'autre agrégat réagit ensuite, dans sa propre transaction. C'est la cohérence éventuelle (eventual consistency) : les deux agrégats finissent cohérents, mais pas dans le même instant atomique.

// Le contexte Commandes publie ; le contexte Logistique réagit.
class GestionnaireExpédition {
	// Abonné à l'événement, dans SA propre transaction.
	async surPaiementConfirmé(e: PaiementConfirmé): Promise<void> {
		const expédition = Expédition.planifier(e.commandeId);
		await this.dépôt.sauver(expédition);
	}
}

À retenir

Les événements de domaine sont aussi la colle de l'intégration entre contextes délimités. Le contexte Commandes ignore tout du contexte Facturation ou Logistique ; il se contente de proclamer CommandePassée, et quiconque s'y intéresse y réagit. C'est l'application concrète de la cartographie de contextes (Context Mapping) par messagerie.

L'ordre causal

Vernon insiste sur la cohérence causale : si un fait en cause un autre, tous les nœuds d'un système distribué doivent les voir dans le même ordre. Si « J'ai perdu mon portefeuille ! » et « Ne t'en fais pas, je l'ai retrouvé ! » arrivent à l'envers, la réponse « C'est super ! » devient absurde. Persister les événements dans leur ordre causal — souvent via un identifiant de séquence — permet à un consommateur d'attendre la cause d'un événement avant de l'appliquer.

CQRS : séparer lecture et écriture

CQRSCommand Query Responsibility Segregation — prolonge un principe ancien, la séparation commande/requête (CQS) formulée par Bertrand Meyer (une fonction agit ou répond, jamais les deux), et l'élève au rang d'architecture (le terme CQRS lui-même a été forgé par Greg Young). L'idée : utiliser deux modèles distincts, l'un pour écrire, l'autre pour lire.

  • Le modèle d'écriture est constitué de vos agrégats riches. Les commandes y entrent, les invariants y sont protégés, les événements en sortent. Il est optimisé pour la cohérence et la logique métier.
  • Le modèle de lecture est une (ou plusieurs) projection dénormalisée, taillée sur mesure pour chaque écran. Les requêtes le frappent directement, sans passer par les agrégats. Il est optimisé pour l'affichage et la performance.
// ❌ Avant : un seul modèle sert à tout.
// La page « historique client » doit charger des agrégats
// Commande complets, juste pour afficher 4 colonnes.
const commandes = await dépôtCommandes.parClient(clientId);
return commandes.map((c) => ({
	numéro: c.numéro(),
	total: c.calculerTotal(),
	statut: c.statut(),
}));
// ✅ Après : côté écriture, on manipule l'agrégat.
class GestionnaireDeCommandes {
	async traiter(cmd: PasserCommande): Promise<void> {
		const commande = Commande.passer(cmd.clientId, cmd.lignes);
		await this.dépôt.sauver(commande);
		await this.bus.publier(commande.tirerÉvénements());
	}
}

// ... côté lecture, une projection plate et immédiate.
interface LigneHistorique {
	numéro: string;
	total: number;
	statut: string;
}

class RequêteHistoriqueClient {
	async exécuter(clientId: string): Promise<LigneHistorique[]> {
		// Lit une table de lecture déjà mise en forme.
		return this.lecture.query(
			'SELECT numero AS "numéro", total, statut FROM historique_client' +
				" WHERE client_id = $1",
			[clientId],
		);
	}
}

Comment le modèle de lecture reste-t-il à jour ? En s'abonnant aux événements du modèle d'écriture. Quand CommandePassée survient, un projecteur insère une ligne dans la table historique_client.

class ProjecteurHistorique {
	async surCommandePassée(e: CommandePassée): Promise<void> {
		await this.lecture.insert("historique_client", {
			client_id: e.clientId,
			numéro: e.numéro,
			total: e.total.montant,
			statut: "en_attente",
		});
	}
}
Bénéfices de CQRSCoûts de CQRS
Lectures rapides, taillées par écranDeux modèles à maintenir
Écriture concentrée sur les invariantsCohérence éventuelle entre les deux
Montée en charge indépendanteComplexité accrue, plus de code
Modèles de lecture multiples possiblesSynchronisation à déboguer

Piège courant

CQRS a un coût réel. Ne l'imposez pas partout : réservez-le aux contextes où la charge de lecture diffère franchement de la charge d'écriture, ou aux écrans qui agrègent des données issues de plusieurs agrégats. Un CRUD simple n'en a pas besoin.

Event Sourcing : l'histoire comme source de vérité

Avec l'Event Sourcing, on franchit un pas de plus. Au lieu de stocker l'état d'un agrégat, on stocke la séquence ordonnée des événements qui lui sont arrivés — son flux d'événements (event stream) — et l'on en fait la source de vérité. L'état n'est plus persisté : il est reconstruit en rejouant les événements depuis le début.

C'est l'analogie du compte bancaire : votre solde n'est pas une donnée première, c'est la somme de tous les mouvements. L'Event Sourcing applique cette idée à chaque agrégat.

Appliquer et rejouer

Deux mécanismes structurent un agrégat « event-sourced » : une méthode appliquer (qui dérive l'état d'un événement) et une reconstitution par rejeu (qui applique tout le flux). Une commande métier valide l'invariant, crée l'événement, puis l'applique.

type ÉvénementCompte =
	| { type: "CompteOuvert"; titulaire: string }
	| { type: "ArgentDéposé"; montant: number }
	| { type: "ArgentRetiré"; montant: number };

class CompteBancaire {
	private solde = 0;
	private ouvert = false;
	private nouveaux: ÉvénementCompte[] = [];

	// 1. Reconstruction : rejouer tout le flux.
	static depuisFlux(flux: ÉvénementCompte[]): CompteBancaire {
		const compte = new CompteBancaire();
		for (const e of flux) compte.appliquer(e);
		return compte;
	}

	// 2. Dérive l'état d'UN événement (sans validation).
	private appliquer(e: ÉvénementCompte): void {
		switch (e.type) {
			case "CompteOuvert":
				this.ouvert = true;
				break;
			case "ArgentDéposé":
				this.solde += e.montant;
				break;
			case "ArgentRetiré":
				this.solde -= e.montant;
				break;
		}
	}

	// 3. Commande métier : valide, émet, applique.
	retirer(montant: number): void {
		if (!this.ouvert) throw new Error("Compte fermé");
		if (montant > this.solde) {
			throw new Error("Fonds insuffisants");
		}
		this.émettre({ type: "ArgentRetiré", montant });
	}

	private émettre(e: ÉvénementCompte): void {
		this.appliquer(e); // change l'état en mémoire
		this.nouveaux.push(e); // à persister
	}

	événementsNonPersistés(): ÉvénementCompte[] {
		return this.nouveaux;
	}
}

Notez la séparation des rôles : appliquer ne fait que muter l'état, sans valider (les événements passés sont des faits acquis, on ne les rejuge pas) ; les commandes métier (retirer, déposer) valident les invariants avant d'émettre. Le magasin d'événements (event store) est une collection append-only : on n'y ajoute que des événements, jamais on ne les modifie — d'où une écriture extrêmement rapide.

Avantages et coûts

Astuce

L'atout majeur de l'Event Sourcing est l'audit total : vous conservez l'enregistrement de tout ce qui s'est jamais produit, au grain de l'occurrence. Conformité, analytique, débogage, et le fameux voyage dans le temps — reconstruire l'état exact de l'agrégat à n'importe quelle date passée.

Les coûts sont réels et ne doivent pas être sous-estimés :

  • Performance de lecture : rejouer des milliers d'événements coûte cher. On atténue cela par des instantanés (snapshots) — un état figé périodique — et par le cache en mémoire.
  • Versionnage des événements : un événement persisté l'est pour toujours ; faire évoluer son schéma (ajouter un champ, renommer) impose une stratégie d'upcasting rigoureuse.
  • Projections obligatoires : on ne peut pas interroger « tous les comptes à découvert » sur un flux d'événements brut. Il faut des projections de lecture — c'est pourquoi, comme le note Vernon, qui adopte l'Event Sourcing est presque toujours obligé d'adopter CQRS.

Event Storming : explorer le domaine par ses événements

L'Event Storming, inventé par Alberto Brandolini, est un atelier collaboratif de modélisation rapide. C'est l'outil qui relie tous les concepts de ce chapitre : on explore un domaine par ses événements.

Le principe est délibérément low-tech : une grande paroi, un long rouleau de papier et des notes autocollantes de couleurs. Experts métier (Domain Experts) et développeurs se mettent debout, feutre en main, sur un pied d'égalité. On y bannit l'UML et le code au profit d'un support visuel et tactile.

La progression typique d'une session :

  1. Les événements (orange) d'abord. On placarde les faits métier au passé — CommandePassée, ColisExpédié — de gauche à droite dans l'ordre du temps. On vise volontairement le trop-plein : plus d'événements = plus d'apprentissage. On affine ensuite.
  2. Les commandes (bleu clair) qui causent chaque événement, à l'impératif (PasserCommande), posées juste à gauche de l'événement qu'elles déclenchent.
  3. Les agrégats (jaune pâle), les « porteurs de données » sur lesquels la commande s'exécute et d'où l'événement émane, placés derrière chaque paire commande/événement.
  4. Les frontières : on trace les contextes délimités et les sous-domaines une fois les flux d'événements clarifiés.

Note

L'Event Storming et l'Event Sourcing se recouvrent largement : storming votre domaine, c'est déjà découvrir le flux d'événements que vous persisterez. Brandolini souligne qu'on peut « storm out » un cœur de domaine (Core Domain) en quelques heures là où une modélisation classique prendrait des semaines — et que les ratés ne coûtent qu'une boulette de papier jetée.

À retenir

  • Un événement de domaine est un fait métier au passé (PaiementRefusé, StockÉpuisé), nommé dans le langage omniprésent et indéniable — contrairement à une commande, qui peut être rejetée.
  • L'agrégat émet ses événements après avoir changé d'état, et l'on persiste l'état et l'événement dans la même transaction ; les abonnés ne sont notifiés qu'après le commit.
  • Les événements découplent agrégats et contextes délimités via la cohérence éventuelle : une transaction par agrégat, le reste réagit ensuite.
  • CQRS sépare un modèle d'écriture (agrégats, invariants) d'un modèle de lecture (projections par écran), tenu à jour par les événements — puissant mais coûteux, à réserver aux cas qui le justifient.
  • L'Event Sourcing fait du flux d'événements la source de vérité : on reconstruit l'état par appliquer + rejeu, on gagne l'audit complet et le voyage dans le temps, au prix des projections, des snapshots et du versionnage.
  • Tous ces patrons sont des prolongements post-Evans 2003 (Young, Dahan, Vernon, Brandolini) ; l'Event Storming est l'atelier qui les met en mouvement autour d'une paroi et de notes autocollantes.