// Event Sourcing Architecture — Référence complète

Le Sourcing
par Événements

Stocker les causes plutôt que les effets. Un pattern de persistance où l’état est reconstitué à partir d’un journal d’événements immuables — Time Travel, Audit complet, et projections multiples inclus.

Event SourcingTypeScriptAggregateEvent StoreTime Travelfp-ts
📖

Le principe fondateur

« Le solde de votre compte bancaire n’est pas une vérité : c’est un calcul. La vérité, ce sont les transactions. » L’Event Sourcing applique ce principe à l’ensemble d’un système : ne jamais stocker l’état courant directement — stocker les événements qui l’ont produit. L’état n’est que la somme des causes passées, recalculable à volonté, à n’importe quel instant.

01

La métaphore : le grand livre de comptabilité

Imagine la comptabilité d’une entreprise. Il y a deux façons de tenir les comptes :

Approche CRUD classique — Tu as un fichier avec le solde actuel. Hier tu avais 1 000 €, aujourd’hui tu notes 1 350 €. Mais pourquoi ? D’où viennent ces 350 € ? Impossible à savoir. Tu ne peux pas auditer, remonter dans le temps, ou détecter une erreur de saisie.

Approche Event Sourcing — Tu tiens un grand livre où chaque ligne est une entrée immuable : dépôt +500 €, achat -200 €, virement +1 050 €. Le solde actuel de 1 350 € n’est que la somme de tous les événements. Tu peux reconstruire l’état à n’importe quel moment, auditer chaque décision, rejouer l’histoire.

C’est exactement l’Event Sourcing : les événements sont la vérité. L’état est un dérivé.

⛔ Approche CRUD — UPDATE balance = 1350
L'historique des mutations est perdu
Impossible d'auditer "qui a modifié quoi et pourquoi"
Pas de time travel ni de replay
Une seule représentation de l'état
Correction impossible sans trace
✅ Approche Event Sourcing — APPEND event
Historique complet et immuable des causes
Audit natif, traçabilité totale
Time travel : état à n'importe quel point
Projections multiples et reconstruibles
Correction via événement compensatoire
02

Les concepts fondamentaux

📋
Domain Event
Événement Domaine
Un fait métier passé, immuable. Nommé au passé composé : MoneyDeposited, AccountClosed. Contient toutes les données nécessaires pour reconstituer ce qui s'est passé.
ImmuableFait passéAppend-only
« Un événement n'est pas "dépose de l'argent" — c'est "de l'argent A ÉTÉ déposé". »
🏛️
Event Store
Journal d'Événements
Base de données append-only qui persiste tous les événements. Comme Git : on n'écrase jamais l'histoire, on ajoute des commits. Organisée en streams par agrégat.
Append-onlyStreamsOptimistic Lock
« EventStoreDB, PostgreSQL append-only, ou Apache Kafka selon les besoins. »
🧮
Aggregate + Rehydration
Reconstitution d'état
L'état courant de l'agrégat est reconstruit en replaying tous les événements passés. C'est un Array.reduce() appliqué à l'histoire : fold(events) → state.
Fold / ReducePure functionDeterministe
📊
Projection / Read Model
Vue Matérialisée
Représentation optimisée pour la lecture, construite en écoutant les événements. On peut avoir autant de projections que de besoins : soldes, historique, détection de fraude, analytics…
CQRS ReadReconstruibleOptimisé query
📸
Snapshot
Instantané d'optimisation
Capture de l'état à un moment précis pour éviter de rejouer des milliers d'événements à chaque chargement. Optionnel — on rejoue uniquement les événements postérieurs au snapshot.
OptimisationOptionnelPeriodicite
Time Travel
Voyage dans le temps
Reconstituer l'état d'un agrégat à n'importe quel instant passé en rejouant uniquement les événements antérieurs à ce point. Outil de débogage métier inestimable.
DebugAuditReplay partiel
03

