Skip to content

Event-driven : outbox, CDC et sagas #503

Description

@khalilbenaz

lede: L'outbox garantit qu'un événement part si et seulement si la transaction commit, le CDC le lit du journal, et les sagas coordonnent l'échec réparti — le triptyque event-driven fiable.
lede_en: The outbox guarantees an event fires if and only if the transaction commits, CDC reads it from the log, and sagas coordinate distributed failure — the reliable event-driven triptych.
title_en: Event-driven: outbox, CDC and sagas

Le problème : la double écriture

Le bug fondateur de tout système event-driven est la double écriture. Un service veut à la fois persister en base et publier un événement sur un broker. Ces deux systèmes n'ont pas de transaction commune. Que se passe-t-il si la base commit mais que le broker est injoignable ? Ou l'inverse ? On obtient une incohérence silencieuse : une commande enregistrée dont personne n'est notifié, ou un événement émis pour une transaction qui a rollback. Aucun try/catch ne résout cela proprement — c'est un problème d'atomicité entre deux ressources, et il exige un patron dédié.

L'outbox : atomicité par la table

Le pattern transactional outbox replie le problème dans une seule transaction. Au lieu de publier directement, le service écrit l'événement dans une table outbox de la même base, dans la même transaction que le changement métier. Soit les deux commit, soit aucun. Un processus séparé lit ensuite l'outbox et publie vers le broker.

BEGIN;
  UPDATE orders SET status = 'PAID' WHERE id = 42;
  INSERT INTO outbox (id, aggregate, type, payload)
    VALUES (gen_random_uuid(), 'order', 'OrderPaid',
            '{"orderId":42,"amount":1990}');
COMMIT;

La garantie devient : l'événement existe si et seulement si le changement métier a commit. La publication vers le broker est ensuite un problème de livraison au moins une fois — plus de perte, seulement d'éventuels doublons, que les consommateurs gèrent par idempotence.

CDC : lire le journal plutôt que sonder

Comment vider l'outbox ? Deux voies. La naïve : un job qui SELECT ... WHERE published = false en boucle — simple, mais génère du polling et de la contention. La robuste : le Change Data Capture. Un outil comme Debezium lit directement le journal de réplication (WAL Postgres, binlog MySQL) et streame chaque insertion dans l'outbox vers Kafka, sans requête sur la table.

  • Avantages CDC — pas de polling, latence basse, capte l'ordre exact des commits, découple totalement le producteur du broker.
  • Coût — infrastructure supplémentaire (connecteur, Kafka Connect), une courbe d'apprentissage réelle, et une sensibilité aux migrations de schéma.

Une variante élégante : le outbox pattern de Debezium route directement les lignes outbox vers le bon topic selon une colonne, transformant la table en journal d'événements propre.

Les sagas : coordonner l'échec réparti

Une fois les événements fiables, reste le problème d'orchestrer une transaction métier qui traverse plusieurs services. Sans transaction distribuée (qu'on évite — le 2PC ne passe pas à l'échelle), on utilise une saga : une suite d'étapes locales, chacune avec sa compensation en cas d'échec.

  • Chorégraphie — chaque service réagit aux événements des autres, sans chef d'orchestre. Simple à démarrer, vite illisible : la logique globale n'existe nulle part.
  • Orchestration — un orchestrateur central (souvent une state machine) pilote les étapes et déclenche les compensations. Plus de code, mais un flux lisible et traçable.

Exemple : réserver stock → débiter paiement → confirmer commande. Si le paiement échoue, la saga déclenche libérer stock. Il n'y a pas de rollback global — on compense sémantiquement ce qui a déjà eu lieu.

Pièges et limites

  • L'idempotence n'est pas optionnelle. L'outbox+CDC livre au moins une fois. Tout consommateur qui n'est pas idempotent produira des doublons métier (double débit, double email). Dédupliquez par clé d'événement.
  • L'ordre n'est garanti que par partition. Kafka ordonne dans une partition, pas globalement. Clé de partition = clé d'agrégat, sinon les événements d'une même commande se doublent.
  • Les compensations ne sont pas des rollbacks. Rembourser n'annule pas le fait d'avoir débité — il y a une trace, des effets de bord. Concevez les compensations comme des actions métier réelles.
  • L'outbox grossit. Sans purge des événements publiés, la table enfle. Prévoyez une rétention.

