PrepZone Logo
PrepZone

Transactional Outbox Pattern (Deep Dive)

Atomically save the order and the outbox row — then relay to Kafka without dual-write races.

Why this matters

  • VaultCommerce order INSERT and outbox INSERT commit atomically in Postgres.
  • Relay polls outbox with FOR UPDATE SKIP LOCKED — horizontal scale without duplicate publish.
  • At-least-once relay means consumers still need idempotency.
  • Industry standard before reaching for Kafka transactions alone.
@TransactionalOrder + outbox row
Outbox relay
Kafka topic
Save business row and outbox row in one DB transaction. Relay process publishes to Kafka asynchronously.

Outbox table design

Columns: id, aggregate_type, aggregate_id, event_type, payload (JSON/Avro bytes), created_at, published_at. Index on (published_at) WHERE published_at IS NULL. VaultCommerce relay batches 100 rows per tick.

Key points

  • Outbox table — staging events in OLTP alongside business rows
  • Relay — process that reads outbox and publishes to Kafka
  • Same transaction — business write + outbox insert atomic
  • SKIP LOCKED — concurrent relay workers without row contention
  • Debezium outbox event router — CDC-based relay from outbox table

Relay implementation

Debezium outbox SMT or custom poller. Poller marks published_at after Kafka ack. Crash between publish and mark → duplicate on relay (safe with idempotent consumers). VaultCommerce uses Debezium outbox router for zero custom relay code.

VaultCommerce rollout checklist

Before promoting changes that touch the VaultCommerce order and payment event backbone, run the staging KRaft cluster (Kafka 3.7+, Schema Registry 7.x) through a 10k events/min soak test. Compare producer request latency p99 and consumer lag per group against the pre-deploy baseline. DB transaction includes outbox row. Document the change in the internal topic registry, attach Grafana screenshots to the change ticket, and keep an engineer on lag dashboards for 30 minutes after production rollout — roll back the service release before altering broker-level settings if lag or under-replicated partitions spike.

Java
// VaultCommerce — atomic order + outbox in one @Transactional
@Transactional
public Order placeOrder(PlaceOrderCommand cmd) {
    Order order = orderRepo.save(Order.from(cmd));
    outboxRepo.save(new OutboxEvent(
        UUID.randomUUID(),
        "Order",
        order.getId(),
        "OrderPlaced",
        avroMapper.toBytes(new OrderPlaced(order.getId(), order.getCustomerId(), order.getTotal()))
    ));
    return order; // Kafka publish happens async via relay — not here
}

Quick recall

Everything you need if you only revisit this box.

  1. DB transaction includes outbox row.
  2. Relay publishes after commit.
  3. Consumers remain idempotent for relay retries.

Test yourself

Answer these before moving on — recall is what makes it stick.