CQRS et Event sourcing

Ces deux notions sont presque toujours présentées ensemble, et c’est la première chose à défaire. Ce sont deux décisions indépendantes, aux profils de coût radicalement différents : CQRS est bon marché et souvent déjà à moitié fait sans qu’on l’ait nommé ; l’event sourcing est l’une des décisions les plus chères de tout le catalogue, et elle est rarement justifiée.

Sans event sourcingAvec event sourcing
Sans CQRSLe cas ordinaire, et souvent le bonPresque impraticable : plus aucune requête possible
Avec CQRSLe cas utile, et de très loin le plus fréquentRéservé aux domaines dont l’historique est le produit

La seule dépendance réelle va dans un sens que l’on énonce rarement : l’event sourcing impose CQRS, puisqu’un flux d’événements ne se requête pas. L’inverse est faux — CQRS n’a aucun besoin d’event sourcing, et c’est la combinaison qu’il faut viser par défaut.


CQRS

D’où vient le besoin

Le problème est déjà posé, sans être nommé, dans trois pages précédentes.

Un agrégat est conçu pour écrire en respectant des invariants. Un dépôt dégénère en DAO à quarante méthodes dès qu’on lui demande de servir des écrans. Et l’hexagone n’a pas de bonne réponse pour une liste paginée, triée, filtrée sur six critères.

Ces trois symptômes ont une seule cause :

Un même modèle ne peut pas être simultanément optimal pour décider et pour afficher.

Décider demande des invariants, une frontière transactionnelle, un objet cohérent et petit. Afficher demande des jointures, de la dénormalisation, de la pagination, et exactement les champs de l’écran — ni plus, ni moins. Ce sont des exigences opposées, et vouloir les satisfaire avec le même modèle produit soit un agrégat obèse, soit un dépôt illisible.

Le principe

Command Query Responsibility Segregation — la formule est de Greg Young, dérivée du Command-Query Separation de Bertrand Meyer. Chez Meyer, la règle porte sur la méthode : une méthode change l’état ou renvoie une donnée, jamais les deux. CQRS hisse la même règle du niveau de la méthode à celui du modèle.

Modèle d’écritureModèle de lecture
ContientAgrégats, invariants, règlesDes projections façonnées comme l’écran
Optimisé pourDéciderAfficher
AccèsPar la racine d’agrégatDirectement, en lecture seule
TransactionOuiAucune
Forme du résultatUn agrégatUne structure plate, sans comportement
Warning

La confusion la plus répandue : CQRS ne veut pas dire « deux bases de données ». C’est la forme maximale, celle qui coûte le plus cher, et elle n’est nécessaire que dans une minorité de cas. La forme minimale, c’est deux chemins de code contre la même base — et elle apporte déjà l’essentiel du bénéfice.

La plupart des équipes qui disent avoir « fait du CQRS » et l’avoir regretté avaient sauté directement à la forme maximale.

Les cinq degrés

CQRS n’est pas binaire. C’est une échelle, et savoir où l’on se place est plus utile que le nom lui-même.

DegréChemin de lectureCoûtCohérence
0Le même modèle pour toutNulImmédiate
1Requêtes SQL dédiées renvoyant des structures d’affichage, même baseTrès faibleImmédiate
2Tables ou vues de lecture, mises à jour dans la même transactionFaibleImmédiate
3Projections mises à jour par événements, même baseMoyenDifférée
4Base de lecture séparée — moteur de recherche, cache, répliqueÉlevéDifférée

Le degré 1 est presque toujours le bon, et c’est ce que « CQRS » devrait vouloir dire par défaut. Il ne demande aucune infrastructure, aucun événement, aucune cohérence différée : simplement d’admettre qu’une requête d’affichage n’a pas à passer par un agrégat.

Les degrés 3 et 4 franchissent une frontière qualitative — celle de la cohérence différée — et se décident sur un besoin nommé : volume de lecture qui écrase les écritures, recherche plein texte, tableaux de bord agrégés.

Le degré 1, en code

