ediverse Explore the platform

Spotlight PEPPOL BIS Billing 3.0 The EU e-invoicing mandate is here — France Sept 2026, Belgium Jan 2026, Germany 2025.

Transactional Outbox

A business command writes its business state and the event to publish in the same local DB transaction. An asynchronous relay drains the outbox table to the broker. Two writes become one local ACID guarantee, plus an eventually-consistent publication.

Problem

The classic dual-write scenario: an API ingests an EDIFACT ORDERS, must persist the order in PostgreSQL and publish an OrderReceived event to Kafka so downstreams (ERP, WMS, notifications) react. If you write to PostgreSQL first then publish to Kafka and the process crashes in between, the order exists in DB but no consumer is notified — lost business event. If you publish to Kafka first, you risk the inverse situation (consumer reacts to an order that was never committed). 2PC between PostgreSQL and Kafka exists (XA) but pays in latency and blocks on coordinator failure.

Forces

  • Business atomicity: guarantee that no event is published without persisted state and vice-versa.
  • No 2PC: avoid XA between DB and broker (see 2PC).
  • Acceptable latency: publishing can be delayed from ms to seconds depending on the drain strategy.
  • Consumer idempotency: the relay may publish multiple times (at-least-once); consumers must deduplicate.
  • Order per aggregate: order of events for one order must be preserved.

Solution

Create an outbox table in the same database as the business tables. Any transaction modifying business state also writes the event-to-publish into outbox in one transaction. A relayer process drains this table periodically or via CDC, publishes to Kafka, marks the entry as processed (or removes it). Two drain strategies:

  • Polling publisher: a worker reads every N seconds rows WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED, publishes, updates. Simple, but adds DB load.
  • CDC publisher: Debezium reads the PostgreSQL WAL and publishes outbox inserts directly to Kafka. Zero lag, zero app load, but adds Debezium to the stack.

Structure

            ┌────── Application ──────┐
            │                          │
            │  BEGIN TX;               │
            │  INSERT INTO orders ...; │
            │  INSERT INTO outbox ...; │ ◄── same TX
            │  COMMIT;                 │
            │                          │
            └──────────┬───────────────┘
                       │
                       ▼
            ┌── PostgreSQL ───────────┐
            │  orders (business state) │
            │  outbox (events)         │
            └──────┬───────────────────┘
                   │
              ┌────┴────┐
              │         │
         polling     CDC (Debezium)
              │         │
              ▼         ▼
            ┌── Kafka ────────────────┐
            │  topic: edi.order.events │
            └──────────────────────────┘

EDI implementation

Typical schema for a 2026 EDI hub, with per-aggregate ordering preserved:

-- Outbox table with monthly partitioning for archiving
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;

-- Business EDIFACT ORDERS ingestion transaction
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;

-- Polling drain worker (batch of 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;

For Debezium, configure the PostgreSQL connector with outbox.table.name=outbox and use the Outbox Event Router SMT which transforms the insert into a structured key/value Kafka event automatically. Partition Kafka by aggregate_id to preserve order per order.

Anti-patterns

  • Outbox in a different database than the business one — loses atomicity, back to dual-write problem.
  • No purge — the outbox table grows indefinitely, performance degrades. Archive to cold storage or delete after publication confirmed.
  • No index on WHERE published_at IS NULL — polling becomes a full table scan, latency explodes.
  • Confusing outbox and event store — outbox is ephemeral (drained and purged), event store is permanent (see Event Sourcing).
  • No Kafka partition key — order per aggregate is lost, consumer sees InvoiceAcked before InvoiceIssued.

Sources

  • Richardson C. — Pattern: Transactional Outbox, microservices.io. The canonical reference page. microservices.io
  • Debezium Documentation — Outbox Event Router SMT. debezium.io
  • Confluent — The Outbox Pattern in Practice, Gunnar Morling, 2019. The article that popularised the Debezium + Kafka implementation.
  • Kleppmann M. — Designing Data-Intensive Applications, O'Reilly 2017, ch. 11 ("Stream Processing").
  • Richardson C. — Microservices Patterns, Manning 2018, §3.2 ("Reliably publishing events").