L’Event Store — le grand livre immuable

Chaque agrégat possède son propre stream d’événements. Les événements sont ordonnés, versionnés et ne sont jamais modifiés ni supprimés. La version est utilisée pour l’Optimistic Concurrency Control — si deux processus tentent d’écrire en même temps avec la même version, le second est rejeté.

temps →
VERSION 1 · 2024-01-10
AccountCreated
id: acc-42
owner: Alice
balance: 0 EUR
VERSION 2 · 2024-01-12
MoneyDeposited
accountId: acc-42
amount: 500 EUR
desc: Salaire
VERSION 3 · 2024-01-15
MoneyWithdrawn
accountId: acc-42
amount: 200 EUR
reason: Achat ligne
VERSION 4 · 2024-01-20
MoneyDeposited
accountId: acc-42
amount: 1050 EUR
desc: Virement
VERSION 5 · 2024-03-01
AccountClosed
accountId: acc-42
reason: Customer req.
finalBalance: 1350 EUR
🔒 Jamais modifié · Jamais supprimé · Append-only

La reconstitution de l’état est un simple fold (équivalent à Array.reduce()) sur la séquence des événements :

balance: 0
initial
+500
balance: 500
-200
balance: 300
+1050
balance: 1350
état courant
La règle du fold : etat courant = events.reduce(applyEvent, initialState)
Le même ensemble d’événements produit toujours le même état — la fonction applyEvent est pure et déterministe.
C’est le coeur mathématique de l’Event Sourcing.
04

Vue d’ensemble de l’architecture

L’Event Sourcing s’articule autour de quatre zones distinctes : les commandes (intentions), l’agrégat (validation + émission), l’Event Store (persistance immuable), et les projections (lecture optimisée).

Commands
Commandes — Les Intentions
CreateAccountDepositMoneyWithdrawMoneyCloseAccountCommandBus / Dispatcher
Aggregate
Agrégat — Validation + Émission d’événements
BankAccount (Aggregate)Règles métierrecordEvent()applyEvent() — reducer purrehydrate() — factory
Event Store
Event Store — Persistence Append-Only
append(aggregateId, events, expectedVersion)loadEvents(aggregateId, fromVersion?)Optimistic Concurrency ControlSnapshots (optionnel)
Event Bus
Event Bus — Diffusion aux Projections
publish(event)subscribe(eventType, handler)Kafka / RabbitMQ / in-process
Projections
Projections — Read Models (CQRS)
AccountBalanceProjectionTransactionHistoryProjectionFraudDetectionProjectionAnalyticsDashboardProjectionrebuild() — reconstruire depuis zéro
Principe clé : Chaque projection écoute les mêmes événements et construit sa propre représentation optimisée pour son besoin de lecture. On peut supprimer et reconstruire n’importe quelle projection à tout moment en rejouant l’intégralité de l’Event Store — sans perte de données.
05

Implémentation OOP — TypeScript / NestJS

L’approche orientée objet organise le code autour de l’agrégat qui encapsule à la fois la logique de validation des commandes, l’émission d’événements, et l’application des événements à l’état interne.