Deux chemins qui ne se ressemblent pas, et c’est le but.

​
// ════════ Chemin d'ÉCRITURE : agrégat, invariant, transaction ════════
export class ConfirmerReservation {
  constructor(private readonly reservations: ReservationRepository) {}

  async executer(id: ReservationId): Promise<void> {
    const reservation = await this.reservations.parId(id);
    if (!reservation) throw new ReservationInconnue(id);

    reservation.confirmer();                    // La règle vit ici

    await this.reservations.enregistrer(reservation);
  }
}

// ════════ Chemin de LECTURE : requête dédiée, aucun agrégat ════════
export interface LigneAgenda {                  // Façonné comme l'écran
  reservationId: string;
  spectacle: string;
  salle: string;
  debut: string;
  places: number;
  statut: string;
}

export class AgendaDuJour {
  constructor(private readonly pool: Pool) {}

  async pour(jour: string, taille: number, decalage: number): Promise<LigneAgenda[]> {
    const { rows } = await this.pool.query(
      `SELECT r.id AS "reservationId", sp.titre AS spectacle, sa.nom AS salle,
              se.debut, r.places, r.statut
         FROM reservations r
         JOIN seances    se ON se.id = r.seance_id
         JOIN spectacles sp ON sp.id = se.spectacle_id
         JOIN salles     sa ON sa.id = se.salle_id
        WHERE se.debut::date = $1
        ORDER BY se.debut
        LIMIT $2 OFFSET $3`,
      [jour, taille, decalage],
    );
    return rows;    // Aucune entité, aucun dépôt, aucune correspondance de domaine
  }
}
# ════════ Chemin d'ÉCRITURE : agrégat, invariant, transaction ════════
class ConfirmerReservation:
    def __init__(self, reservations: ReservationRepository) -> None:
        self._reservations = reservations

    def executer(self, id: ReservationId) -> None:
        reservation = self._reservations.par_id(id)
        if reservation is None:
            raise ReservationInconnue(id)

        reservation.confirmer()                 # La règle vit ici

        self._reservations.enregistrer(reservation)


# ════════ Chemin de LECTURE : requête dédiée, aucun agrégat ════════
from dataclasses import dataclass


@dataclass(frozen=True)
class LigneAgenda:                              # Façonné comme l'écran
    reservation_id: str
    spectacle: str
    salle: str
    debut: datetime
    places: int
    statut: str


class AgendaDuJour:
    def __init__(self, connexion: Connection) -> None:
        self._connexion = connexion

    def pour(self, jour: date, taille: int, decalage: int) -> list[LigneAgenda]:
        lignes = self._connexion.execute(
            """
            SELECT r.id, sp.titre, sa.nom, se.debut, r.places, r.statut
              FROM reservations r
              JOIN seances    se ON se.id = r.seance_id
              JOIN spectacles sp ON sp.id = se.spectacle_id
              JOIN salles     sa ON sa.id = se.salle_id
             WHERE se.debut::date = %s
             ORDER BY se.debut
             LIMIT %s OFFSET %s
            """,
            (jour, taille, decalage),
        )
        return [LigneAgenda(*l) for l in lignes]   # Ni ORM, ni dépôt, ni domaine
// ════════ Chemin d'ÉCRITURE : agrégat, invariant, transaction ════════
impl<R: ReservationRepository> ConfirmerReservation<R> {
    pub fn executer(&self, id: ReservationId) -> Result<(), Erreur> {
        let mut reservation = self.reservations
            .par_id(id)?
            .ok_or(Erreur::ReservationInconnue(id))?;

        reservation.confirmer()?;               // La règle vit ici

        self.reservations.enregistrer(&reservation)
    }
}

// ════════ Chemin de LECTURE : requête dédiée, aucun agrégat ════════
#[derive(sqlx::FromRow, serde::Serialize)]
pub struct LigneAgenda {                        // Façonné comme l'écran
    pub reservation_id: i64,
    pub spectacle: String,
    pub salle: String,
    pub debut: NaiveDateTime,
    pub places: i32,
    pub statut: String,
}

