Outbox pattern in practice with Spring Boot
How to guarantee at-least-once delivery without distributed transactions.
Author: Danilo FernandoPublished on March 01, 2025
The problem
Reliably publishing events next to a database transaction is one of the classic pitfalls in distributed systems. If you write to Postgres and then publish to Kafka:
@Transactional
public void confirm(Order order) {
orderRepository.save(order); // ✅ inside DB transaction
kafkaTemplate.send("orders", order); // ❌ outside the transaction
}
A crash between the two calls is enough for the DB to say “confirmed” while the rest of the world never hears about it.
Outbox pattern
The idea is to write the event in the same transaction, in an outbox
table. A relay reads the table and actually publishes it.
Minimal schema
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished_idx
ON outbox (created_at) WHERE published_at IS NULL;
Relay
A simple job reads unpublished events, ships them to the broker and marks them as published. What matters:
- Consumer idempotency — the relay will republish on failure.
- Lag monitoring — alert if
outboxgrows faster than it drains. - Controlled batches — don’t lock the DB pulling 10k rows at a time.
Gotchas
- Never rely on strict broker ordering; partition by
aggregate_id. - Don’t dead-letter into the same table — create
outbox_failed. - Always record the payload schema version.
When to avoid
If your system doesn’t need at-least-once guarantees, outbox is overkill.
Spring’s @TransactionalEventListener covers 80% of cases.
#events #spring-boot #kafka