Skip to content

The order exists. Why has the warehouse never heard about it?

One order, five failure boundaries · Part 2 of 5 · 7 min read

Read the worked explanation ↓

A customer can see order o481 in the app. Support can find it in the database. The queue looks healthy, but the warehouse has no record of it. Restarting the order service does nothing.

Interview question. The order service commits to its database and then publishes OrderCreated to a broker. The publish can fail. Design a path that does not forget committed events, even when the publisher crashes. Explain the ordering requirement and whether the warehouse might receive duplicates.

We are not using a transaction spanning the database and the broker. An accepted order is not yet a completed shipment.

Think first

The order exists in the database, but OrderCreated never reaches the queue if the application crashes after committing. Design publication and recovery without a distributed database-broker transaction.

Take a few minutes to form an approach, then compare it with the worked explanation.

Reveal the worked explanation

Why switching the order of two calls cannot fix it

The first design is simple: commit the order, then publish the event. Crash after the commit, and the event never reaches the broker.

So someone suggests publishing first. That moves the failure. The broker may accept OrderCreated, and then the database transaction may fail. The warehouse hears about an order that does not exist.

Publishing while the database transaction is open does not give the broker the ability to roll back alongside the database. The two systems still have different commit boundaries. This is the dual-write problem.

“Retry the publish” sounds sensible, but after a process restart, what tells the new process which events were owed? We need to record the obligation before we depend on memory or a network call.

Commit the obligation with the order

In the same database transaction that accepts o481, insert an outbox row for event e901. The row carries its stable event ID, order ID, order version, event type, and the committed facts that a consumer needs. It starts as pending.

One database transaction
  order:  o481 accepted
  outbox: e901 OrderCreated for o481, version 1
COMMIT

The commit creates an order and durable evidence that publication is still required, or neither. It does not atomically commit the broker. A relay must now turn this stored obligation into a broker message.

For a first implementation, a polling relay can claim pending rows briefly, send to the broker outside the database transaction, then record publication only after the required broker acknowledgment. A claim lease or generation allows other relays to recover abandoned work without allowing a stale relay to overwrite newer bookkeeping.

Now picture the uncomfortable failure:

Relay sends e901
Broker accepts and acknowledges e901
Relay crashes before recording success
Another relay later sends e901 again

The outbox still says pending. It cannot know whether the previous send was accepted. Retrying is the correct recovery action under this design, and the broker may receive the same event twice. If we mark it published before sending, a crash can lose the event instead. The outbox gives us recoverable publication, not a universal exactly-once effect.

Do not regenerate the event ID for a retry. e901 must remain e901, because the receiver needs a way to recognize repeated delivery. A broker acknowledgment also needs a defined durability setting. Putting the message into a producer's local buffer is not the same as durable broker acceptance.

Why the message key is not an ordering protocol

Suppose an order is created at version 1 and cancelled at version 2. Two relays race. Version 2 reaches the broker first. Both messages share the same order ID as their partition key.

The broker preserves the order it received, 2 then 1. It cannot reconstruct the order of the original database commits merely from the key.

If consumers require ordered transitions per order, the publication path must preserve that sequence. One approach is to assign versions under the order's database transaction and prevent publication of version 2 until version 1 is resolved. Different orders can publish in parallel. A consumer also needs to reject or repair stale and missing versions because duplicate publications can outlive the original attempt.

Notice how specific that guarantee is. We need order for one order, not a single global order for all customers. A global sequence would add coordination with no benefit to this business rule.

The part operations will notice

The queue can be empty while the outbox has a million pending rows. Monitoring broker lag alone would miss the problem entirely. I would watch the age of the oldest unpublished event, pending count, relay success rate, permanent rejection reasons, and end-to-end time from order acceptance to warehouse acknowledgment.

Suppose checkout accepts 2,000 orders per second while the broker is unavailable for 15 minutes. That is 1.8 million outstanding events. When the broker returns, the relay handles 5,000 per second but another 2,000 arrive each second. The net drain rate is 3,000 per second, so clearing the old backlog takes 10 minutes under those assumptions.

That figure describes time to empty the backlog, not the wait experienced by every event. It also assumes the broker, database and downstream consumers can support the recovery load. If the outbox has limited disk space, admission must eventually slow or stop new work rather than accept more obligations than can be stored.

Change data capture is a valid alternative to polling. It reads committed database changes and publishes them. It brings its own progress and log-retention risks. The lesson is not “always poll” or “always use CDC.” It is to keep the publication obligation durable and understand the relay's recovery boundary.

We can now make it very unlikely that an accepted event is forgotten, provided the data survives and recovery eventually makes progress. We cannot promise it arrives only once. The warehouse receives e901, reserves a lamp, and crashes before saving its broker offset.

