Transactional Outbox Pattern: Reliable Event Publishing in Microservices

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Quick Start (5 Min): Stop the Dual-Write Nightmare

Picture a typical checkout flow: a customer places an order on your store. Your service commits the order to PostgreSQL and immediately dispatches an order_created event to RabbitMQ or Apache Kafka.

# The naive approach: dual-write anti-pattern
def create_order(order_data):
    db.orders.insert(order_data)  # Step 1: Saves to Postgres
    message_broker.publish("order_created", order_data)  # Step 2: What if this fails?

What happens if Kafka drops the connection right after your database commits? The database holds the order, but your billing, warehouse, and notification services never hear about it. Reversing the sequence backfires just as badly. If you publish to the broker first and your database transaction rolls back, downstream workers end up processing a phantom order.

Engineers call this the dual-write problem. Because relational databases and message brokers do not share a single distributed transaction, coordinating writes across both systems is notoriously unreliable. Two-Phase Commit (2PC) is far too slow and brittle for microservices. Instead, we lean on the Transactional Outbox Pattern.

The 5-Minute Outbox Solution

Stop talking to two separate systems in your request path. Write both your business entity and your outbound message into the same database inside a single atomic ACID transaction.

-- Create an outbox table in your database
CREATE TABLE outbox_messages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(50) NOT NULL,
    aggregate_id VARCHAR(50) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    processed BOOLEAN DEFAULT FALSE
);

Now commit both records together:

def place_order(order_data):
    with db.transaction():
        order_id = db.orders.insert(order_data)
        db.outbox.insert({
            "aggregate_type": "order",
            "aggregate_id": str(order_id),
            "event_type": "OrderPlaced",
            "payload": order_data,
            "processed": False
        })

They share one local transaction. Either both records commit to disk, or neither does. A dedicated background worker then reads rows from outbox_messages and dispatches them to your broker safely.

Deep Dive: How the Transactional Outbox Pattern Works

This pattern decouples your business domain logic from asynchronous message transport. It converts real-time external network calls into a dependable database-driven pipeline.

Core Components

  • Command Handler: Processes incoming API calls, updates domain tables like orders, and writes outgoing events to outbox_messages inside the local transaction.
  • Outbox Table: Operates as a durable, queryable staging queue directly inside your primary datastore (PostgreSQL, MySQL, or SQLite).
  • Message Relay: An asynchronous background worker that extracts pending messages from the outbox table and forwards them to your broker (such as Kafka, RabbitMQ, or AWS SNS/SQS).

Two Main Message Relay Strategies

1. Polling Publisher

A background worker queries the database on a set cadence (for instance, every 500ms):

SELECT * FROM outbox_messages
WHERE processed = FALSE
ORDER BY created_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED;

The FOR UPDATE SKIP LOCKED clause is critical here. It tells Postgres to skip any rows currently claimed by other worker threads. You can run 4 to 8 relay instances concurrently without distributed locks or race conditions. Once a batch lands in your broker, the worker updates processed = TRUE or deletes the rows outright.

2. Transaction Log Tailing (Change Data Capture / CDC)

Rather than running polling queries against your primary tables, you can tap directly into the engine’s transaction stream. Tools like Debezium read the PostgreSQL Write-Ahead Log (WAL) or MySQL binlog and stream mutations to Kafka with sub-10ms latency. This eliminates polling queries completely and offloads query strain from your primary database.

Advanced Usage: Handling Concurrency, Ordering, and Failures

Getting the basic loop working takes an afternoon. Handling production failures takes extra discipline.

At-Least-Once Delivery & Idempotency

Network failures are inevitable. A relay worker might successfully push a message to Kafka, then crash right before committing processed = TRUE back to PostgreSQL. When that worker boots back up, it will resend the exact same event. The outbox pattern guarantees at-least-once delivery, never strictly once.

Your downstream consumers must be idempotent. Always record processed message IDs in a dedicated deduplication table:

def handle_order_placed(event):
    event_id = event["id"]
    with db.transaction():
        if db.processed_events.exists(event_id):
            return  # Duplicate message received; ignore it safely
        
        apply_billing(event["payload"])
        db.processed_events.insert({"event_id": event_id})

Partitioning and Event Ordering

Consider an order updated three times in two seconds: created, address modified, and canceled. If these reach your shipping service out of sequence, you risk fulfilling a canceled order. When publishing outbox rows to Kafka, set the message key to your aggregate_id (such as order_id). Kafka hashes this key to pin every update for that specific order to the exact same partition, preserving strict FIFO ordering.

High-Volume Outbox Cleanup

An e-commerce service handling 50,000 orders a day generates 1.5 million outbox rows every month. Left unmanaged, table bloat slows down scans and exhausts disk space. You have three practical strategies:

  1. Hard Delete on Publish: Run DELETE FROM outbox_messages WHERE id = :id the instant the broker acknowledges the message. This keeps the outbox table lean, rarely exceeding a few dozen rows.
  2. Declarative Table Partitioning: Partition outbox_messages by day. Once a daily partition is processed, run DROP TABLE. This reclaims disk space instantly without triggering heavy row-by-row vacuuming.
  3. Scheduled Batch Pruning: Run a nightly cleanup job during low-traffic windows to remove obsolete rows: DELETE FROM outbox_messages WHERE processed = TRUE AND created_at < NOW() - INTERVAL '3 days' LIMIT 10000;.

Practical Tips: Implementation Checklist and Real-World Advice

Keep these battle-tested rules in mind when deploying outbox tables to production:

  • Keep payloads self-contained: Pack all state changes directly into the event payload. Consumers shouldn’t have to turn around and call your service’s REST API just to fetch missing customer details.
  • Store native structured data: Use PostgreSQL’s JSONB type instead of unindexed strings. It lets you write operational queries against event properties if you ever need to debug stuck payloads.
  • Index for your polling query: Add a partial composite index: CREATE INDEX idx_unprocessed_outbox ON outbox_messages (created_at ASC) WHERE processed = FALSE;. This keeps index lookups blazing fast even if historical records accumulate.
  • Alert on relay lag: Monitor the age of the oldest row where processed = FALSE. If any message sits unhandled for longer than 30 seconds, ping your on-call engineer via PagerDuty.

When seeding staging environments or mocking test payloads, manual data conversion gets tedious. If you need to convert CSV fixture data to JSON for outbox test runs, toolcraft.app/en/tools/data/csv-to-json handles it right in the browser so no internal schemas leave your local machine. It avoids writing throwaway import scripts when verifying edge cases.

Binding event generation directly to your local database transactions stops data drift at the source, giving your microservices a resilient communication backbone that holds up under load.

Share: