Creating an order commonly requires two writes: persist the order in a database and publish an OrderCreated event to a broker. If the database commits and publishing fails, inventory and email services never learn about the order. If publishing succeeds before the transaction rolls back, consumers process an order that does not exist. This is the dual-write problem.
Transactional Outbox solves it by storing business data and an event in the same database transaction. An independent relay reads committed events and sends them to Kafka, RabbitMQ, SQS, or another broker.
Why is “commit, then publish” unsafe?
$order = $orders->create($input);
$database->commit();
$broker->publish('OrderCreated', $order);
The process can crash after commit but before publish. Retrying the HTTP request is not a reliable repair when the client received a timeout or request idempotency is incomplete. Publishing before commit creates the opposite inconsistency.
Two independent systems cannot participate in a normal local database transaction. Distributed transactions have different costs and support constraints; an outbox is often a more practical choice for web architectures.
Transactional Outbox flow
- The application starts a database transaction.
- It writes the business change, such as a new order.
- It inserts an event into the outbox table in that same transaction.
- Commit makes both visible, or rollback removes both.
- A relay reads unpublished events and sends them to the broker.
- The relay marks them published, or CDC records its read position.
- Consumers process each event idempotently.
An outbox keeps the event atomic with the source transaction; it does not automatically make the entire pipeline exactly-once.
1. Design the outbox table
CREATE TABLE outbox_events (
id uuid PRIMARY KEY,
aggregate_type varchar(100) NOT NULL,
aggregate_id varchar(100) NOT NULL,
event_type varchar(150) NOT NULL,
event_version integer NOT NULL DEFAULT 1,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
last_error text
);
CREATE INDEX idx_outbox_pending
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;
The unique id supports deduplication. aggregate_id usually becomes the message key so events for one order or customer enter the same partition. event_version versions the payload schema, not the application release.
Include the data consumers need at event time, while avoiding secrets and unnecessary record snapshots. An event is an immutable contract; do not modify historical payloads after insertion.
2. Write business data and the event atomically
BEGIN;
INSERT INTO orders (id, customer_id, total, status)
VALUES (:order_id, :customer_id, :total, 'pending');
INSERT INTO outbox_events (
id, aggregate_type, aggregate_id,
event_type, event_version, payload
) VALUES (
:event_id, 'order', :order_id,
'OrderCreated', 1, :payload::jsonb
);
COMMIT;
Do not call the broker inside this transaction. A network call increases lock duration, and the broker still does not join the database's atomic commit. Keep the transaction local, short, and reversible.
Generate the event ID before insertion. Validate payloads through schemas or tests before commit so an invalid event does not enter the outbox and block the relay.
3. Relay events through polling
A polling publisher is approachable for moderate volume without CDC infrastructure. Multiple workers can claim batches using FOR UPDATE SKIP LOCKED:
BEGIN;
SELECT id, aggregate_type, aggregate_id,
event_type, event_version, payload
FROM outbox_events
WHERE published_at IS NULL
AND available_at <= now()
ORDER BY occurred_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100;
Publishing while holding the transaction simplifies coordination but holds locks and connections longer. Claiming a batch with a lease and committing before publish scales better, but expired claims must recover events when a worker dies.
SKIP LOCKED fits queue-like access. Its inconsistent view makes it unsuitable for general business queries.
4. The duplicate-delivery window
A relay can publish successfully and crash before setting published_at. The next run sends the event again. Marking it first creates a loss window if publishing then fails. Polling outboxes therefore usually provide at-least-once delivery.
Broker deduplication or idempotent producers do not remove every duplicate across an end-to-end distributed workflow. Carry the event ID in a header or envelope and make consumers safe to repeat.
5. Idempotent consumers with an inbox
CREATE TABLE consumed_events (
consumer_name varchar(100) NOT NULL,
event_id uuid NOT NULL,
consumed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
A consumer starts a transaction, inserts the deduplication key, and updates business data. A uniqueness conflict means that event was already handled and can be acknowledged without repeating the side effect:
BEGIN;
INSERT INTO consumed_events (consumer_name, event_id)
VALUES ('inventory-service', :event_id)
ON CONFLICT DO NOTHING;
-- Continue only when the insert created a row
UPDATE inventory
SET reserved = reserved + :quantity
WHERE product_id = :product_id;
COMMIT;
Database transactions cannot cover email, payment, or third-party APIs. Use a provider idempotency key, another local outbox, or a persistent state machine so retries do not send or charge twice.
6. Retries, backoff, and dead letters
Retry temporary network failures with exponential backoff and jitter. Permanent payload failures should not spin forever. After a threshold, place the event in a failed/dead-letter state while preserving it for investigation.
UPDATE outbox_events
SET attempts = attempts + 1,
available_at = now() + (:delay_seconds * interval '1 second'),
last_error = :safe_error
WHERE id = :event_id;
Do not store credentials or sensitive responses in last_error. Monitor oldest-event age, pending count, publish latency, retry rate, and dead-letter count.
7. Event ordering and aggregate versions
Global ordering is expensive and rarely required. Ordering by aggregate, such as one order_id, is usually enough. Use aggregate_id as a partition key and maintain a monotonically increasing aggregate sequence/version.
Consumers should not rely solely on timestamps from different machines. They can store the last applied version, ignore stale events, and temporarily defer an event that arrives before a missing version. Define what happens when a gap persists.
8. Evolve event schemas safely
A consumer may lag behind the producer by days. Do not suddenly change field meanings or remove fields. Prefer additive evolution: optional fields, sensible defaults, and a new event version for breaking changes.
{
"event_id": "...",
"event_type": "OrderCreated",
"event_version": 1,
"occurred_at": "2026-09-19T14:00:00+07:00",
"aggregate_id": "order-123",
"data": {
"customer_id": "customer-9",
"total": 1250000,
"currency": "VND"
}
}
Use contract tests or a schema registry as the number of producers and consumers grows. Event names should describe facts that happened, not ambiguous commands.
9. Polling or CDC?
Polling is simple and easy to debug for small and medium systems. You own workers, leases, retries, cleanup, and polling intervals.
CDC reads the database change log. For example, Debezium can capture the outbox and route records to Kafka through its Outbox Event Router. The application only inserts events, while the connector publishes them. Throughput and latency can improve, but Kafka Connect, offsets, replication slots, and operations add complexity.
CDC does not eliminate consumer idempotency. Connectors or brokers can still redeliver under some failures. Choose according to scale and operating capability, not novelty.
10. Clean up outbox data
An ever-growing table inflates indexes and backups. Do not delete events immediately when audit or replay is required, but define retention. In PostgreSQL, delete in small batches and monitor vacuum; high-volume systems can partition by time and drop old partitions.
DELETE FROM outbox_events
WHERE id IN (
SELECT id
FROM outbox_events
WHERE published_at < now() - interval '14 days'
ORDER BY published_at
LIMIT 5000
);
Do not replay by mass-resetting published_at without safeguards. Build an audited, filtered, rate-limited replay tool and confirm duplicate impact downstream.
11. Observability and alerts
- Age of the oldest pending event.
- Pending, retrying, and dead-letter counts by event type.
- Latency from
occurred_atto broker acknowledgement. - Publish throughput and error rate.
- Consumer lag, duplicate count, and processing failures.
- Table/index size, vacuum health, and replication lag.
Alert on business delay, not merely process liveness. A healthy worker with an event waiting for 30 minutes is still an incident.
12. Common design mistakes
- Inserting the outbox row after the business transaction commits.
- Calling the broker inside the transaction and assuming atomicity.
- Omitting unique event IDs and consumer deduplication.
- Assuming broker exactly-once covers external side effects.
- Using timestamps as the only ordering mechanism.
- Publishing oversized internal models, secrets, or unstable payloads.
- Having no retention, dead-letter flow, replay tooling, or event-age metric.
Production checklist
- Write the business row and outbox row in one local transaction.
- Give each event an ID, type, version, aggregate ID, and timestamp.
- Implement relay backoff, safe leases/locks, and dead letters.
- Deduplicate in the same transaction as the consumer's business update.
- Protect external side effects with idempotency keys or state machines.
- Define ordering by aggregate or partition without overpromising.
- Maintain a backward-compatible event-schema strategy.
- Provide dashboards, retention, controlled replay, and an incident runbook.
Conclusion
Transactional Outbox converts an unsafe dual write into two recoverable steps: commit data with the intent to publish, then relay the committed event to the broker. It embraces the reality of retries and duplicates in distributed systems. Reliability comes from event IDs, idempotent consumers, scoped ordering, stable schemas, and complete observability.




No comments yet. Be the first to share your thoughts.