Part 189: The Transactional Outbox Pattern with SQL

Part 189 Technology Updated September 12, 2026

Part 189: The Transactional Outbox Pattern with SQL

Guaranteeing 100% reliable event publishing across microservices without distributed transactions.


1. The Dual-Write Problem

In microservices, you often need to save state to a database AND publish a message to Kafka / RabbitMQ: - If the DB commit succeeds, but network fails before Kafka publish: Event lost! - If Kafka publish succeeds, but DB commit rolls back: Ghost event sent!


2. The Solution: The Outbox Table

Save business data AND the event into the database inside the exact same ACID transaction:

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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    processed_at TIMESTAMPTZ NULL
);

3. Atomic Write

BEGIN;

-- 1. Save business record
INSERT INTO orders (id, customer_id, total, status) 
VALUES ('ord_123', 42, 99.95, 'placed');

-- 2. Save event to outbox table in SAME transaction!
INSERT INTO outbox_messages (aggregate_type, aggregate_id, event_type, payload)
VALUES (
    'Order', 
    'ord_123', 
    'OrderPlaced', 
    '{"order_id": "ord_123", "customer_id": 42, "total": 99.95}'::jsonb
);

COMMIT;

4. De-queuing with FOR UPDATE SKIP LOCKED

A background worker polls the outbox without blocking other workers:

WITH next_messages AS (
    SELECT id 
    FROM outbox_messages 
    WHERE processed_at IS NULL
    ORDER BY created_at ASC
    LIMIT 100
    FOR UPDATE SKIP LOCKED -- Multiple workers can run simultaneously!
)
UPDATE outbox_messages
SET processed_at = NOW()
WHERE id IN (SELECT id FROM next_messages)
RETURNING *;

Key Takeaways

  • The Outbox pattern guarantees at-least-once message delivery in microservice architectures.

In Part 190, we explore Distributed Transactions: 2PC vs Saga Orchestration in SQL!