The transactional outbox pattern is a way to guarantee that an event published by your application is either successfully saved to your database and sent to your message broker, or neither happens, even if the system crashes mid-operation.

Let’s see it in action. Imagine a simple e-commerce scenario. When a new order is placed, we want to:

  1. Save the order details to our orders table.
  2. Publish an OrderPlaced event to a message broker (like Kafka or RabbitMQ) so other services can react.

Here’s a simplified SQL schema for our orders table and a new outbox_events table:

CREATE TABLE orders (
    order_id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    order_date TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(50) NOT NULL DEFAULT 'PENDING'
);

CREATE TABLE outbox_events (
    event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type VARCHAR(100) NOT NULL,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id UUID NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    processed_at TIMESTAMP WITH TIME ZONE NULL
);

Now, consider the code that handles placing an order. Instead of directly publishing to Kafka, we’ll insert the event into our outbox_events table within the same database transaction as saving the order.

@Transactional
public Order placeOrder(PlaceOrderCommand command) {
    // 1. Save the order to the database
    Order order = Order.builder()
        .orderId(UUID.randomUUID())
        .customerId(command.getCustomerId())
        .build();
    orderRepository.save(order);

    // 2. Create the outbox event
    OutboxEvent orderPlacedEvent = OutboxEvent.builder()
        .eventType("OrderPlaced")
        .aggregateType("Order")
        .aggregateId(order.getOrderId())
        .payload(Map.of("orderId", order.getOrderId(), "customerId", order.getCustomerId()))
        .build();

    // 3. Save the event to the outbox table
    outboxEventRepository.save(orderPlacedEvent);

    return order;
}

The magic happens because the Order and the OutboxEvent are saved atomically. If the database commit succeeds, both are durable. If the transaction fails (e.g., due to a constraint violation, or the database server goes down), neither is saved. This prevents the "split-brain" scenario where an order is saved but its corresponding event is lost.

How does the event actually get published? That’s the job of a separate, asynchronous process. This process polls the outbox_events table for records where processed_at is NULL.

Here’s a conceptual view of the publisher:

@Scheduled(fixedRate = 5000) // Poll every 5 seconds
public void publishOutboxEvents() {
    List<OutboxEvent> pendingEvents = outboxEventRepository.findByProcessedAtIsNull();

    for (OutboxEvent event : pendingEvents) {
        try {
            // Publish the event to Kafka/RabbitMQ
            messageBroker.publish(event.getEventType(), event.getPayload());

            // Mark the event as processed within a *new* transaction
            // This ensures the event is marked as processed only if publishing succeeds
            markAsProcessed(event.getEventId());

        } catch (Exception e) {
            // Log the error and retry later. The event remains unprocessed.
            log.error("Failed to publish event: {}", event.getEventId(), e);
        }
    }
}

@Transactional
private void markAsProcessed(UUID eventId) {
    OutboxEvent event = outboxEventRepository.findById(eventId)
        .orElseThrow(() -> new IllegalStateException("Event not found: " + eventId));
    event.setProcessedAt(Instant.now());
    outboxEventRepository.save(event);
}

This publisher process must be idempotent. If it publishes an event and then crashes before marking it as processed, the next run will attempt to publish the same event again. The message broker should ideally handle duplicate messages (e.g., Kafka’s at-least-once delivery with consumer deduplication). Our markAsProcessed method, being in its own transaction, ensures that we only mark an event as processed after a successful publish attempt.

The core problem this solves is achieving reliable event publishing in distributed systems where network partitions, service failures, and database issues are common. It decouples the primary business logic (saving an order) from the side-effect of publishing an event, while still maintaining transactional integrity. The outbox_events table acts as a durable buffer, ensuring no events are lost even if the message broker is temporarily unavailable.

A common pitfall is failing to handle the processed_at timestamp correctly. If the markAsProcessed method isn’t transactional, or if it’s executed in the same transaction as the initial order/event save, you reintroduce the possibility of data inconsistency. It must be a separate, atomic operation that commits only after the external system (message broker) acknowledges receipt or the publish operation is confirmed. This separation ensures that if the publish fails, the event remains in the outbox, and if the markAsProcessed transaction fails, the event will be retried.

Once events are reliably published, the next challenge is managing the lifecycle of these outbox events, specifically cleaning up old, processed records from the outbox_events table to prevent it from growing indefinitely.

Want structured learning?

Take the full Event-driven course →