pub struct AgendaDuJour {
    pool: PgPool,
}

impl AgendaDuJour {
    pub async fn pour(
        &self,
        jour: NaiveDate,
        taille: i64,
        decalage: i64,
    ) -> Result<Vec<LigneAgenda>, sqlx::Error> {
        sqlx::query_as::<_, LigneAgenda>(
            r#"SELECT r.id AS reservation_id, sp.titre AS spectacle, sa.nom AS salle,
                      se.debut, r.places, r.statut
                 FROM reservations r
                 JOIN seances    se ON se.id = r.seance_id
                 JOIN spectacles sp ON sp.id = se.spectacle_id
                 JOIN salles     sa ON sa.id = se.salle_id
                WHERE se.debut::date = $1
                ORDER BY se.debut
                LIMIT $2 OFFSET $3"#,
        )
        .bind(jour).bind(taille).bind(decalage)
        .fetch_all(&self.pool)
        .await                      // Aucun agrégat, aucune correspondance
    }
}

Quatre observations sur ce code, qui sont tout l’intérêt du degré 1.

La requête joint quatre tables — ce qui serait odieux à obtenir par des dépôts d’agrégats, et qui est ici une ligne de SQL lisible.

LigneAgenda n’est pas un objet du domaine : pas de comportement, pas d’invariant, pas d’identité. C’est une structure de transport, et c’est très bien.

Le chemin de lecture ne passe ni par un dépôt, ni par une entité, ni par une correspondance. En architecture hexagonale, c’est un second port de sortie — un port de requête — assumé comme tel, et non une entorse.

L’agrégat, déchargé de l’affichage, peut rester petit : c’est la règle 2 de la conception d’agrégats.

Ce que CQRS coûte

Deux chemins à maintenir. Ajouter un champ affiché touche l’écriture et la lecture. C’est le coût principal, et il est proportionnel au nombre d’écrans.

La tentation d’écrire par la lecture. Un UPDATE glissé dans le chemin de requête contourne tous les invariants. C’est une règle à faire respecter explicitement — le chemin de lecture n’a le droit qu’à des SELECT, et une revue de code doit le vérifier.

Au-delà du degré 2, la cohérence différée devient visible. C’est un changement de nature, traité juste en dessous.

Le piège de l’utilisateur qui ne voit pas sa propre écriture

Dès le degré 3, un scénario se produit systématiquement et détruit la confiance : l’utilisateur crée quelque chose, est redirigé vers la liste, et ne le voit pas. La projection n’est pas encore à jour. Techniquement correct, humainement inacceptable.

Trois remèdes, du plus simple au plus lourd :

RemèdePrincipeLimite
Renvoyer le résultat de la commandeLa réponse à l’écriture contient l’objet créé ; l’écran l’affiche sans relireNe couvre que l’écran qui suit immédiatement
Affichage optimisteL’interface insère la ligne localement en attendant la projectionDivergence visible si l’écriture échoue
Lire ses propres écrituresRouter les lectures de cet utilisateur vers la source d’écriture pendant quelques secondesComplexité d’infrastructure réelle

Le premier suffit dans la grande majorité des cas, et il est presque gratuit. Le choix doit être fait avant de passer au degré 3, jamais après le premier retour d’utilisateur.


Event sourcing

Le principe

L’état n’est pas stocké. Ce qui est stocké, c’est la suite ordonnée et immuable des événements qui l’ont produit. L’état courant est un calcul.

   Stockage classique              Event sourcing
   ─────────────────              ──────────────
   comptes                        evenements
   ┌────┬────────┐                ┌────┬──────────────────┬─────────┐
   │ id │ solde  │                │ n° │ type             │ données │
   ├────┼────────┤                ├────┼──────────────────┼─────────┤
   │ 42 │ 150,00 │                │  1 │ CompteOuvert     │ …       │
   └────┴────────┘                │  2 │ ArgentDepose     │ 200,00  │
                                  │  3 │ ArgentRetire     │  50,00  │
   UPDATE … SET solde = 150       └────┴──────────────────┴─────────┘
   L'histoire est perdue          solde = fold(événements) = 150,00