Verdict

En 2026, ce triptyque — outbox pour l'atomicité, CDC pour la livraison sans polling, sagas pour la cohérence répartie — est le socle éprouvé de l'event-driven fiable. Ne bricolez pas la double écriture avec un publish() après le commit() : c'est la source numéro un d'incohérences en production. Commencez par l'outbox, qui à lui seul élimine la majorité des pertes, puis ajoutez CDC et sagas quand l'échelle et la coordination l'exigent.

The problem: the dual write

The founding bug of every event-driven system is the dual write. A service wants to both persist to a database and publish an event to a broker. These two systems share no transaction. What happens if the DB commits but the broker is unreachable? Or the reverse? You get silent inconsistency: a recorded order nobody is notified about, or an event emitted for a transaction that rolled back. No try/catch solves this cleanly — it is an atomicity problem across two resources, and it demands a dedicated pattern.

The outbox: atomicity through a table

The transactional outbox pattern folds the problem into a single transaction. Instead of publishing directly, the service writes the event into an outbox table in the same database, in the same transaction as the business change. Either both commit or neither. A separate process then reads the outbox and publishes to the broker.

BEGIN;
  UPDATE orders SET status = 'PAID' WHERE id = 42;
  INSERT INTO outbox (id, aggregate, type, payload)
    VALUES (gen_random_uuid(), 'order', 'OrderPaid',
            '{"orderId":42,"amount":1990}');
COMMIT;

The guarantee becomes: the event exists if and only if the business change committed. Publishing to the broker is then an at-least-once delivery problem — no more loss, only possible duplicates, which consumers handle via idempotency.

CDC: read the log rather than poll

How do you drain the outbox? Two paths. The naive one: a job looping SELECT ... WHERE published = false — simple, but generating polling and contention. The robust one: Change Data Capture. A tool like Debezium reads the replication log directly (Postgres WAL, MySQL binlog) and streams each outbox insertion to Kafka, without querying the table.

  • CDC upsides — no polling, low latency, captures the exact commit order, fully decouples producer from broker.
  • Cost — extra infrastructure (connector, Kafka Connect), a real learning curve, and sensitivity to schema migrations.

An elegant variant: Debezium's outbox pattern routes outbox rows directly to the right topic based on a column, turning the table into a clean event log.

Sagas: coordinating distributed failure

Once events are reliable, the remaining problem is orchestrating a business transaction that spans several services. Without distributed transactions (which we avoid — 2PC doesn't scale), you use a saga: a sequence of local steps, each with its compensation on failure.

  • Choreography — each service reacts to others' events, with no conductor. Simple to start, quickly illegible: the global logic lives nowhere.
  • Orchestration — a central orchestrator (often a state machine) drives the steps and triggers compensations. More code, but a legible, traceable flow.

Example: reserve stock → charge payment → confirm order. If payment fails, the saga triggers release stock. There is no global rollback — you semantically compensate what already happened.

Pitfalls and limits

  • Idempotency is not optional. Outbox+CDC delivers at least once. Any non-idempotent consumer will produce business duplicates (double charge, double email). Deduplicate by event key.
  • Order is guaranteed only per partition. Kafka orders within a partition, not globally. Partition key = aggregate key, otherwise events for one order interleave.
  • Compensations are not rollbacks. Refunding doesn't undo the fact of charging — there is a trace, side effects. Design compensations as real business actions.
  • The outbox grows. Without purging published events, the table swells. Plan a retention policy.

Verdict

In 2026, this triptych — outbox for atomicity, CDC for polling-free delivery, sagas for distributed consistency — is the proven foundation of reliable event-driven systems. Don't hack the dual write with a publish() after commit(): it is the number-one source of production inconsistency. Start with the outbox, which alone eliminates most loss, then add CDC and sagas when scale and coordination demand it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions