SystemDesign.website
Track 5 ยท Module 5.5 ยท Communication Patterns & Asynchronous Systems

The Transactional Outbox Pattern & CDC

How modern event-driven architectures solve the Dual-Write Disaster. Learn how to atomically publish events to Kafka without distributed transactions using The Transactional Outbox Pattern and Change Data Capture (Debezium).

โฑ๏ธ Reading Time: ~26 min
๐ŸŽฏ Technologies: Postgres WAL, MySQL Binlog, Debezium, Apache Kafka
๐ŸŽฌ Format: Receipt Drawer Analogy + Live Dual-Write Simulator

๐Ÿ’ฅ 1. The Dual-Write Disaster (Why Microservices Fail)

๐Ÿงพ The Analogy: The Cashier's Receipt Drawer

Imagine you run an e-commerce order service. When a user buys a laptop, your service must do two things:
1. Save the order to your SQL database: INSERT INTO orders
2. Publish an event to Kafka: kafka.publish("OrderPlaced") so the warehouse packs the item.

What happens if you simply write to both systems one after the other?

Scenario A: DB First, Kafka Second

1. Database transaction commits successfully (Customer is charged $1,200).
2. Server crashes or Kafka network drops before sending the message!

๐Ÿšจ The Bug: Customer was charged, but Kafka never received the event. The warehouse never ships the laptop!
Scenario B: Kafka First, DB Second

1. Message sent to Kafka (Warehouse starts packing the laptop).
2. Database write fails due to a unique constraint violation or DB timeout!

๐Ÿšจ The Bug: Warehouse ships a $1,200 laptop for an order that does not exist in the database!
๐Ÿ’ก Why Can't We Use Distributed Transactions (2PC / XA)?Two-Phase Commit (2PC) is notoriously slow, introduces single points of failure, locks databases, and almost no modern message broker (Kafka, RabbitMQ, SQS) supports XA transactions. We need an asynchronous, lock-free pattern!

๐Ÿ“ฅ 2. The Solution: The Transactional Outbox Pattern

Instead of talking to two different distributed systems (Database + Kafka), we only write to our local SQL database inside a single, atomic ACID transaction!

1Step 1: Atomic Local Transaction (Orders + Outbox Table)

Insert the order into the orders table AND write the message payload into an outbox_events table in the exact same local ACID transaction:

BEGIN TRANSACTION;
INSERT INTO orders (id, user_id, amount) VALUES (101, 42, 1200.00);
INSERT INTO outbox_events (id, aggregate_type, aggregate_id, payload)
VALUES ('evt_88', 'Order', 101, '{"user_id": 42, "amount": 1200.00}');
COMMIT;
2Step 2: The Message Relay / Change Data Capture (CDC)

A separate, dedicated background process (or CDC engine like Debezium) reads newly committed rows from the outbox_events table and streams them into Apache Kafka.

3Step 3: Downstream Consumer Idempotency

Downstream microservices (Inventory, Notification) receive the event and check processed_events in Redis/SQL before taking action to guarantee Exactly-Once business processing.

โš™๏ธ 3. Polling Publisher vs Transaction Log Tailing (CDC)

How should the background relay read from the outbox table? There are two primary architectural approaches:

Approach A: Polling Publisher (Cron / Background Worker)

A background thread queries the database every 500ms:SELECT * FROM outbox_events WHERE processed = false LIMIT 100 FOR UPDATE SKIP LOCKED;

โ€ข Pros: Simple to implement in code without external infrastructure.
โ€ข Cons: Periodic query overhead on DB CPU; polling latency (500ms).
Approach B: Transaction Log Tailing / CDC (Debezium)

Debezium connects as a replication replica and directly tails the database transaction log (Postgres WAL / MySQL binlog).

โ€ข Pros: Zero SQL query load on the database! Sub-millisecond latency.
โ€ข Cons: Requires running Kafka Connect and Debezium infrastructure.

๐Ÿ’ป 4. Production Transactional Outbox Implementation (Multi-Language)

Here is the production implementation of an atomic Order + Outbox insertion across 5 languages:

Select Language:
// Python 3.10+ SQLAlchemy Transactional Outbox
from sqlalchemy.orm import Session
import uuid, json

def create_order_with_outbox(session: Session, user_id: int, amount: float):
    with session.begin():  # Single atomic ACID transaction
        # 1. Insert domain entity into orders table
        order = Order(id=uuid.uuid4(), user_id=user_id, amount=amount, status="PENDING")
        session.add(order)

        # 2. Insert event payload into outbox_events table
        event_payload = {"order_id": str(order.id), "user_id": user_id, "amount": amount}
        outbox = OutboxEvent(
            id=uuid.uuid4(),
            aggregate_type="ORDER",
            aggregate_id=str(order.id),
            event_type="ORDER_CREATED",
            payload=json.dumps(event_payload)
        )
        session.add(outbox)
    # If commit succeeds, both exist. If DB fails, neither exists!

๐Ÿงช 5. Interactive Lab: Dual-Write Failure vs Outbox Safety

โšก Real-Time Consistency Engine

Dual-Write vs Transactional Outbox Simulator

Trigger network crashes and test data integrity!
1. Architecture Pattern:
2. Network Status:
3. Trigger Action:
๐Ÿ’พ SQL Database0 Orders
No orders saved yet.
๐Ÿ“ฅ Outbox Table0 Events
Outbox table empty.
๐Ÿ“จ Apache Kafka0 Messages
No messages published.
๐Ÿš€ Transactional Outbox Active: Click "๐Ÿ›’ Place Order" to see the atomic ACID commit and CDC event streaming!

๐ŸŽฏ 6. Test Your Understanding

Scenario 1: Downstream Duplicate Messages

The Transactional Outbox pattern guarantees At-Least-Once delivery to Kafka. If Debezium publishes an event but crashes right before acknowledging, the event will be sent twice. How must downstream microservices handle this?

Scenario 2: Polling vs Debezium CDC

Your production database processes 50,000 orders/second. Why is Debezium (Transaction Log Tailing) strictly superior to a Polling Publisher worker script?

โ—‹

Completed this module?

Mark as finished to update your curriculum progress tracker.