in

Outbox Pattern with Spring Boot and Kafka: Reliable Events

Writing to the database and publishing to Kafka are two separate operations, and a crash between them leaves systems inconsistent. The outbox pattern solves this dual-write problem with one transaction. We cover a polling relay in Spring Boot, CDC with Debezium, ordering and idempotency guarantees and day-two operations, with code.

Diagram showing a business row and an outbox row written in one transaction and relayed to Kafka

When an order is saved, the inventory, billing and notification services need to know; the standard way is to publish an event. The problem starts here: writing to the database and sending a message to Kafka are two separate operations on two separate systems, with no atomicity between them. This is called the dual-write problem, and it is the most common source of inconsistency in distributed systems.

The outbox pattern solves it with one idea: write the event not to Kafka but to an outbox table in the same transaction as the business record; let a separate component move the table to Kafka. This article covers the problem, the pattern, two relay options in Spring Boot (polling and Debezium CDC), ordering and idempotency guarantees and day-two operations, with code.

The dual-write problem

@Transactional
public void place(OrderRequest request) {
    Order o = repository.save(Order.from(request));
    kafka.send("order-events", o.getNumber(), new OrderPlaced(o));   // 1) if sent here:
}                                                                    //    the transaction hasn't committed yet; a rollback leaves a "ghost event"

Move the send after the commit (@TransactionalEventListener(AFTER_COMMIT)) and the second scenario appears: the commit happened, the application crashed while sending, or the broker was unreachable — the order exists, the event doesn't. Adding retries doesn't help; if the process dies there is nobody left to retry. Kafka transactions don't solve it either, because the database is not part of that transaction.

The pattern: an event is a row too

CREATE TABLE outbox (
    id             UUID PRIMARY KEY,
    aggregate_type VARCHAR(64)  NOT NULL,    -- 'order'
    aggregate_id   VARCHAR(64)  NOT NULL,    -- order number → Kafka key (ordering)
    event_type     VARCHAR(128) NOT NULL,    -- 'OrderPlaced'
    payload        JSONB        NOT NULL,
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
    sent_at        TIMESTAMPTZ,
    attempts       INT          NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX outbox_pending_idx ON outbox (next_attempt_at) WHERE sent_at IS NULL;
@Transactional
public OrderNumber place(OrderRequest request) {
    Order o = repository.save(Order.from(request));
    outbox.save(OutboxEntry.of("order", o.getNumber().value(),
                "OrderPlaced", json.write(OrderPlaced.from(o))));
    return o.getNumber();          // one commit: both exist, or neither does
}

The business record and the event now live together atomically. One question remains: who moves the outbox row to Kafka?

Relay option 1: Polling publisher

The simplest route, and sufficient for most enterprise scenarios: a scheduled job fetches pending rows, sends them, marks them. To stop multiple application instances from grabbing the same row, use FOR UPDATE SKIP LOCKED:

public interface OutboxRepository extends JpaRepository<OutboxEntry, UUID> {
    @Query(value = """
        SELECT * FROM outbox
        WHERE sent_at IS NULL AND next_attempt_at <= now()
        ORDER BY created_at
        LIMIT 200
        FOR UPDATE SKIP LOCKED
        """, nativeQuery = true)
    List<OutboxEntry> lockPending();
}

@Component
class OutboxRelay {
    private final OutboxRepository outbox;
    private final KafkaTemplate<String, String> kafka;
    private final TransactionTemplate tx;

    @Scheduled(fixedDelay = 500)
    void run() {
        tx.executeWithoutResult(status -> {
            for (OutboxEntry e : outbox.lockPending()) {
                try {
                    kafka.send("order-events", e.aggregateId(), e.payload())
                         .get(5, TimeUnit.SECONDS);                       // wait for broker ack (acks=all)
                    e.markSent();
                } catch (Exception ex) {
                    e.postpone(Duration.ofSeconds((long) Math.min(600, Math.pow(2, e.attempts()))));
                }
            }
        });
    }
}

Kafka producer settings are part of this route: acks=all, enable.idempotence=true, max.in.flight.requests.per.connection=5 (ordering is preserved with the idempotent producer). Latency is sub-second; the cost is one small query every 500 ms.

Relay option 2: CDC with Debezium

At high volume or across many services, Debezium — which tails the database's transaction log — replaces polling. Every INSERT into the outbox table is read from the log and routed to the right topic by Debezium's outbox event router transformation:

{
  "name": "order-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "db", "database.dbname": "erp", "topic.prefix": "erp",
    "table.include.list": "public.outbox",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.table.field.event.payload": "payload"
  }
}

