Place an order, commit it, then publish OrderPlaced. If the broker rejects
the send, the order remains placed and nothing durable says publication is
still required.
Reverse the sequence. Publish OrderPlaced, then commit the order. If the
database rolls back, the broker now carries a fact which the source database
never accepted.
A transactional outbox changes the sequence again. Insert the order and a pending message in one local transaction. Rollback removes both. Commit makes both durable. A separate relay can keep trying the message after the request which placed the order has ended.
That closes the gap between the business commit and recording the obligation
to publish. It does not make publication exactly once. Crash the relay after
the broker accepts the message but before published_at is stored, and the
next relay run sends the same message again.
An outbox does not remove failure. It moves the failure window into a state machine which can be observed and repaired.
Two direct sends, two contradictory truths
The reproducible fixture runs both direct sequences before introducing an outbox.
In the send-after-commit case, SQLite contains:
orders=[order-1: placed]
outbox=[]
broker_receipts=[]
exception=Controlled broker rejection.The local truth is complete, but the publication obligation disappeared with the failed request. A scheduled reconciliation might later rediscover it from the order table. The direct path itself cannot distinguish “not attempted” from “attempted and rejected.”
The send-before-commit case reverses the inconsistency:
orders=[]
broker_receipts=[message-1: OrderPlaced]
exception=Controlled database failure.The broker acknowledgement cannot participate in the SQLite rollback. One consumer may already be acting on an order which does not exist.
AWS’s Transactional outbox guidance frames this as a dual-write problem: one operation needs to change a database and notify another system, but failure can split those writes. The outbox narrows the atomic boundary to one database transaction:
$database->beginTransaction();
$orders->insert($order);
$outbox->insert($orderPlaced);
$database->commit();The fixture proves both sides. A clean commit leaves one placed order and one
pending message with stable ID message-1. A controlled exception before
commit leaves neither. Mutations which omit the outbox insert or move it after
the transaction fail those controls.
The guarantee is local and specific: committed business state has a committed publication record. The broker is not inside that transaction.
The relay is a second state machine
The fixture’s outbox row carries more than an event payload:
message_id
aggregate_id
aggregate_sequence
event_type
payload
occurred_at
available_at
attempt_count
claim_owner
claim_until
published_at
last_errorThose columns describe transitions:
pending -> claimed -> published
\-> backoff -> claimed
\-> expired claim -> releasedCalling this “a table a cron job reads” understates the design. Two workers must not own the same live claim. A rejected send needs an attempt, error, and next eligible time. A dead worker’s claim needs an expiry. A confirmed row needs a retention policy which cannot delete pending work.
The clean relay performs a small, reviewable sequence:
- select one eligible message whose predecessor is already published;
- claim it with a worker ID and lease expiry;
- increment the attempt count;
- send the stored message ID and payload;
- after broker acceptance, store
published_atand clear the claim.
When the synthetic broker rejects step 4, no receipt exists. The row remains
unpublished, attempt count becomes one, last_error contains the rejection,
and available_at moves to the declared retry time. The claim is cleared so a
later worker can try again.
Marking the row published before the send makes the rejection test fail. So does swallowing the error without updating retry state. The relay is not reliable because it has a loop. Its transitions have to preserve what is known and what remains uncertain.
Broker acceptance still leaves an uncertain moment
The difficult case begins after the broker says yes.
The fixture claims message-1, sends it, receives an acknowledgement, and
then raises a controlled crash before local confirmation. The retained state
has one broker receipt, one active lease, and published_at=null.
After the lease expires, another worker cannot know whether the first send failed or succeeded. It sends again. The broker now has two receipts:
message-1
message-1The second attempt finally stores published_at. Reusing the original ID is
important. A mutation creates message-1-attempt-1 and
message-1-attempt-2; the duplicate control fails because the two receipts no
longer identify one logical message.
Chris Richardson’s outbox pattern description names the same crash window: a relay can publish and die before recording that it did so. The resulting contract is at-least-once attempt, not exactly-once effect.
The fixture’s consumer makes that distinction executable. It inserts
(consumer_name, message_id) into an inbox and updates its order count in the
same transaction. Two broker receipts produce one inbox row and one count
increment. Replace the insert-if-absent check with an unconditional replacement
and the effect becomes two.
Idempotency belongs to the consumer’s business effect. A stable message ID makes repetition detectable; it cannot decide whether charging, emailing, or incrementing twice is safe.
Ordering is a policy, not a timestamp sort
The fixture gives each message an aggregate ID and sequence. Message 2 for
order-1 is already eligible by time, while message 1 is still in backoff. The
relay sends neither. Once message 1 becomes eligible and is confirmed, message
2 follows it.
That policy prevents a later fact for one aggregate from passing an unpublished predecessor. It also lets one failing message block that aggregate’s later messages. The operational snapshot counts those blocked followers so the cost is visible.
Another system may choose partition ordering, no ordering, or a consumer which
can reconcile reordered facts. The event type does not choose for you. Neither
does occurred_at: timestamps record time, while the aggregate sequence states
the publication order this fixture promises.
The mutation which removes the predecessor query publishes message 2 first. That failure proves the selected policy is enforced; it does not claim every outbox should make the same throughput trade.
Pending work needs operator verbs
A relay which retries forever without telling anyone is a quiet data-loss machine with extra rows.
The fixture exposes five deterministic signals at a fixed clock:
| Signal | Retained value |
|---|---|
| Unpublished messages | 3 |
| Oldest unpublished age | 7,200 seconds |
| Total attempts | 3 |
| Expired claims | 1 |
| Blocked followers | 1 |
These values are behavioral controls, not recommended alerts. Production thresholds need real traffic, latency promises, and an agreed recovery budget.
The repair surface is deliberately narrow. Releasing a claim requires one message ID and proof that its lease expired. A live lease remains protected. Requeueing requires one unpublished message ID; it clears that row’s error and availability without touching another failed row.
Two mutations show why the preconditions matter. One releases a live claim. The other turns named requeue into a bulk reset. Both tests fail. “Set all stuck rows back to pending” is easy to type and difficult to reverse once live workers are involved.
An operator procedure should therefore answer:
- Which exact message is being changed?
- Is it unpublished?
- Is its claim absent or expired?
- What evidence says retry is safe?
- Which metric and row state should change afterward?
The fixture proves those local preconditions. It does not prove that the commands are sufficient for a particular broker, deployment, or incident.
Retention must distrust age alone
An outbox grows. Deleting every row older than a date is tempting and unsafe. A week-old pending message is not equivalent to a week-old confirmed message.
The fixture seeds four rows: old published, recent published, old failed, and
old actively claimed. Its retention query removes only the old row with a
non-null published_at and no claim. Pending, claimed, and recent rows remain.
Change that query to select by occurred_at alone and the retention test
deletes unpublished work. Age can choose among rows already proven safe to
remove; it cannot supply the proof.
Retention also affects investigation and deduplication. Deleting a confirmed message may remove the easiest evidence for what was sent. Deleting consumer inbox IDs too soon can make an old duplicate effective again. The correct period belongs to the delivery and audit contract, not to a generic cleanup job. This fixture deliberately measures no production retention period.
Polling is one relay, not the pattern
The fixture uses a polling query because it makes claims, backoff, and ordering visible in one SQLite example. It is not the only relay design.
Debezium’s Outbox Event Router captures committed outbox inserts from the database log and maps stable event ID, aggregate key, type, and payload to broker messages. Change data capture can remove an application polling query. It does not remove envelope governance, duplicate handling, consumer idempotency, lag monitoring, retention, or repair.
A direct post-commit send plus reconciliation can also be enough when missing messages are cheap to derive from authoritative state and the scan latency is acceptable. A synchronous remote call may be more honest when the use case cannot complete without the remote answer. A broker or platform transaction may offer another concrete boundary, but its exact resource and failure contract must be verified rather than summarized as “exactly once.”
Publishing Events Is Not Event Sourcing explains a different choice: whether event history itself is authoritative. An outbox row can carry a publishable fact while the order row remains the source of current domain state. Adding an outbox does not silently turn the application into an event-sourced one.
Choose the outbox when the local transaction must leave durable evidence that publication is owed. Then accept the relay it creates. Preserve message identity, decide ordering, expect duplicate delivery, make consumer effects idempotent, expose pending age and expired claims, restrict repair, and purge only confirmed work.
The outbox closes one dangerous gap. Its value comes from making the remaining gaps explicit enough to operate.