La différence de fond n’est pas technique. Un UPDATE solde = 150 conserve le quoi et perd le pourquoi ; une suite ArgentDepose, ArgentRetire, FraisPrélevés conserve l’intention de chaque changement.

Un agrégat événementiel

La structure est toujours la même, et repose sur une distinction qu’il ne faut jamais brouiller : décider peut refuser, appliquer ne refuse jamais.

​
type EvenementCompte =
  | { type: 'CompteOuvert'; titulaire: string }
  | { type: 'ArgentDepose'; centimes: number }
  | { type: 'ArgentRetire'; centimes: number };

export class Compte {
  private solde = Montant.zero();
  private ouvert = false;
  private readonly nouveaux: EvenementCompte[] = [];

  // ── Reconstitution : rejouer l'historique. AUCUNE règle ici. ──
  static rejouer(historique: EvenementCompte[]): Compte {
    const compte = new Compte();
    historique.forEach((e) => compte.appliquer(e));
    return compte;
  }

  // ── Décision : vérifier les règles, puis émettre un FAIT ──
  retirer(montant: Montant): void {
    if (!this.ouvert) throw new CompteFerme();
    if (this.solde.moins(montant).estNegatif()) throw new SoldeInsuffisant();
    this.emettre({ type: 'ArgentRetire', centimes: montant.enCentimes() });
  }

  private emettre(e: EvenementCompte): void {
    this.appliquer(e);
    this.nouveaux.push(e);
  }

  // ── Application : muter l'état. Jamais de règle : le passé est déjà valide. ──
  private appliquer(e: EvenementCompte): void {
    switch (e.type) {
      case 'CompteOuvert':
        this.ouvert = true;
        break;
      case 'ArgentDepose':
        this.solde = this.solde.plus(Montant.depuisCentimes(e.centimes));
        break;
      case 'ArgentRetire':
        this.solde = this.solde.moins(Montant.depuisCentimes(e.centimes));
        break;
    }
  }

  prelever(): EvenementCompte[] { return this.nouveaux.splice(0); }
}
from dataclasses import dataclass, field
from typing import Union


@dataclass(frozen=True)
class CompteOuvert:
    titulaire: str


@dataclass(frozen=True)
class ArgentDepose:
    centimes: int


@dataclass(frozen=True)
class ArgentRetire:
    centimes: int


EvenementCompte = Union[CompteOuvert, ArgentDepose, ArgentRetire]


class Compte:
    def __init__(self) -> None:
        self._solde = Montant.zero()
        self._ouvert = False
        self._nouveaux: list[EvenementCompte] = []

    # ── Reconstitution : rejouer l'historique. AUCUNE règle ici. ──
    @classmethod
    def rejouer(cls, historique: list[EvenementCompte]) -> "Compte":
        compte = cls()
        for e in historique:
            compte._appliquer(e)
        return compte

    # ── Décision : vérifier les règles, puis émettre un FAIT ──
    def retirer(self, montant: Montant) -> None:
        if not self._ouvert:
            raise CompteFerme
        if (self._solde - montant).est_negatif():
            raise SoldeInsuffisant
        self._emettre(ArgentRetire(montant.en_centimes()))

    def _emettre(self, e: EvenementCompte) -> None:
        self._appliquer(e)
        self._nouveaux.append(e)

    # ── Application : muter l'état. Jamais de règle. ──
    def _appliquer(self, e: EvenementCompte) -> None:
        match e:
            case CompteOuvert():
                self._ouvert = True
            case ArgentDepose(centimes):
                self._solde = self._solde + Montant(centimes)
            case ArgentRetire(centimes):
                self._solde = self._solde - Montant(centimes)

    def prelever(self) -> list[EvenementCompte]:
        evenements, self._nouveaux = self._nouveaux, []
        return evenements
