Appearance
Services How We Work Portfolio Why Us About Us FAQ Blog Contact & Consultation Start a Project
Software Architecture

Designing High-Throughput Event-Driven Architectures: Conquering the Dual-Write Problem with Transactional Outbox and CDC

In enterprise software engineering, transitioning from a centralized monolith to a distributed, event-driven microservices architecture is frequently hailed as the ultimate cure for organizational bottlenecks and monolithic scaling limits. However, distribution introduces fundamental fallacies of network computing. Among the most destructive and deceptively simple architectural hazards is the Dual-Write Problem: the challenge of atomically mutating local persistent state while dispatching a corresponding domain event to an external message broker like Apache Kafka or RabbitMQ.

When an engineer attempts to persist an entity to a relational database (such as PostgreSQL) and publish an event to a message broker within the same application execution path, they inevitably bridge two non-cooperating distributed consensus realms. There is no shared transactional context. If the database commit succeeds but the message broker rejects the message due to a network partition, downstream microservices become desynchronized. Conversely, if the message publishes first and the database transaction aborts due to an unhandled constraint violation, phantom events permeate the distributed ecosystem. At enterprise scale—processing tens of thousands of write operations per second—even a 0.01% error rate translates into thousands of corrupted ledger records, orphaned fulfillments, and broken invariants daily.

This technical blueprint presents an enterprise-grade architectural remedy: combining the Transactional Outbox Pattern with Change Data Capture (CDC) via PostgreSQL Logical Decoding and Debezium, paired with strict downstream idempotency primitives. We will analyze the mechanics, trade-offs, production infrastructure configurations, and concrete failure modes of this architecture.

The Anatomy of Failure: Why Naive Dual-Writes Inevitably Corrupt State

To understand why traditional mitigation strategies fail, we must dissect the execution flow of an application-layer dual-write. Consider the canonical service code running in an enterprise application:

// NAIVE APPLICATION-LEVEL DUAL-WRITE ANTIPATTERN
public async function handleOrderCreation(CreateOrderCommand $command): Order
{
    $this->db->beginTransaction();
    try {
        $order = $this->orderRepository->save(Order::fromCommand($command));
        $this->db->commit();
    } catch (Throwable $e) {
        $this->db->rollBack();
        throw $e;
    }

    // CRITICAL RACE / FAILURE WINDOW:
    // If the process crashes here (OOM, SIGKILL, pod eviction),
    // or the network between app and Kafka drops, the event is lost forever.
    $this->kafkaProducer->send(
        topic: 'orders.v1.events',
        key: $order->getId(),
        payload: json_encode($order->toEventPayload())
    );

    return $order;
}

Swapping the order of operations—publishing the event before committing the database transaction—is equally catastrophic. If the broker accepts the message and downstream consumers immediately react (e.g., deducting payment or reserving inventory), the originating database transaction might fail milliseconds later due to an optimistic locking collision (412 Precondition Failed or PostgreSQL SerializationFailure 40001). The rest of the platform has now taken irrevocable real-world actions based on a transaction that never officially occurred.

Why Not Distributed Transactions (2PC / XA)? Historically, architects reached for Two-Phase Commit protocols. In modern cloud-native architectures, XA transactions are considered an anti-pattern. Modern message brokers like Apache Kafka do not implement the XA specification for cross-system distributed coordination. Furthermore, 2PC is a blocking protocol: if the coordinator or any participant becomes unreachable during the prepare phase, locks are held indefinitely, catastrophic latency cascades through the system, and availability plummets under CAP theorem constraints.

The Architectural Anatomy of the Transactional Outbox Pattern

The solution is to decouple event dispatch from cross-network calls by making persistence of both the business aggregate and the intent-to-publish an atomic operation within a single storage engine. By writing outbound messages to a dedicated relational table—the outbox—in the exact same ACID transaction as the domain entities, local transactionality guarantees that either both are stored or neither is.

Once persisted, an asynchronous process must transport these outbox records into the message broker. There are two primary paradigms to accomplish this relay:

  1. Polling Publisher: A background worker continuously queries the outbox table using queries like SELECT * FROM outbox WHERE processed = false ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED, sends them to Kafka, and updates the row status.
  2. Transaction Log Tailing (CDC): A specialized process intercepts database transaction log segments (e.g., PostgreSQL Write-Ahead Logs) as they are committed to disk, parsing low-level row mutations and streaming them directly to Kafka without executing SQL against the primary execution engine.

The Fatal Flaws of Polling at Scale

While the Polling Publisher approach seems simple to implement in application code, it fails under sustained production workloads. Continuous indexing and querying of the outbox table induces significant I/O pressure and buffer pool churn. Rapid row creation and deletion causes severe PostgreSQL table and index bloat, triggering aggressive Autovacuum worker activity that starves production queries of IOPS.

Furthermore, polling intervals introduce intrinsic end-to-end latency. If the worker polls every 500 milliseconds, downstream services experience artificial delays. If high-throughput bursts saturate the poller, backlog processing degrades horizontally. Consequently, high-performance architectures bypass application polling entirely in favor of Transaction Log Tailing via Change Data Capture.