What should happen when it reads e901 again?

Go deeper

More to examine in a design review

Polling relay or database change capture?

A polling relay needs a claim protocol, an index for finding pending rows, and cleanup. A lease lets another relay recover abandoned claims. The lease only governs the database claim. It cannot retract an already in-flight publish, so repeated delivery remains possible.

With change data capture, or CDC, a connector follows the committed change stream, often by reading the database log. That can avoid repeated polling queries, but it does not remove progress and retention problems. If a PostgreSQL logical decoding slot stops advancing, retained log data may grow. After a crash, a connector may resend changes. The sender still needs stable event identities.

The two implementations may use different schemas. Debezium's documented outbox router works naturally with insert-oriented rows. A polling relay that mutates rows from pending to claimed to published is not automatically a drop-in Debezium configuration. Choose and test one path deliberately.

Ordering after multiple changes

A broker partition orders arrivals, not the original database commit decisions. The message key should usually keep events for one aggregate together. That is helpful, but two relays can still publish version 2 before version 1 unless the source or relay constrains them.

For strict per-order transitions, commit a monotonic order version with each update. Arrange the relay to publish versions in that order for each order, or use a CDC path whose ordered source log and routing assumptions support the needed sequence. A consumer may still encounter an older duplicate after a newer message, and it should recognize this from the event identity or version.

Do not serialize every event in the company globally just to maintain order within one order. That creates a hot sequence and unnecessary coupling.

How much evidence can you afford to forget?

The outbox is the recoverable publication obligation before broker acceptance. The broker is a recoverable copy only under its own durability and retention contract. A consumer still needs proof of its business effect after processing. Deleting outbox rows immediately after a valid broker acknowledgment can be fine, provided the required recovery and audit paths no longer depend on those rows.

An event that permanently fails validation should not silently disappear. Keep a repairable record, alert on oldest pending age, and decide whether new order admission must slow when storage fills. The outbox preserves obligations during failures only while those obligations remain durable and the relay eventually makes progress.

Check your reasoning

  • Shows why both commit-then-publish and publish-then-commit have a failure window
  • Stores an outbox event and order in the same local transaction
  • Describes relay acknowledgment, restart, and the possibility of duplicate publication
  • Distinguishes per-order business ordering from broker arrival ordering
  • Names the oldest pending event, backlog growth, and capacity limits as operating signals

Common mistake: Using order ID as the broker key does not stop two relays from publishing version 2 before version 1.

The broker acknowledges e901, then the relay crashes before marking its outbox row published. What should recovery do?

Read the answer explanation

Retry the same event ID and allow consumers to recognize a duplicate — The pending row cannot distinguish accepted from lost publication. Retrying with the same ID avoids silent loss, while allowing duplicate delivery when the previous publish succeeded.

Apply it elsewhere

A user profile update commits to Postgres, but a search index must be notified through Kafka. A process restart sometimes leaves the search index stale forever. What should you add?

Compare your approach

Persist a versioned indexing obligation in the same transaction as the profile update or derive committed changes through CDC. Give events stable IDs, handle repeated delivery at the indexer, and monitor the oldest unpublished or unapplied change. Require version-aware application if updates can arrive out of order.

One more constraint

A relay pool doubles in size. Cancellation events start arriving before creation events for the same order. Where is the ordering guarantee broken?

Quick revision

The answer in an interview

A database commit followed by a broker publish is a dual write. If the process stops between the two, the accepted order has no event. Publishing first only reverses the failure. I would store an outbox event in the same database transaction as the order. That leaves durable evidence of the publication obligation.

A relay publishes pending rows and marks them complete only after the required broker acknowledgment. If it crashes after the broker accepted the event, it may publish the same event ID again. Consumers therefore need duplicate-safe effects. For ordering, I would version changes per order and ensure the publication path preserves that order, rather than assuming a shared partition key fixes relay races.

Operationally I would alert on oldest pending event age and end-to-end acceptance-to-consumption time. The outbox can fill during an outage, so capacity and admission matter too.

Remember: Atomic obligation, recoverable relay, duplicate delivery, per-order ordering.

Trap: An empty broker backlog cannot prove that nothing is stuck in the outbox.

References

  • Transactional outbox pattern · AWS Prescriptive Guidance

    The dual-write problem and durable publication intent stored with business data.

  • Pattern: Transactional outbox · Chris Richardson, Microservices.io

    Atomic outbox recording, relay recovery, ordering requirements, and the possibility of repeated publication.

  • Outbox Event Router · Debezium 3.4 documentation

    Distinct event and aggregate identities, message routing, and insert-oriented outbox expectations.

  • Logical Decoding Concepts · PostgreSQL 18 documentation

    Logical decoding replay after crashes and retention responsibilities of replication slots.

Read across the boundary