#[derive(Clone)]
pub enum EvenementCompte {
    CompteOuvert { titulaire: String },
    ArgentDepose { centimes: i64 },
    ArgentRetire { centimes: i64 },
}

#[derive(Default)]
pub struct Compte {
    solde: i64,
    ouvert: bool,
    nouveaux: Vec<EvenementCompte>,
}

impl Compte {
    // ── Reconstitution : rejouer l'historique. AUCUNE règle ici. ──
    pub fn rejouer(historique: &[EvenementCompte]) -> Self {
        let mut compte = Self::default();
        for e in historique {
            compte.appliquer(e);
        }
        compte
    }

    // ── Décision : vérifier les règles, puis émettre un FAIT ──
    pub fn retirer(&mut self, centimes: i64) -> Result<(), ErreurCompte> {
        if !self.ouvert {
            return Err(ErreurCompte::CompteFerme);
        }
        if self.solde - centimes < 0 {
            return Err(ErreurCompte::SoldeInsuffisant);
        }
        self.emettre(EvenementCompte::ArgentRetire { centimes });
        Ok(())
    }

    fn emettre(&mut self, e: EvenementCompte) {
        self.appliquer(&e);
        self.nouveaux.push(e);
    }

    // ── Application : muter l'état. Jamais de règle. ──
    fn appliquer(&mut self, e: &EvenementCompte) {
        match e {
            EvenementCompte::CompteOuvert { .. } => self.ouvert = true,
            EvenementCompte::ArgentDepose { centimes } => self.solde += centimes,
            EvenementCompte::ArgentRetire { centimes } => self.solde -= centimes,
        }
    }

    pub fn prelever(&mut self) -> Vec<EvenementCompte> {
        std::mem::take(&mut self.nouveaux)
    }
}
Warning

La faute qui casse tout : mettre une règle dans appliquer. Un if solde < 0 then throw placé là rendrait impossible le rejeu de l’historique le jour où la règle change — les événements passés, parfaitement légitimes à leur époque, seraient refusés. appliquer ne juge pas, il constate. Toutes les règles vivent dans les méthodes de décision.

Ce que l’event sourcing apporte réellement

Un historique complet et non falsifiable. La piste d’audit n’est pas une fonctionnalité ajoutée : c’est la donnée elle-même. Aucun UPDATE ne peut effacer une trace, parce qu’il n’y a pas d’UPDATE.

L’intention, pas seulement l’état. solde = 150 ne dit pas pourquoi. RemiseCommercialeAccordee, AvoirApplique, FraisPreleves le disent.

La capacité de répondre à des questions non anticipées. C’est le bénéfice le plus sous-estimé : on peut construire aujourd’hui une projection qui répond à une question posée pour la première fois, en rejouant cinq ans de passé. Un stockage classique a jeté l’information.

Le débogage temporel. Reconstituer l’état exact d’un agrégat à l’instant d’un incident, et rejouer la suite.

Ce que l’event sourcing coûte

Cette section est volontairement brutale, parce que la notion est très survendue.

Le versionnement des événements — le vrai problème, et il est permanent. Les événements sont conservés pour toujours. Renommer un champ ou changer une structure signifie que le code doit rester capable de lire la version de 2019, indéfiniment. On y répond par des convertisseurs successifs ou un schéma tolérant, mais la dette ne s’éteint jamais. C’est la difficulté numéro un, et elle n’apparaît qu’au bout de deux ans.

Aucune requête possible. Il n’y a rien à interroger : WHERE statut = 'actif' n’a pas de sens sur un flux. Toute lecture exige une projection — donc CQRS, obligatoire et non optionnel.

Les instantanés. Un agrégat à cent mille événements ne peut pas être rejoué intégralement à chaque accès. Il faut des instantanés périodiques, donc leur génération, leur invalidation et leur versionnement.

La suppression est structurellement impossible. Le droit à l’effacement du RGPD se heurte de plein fouet à un journal immuable. La réponse usuelle est le chiffrement par sujet, dont on jette la clé — technique éprouvée, mais qui ajoute une gestion de clés à un système qui n’en voulait pas.

