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
outboxvers 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
InvoiceAckedavantInvoiceIssued.
Patterns liés
- Outbox (vue architecturale) — la version générique.
- Transactional Inbox — le miroir consumer du même besoin.
- Change Data Capture — alternative ou complément (CDC sur les tables métier au lieu de l'outbox).
- Idempotency Key (émetteur) — le relayeur doit utiliser une clé idempotente pour ne pas créer de doublons côté consumer.
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 »).