domain/events/bank-account.events.ts
// --- Les Evenements -- faits immuables nommes au passe --- type EventType = | 'AccountCreated' | 'MoneyDeposited' | 'MoneyWithdrawn' | 'AccountClosed' interface DomainEvent { readonly eventId: string readonly eventType: EventType readonly aggregateId: string readonly version: number readonly occurredOn: Date readonly payload: unknown } interface AccountCreatedPayload { owner: string; initialBalance: number } interface MoneyDepositedPayload { amount: number; currency: string; description?: string } interface MoneyWithdrawnPayload { amount: number; currency: string; reason: string } interface AccountClosedPayload { reason: string }
domain/aggregates/bank-account.aggregate.ts
type AccountStatus = 'ACTIVE' | 'CLOSED' interface AccountState { id: string; owner: string; balance: number status: AccountStatus; version: number } class BankAccount { private state: AccountState private uncommittedEvents: DomainEvent[] = [] private constructor(state: AccountState) { this.state = state } // -- Factory : rehydrater depuis l'Event Store (le fold) -- static rehydrate(events: DomainEvent[]): BankAccount { const initialState: AccountState = { id: '', owner: '', balance: 0, status: 'ACTIVE', version: 0 } // Array.reduce() = le coeur d'Event Sourcing const state = events.reduce( (s, e) => BankAccount.apply(s, e), initialState ) return new BankAccount(state) } // -- Reducer pur : un evenement -> nouvel etat -- private static apply(state: AccountState, event: DomainEvent): AccountState { switch (event.eventType) { case 'AccountCreated': { const p = event.payload as AccountCreatedPayload return { ...state, id: event.aggregateId, owner: p.owner, balance: p.initialBalance, status: 'ACTIVE', version: event.version } } case 'MoneyDeposited': { const p = event.payload as MoneyDepositedPayload return { ...state, balance: state.balance + p.amount, version: event.version } } case 'MoneyWithdrawn': { const p = event.payload as MoneyWithdrawnPayload return { ...state, balance: state.balance - p.amount, version: event.version } } case 'AccountClosed': return { ...state, status: 'CLOSED', version: event.version } default: return state } } // -- Commandes : validation metier + emission d'evenements -- deposit(amount: number, currency = 'EUR'): void { if (this.state.status === 'CLOSED') throw new Error('Impossible de deposer sur un compte ferme') if (amount <= 0) throw new Error('Le montant doit etre positif') this.recordEvent('MoneyDeposited', { amount, currency } as MoneyDepositedPayload) } withdraw(amount: number, reason: string, currency = 'EUR'): void { if (this.state.status === 'CLOSED') throw new Error('Compte ferme') if (amount > this.state.balance) throw new Error(`Solde insuffisant: ${this.state.balance} < ${amount}`) this.recordEvent('MoneyWithdrawn', { amount, currency, reason } as MoneyWithdrawnPayload) } close(reason: string): void { if (this.state.status === 'CLOSED') throw new Error('Compte deja ferme') this.recordEvent('AccountClosed', { reason } as AccountClosedPayload) } // -- Enregistrement interne : applique + met en file d'attente -- private recordEvent(eventType: EventType, payload: unknown): void { const newVersion = this.state.version + 1 const event: DomainEvent = { eventId: crypto.randomUUID(), eventType, aggregateId: this.state.id, version: newVersion, occurredOn: new Date(), payload } this.state = BankAccount.apply(this.state, event) // etat local immediat this.uncommittedEvents.push(event) // en attente de persistance } getUncommittedEvents(): DomainEvent[] { return [...this.uncommittedEvents] } clearUncommittedEvents(): void { this.uncommittedEvents = [] } getState(): Readonly<AccountState> { return { ...this.state } } }
infrastructure/event-store/in-memory.event-store.ts
interface EventStore { append(id: string, events: DomainEvent[], expectedVersion: number): Promise<void> loadEvents(id: string, fromVersion?: number): Promise<DomainEvent[]> } class InMemoryEventStore implements EventStore { private streams = new Map<string, DomainEvent[]>() async append(id: string, events: DomainEvent[], expectedVersion: number): Promise<void> { const stream = this.streams.get(id) ?? [] const currentVersion = stream.length > 0 ? stream[stream.length - 1].version : 0 // Optimistic Concurrency Control if (currentVersion !== expectedVersion) throw new Error(`Conflit de version: attendu ${expectedVersion}, actuel ${currentVersion}`) this.streams.set(id, [...stream, ...events]) } async loadEvents(id: string, fromVersion = 0): Promise<DomainEvent[]> { return (this.streams.get(id) ?? []).filter(e => e.version > fromVersion) } } // -- Repository : orchestre load -> rehydrate -> save -- class BankAccountRepository { constructor(private readonly eventStore: EventStore) {} async findById(id: string): Promise<BankAccount> { const events = await this.eventStore.loadEvents(id) if (events.length === 0) throw new Error(`Compte ${id} introuvable`) return BankAccount.rehydrate(events) } async save(account: BankAccount): Promise<void> { const uncommitted = account.getUncommittedEvents() if (uncommitted.length === 0) return const currentVersion = account.getState().version - uncommitted.length await this.eventStore.append(account.getState().id, uncommitted, currentVersion) account.clearUncommittedEvents() } } // -- Time Travel : etat a la version N -- async function getStateAtVersion(store: EventStore, id: string, version: number) { const allEvents = await store.loadEvents(id) const pastEvents = allEvents.filter(e => e.version <= version) return BankAccount.rehydrate(pastEvents) // Etat a v2 -> balance: 500 EUR }
06

Implémentation Fonctionnelle — fp-ts / Railway

L’Event Sourcing est naturellement fonctionnel : un fold est une opération purement fonctionnelle. Avec fp-ts, on modélise les commandes comme des fonctions State → Either<Error, Event[]>, ce qui garantit une gestion d’erreur explicite et composable via le Railway Oriented Programming.

domain/bank-account.functional.ts
import { pipe } from 'fp-ts/function' import * as E from 'fp-ts/Either' import * as A from 'fp-ts/Array' import * as O from 'fp-ts/Option' // --- Les Types Algebriques --- type AccountEvent = | { readonly _tag: 'AccountCreated'; owner: string; initialBalance: number } | { readonly _tag: 'MoneyDeposited'; amount: number } | { readonly _tag: 'MoneyWithdrawn'; amount: number } | { readonly _tag: 'AccountClosed' } type AccountState = | { readonly _tag: 'Uninitialized' } | { readonly _tag: 'Active'; owner: string; balance: number } | { readonly _tag: 'Closed'; owner: string; finalBalance: number } type AccountError = | { readonly _tag: 'InsufficientFunds'; available: number; requested: number } | { readonly _tag: 'AccountAlreadyClosed' } | { readonly _tag: 'InvalidAmount'; amount: number } // --- Le Reducer Pur (fold) --- const applyEvent = (state: AccountState, event: AccountEvent): AccountState => { switch (state._tag) { case 'Uninitialized': if (event._tag === 'AccountCreated') return { _tag: 'Active', owner: event.owner, balance: event.initialBalance } return state case 'Active': switch (event._tag) { case 'MoneyDeposited': return { ...state, balance: state.balance + event.amount } case 'MoneyWithdrawn': return { ...state, balance: state.balance - event.amount } case 'AccountClosed': return { _tag: 'Closed', owner: state.owner, finalBalance: state.balance } default: return state } case 'Closed': return state // etat terminal } } // Le fold -- reconstitution depuis la liste complete des evenements const rehydrate = (events: ReadonlyArray<AccountEvent>): AccountState => events.reduce(applyEvent, { _tag: 'Uninitialized' } as AccountState) // --- Les Decisions (commandes -> Railway) --- const deposit = (amount: number) => (state: AccountState): E.Either<AccountError, AccountEvent[]> => { if (state._tag !== 'Active') return E.left({ _tag: 'AccountAlreadyClosed' }) if (amount <= 0) return E.left({ _tag: 'InvalidAmount', amount }) return E.right([{ _tag: 'MoneyDeposited', amount }]) } const withdraw = (amount: number) => (state: AccountState): E.Either<AccountError, AccountEvent[]> => { if (state._tag !== 'Active') return E.left({ _tag: 'AccountAlreadyClosed' }) if (amount <= 0) return E.left({ _tag: 'InvalidAmount', amount }) if (amount > state.balance) return E.left({ _tag: 'InsufficientFunds', available: state.balance, requested: amount }) return E.right([{ _tag: 'MoneyWithdrawn', amount }]) } // --- Time Travel fonctionnel --- const getStateAtVersion = ( events: ReadonlyArray<AccountEvent>, version: number ): O.Option<AccountState> => pipe( events, A.takeLeft(version), rehydrate, state => (state._tag === 'Uninitialized' ? O.none : O.some(state)) )
Railway Oriented Programming : chaque décision retourne un Either<Error, Event[]>. Les erreurs sont des valeurs typées, pas des exceptions. La fonction processDeposit compose validation et émission d’événements dans un pipeline fonctionnel clair et testable.
07

Les Projections — construire les Read Models

Les projections sont l’une des forces majeures de l’Event Sourcing. Chaque projection écoute le flux d’événements et maintient sa propre représentation optimisée pour un besoin de lecture spécifique. On peut en avoir autant que nécessaire — et on peut toutes les supprimer et reconstruire en rejouant l’Event Store.

ProjectionÉvénements écoutésRead Model produitUsage
AccountBalanceProjectionAccountCreated, MoneyDeposited, MoneyWithdrawn, AccountClosedMap<accountId, { balance, owner }>GET /accounts/:id/balance
TransactionHistoryProjectionMoneyDeposited, MoneyWithdrawnListe paginée et indexée de transactionsGET /accounts/:id/history
FraudDetectionProjectionMoneyWithdrawnAlertes si > 1000 € en 5 minGET /fraud/alerts
AnalyticsDashboardProjectionTous les événementsAgrégats statistiques, KPIsGET /analytics/summary
infrastructure/projections/account-balance.projection.ts
interface AccountBalanceView { accountId: string; owner: string; balance: number; lastUpdated: Date } class AccountBalanceProjection { private store = new Map<string, AccountBalanceView>() handle(event: DomainEvent): void { switch (event.eventType) { case 'AccountCreated': { const p = event.payload as AccountCreatedPayload this.store.set(event.aggregateId, { accountId: event.aggregateId, owner: p.owner, balance: p.initialBalance, lastUpdated: event.occurredOn, }) break } case 'MoneyDeposited': { const model = this.store.get(event.aggregateId); if (!model) return const p = event.payload as MoneyDepositedPayload this.store.set(event.aggregateId, { ...model, balance: model.balance + p.amount, lastUpdated: event.occurredOn }) break } case 'MoneyWithdrawn': { const model = this.store.get(event.aggregateId); if (!model) return const p = event.payload as MoneyWithdrawnPayload this.store.set(event.aggregateId, { ...model, balance: model.balance - p.amount, lastUpdated: event.occurredOn }) break } case 'AccountClosed': this.store.delete(event.aggregateId) break } } getBalance(id: string): AccountBalanceView | undefined { return this.store.get(id) } // POUVOIR CLE : supprimer et reconstruire depuis zero a tout moment async rebuild(allEvents: DomainEvent[]): Promise<void> { this.store.clear() // table rase allEvents.forEach(e => this.handle(e)) // replay complet } }
08

Structure de projet NestJS recommandée

src/ ├── domain/ ← Règles métier — zéro dépendance │ ├── events/ │ │ ├── bank-account.events.ts ← DomainEvent, payloads typés │ │ └── event-type.enum.ts │ └── aggregates/ │ └── bank-account.aggregate.ts ← rehydrate(), apply(), recordEvent() ├── application/ ← Cas d'usage — orchestration │ ├── commands/ │ │ ├── deposit-money.command.ts │ │ ├── withdraw-money.command.ts │ │ └── create-account.command.ts │ ├── handlers/ │ │ ├── deposit-money.handler.ts ← findById → deposit → save │ │ └── withdraw-money.handler.ts │ └── ports/ │ └── event-store.port.ts ← Interface (Port) ├── infrastructure/ ← Détails techniques │ ├── event-store/ │ │ ├── in-memory.event-store.ts ← Tests / dev │ │ ├── postgres.event-store.ts ← Production │ │ └── eventstoredb.adapter.ts ← EventStoreDB │ ├── projections/ │ │ ├── account-balance.projection.ts │ │ ├── transaction-history.projection.ts │ │ └── fraud-detection.projection.ts │ └── repositories/ │ └── bank-account.repository.ts ← load → rehydrate → save └── presentation/ ← API REST / Controllers ├── controllers/ │ └── bank-account.controller.ts └── queries/ └── get-balance.query.ts ← Lit depuis les projections
09

Les pièges classiques

⚠️
Piège 01
Événement ≠ mutation technique
Un événement doit être un fait métier, pas une opération technique.
❌ BalanceFieldUpdated ✅ MoneyDeposited
⚠️
Piège 02
Schéma d'événement immuable
Un événement persisté est gravé dans le marbre. Utiliser un Upcaster pour migrer les anciens formats vers de nouveaux schémas.
« On ne modifie pas l'histoire. On l'interprète différemment. »
⚠️
Piège 03
Pas de Snapshots = lenteur
Sans snapshots, rejouer 50 000 événements à chaque requête est prohibitif. Implémenter des snapshots périodiques pour les agrégats à longue durée de vie.
🔀
Confusion 01
ES ≠ Event-Driven Architecture
L'Event Sourcing est un pattern de persistance. L'EDA est un pattern de communication. Ils se combinent souvent mais sont distincts et indépendants.
🔀
Confusion 02
ES ≠ CQRS obligatoire
L'Event Sourcing se marie bien avec CQRS mais ne l'implique pas. On peut faire de l'Event Sourcing sans CQRS, et du CQRS sans Event Sourcing.
💾
Choix tech
L'Event Store n'est pas SQL
Pas une table SQL ordinaire : EventStoreDB (natif), Apache Kafka (distribué), ou une table PostgreSQL append-only avec version et SERIAL.
10

Quand utiliser l’Event Sourcing ?

CritèreDescriptionVerdict
Audit obligatoireFinance, santé, logistique, conformité réglementaire — traçabilité totale requise✓ Idéal
Time travel métierBesoin de reconstituer l'état à un instant passé pour débogage ou litige✓ Idéal
Plusieurs Read ModelsAnalytics, reporting, dashboard, alertes — chaque besoin a sa projection✓ Idéal
Workflow complexeProcessus métier multi-étapes avec corrections, annulations, compensations✓ Recommandé
Intégration DDDAgrégats riches, logique métier forte, frontières de contexte claires✓ Synergie
CRUD simpleBlog, catalogue produit, gestion de contenu sans exigence d'audit✗ Over-engineering
Équipe sans expériencePas de formation DDD / Event Sourcing dans l'équipe⚠ Formation requise
Données éphémèresSessions, cache, préférences UI, données temporaires✗ Inadapté
Volume extrême sans snapshotMillions d'événements par agrégat sans stratégie de snapshot⚠ Snapshot obligatoire
11

Avantages & Inconvénients

Inconvénients & Complexité
Courbe d'apprentissage élevée (DDD requis)
Migrations de schéma d'événements délicates (Upcasters)
Latence de lecture si projections asynchrones (Eventual Consistency)
Besoin d'une stratégie de Snapshots pour les grands streams
Infrastructure supplémentaire (EventStoreDB / Kafka)
Requêtes complexes sur l'état historique difficiles
Avantages & Puissance
Auditabilité native et complète — chaque changement tracé
Time travel : état reconstitué à n'importe quel instant passé
Projections reconstruibles — nouvelles vues possibles à tout moment
Débogage production exceptionnel : replay des scénarios réels
Logique métier isolée, pure et testable (reducers déterministes)
Intégration naturelle avec CQRS et DDD
Événement Domaine
Event Store (Append-only)
Agrégat + Fold
Projections (Read Models)
Snapshot (Optimisation)
Time Travel