CQRS et event sourcing¶
Séparer les chemins lecture et écriture, stocker les événements plutôt que l'état — les patterns avances de persistance distribuée.
CQRS au niveau système¶
Au niveau système, CQRS signifie des services distincts, des bases de données distinctes, et des schémas optimises independamment pour les écritures et les lectures.
graph LR
Client -->|"Command"| WS[Write Service]
WS -->|"persist"| WDB[(Write DB<br/>PostgreSQL)]
WDB -->|"CDC / events"| EB[Event Bus]
EB -->|"projections"| PRJ[Projector]
PRJ -->|"materialize"| RDB[(Read DB<br/>Elasticsearch / Redis)]
RDB -->|"query"| QS[Query Service]
QS -->|"Response"| Client Write path : les commandes (mutations) passent par le Write Service, persistees dans une base optimisee pour les écritures et la cohérence transactionnelle (PostgreSQL). Les changements sont publies via CDC ou en sortie directe du service.
Read path : les événements alimentent des Projectors qui materialisent des Read Models dans des bases optimisees pour les lectures — Elasticsearch pour la recherche, Redis pour le cache, une base analytique pour les dashboards.
Quand le CQRS système est justifie¶
| Justifie | Non justifie |
|---|---|
| Scalabilité asymetrique (lectures 100x écritures) | Trafic uniforme lecture/écriture |
| Projections multiples (mobile, reporting, recherche) | Une seule vue des données |
| Read Model consolide des données de plusieurs services | Données d'un seul service |
| Équipe capable de gérer l'eventual consistency | Équipe junior, premier projet distribué |
Le coût de l'eventual consistency¶
Le CQRS système introduit un délai entre l'écriture et sa visibilité dans le Read Model. Ce délai est généralement de quelques millisecondes a quelques secondes. C'est acceptable pour la majorité des lectures mais pas quand l'utilisateur doit voir immédiatement le résultat de sa propre action.
Gérer le lag de projection¶
Le délai entre une écriture et sa visibilité dans le Read Model doit être géré explicitement. Quatre stratégies.
Optimistic UI¶
Le client appliqué localement la mise à jour sans attendre la confirmation du Read Model. L'interface affiche immédiatement le résultat attendu. Si la commande échoué côté serveur, le client annule la mise à jour locale.
sequenceDiagram
participant UI as Interface
participant W as Write Service
participant R as Read Model
UI->>UI: affiche immediatement le changement
UI->>W: POST /orders (async)
W-->>UI: 201 Created
Note over R: Read Model mis a jour<br/>quelques secondes plus tard Read-after-write depuis le Write DB¶
Immédiatement après une mutation, lire depuis le Write DB pour afficher le résultat à l'utilisateur. Les lectures suivantes (par d'autres utilisateurs) passent par le Read Model.
POST /orders → Write DB
GET /orders/ORD-001 (meme session) → route vers Write DB
GET /orders (listing general) → route vers Read Model
Version token¶
La commande retourné un token de version. Le client passe ce token dans les lectures suivantes. Le Read Service attend que le Read Model atteigne au moins cette version avant de répondre.
POST /orders → {orderId: "ORD-001", version: 42}
GET /orders/ORD-001?after_version=42 → attend que projection >= 42
Polling avec timeout¶
Le client redemande périodiquement jusqu'à ce que le changement apparaisse. Simple mais pas elegant. Acceptable pour les interfaces d'administration ou les processus batch.
| Stratégie | Complexité | Expérience utilisateur | Implémentation |
|---|---|---|---|
| Optimistic UI | Moyenne | Excellente | Logique client |
| Read-after-write | Faible | Bonne | Routage conditionnel |
| Version token | Élevée | Bonne | Coordination Write/Read |
| Polling | Faible | Acceptable | Timer client |
Event sourcing au niveau système¶
Au lieu de persister l'état courant, on persiste les événements qui ont produit cet état. L'état courant est reconstruit en rejouant les événements depuis le début.
Au lieu de order.status = "shipped", on persiste OrderShipped { orderId, shippedAt, carrier }.
graph LR
C[Command] -->|"validated"| ES[(Event Store<br/>append-only)]
ES -->|"stream"| P1[Projector A<br/>Order Status]
ES -->|"stream"| P2[Projector B<br/>Customer History]
ES -->|"stream"| P3[Projector C<br/>Analytics]
P1 --> RM1[(Read Model<br/>Orders View)]
P2 --> RM2[(Read Model<br/>Timeline)]
P3 --> RM3[(Read Model<br/>Metrics)] Avantages système¶
- Audit complet : l'historique est intrinsèque, pas un ajout ultérieur
- Replay : rejouer les événements pour corriger une projection incorrecte
- Nouvelles projections : ajouter un Read Model sans migration — rejouer les événements existants
- Debugging : reproduire exactement l'état du système a n'importe quel instant passe
- Temporal queries : répondre a "quel etait l'état à la date X ?" sans table d'historique
Coûts système¶
- Eventual consistency : les Read Models sont toujours en retard sur l'Event Store
- Schéma évolution : faire évoluer le format des événements passes est complexe
- Snapshots : sans snapshots, reconstruire un agregat avec 50 000 événements est inacceptable
- Complexité opérationnelle : l'Event Store doit être hautement disponible — toute perte est irreversible
- Volume : stocker tous les événements depuis le début peut représenter des volumes importants
Quand l'utiliser¶
L'event sourcing est justifie dans les domaines ou l'historique a de la valeur intrinsèque : finance (mouvements de fonds tracables), conformité reglementaire, gestion de stock, domaines ou "que s'est-il passe ?" est aussi important que "quel est l'état actuel ?".
Pour les domaines CRUD simples ou l'historique n'a pas de valeur métier, la complexité ne se justifie pas.
Stratégies de projection¶
Les projections transforment le flux d'événements en Read Models optimises pour des cas d'usage spécifiques.
Types de projections¶
| Type | Description | Cas d'usage |
|---|---|---|
| Live | Mise à jour en temps réel à chaque événement | Vue courante, dashboard |
| Catch-up | Rejoue depuis un checkpoint | Nouveau Read Model, correction |
| Partitioned | Projection par shard ou par tenant | Multi-tenant, isolation |
| Aggregating | Calcule des aggregats (sommes, moyennes, compteurs) | Analytics, reporting |
Architecture d'un projector¶
graph LR
ES[(Event Store)] -->|subscribe| POS["position: 1042"]
subgraph Projector
H[Event Handler]
CP[Checkpoint Store]
end
ES -->|stream| H
H -->|write| RM[(Read Model)]
H -->|save position| CP Le projector maintient un checkpoint : la position du dernier événement traite. En cas de crash, il reprend depuis le dernier checkpoint. C'est pourquoi le handler doit être idempotent : un événement peut être rejoue après un crash entre le traitement et la sauvegarde du checkpoint.
Projection rebuilds¶
Quand un bug dans le projector a produit des données incorrectes dans le Read Model, on peut :
- Supprimer le Read Model
- Remettre le checkpoint à zero
- Rejouer tous les événements
C'est l'un des avantages majeurs de l'event sourcing : le Read Model est derivable, pas une source de vérité. Si les données sont corrompues, on les régénéré.
Attention au temps de rebuild : rejouer des millions d'événements peut prendre des heures. Stratégies pour accélérer :
- Paralliser par partition (chaque shard de l'Event Store projette independamment)
- Utiliser des snapshots intermédiaires comme point de départ
- Projeter en batch plutôt qu'événement par événement
Snapshotting¶
Sans snapshots, reconstruire l'état d'un agregat nécessité de rejouer tous ses événements depuis le début. Sur un agregat avec des dizaines de milliers d'événements, c'est inacceptable.
Principe¶
Prendre un snapshot périodique de l'état courant de l'agregat. À la reconstruction, charger le snapshot le plus récent puis rejouer uniquement les événements postérieurs.
Stratégies de déclenchement¶
| Stratégie | Description | Avantage |
|---|---|---|
| Seuil de compteur | Snapshot tous les N événements (ex. 100) | Simple, prévisible |
| Seuil de temps | Snapshot toutes les N minutes/heures | Indépendant du volume |
| À la demande | Snapshot après une action coûteuse | Contrôle fin |
| En arriere-plan | Processus asynchrone qui snapshote les agregats | Pas d'impact sur le chemin critique |
Implémentation¶
graph TD
subgraph ES1["Event Store (v1 a v100)"]
E1["OrderCreated → v1"]
E2["ItemAdded → v2"]
E3["ItemAdded → v3"]
E4["... → ..."]
E5["PaymentReceived → v100"]
E1 --> E2 --> E3 --> E4 --> E5
end
SNAP["Snapshot Store<br/>aggregateId: ORD-001<br/>version: 100<br/>state: ..."]
subgraph ES2["Event Store (suite)"]
E6["Shipped → v101"]
E7["Delivered → v102"]
E6 --> E7
end
E5 -.->|"snapshot"| SNAP
SNAP -->|"load snapshot v100"| REC["Reconstruction"]
E7 -->|"apply events v101..v102"| REC
REC -->|"= etat courant en 2 etapes<br/>au lieu de 102"| RESULT["Etat courant"] Snapshots et schéma évolution¶
Quand le format de l'état de l'agregat évolue, les anciens snapshots deviennent incompatibles. Deux approches :
- Versionner les snapshots : inclure un numéro de version. Appliquer une migration à la lecture si le snapshot est dans un ancien format.
- Invalider les snapshots : après un changement de schéma, marquer les anciens snapshots comme invalides. La prochaine reconstruction rejouera depuis le début (ou depuis un snapshot valide plus ancien).
Schéma évolution des événements¶
Un événement OrderShipped v1 a un champ carrier: string. La v2 ajoute trackingUrl: string. Les anciennes instances sont toujours dans l'Event Store.
Upcasting¶
À la lecture, transformer les événements anciens en version courante à la volee. Le code applicatif ne voit que la dernière version.
graph LR
ES["EventStore"] --> V1["OrderShipped_v1"]
V1 --> UP["Upcaster"]
UP --> V2["OrderShipped_v2"]
V2 --> H["Handler"] L'upcaster ajoute les champs manquants avec des valeurs par défaut ou calculees. Les transformations s'accumulent avec les versions : v1 → v2 → v3.
Avantage : le code applicatif ne géré qu'une version. Inconvénient : la chaîne d'upcasters devient longue et fragile.
Versioning explicite¶
Le type de l'événement inclut la version. Le code géré toutes les versions actives.
Avantage : explicite, pas de transformation implicite. Inconvénient : plus verbeux, le handler doit gérer plusieurs versions.
Règles d'évolution¶
| Opération | Safe ? | Explication |
|---|---|---|
| Ajouter un champ optionnel | Oui | Les anciens événements n'ont pas le champ |
| Supprimer un champ | Risque | Les consumers qui l'utilisent cassent |
| Renommer un champ | Non | Équivalent a suppression + ajout |
| Changer le type d'un champ | Non | Incompatibilite binaire |
| Ajouter un nouveau type | Oui | Les anciens consumers ignorent le type inconnu |
Les événements sont immuables
Ne jamais modifier un événement déjà persiste dans l'Event Store. C'est le journal de bord du système. Si un événement est incorrect, émettre un événement compensatoire (ex. OrderShippedCorrected). L'historique reste intact et auditable.
CQRS sans event sourcing¶
CQRS et event sourcing sont souvent presentes ensemble mais sont indépendants.
CQRS sans event sourcing¶
Write DB relationnel normal, les mutations sont persistees en état final. CDC propage les changements vers les Read Models. Moins complexe, bon premier pas.
graph LR
WS[Write Service] -->|"INSERT/UPDATE"| WDB[(PostgreSQL)]
WDB -->|"CDC (Debezium)"| EB[Kafka]
EB --> PRJ[Projector]
PRJ --> RDB[(Elasticsearch)]
QS[Query Service] --> RDB Event sourcing sans CQRS¶
L'Event Store est la seule base, pas de séparation des chemins. Rare en pratique — les projections materialisees sont presque toujours nécessaires pour la performance.
CQRS + event sourcing¶
Le Write path persiste des événements, les Read Models sont des projections. La combinaison la plus puissante et la plus complexe.
Trajectoire recommandee¶
- Début : monolithe avec une seule base PostgreSQL
- Étape 1 : CQRS sans event sourcing — CDC vers Read Models specialises
- Étape 2 : event sourcing sur les sous-domaines qui en ont besoin (audit, finance)
- Étape 3 : CQRS + event sourcing generalise si la complexité le justifie
Chaque étape doit être motivee par un besoin concret, pas par une anticipation théorique. La majorité des systèmes n'ont jamais besoin de dépasser l'étape 1.
Le Read Model est jetable
C'est la propriété la plus liberatrice du CQRS : le Read Model peut être détruit et reconstruit à tout moment. Si la structure ne convient plus, on en crée un nouveau avec un schéma différent, on rejoue les événements ou le CDC, et on bascule. Pas de migration de schéma sur le Read Model — on le régénéré.
Chapitre suivant : Fiabiliser — résilience, observabilité et performance.