Back to Saga & Distributed Transactions
Q3Question 3 of 7

What is the Outbox pattern, why does writing to the DB and publishing to Kafka in one method risk inconsistency, and how do the outbox table + CDC (Debezium) guarantee delivery?

Key Concepts
  • The dual-write problem: writing to the DB and Kafka in one method risks one succeeding and the other failing.
  • The Outbox pattern writes the business change and the event row in the SAME local database transaction.
  • A separate relay (polling publisher or CDC via Debezium) reads committed outbox rows and publishes to Kafka.
  • CDC tails the database write-ahead log with zero query load and captures every commit in order.
  • The guarantee is at-least-once delivery: the relay may re-send on crash, so consumers must be idempotent.
  • Use the aggregate ID as the Kafka key to route all events for one account to the same partition for ordering.
  • Published outbox rows need a retention job to prevent unbounded table growth.

Definition

Write the business change and the event into the same database transaction, then let a separate relay ship the event -- never do two writes to two systems in one method and hope both succeed.
  • Dual-write problem – writing to a database and publishing to Kafka in one method touches two independent systems with no shared transaction, so one can succeed while the other fails.
  • Outbox pattern – the service writes the business row AND an outbox row inside the SAME local ACID transaction, guaranteeing they commit or roll back together.
  • Relay – a separate process reads committed outbox rows and publishes them to Kafka; CDC (Debezium) tails the WAL with zero query load.
  • At-least-once – the relay may re-send on crash, so consumers must be idempotent (deduplicate by event ID).
  • Ordering – using the aggregate ID as the Kafka key routes all events for one account to the same partition.

Rajesh transfers 50,000 rupees at National Bank of India and the bank must both debit his account AND send an SMS notification. The teller updates the ledger and drops the SMS slip into a locked outbox drawer in the SAME single action (one local transaction). A separate messenger comes later, opens the drawer, and delivers every slip. Since the ledger and the drawer are in the same safe, they either both commit or both roll back.

Understanding the Concepts

What is the dual-write problem?

A naive method does accountRepo.save(account) then kafkaTemplate.send(event). These touch two independent systems with no shared transaction. Four failure modes exist:

  1. DB commit succeeds, process crashes before Kafka send: money moved but no event published.
  2. Kafka send succeeds, DB transaction rolls back: event published for a transfer that never happened.
  3. Kafka send times out but actually succeeded: retry publishes a duplicate.
  4. Kafka broker is down: the whole method fails even though the business change was valid.
JAVA
// WRONG: dual write – DB + Kafka in one method
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
    Account from = accountRepo.findById(fromId).orElseThrow();
    from.debit(amount);
    accountRepo.save(from);  // DB write

    kafkaTemplate.send("transfer.events", new TransferEvent(fromId, toId, amount)); // Kafka write
    // If crash happens HERE → DB committed but event lost!
}

How does the Outbox pattern fix it?

Instead of writing to Kafka, write an outbox row in the SAME database transaction.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TransferService {

    private final AccountRepository accountRepo;
    private final OutboxRepository outboxRepo;

    public TransferService(AccountRepository accountRepo, OutboxRepository outboxRepo) {
        this.accountRepo = accountRepo;
        this.outboxRepo = outboxRepo;
    }

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        Account from = accountRepo.findById(fromId).orElseThrow();
        from.debit(amount);
        accountRepo.save(from);

        // Outbox row in the SAME transaction
        outboxRepo.save(new OutboxEvent(
            UUID.randomUUID().toString(),
            "Transfer",
            fromId, // aggregate ID
            String.format("{\"from\":\"%s\",\"to\":\"%s\",\"amount\":%s}", fromId, toId, amount)
        ));
        // Both rows commit or roll back together – no window of inconsistency
    }
}

How does the relay publish events?

There are two approaches.

Polling publisher

A scheduled job selects unpublished rows, sends them to Kafka, and marks them published.

CDC with Debezium (production-grade)

Debezium tails the database write-ahead log. It sees committed INSERTs into the outbox table and streams them to Kafka. CDC adds zero query load, captures every commit in order, and does not miss rows between poll intervals.