Production Blueprint: PostgreSQL Logical Decoding, Debezium, and Kafka

In this architecture, PostgreSQL’s native Write-Ahead Logging (WAL) acts as the single source of truth. By configuring PostgreSQL for logical replication, the database streams decoded row-level changes to connected consumers via the pgoutput plugin. Debezium, operating within a distributed Kafka Connect cluster, acts as a replication client, transforming WAL entries into Kafka records with sub-millisecond overhead.

1. PostgreSQL Engine Configuration

First, PostgreSQL must be configured to generate sufficient information in its WAL stream for logical decoding. The following directives must be set in postgresql.conf:

# Enable logical decoding in PostgreSQL
wal_level = logical
max_wal_senders = 10
max_replication_slots = 10

# Ensure WAL retention is adequate to survive brief network disconnections
wal_keep_size = 8192MB

# Minimize lock contention on outbox tables
shared_preload_libraries = 'pgoutput'

2. The Optimized Outbox Schema

The outbox schema must be lean. Avoid storing massive JSON blobs when only domain events are required. Ensure columns are structured to facilitate native routing within Kafka Connect transforms:

CREATE TABLE outbox_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(255) NOT NULL,
    aggregate_id VARCHAR(255) NOT NULL,
    event_type VARCHAR(255) NOT NULL,
    payload JSONB NOT NULL,
    headers JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP()
);

-- Crucial for performance: Avoid indexes on write-heavy outbox tables 
-- if we are strictly using CDC. The CDC reader reads the WAL, not the index!
-- Indexes on this table only increase write write-amplification and WAL volume.

3. Production Debezium Connector Configuration

Deploy a Debezium PostgreSQL connector onto your distributed Kafka Connect cluster. Rather than broadcasting the raw internal CDC record structure (which includes before and after metadata), we leverage Debezium’s native Outbox Event Router Single Message Transform (SMT). This transform automatically unwraps the payload, maps the aggregate_id to the Kafka record key (preserving partition ordering), and routes the message to a dynamic topic named after the aggregate_type.

{
  "name": "orders-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "plugin.name": "pgoutput",
    "database.hostname": "postgres-primary.internal",
    "database.port": "5432",
    "database.user": "debezium_cdc",
    "database.password": "${file:/secrets/db-credentials.properties:cdc_password}",
    "database.dbname": "commerce_db",
    "database.server.name": "commerce_engine",
    "table.include.list": "public.outbox_events",
    "tombstones.on.delete": "false",
    
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.route.topic.replacement": "domain.events.${routedByValue}",
    "transforms.outbox.table.field.event.id": "id",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.table.field.event.timestamp": "created_at",
    "transforms.outbox.table.fields.additional.placement": "event_type:header:eventType"
  }
}

Downstream Guarantees: The Idempotent Consumer Pattern

Moving from dual-writes to outbox-driven CDC solves the producer-side delivery guarantee. However, distributed systems theory dictates that Kafka cannot provide physical end-to-end exactly-once delivery across disjoint systems without consumer coordination. Debezium guarantees at-least-once delivery. Network blips, connector restarts, or consumer rebalances can cause identical messages to be received by downstream consumers multiple times.

Therefore, our consumers must be architected for mathematical idempotency: $f(f(x)) = f(x)$. If an event is ingested three times, the system state must change only on the initial ingestion.

Production Idempotent Consumer Implementation

The most resilient mechanism is storing processed message identifiers within the consuming service’s local transactional database using an atomic ledger check. Below is a production-grade consumer implementation pattern leveraging PostgreSQL’s ON CONFLICT DO NOTHING construct within a single transactional boundary:

<?php

declare(strict_types=1);

namespace AppInfrastructureMessaging;

use PDO;
use RuntimeException;
use Throwable;

final class IdempotentKafkaConsumer
{
    public function __construct(
        private PDO $pdo,
        private InventoryManager $inventory
    ) {}

    public function consume(string $eventId, string $eventType, array $payload): void
    {
        $this->pdo->beginTransaction();

        try {
            // 1. Atomic Dedup Lock: Attempt to insert the message footprint
            $stmt = $this->pdo->prepare(
                'INSERT INTO consumed_messages (message_id, event_type, processed_at)
                 VALUES (:id, :type, NOW())
                 ON CONFLICT (message_id) DO NOTHING'
            );
            $stmt->execute(['id' => $eventId, 'type' => $eventType]);

            // If rowCount === 0, the event has already been committed to the ledger
            if ($stmt->rowCount() === 0) {
                $this->pdo->rollBack();
                // Acknowledge offset immediately; event is safely ignored
                return;
            }

            // 2. Execute Domain Business Logic
            match ($eventType) {
                'OrderCreated' => $this->inventory->reserveStock(
                    $payload['order_id'],
                    $payload['sku'],
                    $payload['quantity']
                ),
                default => throw new RuntimeException("Unhandled event type: {$eventType}"),
            };

            // 3. Atomically commit both domain change and deduplication entry
            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e; // Allow Kafka partition consumer to re-try according to backoff policy
        }
    }
}

