When building microservices architectures, one of the trickiest challenges is maintaining data consistency across service boundaries. The outbox pattern is an elegant solution that guarantees your events reach their destinations, even when things go wrong. Let’s explore how it works and why you should care.
The Problem: Dual Writes
Imagine you’re building an e-commerce system with an Order Service and an Inventory Service. When a customer places an order, you need to:
- Save the order to the database
- Publish an
OrderCreatedevent so the Inventory Service can reserve stock
The naive approach looks tempting:
@Transactional
public void createOrder(Order order) {
// Save to database
orderRepository.save(order);
// Publish event
eventPublisher.publish(new OrderCreatedEvent(order.getId()));
}
But this has a critical flaw: the dual write problem. If the database save succeeds but event publishing fails (network issue, message broker down), you have an order in your system that the Inventory Service never learned about. Your data is inconsistent.
You could retry publishing, but what if the save succeeds, the event publishes, then the process crashes before returning? Now you might publish twice. Both scenarios create data integrity issues that are notoriously hard to debug.
The Outbox Pattern: A Better Way
The outbox pattern solves this elegantly using a single database transaction. Instead of directly publishing events, you:
- Write both your business data AND the event to the database in a single transaction
- Have a separate process poll the outbox table and publish events reliably
This decouples the business transaction from event publishing, ensuring consistency.
How It Works
┌──────────────────────────────────────────────────┐
│ Your Microservice │
├──────────────────────────────────────────────────┤
│ │
│ 1. Single Transaction │
│ ├─ INSERT INTO orders (order data) │
│ └─ INSERT INTO outbox (event record) │
│ │
│ 2. Outbox Poller (separate process/thread) │
│ ├─ SELECT * FROM outbox WHERE published=false │
│ ├─ FOR EACH event: publish to message broker │
│ └─ UPDATE outbox SET published=true │
│ │
└──────────────────────────────────────────────────┘
Implementation Example
The Outbox Table
First, create a table to store unpublished events:
CREATE TABLE outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSON NOT NULL,
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP NULL
);
CREATE INDEX idx_outbox_published ON outbox(published, created_at);
The Event Class
public class OutboxEvent {
private Long id;
private String aggregateId;
private String aggregateType;
private String eventType;
private String payload;
private boolean published;
private LocalDateTime createdAt;
private LocalDateTime publishedAt;
// getters and setters...
}
Publishing Events Via Outbox
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
@Transactional
public Order createOrder(CreateOrderCommand command) {
// Save the order
Order order = new Order(command);
Order savedOrder = orderRepository.save(order);
// Create and save the outbox event
OrderCreatedEvent event = new OrderCreatedEvent(
savedOrder.getId(),
savedOrder.getCustomerId(),
savedOrder.getTotal()
);
OutboxEvent outboxEvent = OutboxEvent.builder()
.aggregateId(savedOrder.getId().toString())
.aggregateType("Order")
.eventType("OrderCreated")
.payload(objectMapper.writeValueAsString(event))
.published(false)
.createdAt(LocalDateTime.now())
.build();
outboxRepository.save(outboxEvent);
return savedOrder;
}
}
The key insight: everything happens in a single @Transactional block. Either both the order and event are persisted, or neither are. No inconsistent state.
The Outbox Poller
A separate component regularly polls the outbox and publishes events:
@Component
public class OutboxPoller {
private final OutboxRepository outboxRepository;
private final MessagePublisher messagePublisher;
private final ObjectMapper objectMapper;
private static final Logger logger = LoggerFactory.getLogger(OutboxPoller.class);
@Scheduled(fixedDelay = 1000) // Poll every second
public void pollOutbox() {
List<OutboxEvent> unpublishedEvents =
outboxRepository.findByPublishedFalseOrderByCreatedAt();
for (OutboxEvent event : unpublishedEvents) {
try {
// Publish to message broker
messagePublisher.publish(
event.getEventType(),
event.getPayload()
);
// Mark as published
event.setPublished(true);
event.setPublishedAt(LocalDateTime.now());
outboxRepository.save(event);
logger.info("Published event: {}", event.getId());
} catch (Exception e) {
logger.error("Failed to publish event: {}", event.getId(), e);
// Don't mark as published; will retry next poll
}
}
}
}
Handling Failure Scenarios
Scenario 1: Database Writes Fail
The entire transaction rolls back. No order, no event. Next attempt starts fresh. ✓
Scenario 2: Event Publishing Fails
The event remains in the outbox with published=false. The poller retries indefinitely. ✓
Scenario 3: Process Crashes After Publishing
On restart, the outbox still contains the event. If we published twice, consumers must handle idempotency (more below). The poller will find it and try again. ✓
Advanced Considerations
Idempotency
Since events might be published multiple times, consumers must be idempotent:
@Service
public class InventoryService {
private final InventoryRepository inventoryRepository;
private final ProcessedEventRepository processedEventRepository;
public void handleOrderCreated(OrderCreatedEvent event) {
// Check if we've already processed this event
if (processedEventRepository.existsById(event.getId())) {
return; // Already processed
}
// Process the event
Inventory inventory = inventoryRepository.findByProductId(event.getProductId());
inventory.decreaseAvailable(event.getQuantity());
inventoryRepository.save(inventory);
// Record that we've processed this event
processedEventRepository.save(new ProcessedEvent(event.getId()));
}
}
Batch Processing
For high-volume scenarios, process events in batches:
@Scheduled(fixedDelay = 500)
public void pollOutboxBatch() {
List<OutboxEvent> unpublishedEvents =
outboxRepository.findByPublishedFalseOrderByCreatedAt(
PageRequest.of(0, 100) // Batch of 100
);
unpublishedEvents.forEach(this::publishAndMarkSafe);
}
Pros and Cons
Advantages
- Atomicity: Business data and events are persisted together
- Reliability: No events are lost due to failures
- Simplicity: Easier to understand than saga patterns
- Standard approach: Well-proven in industry
- Works with any message broker: Not tied to specific technology
Disadvantages
- Eventually consistent: Events aren’t published immediately
- Database overhead: Extra table and writes
- Polling latency: Outbox poller introduces delay
- Storage: Old events accumulate (need cleanup policy)
- Complexity for consumers: Must handle idempotency
Best Practices
-
Index for polling: Create an index on
(published, created_at)for efficient queries - Clean up old events: Archive or delete published events regularly
- Monitor outbox lag: Alert if unpublished events accumulate
- Use unique keys: Prevent accidental duplicate publishing
- Make consumers idempotent: Always assume events might be redelivered
- Log everything: Track publishing attempts for debugging
Conclusion
The outbox pattern elegantly solves the dual-write problem in microservices by leveraging database transactions. It’s simple to implement, reliable, and has become a standard approach in event-driven architectures.
While it introduces some complexity (eventual consistency, idempotent consumers), it eliminates the worst failure modes of naive event publishing. For most microservices systems, it’s worth the extra effort.
The pattern works well in combination with event sourcing, CQRS, and saga-based distributed transactions.
Further Reading:
- Chris Richardson: Event Sourcing pattern
- Enterprise Integration Patterns: Message Publishing
Top comments (0)