Retour au blog
mars 2026·5 min de lectureGénéré par IA
Langue:DeutschEnglishFrançaisÖsterreichischKlingonian Version originale: English

Event sourcing contre CRUD traditionnel

#architecture#backend#databases

Une table CRUD traditionnelle vous donne l'état courant d'une ligne et rien sur la façon dont elle y est arrivée. L'event sourcing inverse cela : il stocke chaque changement qui s'est jamais produit, et l'état courant devient quelque chose que l'on calcule, non que l'on stocke. Cette inversion résout de vrais problèmes et en crée quelques nouveaux : il vaut mieux comprendre les deux avant d'y recourir.

CRUD : l'état en place, l'historique nulle part

Une mise à jour normale d'une table orders écrase la ligne :

UPDATE orders SET status = 'shipped', shipped_at = now() WHERE id = 4821;

Une fois ceci exécuté, l'ancien statut a disparu, à moins que quelque chose d'autre (une table de journal d'audit, des logs applicatifs, un trigger) l'ait capturé séparément, comme un ajout après coup greffé sur le vrai modèle de données. Si un client demande « pourquoi ma commande apparaît-elle comme expédiée alors que je n'ai jamais reçu de notification d'expédition », et que la réponse exige de savoir qu'elle est brièvement passée à shipped puis revenue en arrière à cause d'un bug il y a trois jours, une table CRUD n'a tout simplement plus cette information.

Event sourcing : le journal est la source de vérité

Au lieu de stocker l'état courant, vous stockez une séquence d'événements en ajout seul, et l'état courant se déduit en les rejouant :

// events, in order, for order 4821
[
  { type: 'OrderPlaced', orderId: 4821, items: [...], at: '2026-03-20T10:00:00Z' },
  { type: 'PaymentReceived', orderId: 4821, amount: 4999, at: '2026-03-20T10:01:12Z' },
  { type: 'OrderShipped', orderId: 4821, carrier: 'ups', at: '2026-03-22T14:30:00Z' },
]

L'état courant est un pliage (fold) sur cette liste :

function projectOrder(events) {
  return events.reduce((state, event) => {
    switch (event.type) {
      case 'OrderPlaced':
        return { ...state, id: event.orderId, items: event.items, status: 'placed' };
      case 'PaymentReceived':
        return { ...state, status: 'paid', paidAmount: event.amount };
      case 'OrderShipped':
        return { ...state, status: 'shipped', carrier: event.carrier };
      default:
        return state;
    }
  }, {});
}

const currentOrder = projectOrder(eventsForOrder(4821));

Rien n'est supprimé ni écrasé ici. L'historique complet (chaque état par lequel la commande est passée, et exactement quand) est inhérent au modèle, et non un système séparé qu'il faut penser à entretenir.

Ce que vous gagnez réellement

  • Une vraie piste d'audit, gratuitement. « Ce qui s'est passé, et dans quel ordre » est la donnée primaire, et non un journal dérivé à garder synchronisé avec la « vraie » table.
  • Le voyage dans le temps. Vous pouvez projeter l'état à n'importe quel moment du passé en rejouant les événements jusqu'à cet horodatage seulement : réellement utile pour le débogage (« à quoi cela ressemblait-il juste avant l'arrivée du rapport de bug ») et pour les exigences de conformité qui posent exactement cette question.
  • De nouveaux modèles de lecture sans toucher à la logique d'écriture. Besoin d'une nouvelle vue des données (chiffre d'affaires total par jour, commandes par transporteur) pour un tableau de bord qui n'existait pas à la construction du système ? Écrivez une nouvelle fonction de projection sur le flux d'événements existant. Les événements eux-mêmes ne changent jamais.
  • Des chemins d'écriture et de lecture découplés. Les événements sont le modèle d'écriture ; les projections sont des modèles de lecture construits à partir d'eux, souvent de façon asynchrone. Cela mène naturellement au CQRS (Command Query Responsibility Segregation), où écritures et lectures peuvent évoluer et passer à l'échelle indépendamment.

Ce que cela coûte réellement

  • Toute requête a besoin d'une projection. « Quel est le statut actuel de la commande 4821 » n'est plus une simple recherche de ligne : c'est rejouer tous les événements accumulés par cette commande, à moins de mettre en cache l'état projeté (un « instantané ») et de ne rejouer que les événements postérieurs au dernier instantané.
// snapshot every 50 events instead of replaying from the start each time
function projectWithSnapshot(orderId, snapshot, eventsSinceSnapshot) {
  return eventsSinceSnapshot.reduce(applyEvent, snapshot ?? initialState());
}
  • L'évolution du schéma est plus difficile, pas plus facile. Vous ne pouvez jamais changer ce qu'un ancien événement signifiait : le code écrit aujourd'hui doit encore interpréter correctement un événement OrderPlaced vieux de trois ans, dans la forme qu'il avait alors. Ajouter un nouveau type d'événement est facile ; changer la structure d'un événement existant implique de le versionner (OrderPlacedV2) et de conserver indéfiniment l'ancien gestionnaire.
  • La suppression est volontairement délicate. « Supprimer les données de ce client » (une vraie exigence du RGPD) s'oppose directement à « les événements sont immuables et jamais supprimés ». Les vrais systèmes gèrent cela par le crypto-shredding (chiffrer les champs personnels avec une clé par client, puis supprimer la clé pour rendre les données irrécupérables) plutôt qu'en retirant réellement des événements du journal.
  • Déboguer exige de penser en séquences, pas en instantanés. « Qu'est-ce qui ne va pas avec cette ligne » devient « qu'est-ce qui ne va pas avec cette séquence de 40 événements et la logique de projection qui les plie » : un état d'esprit de débogage vraiment différent, qui demande du temps à une équipe pour s'y sentir à l'aise.

Là où cela vaut le coup

L'event sourcing se rentabilise quand l'historique a en lui-même une vraie valeur métier : grands livres financiers, cycles de vie de commandes, tout ce qui a des exigences d'audit ou de conformité, ou les systèmes où « ce qui a changé et pourquoi » est une question de premier plan que les utilisateurs posent réellement. Une table users qui suit display_name et avatar_url n'en a presque jamais besoin ; personne ne réclame la piste d'audit de la photo de profil de quelqu'un.

Un compromis : tables outbox/audit sans event sourcing complet

Il n'est pas obligatoire de choisir tout ou rien. Un schéma pratique courant est une table CRUD normale pour l'état courant, plus une table order_events en ajout seul alimentée dans la même transaction que chaque écriture :

BEGIN;
UPDATE orders SET status = 'shipped' WHERE id = 4821;
INSERT INTO order_events (order_id, type, payload, at)
  VALUES (4821, 'OrderShipped', '{"carrier":"ups"}', now());
COMMIT;

Cela donne la piste d'audit et les bénéfices du voyage dans le temps pour le débogage sans transformer chaque lecture en rejeu : l'état courant reste une recherche directe de ligne. Vous perdez la souplesse du « les modèles de lecture ne sont que de nouvelles projections », mais pour la plupart des applications, c'est un échange équitable au vu de la simplicité de construction et de raisonnement.

À retenir

CRUD stocke où vous en êtes ; l'event sourcing stocke comment vous y êtes arrivé et en déduit où vous en êtes. La piste d'audit, le voyage dans le temps et les modèles de lecture flexibles sont réels et précieux, mais ils s'accompagnent d'une évolution de schéma plus difficile, d'une suppression délicate et d'un modèle de débogage fondamentalement différent. Recourez à l'event sourcing complet quand l'historique est la raison d'être du système, et pas seulement un plus ; sinon, une table d'audit à côté d'un CRUD normal apporte l'essentiel du bénéfice pour une fraction de la complexité.

Plus récentGuide pratique de l'authentification par JWTPlus ancienLogs structurés : pourquoi le débogage par printf ne passe pas à l'échelle