Operational Resilience: Avoiding Critical Production Pitfalls

While the Transactional Outbox + CDC pattern is exceptionally robust, operating it at high scale requires addressing operational edge cases that can crash databases or stall replication pipelines.

1. PostgreSQL WAL Bloat and Replication Slot Stoppage

A PostgreSQL logical replication slot retains WAL files on disk until the consumer (Debezium) explicitly acknowledges their receipt. If your Kafka Connect cluster crashes, runs out of memory, or encounters network isolation, PostgreSQL will continue to retain every single WAL segment generated by writes across the entire database.

Operational Risk: If left unmonitored, PostgreSQL disks will completely fill, causing the storage engine to immediately transition to read-only emergency mode or crash hard.

Remediation: Always set the max_slot_wal_keep_size parameter in your database configuration. This puts a hard ceiling on WAL retention for replication slots. While exceeding this limit invalidates the slot and requires a fresh CDC snapshot, it saves your production database from fatal downtime due to disk saturation:

-- Cap maximum replication slot retention to 64GB
ALTER SYSTEM SET max_slot_wal_keep_size = '65536MB';
SELECT pg_reload_conf();

2. Outbox Table Pruning Strategies

Because rows written to outbox_events are tail-read via WAL, physical rows inside the PostgreSQL table are obsolete the instant their WAL transaction commits. Left uncleaned, the outbox table accumulates millions of records, degrading performance.

Standard DELETE FROM outbox_events WHERE created_at < NOW() - INTERVAL '1 day' commands generate severe WAL bloat and row locking. Instead, employ declarative table partitioning by date or time slice:

CREATE TABLE outbox_events (
    id UUID NOT NULL,
    aggregate_type VARCHAR(255) NOT NULL,
    aggregate_id VARCHAR(255) NOT NULL,
    event_type VARCHAR(255) NOT NULL,
    payload JSONB NOT NULL,
    headers JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Dropping partitions is an instantaneous metadata operation: zero WAL bloat!
DROP TABLE IF EXISTS outbox_events_y2026_m03_d28;

Alternatively, the Debezium Outbox Router transform supports an option to automatically execute atomic deletes of outbox rows after streaming (via the route.by.field and table delete coordination), or you can execute the Truncate-Partition lifecycle via scheduled cron tasks.

Architectural Decision Matrix: Choosing the Right Event Pattern

Pattern Throughput Capacity Complexity Consistency Guarantee Primary Failure Vector
Application Dual-Write Low / Medium Very Low Eventual Inconsistency (High Loss) Network drops between DB commit and broker publish.
Outbox + Polling Publisher Medium (< 2k writes/sec) Low / Moderate At-Least-Once DB locking, vacuum churn, queue backlog latency.
Outbox + CDC (Debezium/WAL) Ultra-High (> 50k writes/sec) Moderate / High Strict At-Least-Once WAL accumulation, replication slot management.
Native Event Sourcing High Extremely High Deterministic Consistency Schema evolution challenges, projection rebuilding overhead.

Conclusion & Strategic Takeaways

In distributed system design, accepting that networks and infrastructure are fundamentally unreliable is the foundational prerequisite of high-availability engineering. The dual-write antipattern is a structural flaw that cannot be fixed by retries, circuit breakers, or try-catch blocks.

By adopting the Transactional Outbox Pattern backed by PostgreSQL Logical Decoding and Debezium, enterprise engineering teams achieve clear architectural separation of concerns:

  • Transactional Integrity: Application code writes strictly to its native ACID relational storage engine. Cross-system network operations are completely removed from the transaction boundary.
  • Sub-Millisecond Event Streaming: Debezium reads directly from memory and disk-level write-ahead logs, completely circumventing SQL query execution overhead and index maintenance.
  • Robust Resiliency: Network partitions, application process restarts, and Kafka cluster rebalancing events no longer generate orphaned data or silent failures.
  • Downstream Idempotency: Idempotent consumer design guarantees that at-least-once message delivery produces exactly-once business outcomes.

For organizations scaling their microservices infrastructure beyond entry-level throughput, removing the dual-write vulnerability is not an optimization—it is an absolute architectural prerequisite for mission-critical reliability.

Tags: #Apache Kafka #Change Data Capture #Event-Driven Architecture #PostgreSQL #Software Architecture

Tubagus Putra Kencana

Founder & Principal Engineer at Study Morph. Specializing in modern web architecture, cloud systems, and scalable digital platforms.

Interested in Building a Modern Digital System?

Study Morph helps organizations engineer high-performance web applications, custom business platforms, and automated cloud workflows.

Discuss Your Architecture Needs →

Ready to Build Something Great?

Tell us what you want to build. We help turn your ideas into resilient, secure, and production-ready digital software.

Chat WhatsApp