DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Implementing Asynchronous Communication Between Microservices Using Kafka and Spring Boot
  • Combining Temporal and Kafka for Resilient Distributed Systems
  • Building Fault-Tolerant Kafka Consumers in Spring Boot Using Retry, DLQ, and Idempotent Code Patterns
  • Ensuring Data Consistency in Distributed Systems With the Transactional Outbox Pattern

Trending

  • Designing Agentic Systems Like Distributed Systems
  • Apple-OpenAI Fight Escalates With New MacBook Evidence
  • The Startup Time Trick Hiding Inside Your Docker Build
  • Beyond Token Intelligence: Why AI Code Review Needs Cognitive Architectures
  1. DZone
  2. Software Design and Architecture
  3. Microservices
  4. Beyond @Transactional: Solving the Dual-Write Problem in Distributed Microservices

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.

By 
Rahul Tewari user avatar
Rahul Tewari
·
Oct. 06, 26 · Analysis
Likes (1)
Comment
Save
Tweet
Share
212 Views

Join the DZone community and get the full member experience.

Join For Free

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:

Java
 
@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.

Plain Text
 
                   [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]


  1. 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.
  2. Asynchronous relay: An independent background process reads the outbox table and publishes the messages to Kafka.
  3. 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:

SQL
 
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:

Java
 
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:

Java
 
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.

Relational database kafka microservices

Opinions expressed by DZone contributors are their own.

Related

  • Implementing Asynchronous Communication Between Microservices Using Kafka and Spring Boot
  • Combining Temporal and Kafka for Resilient Distributed Systems
  • Building Fault-Tolerant Kafka Consumers in Spring Boot Using Retry, DLQ, and Idempotent Code Patterns
  • Ensuring Data Consistency in Distributed Systems With the Transactional Outbox Pattern

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook