La plupart des applications stockent l’état actuel des choses : une commande a tel statut, tel total. Mais elles perdent le chemin qui a mené à cet état. Qui a ajouté quel article, et quand ? Pourquoi le total a-t-il changé ? L’Event Sourcing répond en stockant non pas l’état, mais la suite des événements qui l’ont produit. Couplé au CQRS, qui sépare le modèle d’écriture du modèle de lecture, il donne des systèmes auditables et performants. Dans ce tutoriel, vous allez construire un petit magasin d’événements et une projection de lecture en TypeScript, autour de la commande de la boutique.
🎯 Ce que vous allez apprendre
- Distinguer CQRS (séparer lecture et écriture) d’Event Sourcing (stocker les événements).
- Reconstruire l’état d’un agrégat en rejouant son historique d’événements.
- Implémenter un magasin d’événements minimal avec abonnement.
- Construire une projection de lecture dénormalisée, mise à jour par les événements.
- Comprendre quand ces patterns valent leur complexité, et quand les éviter.
🛠️ Ce que vous allez construire
Un système où l’écriture émet des événements (commande créée, article ajouté, commande confirmée) et où la lecture est servie par une vue dénormalisée tenue à jour automatiquement. Vous prouverez deux choses : que l’agrégat se reconstruit fidèlement depuis ses seuls événements, et que la projection de lecture reste cohérente sans jamais recalculer un total.
Prérequis
- Node.js 20+ et TypeScript (
npm i -D typescript tsx). - Avoir une idée des agrégats du Domain-Driven Design aide, mais n’est pas indispensable.
- Niveau intermédiaire. Test express : si vous êtes à l’aise avec les types union et le
switchexhaustif en TypeScript, vous êtes prêt. - ⏱️ Temps estimé : ~55 minutes.
Étape 1 — Deux idées à ne pas confondre
On cite souvent CQRS et Event Sourcing ensemble, mais ce sont deux décisions indépendantes. CQRS, terme popularisé par Greg Young à partir du principe de séparation commande-requête de Bertrand Meyer, dit simplement : utilisez un modèle pour modifier les données (le côté commande) et un autre pour les lire (le côté requête). Rien n’oblige à stocker des événements pour faire du CQRS.
Event Sourcing est une décision de stockage : au lieu d’enregistrer l’état courant (« total = 3000 »), on enregistre la séquence des faits (« créée », « article ajouté », « confirmée »), et l’état se recalcule en les rejouant. Les deux se marient bien — les événements du côté écriture alimentent naturellement les vues du côté lecture — mais on peut faire l’un sans l’autre. Garder cette indépendance en tête évite de s’imposer toute la complexité de l’Event Sourcing alors qu’un simple CQRS suffirait.
Pourquoi se donner cette peine ? L’Event Sourcing apporte trois bénéfices qu’aucun stockage d’état ne procure. D’abord un audit parfait : l’historique complet des faits est la donnée elle-même, impossible à falsifier discrètement. Ensuite des requêtes temporelles : on peut reconstruire l’état d’une commande tel qu’il était à n’importe quelle date, simplement en rejouant les événements jusqu’à ce moment. Enfin un débogage facilité : face à un bug, on rejoue exactement la séquence qui l’a provoqué. Ces atouts ont une valeur réelle dans la finance, la logistique ou la conformité — et peu d’intérêt pour des données banales, ce qui dicte quand adopter le pattern.
✅ Point d’étape — Reformulez la différence avec vos mots : CQRS répond à « ai-je un modèle distinct pour lire et pour écrire ? », Event Sourcing répond à « ce que je stocke, est-ce l’état final ou la suite des faits ? ». Si la distinction est claire, la suite coulera de source.
Étape 2 — Définir les événements du domaine
Tout part des événements. On les modélise comme un type union discriminé par un champ type, ce qui permettra à TypeScript de vérifier qu’on traite bien tous les cas. Chaque événement décrit un fait immuable, au passé.
// events.ts
export type DomainEvent =
| { type: 'OrderCreated'; orderId: string; customerId: string }
| { type: 'ItemAdded'; orderId: string; productId: string; unitPriceCents: number; quantity: number }
| { type: 'OrderConfirmed'; orderId: string };
Ces trois événements racontent l’histoire complète d’une commande. Remarquez qu’ils ne contiennent que des données brutes, sérialisables : pas de méthodes, pas de références à des objets vivants. C’est essentiel, car un événement doit pouvoir être écrit sur disque aujourd’hui et relu dans dix ans. La discrimination par type est ce qui rendra le rejeu sûr à l’étape suivante.
Étape 3 — Rejouer l’historique pour reconstruire l’état
Le cœur de l’Event Sourcing est la méthode apply : elle prend un événement et fait évoluer l’état de l’agrégat en conséquence. Reconstruire une commande consiste alors à partir d’un état vide et à appliquer ses événements dans l’ordre. C’est le côté écriture : avant de décider quoi que ce soit, on rejoue le passé pour connaître l’état présent.
// order.ts
import { DomainEvent } from './events';
export class Order {
customerId = '';
status: 'DRAFT' | 'CONFIRMED' = 'DRAFT';
version = 0;
private items = new Map<string, { unitPriceCents: number; quantity: number }>();
apply(event: DomainEvent): void {
switch (event.type) {
case 'OrderCreated':
this.customerId = event.customerId;
break;
case 'ItemAdded': {
const existing = this.items.get(event.productId);
const quantity = (existing?.quantity ?? 0) + event.quantity;
this.items.set(event.productId, { unitPriceCents: event.unitPriceCents, quantity });
break;
}
case 'OrderConfirmed':
this.status = 'CONFIRMED';
break;
}
this.version++;
}
get totalCents(): number {
let total = 0;
for (const item of this.items.values()) {
total += item.unitPriceCents * item.quantity;
}
return total;
}
static fromHistory(events: DomainEvent[]): Order {
const order = new Order();
for (const event of events) {
order.apply(event);
}
return order;
}
}
La fabrique fromHistory illustre tout le principe : donnez-lui la liste des événements d’une commande, elle vous rend l’agrégat dans son état exact, total compris. Le compteur version s’incrémente à chaque événement appliqué ; il servira plus tard à détecter les conflits d’écriture concurrente. Notez qu’on ne stocke jamais le total : il se déduit toujours des faits. C’est ce qui garantit qu’il ne peut pas « mentir » par rapport à l’historique.
Étape 4 — Le magasin d’événements
Il faut maintenant ranger ces événements et permettre de les relire. Un magasin d’événements (event store) stocke les faits par flux — typiquement un flux par agrégat — et notifie les abonnés à chaque ajout. Voici une implémentation en mémoire, suffisante pour comprendre le mécanisme.
// event-store.ts
import { DomainEvent } from './events';
type Subscriber = (event: DomainEvent) => void;
export class InMemoryEventStore {
private readonly streams = new Map<string, DomainEvent[]>();
private readonly subscribers: Subscriber[] = [];
append(streamId: string, events: DomainEvent[]): void {
const stream = this.streams.get(streamId) ?? [];
stream.push(...events);
this.streams.set(streamId, stream);
// notifier les projections après écriture
for (const event of events) {
for (const subscriber of this.subscribers) subscriber(event);
}
}
load(streamId: string): DomainEvent[] {
return this.streams.get(streamId) ?? [];
}
subscribe(handler: Subscriber): void {
this.subscribers.push(handler);
}
}
Deux opérations suffisent : append ajoute des événements à un flux et prévient les abonnés ; load rend l’historique complet d’un flux. En production, on remplacerait cette Map par une table append-only — une base relationnelle convient très bien — et l’on publierait les événements sur une messagerie comme dans le tutoriel sur l’architecture orientée événements. Le contrat, lui, ne changerait pas.
Étape 5 — Le côté commande : décider, puis émettre
Une commande (au sens CQRS : une demande de changement) se traite en trois temps. On charge l’historique, on reconstruit l’agrégat pour connaître l’état, on décide si l’action est permise, et seulement alors on émet de nouveaux événements. La décision vit ici, jamais dans la projection de lecture.
// confirm-order.ts
import { InMemoryEventStore } from './event-store';
import { Order } from './order';
export function confirmOrder(store: InMemoryEventStore, orderId: string): void {
const order = Order.fromHistory(store.load(orderId));
if (order.status === 'CONFIRMED') {
return; // idempotent : confirmer deux fois ne change rien
}
if (order.totalCents === 0) {
throw new Error('Impossible de confirmer une commande vide');
}
store.append(orderId, [{ type: 'OrderConfirmed', orderId }]);
}
Ce gestionnaire n’écrit jamais d’état : il écrit un fait. La règle « pas de confirmation d’une commande vide » s’appuie sur l’état reconstruit, puis se traduit par l’émission — ou non — d’un événement. C’est la séparation des responsabilités du CQRS rendue concrète : le côté commande raisonne sur l’historique et produit des faits, sans se soucier de la façon dont on lira ensuite ces données.
Étape 6 — La projection de lecture
Reconstruire un agrégat à chaque affichage serait coûteux. Le côté requête entretient donc une vue dénormalisée, prête à être lue, mise à jour au fil des événements. C’est une projection : un objet qui s’abonne au magasin et maintient un résumé.
// order-summary-projection.ts
import { DomainEvent } from './events';
export interface OrderSummary {
orderId: string;
customerId: string;
totalCents: number;
status: 'DRAFT' | 'CONFIRMED';
}
export class OrderSummaryProjection {
private readonly view = new Map<string, OrderSummary>();
handle(event: DomainEvent): void {
switch (event.type) {
case 'OrderCreated':
this.view.set(event.orderId, {
orderId: event.orderId, customerId: event.customerId,
totalCents: 0, status: 'DRAFT',
});
break;
case 'ItemAdded': {
const summary = this.view.get(event.orderId);
if (summary) summary.totalCents += event.unitPriceCents * event.quantity;
break;
}
case 'OrderConfirmed': {
const summary = this.view.get(event.orderId);
if (summary) summary.status = 'CONFIRMED';
break;
}
}
}
get(orderId: string): OrderSummary | undefined {
return this.view.get(orderId);
}
}
La projection calcule le total au fur et à mesure : chaque ItemAdded ajoute son montant, sans jamais relire tout l’historique. Une lecture devient donc un simple accès à la vue, instantané. C’est l’autre moitié du marché CQRS : le côté lecture paie un peu de duplication de données en échange d’une rapidité maximale, et il peut adopter une structure totalement différente de celle du côté écriture — exactement ce dont a besoin un tableau de bord ou une page de listing.
Une conséquence importante mérite d’être posée : dès que la projection vit dans un processus ou une base séparés du côté écriture — ce qui est la règle en production — il existe un court délai entre l’émission d’un événement et sa prise en compte dans la vue de lecture. C’est la cohérence à terme déjà rencontrée dans l’architecture événementielle. Concrètement, juste après avoir confirmé une commande, un affichage pourrait montrer l’ancien statut pendant quelques millisecondes. Ce n’est pas un bug, c’est une propriété du modèle, qu’il faut concevoir explicitement : informer l’utilisateur que la mise à jour est en cours, ou afficher l’action qu’il vient de faire sans attendre la projection. Notre exemple synchrone masque ce délai, mais il réapparaît dès qu’on distribue les composants.
Optimiser la reconstruction avec des snapshots
Rejouer dix événements est instantané ; en rejouer cinquante mille à chaque chargement ne l’est plus. La parade classique est le snapshot : on enregistre périodiquement l’état complet de l’agrégat à une version donnée, puis on ne rejoue que les événements survenus depuis ce point. Le principe reste fidèle à l’Event Sourcing — les événements demeurent la source de vérité — mais on s’évite un travail répétitif.
// Reconstruction accélérée par snapshot (schéma de principe)
function loadOrder(store: InMemoryEventStore, snapshot: { state: Order; version: number } | null, orderId: string): Order {
const allEvents = store.load(orderId);
if (!snapshot) {
return Order.fromHistory(allEvents);
}
// ne rejouer que les événements postérieurs au snapshot
const recent = allEvents.slice(snapshot.version);
for (const event of recent) snapshot.state.apply(event);
return snapshot.state;
}
On ne crée un snapshot que lorsqu’un flux devient long — par exemple tous les cent événements. La règle d’or demeure : un snapshot est une optimisation, jamais la vérité. S’il est corrompu ou absent, on doit toujours pouvoir reconstruire l’état en rejouant l’intégralité de l’historique. C’est cette propriété qui rend l’Event Sourcing si robuste face aux erreurs : la donnée fondamentale, immuable, survit à tous les caches et toutes les vues dérivées.
Étape 7 — Câbler et vérifier
Assemblons les morceaux : on abonne la projection au magasin, on émet des événements côté écriture, et l’on vérifie que les deux côtés convergent. Le test prouve à la fois le rejeu d’agrégat et l’exactitude de la projection.
// check.ts — exécuter avec : npx tsx check.ts
import { InMemoryEventStore } from './event-store';
import { OrderSummaryProjection } from './order-summary-projection';
import { Order } from './order';
import { confirmOrder } from './confirm-order';
import { strict as assert } from 'node:assert';
const store = new InMemoryEventStore();
const projection = new OrderSummaryProjection();
store.subscribe(event => projection.handle(event));
// Côté écriture : on émet des faits
store.append('ord-1', [
{ type: 'OrderCreated', orderId: 'ord-1', customerId: 'cust-9' },
{ type: 'ItemAdded', orderId: 'ord-1', productId: 'p1', unitPriceCents: 1500, quantity: 2 },
]);
confirmOrder(store, 'ord-1');
// Reconstruction de l'agrégat depuis le seul historique
const order = Order.fromHistory(store.load('ord-1'));
assert.equal(order.totalCents, 3000);
assert.equal(order.status, 'CONFIRMED');
assert.equal(order.version, 3); // 3 événements appliqués
// La projection de lecture est cohérente, sans recalcul
const summary = projection.get('ord-1');
assert.equal(summary?.totalCents, 3000);
assert.equal(summary?.status, 'CONFIRMED');
// Idempotence : reconfirmer n'émet aucun nouvel événement
confirmOrder(store, 'ord-1');
assert.equal(store.load('ord-1').length, 3);
console.log('Écriture et lecture convergent ✓');
En lançant npx tsx check.ts, vous devez voir Écriture et lecture convergent ✓. Deux résultats comptent : l’agrégat reconstruit affiche un total de 3000 alors que ce total n’a jamais été stocké explicitement, et la projection donne le même chiffre sans avoir rejoué l’historique. La dernière assertion vérifie l’idempotence — reconfirmer ne crée pas un quatrième événement. Vous tenez là un système entièrement piloté par les faits.
🐞 Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
| La projection diverge de l’agrégat | Logique de calcul dupliquée et désynchronisée | Une seule source : les événements ; projeter, ne pas recalculer ailleurs |
| Impossible de modifier un événement passé | On cherche à « corriger » l’historique | Émettre un événement compensatoire ; l’historique est immuable |
| Reconstruction de plus en plus lente | Flux d’événements très longs | Introduire des snapshots périodiques de l’état |
| Total faux après un changement de règle | La nouvelle logique rejoue d’anciens événements autrement | Versionner les événements ; ne jamais réinterpréter le passé |
| Event Sourcing imposé partout | Effet de mode | Le réserver aux domaines où l’audit et l’historique ont une valeur réelle |
✅ Récapitulatif
Vous avez bâti un système où la vérité n’est pas un état figé mais une suite de faits. Le côté écriture reconstruit l’agrégat depuis son historique, décide, puis émet de nouveaux événements ; le magasin les conserve et notifie les abonnés ; la projection de lecture entretient une vue rapide et dénormalisée. Cette séparation, c’est le CQRS ; le stockage par faits, c’est l’Event Sourcing. Ensemble, ils offrent un audit parfait et des lectures performantes — au prix d’une complexité qu’il faut assumer en connaissance de cause.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
| Événement | Fait immuable, sérialisable, au passé |
apply / fromHistory |
Rejouer les événements pour reconstruire l’état |
| Magasin d’événements | Stockage append-only + notification des abonnés |
| Côté commande | Charger, décider, émettre de nouveaux événements |
| Projection | Vue de lecture dénormalisée, mise à jour par les événements |
| Snapshot | État figé périodique pour accélérer la reconstruction |
💪 À vous de jouer
Ajoutez un événement ItemRemoved (retrait d’un article) et faites-le gérer à la fois par l’agrégat (méthode apply) et par la projection, afin que le total reste juste des deux côtés.
Voir une solution
// dans le type union :
| { type: 'ItemRemoved'; orderId: string; productId: string; quantity: number }
// dans Order.apply, cas 'ItemRemoved' :
const item = this.items.get(event.productId);
if (item) {
const left = item.quantity - event.quantity;
if (left <= 0) this.items.delete(event.productId);
else this.items.set(event.productId, { ...item, quantity: left });
}
// dans la projection, soustraire unitPrice * quantity au total
Tutoriels liés
- DDD : modéliser un domaine métier — les agrégats que l’on rejoue ici.
- Architecture orientée événements avec RabbitMQ — publier les événements du magasin vers d’autres services.
Pour aller plus loin
- 🔝 Revenir à la vue d’ensemble : Architecture logicielle moderne.
- Martin Fowler, Event Sourcing et CQRS — les références conceptuelles.
Questions fréquentes
Event Sourcing impose-t-il CQRS ?
En pratique, presque toujours : comme on stocke des événements et non l’état, on a besoin de projections pour lire efficacement, ce qui revient à séparer lecture et écriture. L’inverse n’est pas vrai : on fait du CQRS sans Event Sourcing très couramment.
Comment corriger une erreur dans l’historique ?
On ne modifie jamais un événement passé. On émet un événement correctif (par exemple ItemRemoved ou un événement d’ajustement), qui s’ajoute à la suite. L’historique reflète ainsi la réalité, y compris les erreurs et leurs corrections — ce qui est précisément la valeur d’audit recherchée.
Quand éviter ces patterns ?
Pour des données simples, sans réelle valeur d’historique (un profil utilisateur, des paramètres), l’Event Sourcing ajoute une complexité injustifiée. Réservez-le aux domaines où savoir « comment on en est arrivé là » a une vraie importance métier : finance, logistique, conformité.
Mots-clés : CQRS, event sourcing, magasin d’événements, projection, modèle de lecture, agrégat, TypeScript, rejeu d’événements.