DIAGRAM
  Debezium CDC Flow
  ┌──────────┐  INSERT outbox row  ┌──────────┐
  │ Service  │ ──────────────────► │PostgreSQL│
  └──────────┘                     │ WAL      │
                                   └────┬─────┘
                                        │ tail WAL
                                   ┌────▼─────┐
                                   │ Debezium │
                                   │ CDC      │
                                   └────┬─────┘
                                        │ publish
                                   ┌────▼─────┐
                                   │  Kafka   │
                                   └──────────┘

Why is the guarantee at-least-once?

The relay may publish an event and crash before marking the row published. On restart it re-publishes the same event. Consumers must be idempotent.

JAVA
package com.bank.consumer;

public class TransferEventConsumer {

    private final ProcessedEventRepository processedRepo;

    public void handle(TransferEvent event) {
        if (processedRepo.existsById(event.getEventId())) {
            // Duplicate – skip
            return;
        }
        // Process the event
        processedRepo.save(event.getEventId());
    }
}

How do you maintain ordering?

Use the aggregate ID as the Kafka key. All events for one account land on the same partition, preserving per-account order.

How do you prevent the outbox table from growing?

A retention job deletes published rows older than a window (e.g., 7 days). For high volume, partition the outbox table by day and drop old partitions.

What are common mistakes?

JAVA
// WRONG: dual write to DB and Kafka
accountRepo.save(account);
kafkaTemplate.send(topic, event); // two systems, no shared transaction

// CORRECT: single write to DB with outbox row
accountRepo.save(account);
outboxRepo.save(outboxEvent); // same DB, same transaction

What should a senior engineer know?

  • The Debezium outbox event router transform maps outbox columns to the correct Kafka topic and key automatically.
  • At-least-once means consumers MUST deduplicate; the dedup check and the business write should be in one transaction.
  • For very high volume, partition the outbox table and use parallel relay workers.
  • The outbox pattern converts an impossible distributed atomic write into a reliable local transaction plus at-least-once delivery with idempotent consumers.

Visual Overview

DIAGRAM
  Dual-Write Problem
  ┌──────────┐  1. save()  ┌────────┐
  │ Service  │ ──────────► │   DB   │  ✓ committed
  │          │  2. send()  ┌────────┐
  │          │ ──────────► │ Kafka  │  ✗ crash before send!
  └──────────┘             └────────┘
  Result: DB and Kafka DISAGREE

  Outbox Pattern
  ┌──────────┐  1 transaction  ┌────────┐
  │ Service  │ ──────────────► │   DB   │  business row + outbox row
  └──────────┘                 └────┬───┘  (atomic: both or neither)
                                    │
                               ┌────▼───┐  2. relay reads outbox
                               │Debezium│
                               │  CDC   │
                               └────┬───┘
                                    │  3. publish
                               ┌────▼───┐
                               │ Kafka  │  at-least-once
                               └────────┘

Key Takeaway

The Outbox pattern solves the dual-write problem: the situation where a service must update its database and publish an event to Kafka, but those are two independent systems with no shared transaction. If you simply commit to the database and then send to Kafka in the same method, there is always a window where one succeeds and the other fails, leaving your systems permanently disagreeing.

The fix is to write the business change and an outbox row in the SAME local database transaction, so they commit or roll back together. A separate relay, ideally CDC via Debezium tailing the write-ahead log, reads committed outbox rows and publishes them to Kafka with zero extra query load and in exact commit order.

The honest trade-off is that this gives at-least-once delivery, not exactly-once. The relay can publish and crash before marking the row published, so consumers must deduplicate by event ID. At National Bank of India, Pankaj used the account ID as the Kafka key so all events for one account land on the same partition and stay ordered, and ran a retention job to delete published rows so the outbox table stayed bounded.

Remember one sentence: write the business change and the event into the same database transaction, and let a separate CDC relay publish the event, because that converts an impossible distributed atomic write into a reliable local transaction plus at-least-once delivery with idempotent consumers.

---

Share
Use ← → arrow keys to navigate