Building Resilient Distributed Systems with the Transactional Outbox Pattern in Spring Boot
In distributed architectures, one of the most common failure modes occurs when updating a local relational database and publishing an event to a message broker like Apache Kafka or RabbitMQ.
If your database transaction commits but the network partition prevents Kafka from receiving the event, your downstream services miss critical updates. Conversely, if you publish to Kafka first and the subsequent DB commit rolls back, you broadcast phantom events.
Here is how to solve the dual-write problem using the Transactional Outbox Pattern in Spring Boot.
The Dual-Write Problem
Consider this innocent-looking Spring service:
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = orderRepository.save(new Order(request));
// Danger: What if Kafka broker is temporarily unreachable?
// What if the DB transaction fails to commit right after this line?
kafkaTemplate.send("order-created", new OrderCreatedEvent(order.getId()));
return order;
}
Distributed transactions (such as Two-Phase Commit / XA) introduce catastrophic latency and tight coupling. We need an asynchronous, eventually consistent mechanism that guarantees at-least-once delivery without distributed locks.
Architecture of the Transactional Outbox Pattern
Instead of sending messages directly to the message broker, we write the event into an outbox table within the exact same database transaction that modifies our domain entities.
+--------------------------------------------------------------------------+
| Application Service |
| |
| 1. BEGIN TRANSACTION |
| 2. INSERT INTO orders (...) |
| 3. INSERT INTO outbox_events (aggregate_id, event_type, payload, ...) |
| 4. COMMIT TRANSACTION |
+--------------------------------------------------------------------------+
|
v
+---------------------------+
| PostgreSQL / MySQL Database|
| |
| - orders table |
| - outbox_events table |
+---------------------------+
|
v
Polling Worker or CDC (Debezium)
|
v
+-------------------+
| Apache Kafka |
+-------------------+
Because relational databases guarantee ACID properties, the domain state change and the event record commit or rollback atomically.
Step 1: The Outbox Entity and Schema
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_outbox_pending ON outbox_events (status, created_at)
WHERE status = 'PENDING';
In Java, map this entity using JPA:
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
private UUID id;
@Column(nullable = false)
private String aggregateType;
@Column(nullable = false)
private String aggregateId;
@Column(nullable = false)
private String eventType;
@Column(columnDefinition = "jsonb", nullable = false)
private String payload;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OutboxStatus status;
private int retryCount;
private Instant createdAt;
private Instant processedAt;
// Constructors, Getters, and Setters
}
Step 2: Atomic Saving in the Application Service
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxEventRepository outboxRepository;
private final ObjectMapper objectMapper;
public OrderService(OrderRepository orderRepository,
OutboxEventRepository outboxRepository,
ObjectMapper objectMapper) {
this.orderRepository = orderRepository;
this.outboxRepository = outboxRepository;
this.objectMapper = objectMapper;
}
@Transactional
public Order placeOrder(CreateOrderRequest request) {
Order order = orderRepository.save(new Order(request));
OrderCreatedEvent event = new OrderCreatedEvent(
order.getId(),
order.getCustomerId(),
order.getTotalAmount()
);
OutboxEvent outboxEvent = new OutboxEvent(
UUID.randomUUID(),
"ORDER",
order.getId().toString(),
"OrderCreated",
objectMapper.writeValueAsString(event),
OutboxStatus.PENDING,
Instant.now()
);
outboxRepository.save(outboxEvent);
return order;
}
}
Step 3: Polling and Publishing Worker with Row-Level Locking
To publish outbox records to Kafka safely without race conditions across multiple application instances, use PostgreSQL SKIP LOCKED:
public interface OutboxEventRepository extends JpaRepository<OutboxEvent, UUID> {
@Query(value = """
SELECT * FROM outbox_events
WHERE status = 'PENDING'
ORDER BY created_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<OutboxEvent> lockPendingEvents(@Param("limit") int limit);
}
Now, implement a scheduled publisher:
@Component
public class OutboxPublisher {
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
public OutboxPublisher(OutboxEventRepository outboxRepository,
KafkaTemplate<String, String> kafkaTemplate) {
this.outboxRepository = outboxRepository;
this.kafkaTemplate = kafkaTemplate;
}
@Scheduled(fixedDelay = 500)
@Transactional
public void publishPendingEvents() {
List<OutboxEvent> events = outboxRepository.lockPendingEvents(50);
for (OutboxEvent event : events) {
try {
kafkaTemplate.send(
"domain-events",
event.getAggregateId(), // Kafka partition key guarantees ordering
event.getPayload()
).get(3, TimeUnit.SECONDS);
event.setStatus(OutboxStatus.PROCESSED);
event.setProcessedAt(Instant.now());
} catch (Exception ex) {
event.setRetryCount(event.getRetryCount() + 1);
if (event.getRetryCount() >= 5) {
event.setStatus(OutboxStatus.FAILED);
}
}
}
}
}
Polling Publisher vs. Change Data Capture (CDC / Debezium)
| Feature | Polling Publisher (SKIP LOCKED) |
CDC Engine (Debezium + Kafka Connect) |
|---|---|---|
| Complexity | Extremely low; zero external infra | Requires Kafka Connect cluster & ZooKeeper/KRaft |
| Database Load | Periodic queries against indexed table | Reads PostgreSQL WAL directly (negligible DB CPU) |
| Latency | 200ms - 1s (polling interval) | 5ms - 20ms (sub-second streaming) |
| Recommended For | Small-to-medium systems | High-throughput, enterprise-scale event streams |
Consumer-Side Idempotency
Because the outbox pattern guarantees at-least-once delivery, consumers must be idempotent. Always include an eventId or idempotencyKey in your payloads and record processed event IDs in an idempotent_consumer table on the receiving service before executing business logic.
The Transactional Outbox pattern eliminates the brittleness of distributed dual-writes, providing rock-solid event delivery in microservice architectures.