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.
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.
// 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.
- DB transaction includes outbox row.
- Relay publishes after commit.
- Consumers remain idempotent for relay retries.
Test yourself
Answer these before moving on — recall is what makes it stick.