Beyond @Transactional: Solving the Dual-Write Problem in Distributed Microservices
Stop dual-write data inconsistencies. Learn how to architect the Transactional Outbox Pattern using Java, Spring Boot, and PostgreSQL for reliable Kafka events.
The Illusion of a Single Database
In a traditional monolithic application, maintaining data consistency is straightforward. If you need to create a new order and update warehouse inventory, you wrap the logic inside a single database transaction:
@Transactional
public void placeOrder(OrderRequest request) {
orderRepository.save(request.toOrder());
inventoryRepository.decrementStock(request.getItemId(), request.getQuantity());
}
If the inventory update throws an exception, the relational database rolls back the entire transaction. Either both operations succeed, or neither does.
In a distributed microservices architecture, that safety net disappears.
When your OrderService writes a record to a local PostgreSQL database and immediately publishes an event to an Apache Kafka cluster to notify the InventoryService, you are dealing with two completely independent, non-atomic systems.
The Catastrophic Failure Modes
When you attempt to write to a local database and publish an event within the same business method, one of two failures will inevitably occur:
Scenario A (Database First, Network Fails)
[Save to Database: SUCCESS] ──> [Network / Kafka Outage: FAILS]
Result: Order exists in database, but downstream services are never notified.
Scenario B (Publish First, Database Fails)
[Publish to Kafka: SUCCESS] ──> [Database Unique Constraint Violation: FAILS]
Result: Downstream services charge payment or pack inventory for an order that was never saved.
Distributed two-phase commit (2PC) protocols are notoriously slow, fragile, and rarely supported across modern cloud-native message brokers.
To achieve guaranteed consistency without blocking throughput, the industry-standard architecture is the Transactional Outbox Pattern.
The Blueprint: The Transactional Outbox Pattern
Instead of trying to speak to two external systems at once, the microservice performs all its operations within a single, local database boundary.
[Incoming Request]
│
▼
┌───────────────────────────────────────────────────────────┐
│ Local ACID Transaction │
│ ├── 1. Insert into orders table │
│ └── 2. Insert into outbox_events table │
└───────────────────────────────────────────────────────────┘
│
▼
[Outbox Relay / Change Data Capture (CDC)]
│
▼
[Message Broker: Kafka Topic]
- Atomic local write: The application saves the domain entity (orders) and a corresponding event payload into an outbox_events table inside the exact same local @Transactional boundary.
- Asynchronous relay: An independent background process reads the outbox table and publishes the messages to Kafka.
- Acknowledgment: Once Kafka acknowledges receipt, the relay marks the outbox event as published or removes the row.
1. Database Schema for the Outbox
Define a dedicated outbox table designed for high-throughput polling and sequential reads:
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
processed BOOLEAN DEFAULT FALSE,
processed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_outbox_unprocessed ON outbox_events (created_at) WHERE processed = FALSE;
2. The Application Layer: Atomic Persistence
The Spring service writes both the domain entity and the outbox event in one atomic operation:
package com.example.outbox.service;
import com.example.outbox.dto.OrderRequest;
import com.example.outbox.entity.Order;
import com.example.outbox.entity.OutboxEvent;
import com.example.outbox.repository.OrderRepository;
import com.example.outbox.repository.OutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
public OrderService(OrderRepository orderRepository,
OutboxRepository outboxRepository,
ObjectMapper objectMapper) {
this.orderRepository = orderRepository;
this.outboxRepository = outboxRepository;
this.objectMapper = objectMapper;
}
@Transactional
public void createOrder(OrderRequest request) {
// 1. Persist domain entity
Order order = new Order(UUID.randomUUID(), request.getCustomerId(), request.getTotalAmount());
orderRepository.save(order);
// 2. Persist outbox event inside the exact same transaction
try {
String jsonPayload = objectMapper.writeValueAsString(order);
OutboxEvent outbox = new OutboxEvent(
UUID.randomUUID(),
"ORDER",
order.getId().toString(),
"ORDER_CREATED",
jsonPayload,
Instant.now(),
false
);
outboxRepository.save(outbox);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize outbox event payload", e);
}
}
}
3. The Relay Layer: Reliable Kafka Publishing
An asynchronous background worker polls the unprocessed records in the outbox and delivers them to the broker:
package com.example.outbox.relay;
import com.example.outbox.entity.OutboxEvent;
import com.example.outbox.repository.OutboxRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@Component
public class OutboxMessageRelay {
private static final Logger log = LoggerFactory.getLogger(OutboxMessageRelay.class);
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
public OutboxMessageRelay(OutboxRepository outboxRepository, KafkaTemplate<String, String> kafkaTemplate) {
this.outboxRepository = outboxRepository;
this.kafkaTemplate = kafkaTemplate;
}
@Scheduled(fixedDelayString = "${app.outbox.poll-interval-ms:2000}")
@Transactional
public void publishPendingEvents() {
List<OutboxEvent> pendingEvents = outboxRepository.findTop50ByProcessedFalseOrderByCreatedAtAsc();
for (OutboxEvent event : pendingEvents) {
try {
// Publish using aggregateId as the Kafka partition key to preserve message ordering
kafkaTemplate.send("orders-events", event.getAggregateId(), event.getPayload())
.whenComplete((result, ex) -> {
if (ex == null) {
event.setProcessed(true);
event.setProcessedAt(Instant.now());
outboxRepository.save(event);
log.info("Successfully relayed outbox event: {}", event.getId());
} else {
log.error("Failed to relay event to Kafka: {}", event.getId(), ex);
}
});
} catch (Exception e) {
log.error("Synchronous dispatch failure for outbox event: {}", event.getId(), e);
break; // Halt batch progression to preserve order
}
}
}
}
4. Production Engineering Guardrails
Preserve Partition Ordering
Always use the aggregate_id (such as order_id) as the partition key when publishing the Kafka message. This ensures all state transitions for a single business entity land on the exact same Kafka partition and are processed in strict sequence.
Polling vs. Log-Based CDC (Debezium)
Scheduled polling is easy to set up and ideal for small-to-medium systems. For high-volume enterprise platforms processing thousands of writes per second, replace database polling with Change Data Capture (CDC) engines like Debezium. Debezium reads the database write-ahead log (WAL) directly, streaming changes to Kafka with zero query overhead on the operational database.
Idempotency on the Consumer
The Transactional Outbox pattern guarantees at-least-once delivery. If the relay publishes an event to Kafka but crashes before marking the outbox row as processed, it may re-send the message upon restart. Downstream consumers must maintain an idempotency check (e.g., tracking processed message IDs in Redis) to discard duplicates.
Architectural Strategy Matrix
|
Dimension |
Dual-Write (@Transactional + Kafka) |
Distributed 2PC |
Transactional Outbox Pattern |
|
Data Consistency |
Broken (Silent inconsistencies) |
Strong |
Strong (Eventual consistency) |
|
System Latency |
Low |
High (Blocking locks) |
Ultra-low local execution |
|
Broker Resilience |
Fragile (Network crashes drop events) |
Low |
High (Decoupled publishing) |
|
Operational Simplicity |
Deceptively simple |
Complex |
Straightforward |
Summary
In distributed systems, atomicity cannot cross network boundaries. When you attempt to update a local database and publish a message to an event bus inside the same method, failure is a mathematical certainty over time.
By shifting to the Transactional Outbox Pattern, you leverage the battle-tested ACID guarantees of your relational database to capture domain state and outbound events simultaneously. This eliminates the dual-write anti-pattern, guarantees at-least-once delivery, and builds a dependable bridge between relational transactions and event-driven architecture.
Opinions expressed by DZone contributors are their own.