WL
Software Engineer
Wassim Lagnaoui
Back to Blog

The Outbox Pattern in Spring Boot: Guaranteed Kafka Delivery

How to guarantee at-least-once Kafka message delivery without distributed transactions: using a pattern I applied in a production ecommerce microservices platform.

WL
Wassim Lagnaoui
Software Engineer
Spring Boot Kafka Microservices

The problem: you can't atomically write to a database and publish to Kafka

Imagine your Order Service does two things when an order is placed: it saves the order to PostgreSQL and publishes an order-created event to Kafka. These are two separate systems. If the database write succeeds but Kafka is temporarily down: or the application crashes between the two calls: the Payment Service never receives the event. The order exists in the database but payment is never triggered. Silent data corruption.

The naive approach is a simple try-catch, but that doesn't help: the failure could be a network partition, a JVM crash, or a Kafka broker restart. You can't reliably catch those.

What you need is atomicity: either both things happen, or neither does. The Transactional Outbox Pattern gives you this without requiring a distributed transaction or two-phase commit.

How the pattern works

The core idea is simple: instead of publishing directly to Kafka, you write the event into a dedicated outbox table in the same database transaction as your business write. A separate process (a poller or a change-data-capture relay) reads the outbox table and publishes to Kafka, then marks the record as sent.

Because the business write and the outbox write happen in a single database transaction, they either both succeed or both roll back. Atomicity is guaranteed by the database, not by any distributed protocol.

┌─────────────────────────────────────────────────────┐
│                  Order Service                      │
│                                                     │
│  BEGIN TRANSACTION                                  │
│    INSERT INTO orders (...)          ← business     │
│    INSERT INTO outbox_events (...)   ← event record │
│  COMMIT                                             │
│                                                     │
│  [Outbox Relay]                                     │
│    SELECT * FROM outbox_events WHERE sent = false   │
│    kafkaTemplate.send(topic, event)                 │
│    UPDATE outbox_events SET sent = true             │
└─────────────────────────────────────────────────────┘

Implementation in Spring Boot

Here's how I implemented this in the Order Service of my ecommerce microservices platform.

Step 1: The outbox table entity

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String id;

    private String topic;       // e.g. "order-created"
    private String aggregateId; // e.g. order UUID

    @Column(columnDefinition = "TEXT")
    private String payload;     // JSON-serialized event

    private boolean sent = false;

    @CreationTimestamp
    private LocalDateTime createdAt;
}

Step 2: Write business data + outbox record in one transaction

@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxRepository;
    private final ObjectMapper objectMapper;

    public Order placeOrder(CreateOrderRequest request) {
        // 1. Business write
        Order order = new Order(request);
        orderRepository.save(order);

        // 2. Outbox write: same transaction
        OrderCreatedEvent event = new OrderCreatedEvent(
            order.getId(),
            order.getUserId(),
            order.getTotalAmount()
        );

        OutboxEvent outbox = new OutboxEvent();
        outbox.setTopic("order-created");
        outbox.setAggregateId(order.getId().toString());
        outbox.setPayload(objectMapper.writeValueAsString(event));
        outboxRepository.save(outbox);

        return order;
        // Both saves committed together: or both rolled back
    }
}

Step 3: The relay: a scheduled poller that publishes and marks as sent

@Component
public class OutboxRelay {

    private final OutboxEventRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 1000) // every second
    @Transactional
    public void relay() {
        List<OutboxEvent> pending = outboxRepository
            .findBySentFalseOrderByCreatedAtAsc();

        for (OutboxEvent event : pending) {
            kafkaTemplate.send(event.getTopic(),
                               event.getAggregateId(),
                               event.getPayload());
            event.setSent(true);
            // Marked sent in the same transaction as the read
        }
    }
}

Why not just use @TransactionalEventListener?

Spring's @TransactionalEventListener(phase = AFTER_COMMIT) fires after the transaction commits and publishes to Kafka directly. This looks clean but it breaks the guarantee: if the JVM crashes after commit but before the listener fires, the event is lost. The outbox survives a crash because the record is already in the database.

Handling duplicates on the consumer side

Because the relay uses at-least-once delivery, a consumer might receive the same event twice (e.g. if the relay crashes after publishing but before marking sent). Your consumers need to be idempotent. A simple approach: store processed event IDs in a processed_events table and skip any event whose ID you've already seen.

@KafkaListener(topics = "order-created")
@Transactional
public void handleOrderCreated(OrderCreatedEvent event) {
    if (processedEventRepository.existsById(event.getEventId())) {
        return; // already processed: skip
    }
    // ... business logic ...
    processedEventRepository.save(new ProcessedEvent(event.getEventId()));
}

Key takeaways

  • Never publish to Kafka directly inside a @Transactional method: Kafka can't participate in a database transaction.
  • Write your event to an outbox table in the same transaction as your business data.
  • A separate relay reads and publishes pending events, then marks them sent.
  • Consumers must be idempotent because at-least-once delivery means duplicates can arrive.
  • This pattern requires no additional infrastructure: just a table, a scheduled job, and your existing Kafka setup.

Want to see the full implementation?

The complete ecommerce microservices platform: including all Kafka producers, consumers, and resilience patterns: is on GitHub.