There is no relay in application code; the outbox row lands on the outbox.event.order topic with the order number as its key. Latency is in the milliseconds; the price is operating a Kafka Connect cluster and the database replication settings (wal_level=logical on PostgreSQL).

Criterion

Polling publisher

Debezium CDC

Spring Modulith registry

Latency

Hundreds of ms – a few s

Milliseconds

Immediate (in-process), extra for external publish

Database load

Continuous small queries

Log reading; no queries

Registry table writes/updates

Operational complexity

Low

Medium–high (Kafka Connect)

Low

Ordering

created_at + key; strong with a single relay

Log order; strong

In-process; by key for external publish

Best fit

Medium volume, few services

High volume, many services

Events inside a modular monolith

Ordering and idempotency: the other half of the guarantee

The outbox gives an at-least-once delivery guarantee; if the relay crashes after sending but before marking, the same event goes out a second time. Two complements are therefore needed:

  • Ordering: With the aggregate id (order number) as the Kafka key, the events of one order land on the same partition in production order. There is no ordering guarantee across different orders, and usually none is needed.

  • Consumer idempotency: The consumer writes the ids of events it has processed to a processed_events table in its own transaction; if the same id arrives again, it skips it.

@KafkaListener(topics = "order-events", groupId = "inventory")
@Transactional
public void on(ConsumerRecord<String, String> record) {
    UUID eventId = UUID.fromString(record.headers().lastHeader("id").value()...);
    if (processed.existsById(eventId)) return;               // redelivery: swallow
    inventoryService.reserve(json.read(record.value(), OrderPlaced.class));
    processed.save(new ProcessedEvent(eventId));             // same transaction
}

Day-two operations

  • Cleanup: Sent rows are deleted or archived after 7–30 days; polling slows down as the outbox table grows.

  • Metrics: pending row count, age of the oldest pending row (outbox lag), rows with more than 5 attempts. Alert when lag exceeds a few minutes.

  • DLQ: Rows failing permanently (unserializable payload, unauthorized topic) are moved to a "dead" state and reviewed by a human; they are not retried forever.

  • Schema evolution: Put a version field in the payload; consumers must be able to read the old version too. A Schema Registry (Avro/Protobuf) enforces this rule in multi-service environments.

Pitfalls

  1. Writing the outbox row outside the transaction. Defeats the pattern entirely; the outbox row must be inside the same @Transactional as the business record.

  2. Huge payloads. Put the id plus the changed fields in the event, not the whole document; the consumer fetches from the API if it needs more.

  3. Relying on timestamps for ordering. Clocks differ across instances; the ordering guarantee comes from the key and the partition.

  4. Skipping consumer idempotency. Even when a row shows "sent once", redelivery is always possible.

Frequently asked questions

Does Spring Modulith's event publication registry replace the outbox?

For in-process listeners, yes; it gives the same guarantee with far less code. Externalizing the event to Kafka (@Externalized) happens after the commit; for critical external publishing, republish settings and consumer idempotency are still required.

Don't Kafka transactions solve dual writes?

Kafka transactions only make writes within Kafka atomic (consume–process–produce). A relational database cannot join that transaction; the outbox fills exactly that gap.

When should we move from polling to CDC?

When outbox lag stays above target, when polling load on the database becomes measurable, or when dozens of services use the same pattern. For one or two services and hundreds of events per second, polling is enough.

Conclusion

The outbox pattern reduces "write to the database and send a message" to "write two rows to the database"; together with a relay, key-based ordering and consumer idempotency, it gives the strongest consistency guarantee achievable in distributed systems. Start with polling; move to Debezium when volume and service count grow.

To permanently fix lost events and inconsistencies in your existing integrations, see our integration and automation and software design and architecture services.