Transactional outbox delivery

Write the event intention with the business change

Keep the order update and outbox insert in one transaction. Then give the publisher stable event identities and a clear recovery path for uncertain delivery.

In this article

Define the event contract

Choose the event's meaning before creating its table. OrderConfirmed should describe a completed business transition, not merely that an update statement ran. Include only the fields consumers need and version the payload contract.

Generate a stable event identifier for the committed intention. Retrying publication must reuse it. If the business command itself can be retried, ensure its idempotency rule does not create multiple distinct events for one intended change.

Record the aggregate identifier and version where ordering or stale-event detection matters.

Commit both records together

Use the same database transaction for the business mutation and outbox insert. If either fails, neither should commit. Do not catch the outbox error and allow the order update to proceed as a successful operation.

SQL example
BEGIN;
UPDATE orders
SET status = 'confirmed', version = version + 1
WHERE id = :order_id AND version = :expected_version;
-- Application verifies exactly one row changed before proceeding.
INSERT INTO outbox (event_id, aggregate_id, event_type, payload)
VALUES (:event_id, :order_id, 'OrderConfirmed.v1', :payload);
COMMIT;

This sketch omits schema definitions and error handling. The application must build the payload from the committed transition and roll back if the conditional update did not match. A production implementation also needs indexes and an explicit publication state model.

Claim work without holding a transaction over the network

A publisher can claim a bounded batch using a short transaction and a lease or status transition. PostgreSQL's SKIP LOCKED can help multiple workers avoid waiting on the same selected rows, but it does not establish business ordering by itself.

Avoid holding database locks while waiting for a slow broker unless that tradeoff is deliberate and tested. With a claim-and-release approach, handle expired claims and stale workers so abandoned work becomes eligible again without corrupting state.

Send the stable event identifier with the message. Inspect per-item results when using a batch API, since one successful batch request does not necessarily mean every item was accepted under every service contract.

Mark delivery and preserve uncertainty

After confirmed broker acceptance, record publication status. If the response is lost, leave the event recoverable and expect a possible duplicate on retry. The consumer must handle that duplicate safely.

Test rollback of the business transaction, publisher crashes and duplicate delivery. Verify the consumer's final state, not only the outbox row. Add monitoring for oldest pending event, attempts and terminal failures so delivery problems remain visible after the initial implementation succeeds.

Primary sources

AWS: transactional outbox patternPostgreSQL: SELECT and SKIP LOCKED

References checked 11 September 2026.