Back to listArchitecture • 9 min read

Outbox pattern in practice with Spring Boot

How to guarantee at-least-once delivery without distributed transactions.

Author: Danilo FernandoPublished on March 01, 2025
Outbox pattern in practice with Spring Boot

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:

  1. Consumer idempotency — the relay will republish on failure.
  2. Lag monitoring — alert if outbox grows faster than it drains.
  3. 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