ediverse Explorer la plateforme

À la une PEPPOL BIS Billing 3.0 L’obligation européenne d’e-invoicing arrive : France sept 2026, Belgique janv 2026, Allemagne 2025.

Transactional Outbox

Une commande métier écrit son état business et son événement à publier dans la même transaction DB locale. Un relayeur asynchrone drague la table outbox vers le broker. Deux writes deviennent une garantie ACID locale, plus une publication eventually consistent.

Problème

Le scénario classique du dual-write : une API ingère un EDIFACT ORDERS, doit persister la commande dans PostgreSQL et publier un événement OrderReceived sur Kafka pour que les downstreams (ERP, WMS, notification) réagissent. Si on écrit dans PostgreSQL puis on publie sur Kafka, et que le process crash entre les deux, la commande existe en base sans qu'aucun consumer ne soit notifié — perte d'événement métier. Si on publie sur Kafka d'abord, on risque la situation inverse (consumer réagit à une commande qui n'a jamais été commitée). 2PC entre PostgreSQL et Kafka existe (XA) mais coûte cher en latence et bloque sur panne coordinateur.

Forces

  • Atomicité métier : garantir que aucun événement n'est publié sans état persisté et inversement.
  • Pas de 2PC : éviter XA entre DB et broker (cf 2PC).
  • Latence acceptable : la publication peut être différée de quelques ms à quelques secondes selon la stratégie de drain.
  • Idempotence du consumer : le relayeur peut publier plusieurs fois (at-least-once), les consumers doivent dédupliquer.
  • Ordre par agrégat : l'ordre des événements pour une même commande doit être préservé.

Solution

Créer une table outbox dans la même base que les tables métier. Toute transaction qui modifie l'état métier inscrit aussi l'événement à publier dans outbox en une seule transaction. Un processus relayeur drague cette table périodiquement ou via CDC, publie sur Kafka, et marque l'entrée comme processed (ou la supprime). Deux stratégies de drain :

  • Polling publisher : un worker lit toutes les N secondes les lignes WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED, publie, met à jour. Simple, mais ajoute du load DB.
  • CDC publisher : Debezium lit le WAL PostgreSQL et publie directement les inserts outbox vers Kafka. Zéro lag, zéro load applicatif, mais ajoute Debezium à la stack.

Structure

            ┌────── Application ──────┐
            │                          │
            │  BEGIN TX;               │
            │  INSERT INTO orders ...; │
            │  INSERT INTO outbox ...; │ ◄── même TX
            │  COMMIT;                 │
            │                          │
            └──────────┬───────────────┘
                       │
                       ▼
            ┌── PostgreSQL ───────────┐
            │  orders (état métier)    │
            │  outbox (événements)     │
            └──────┬───────────────────┘
                   │
              ┌────┴────┐
              │         │
         polling     CDC (Debezium)
              │         │
              ▼         ▼
            ┌── Kafka ────────────────┐
            │  topic: edi.order.events │
            └──────────────────────────┘

Implémentation EDI

Schéma type pour un hub EDI 2026, avec ordering par agrégat préservé :

-- Table outbox avec partitionnement par mois pour l'archivage
CREATE TABLE outbox (
  id              BIGSERIAL PRIMARY KEY,
  aggregate_id    VARCHAR(80) NOT NULL,      -- 'ORDER-12345'
  aggregate_type  VARCHAR(40) NOT NULL,      -- 'Order'
  event_type      VARCHAR(80) NOT NULL,      -- 'OrderReceived'
  payload         JSONB NOT NULL,
  headers         JSONB,                     -- correlation_id, source
  created_at      TIMESTAMPTZ DEFAULT now(),
  published_at    TIMESTAMPTZ,
  partition_key   VARCHAR(40) GENERATED ALWAYS AS (aggregate_id) STORED
);
CREATE INDEX outbox_unpublished
  ON outbox (created_at) WHERE published_at IS NULL;

-- Transaction métier ingestion EDIFACT ORDERS
BEGIN;
  INSERT INTO orders (id, partner, total, status, raw_edifact)
  VALUES ('ORD-12345', 'WALMART', 1234.50, 'RECEIVED', $1);

  INSERT INTO outbox (aggregate_id, aggregate_type, event_type, payload)
  VALUES ('ORD-12345', 'Order', 'OrderReceived', $2);
COMMIT;

-- Worker polling drain (batch de 100)
BEGIN;
  SELECT id, aggregate_id, event_type, payload
  FROM outbox
  WHERE published_at IS NULL
  ORDER BY id
  LIMIT 100
  FOR UPDATE SKIP LOCKED;
  -- ... publish to Kafka ...
  UPDATE outbox SET published_at = now() WHERE id = ANY($ids);
COMMIT;

Pour Debezium, configurer le PostgreSQL connector avec outbox.table.name=outbox, et utiliser le Outbox Event Router SMT qui transforme automatiquement l'insert en événement Kafka clé/valeur structuré. Partition Kafka par aggregate_id pour préserver l'ordre par commande.

Anti-patterns

  • Outbox dans une base différente de la base métier — perd l'atomicité, on revient au problème du dual-write.
  • Pas de purge — la table outbox grossit indéfiniment, les performances se dégradent. Archiver vers cold storage ou supprimer après publication confirmée.
  • Pas d'index sur WHERE published_at IS NULL — le polling devient un full table scan, latence explose.
  • Confondre outbox et event store — l'outbox est éphémère (drainée et purgée), l'event store est permanent (cf Event Sourcing).
  • Pas de partition key Kafka — l'ordre par agrégat est perdu, le consumer voit InvoiceAcked avant InvoiceIssued.

Patterns liés

Sources

  • Richardson C. — Pattern: Transactional Outbox, microservices.io. La page de référence canonique. microservices.io
  • Debezium Documentation — Outbox Event Router SMT. debezium.io
  • Confluent — The Outbox Pattern in Practice, Gunnar Morling, 2019. L'article qui a popularisé l'implémentation Debezium + Kafka.
  • Kleppmann M. — Designing Data-Intensive Applications, O'Reilly 2017, chap. 11 (« Stream Processing »).
  • Richardson C. — Microservices Patterns, Manning 2018, §3.2 (« Reliably publishing events »).