Les erreurs sont permanentes. Un défaut qui a émis de mauvais événements les laisse dans le journal pour toujours. On corrige par des événements de compensation, jamais en réparant le passé.

Les compétences sont rares. Peu de gens en ont fait, l’écosystème est mince, et une équipe qui apprend l’event sourcing sur un projet à échéance produira les six problèmes ci-dessus en même temps.

Quand c’est justifié

La liste est courte, et il faut s’y tenir.

  • Une obligation d’audit ou réglementaire où l’historique est une exigence légale et non un confort.
  • Un domaine qui est un journal d’événements par nature : comptabilité, grand livre, mouvements de stock, sinistres d’assurance, dossier médical. Le grand livre comptable est un event store vieux de six siècles.
  • Un besoin réel de répondre demain à des questions inconnues aujourd’hui, sur le passé.
  • Le débogage temporel comme exigence produit, pas comme confort d’équipe.
Warning

Et les raisons qui n’en sont pas.

« On voudra peut-être l’historique plus tard. » Une table d’audit alimentée par les événements de domaine donne 80 % du bénéfice pour 5 % du coût. C’est l’alternative à considérer d’abord, et presque toujours la bonne.

« Ça passe mieux à l’échelle. » Un journal en écriture seule a de bonnes propriétés d’écriture, et un coût de lecture qui les annule.

« Ça va avec les microservices. » Aucun rapport. Voir Monolithe, modulaire, microservices.

La variante raisonnable

Une possibilité que presque personne ne nomme, et qui est pourtant la bonne réponse dans la plupart des cas où l’event sourcing est tentant :

L’appliquer à un seul agrégat.

Rien n’oblige à événementialiser tout un système. Le grand livre, le dossier de sinistre, le portefeuille — l’agrégat dont l’historique est le produit — en event sourcing ; tout le reste en stockage classique. Le coût reste circonscrit, le bénéfice est là où il compte, et les six difficultés ci-dessus ne concernent qu’une partie du modèle.

C’est exactement la logique de dosage par sous-domaine appliquée ailleurs dans cette section : on ne choisit pas une technique pour un système, on la choisit pour un morceau.


Décider

L’ordre des questions, qui évite la quasi-totalité des mauvaises décisions.

  1. Avez-vous un problème de lecture ? Dépôt qui gonfle, écrans lents, agrégats chargés puis jetés à 95 %. → CQRS degré 1. La réponse est presque toujours oui, et il faut presque toujours s’arrêter là.
  2. Le volume ou la nature des lectures impose-t-il un stockage dédié ? Recherche plein texte, tableaux de bord agrégés, lectures qui écrasent les écritures. → degré 3 ou 4, en traitant d’abord la question de la cohérence différée visible.
  3. L’historique est-il une exigence légale, ou le produit lui-même ? → event sourcing sur les agrégats concernés uniquement.
  4. Sinon → ni l’un ni l’autre au-delà du degré 1.

Les pièges

Croire que CQRS impose deux bases. Le degré 1 se fait dans la même base, en une après-midi.

Faire de l’event sourcing pour avoir un historique. Une table d’audit suffit.

Faire de l’event sourcing sans CQRS. Impossible en pratique : il n’y a plus rien à requêter.

Des projections non reconstructibles. Si l’on ne peut pas supprimer une projection et la rebâtir depuis le début du journal, on a perdu le bénéfice principal de l’event sourcing — et on ne s’en apercevra que le jour où il faudra corriger un défaut de projection.

Des événements techniques dans le journal. CacheInvalide, LigneMiseAJour ne sont pas des faits métier. Le journal se remplit de bruit qu’il faudra versionner pour toujours.

Écrire par le chemin de lecture. Un UPDATE dans une requête contourne tous les invariants.

Des événements conçus comme des structures de transport. Un événement est un fait métier nommé au passé, pas un sac de champs modifiés. CommandeMiseAJour n’est pas un événement, c’est un UPDATE déguisé.

Références