Aller au contenu

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 :

  1. Supprimer le Read Model
  2. Remettre le checkpoint à zero
  3. 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.

Reconstitution = snapshot(version N) + evenements(N+1..latest)

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.

OrderShipped_v1 { orderId, carrier }
OrderShipped_v2 { orderId, carrier, trackingUrl }

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

  1. Début : monolithe avec une seule base PostgreSQL
  2. Étape 1 : CQRS sans event sourcing — CDC vers Read Models specialises
  3. Étape 2 : event sourcing sur les sous-domaines qui en ont besoin (audit, finance)
  4. É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.