A shopper should not wait for a receipt email before learning that their paid order exists. Moving email to a background worker removes that wait, but it introduces another question: what makes the work survive a crash?
This lesson connects accepted orders to durable packing and notification work. You will use an outbox, define an event, and locate the boundary between creating one local job and performing an external effect.
In Trace a Synchronous Checkout with a Sequence Diagram, Fieldnote commits payment success, stock allocation, order acceptance, and an outbox entry together. If you start here, assume that atomic local commit already exists. The application is still a modular monolith; the complete route is Online Store Architecture: From Boundaries to Reliable Orders.
Move only work that can finish later
For Fieldnote, checkout ends when a paid order and its stock allocation are durable. Two actions can follow later: creating a packing task for operations staff and scheduling the receipt email.
Payment confirmation cannot simply move to this queue while the page continues to say “accepted.” That would change the business promise. It could be a valid different product, but its interface would need to communicate that the order is still being checked.
Asynchronous work trades immediate completion for independence from a request's lifetime. The cost is visible lag, more recovery states, and an obligation to monitor unfinished work.
Close the gap between saving and scheduling
Consider this unsafe implementation: commit the accepted order, then call enqueueReceipt(). If the application crashes between those steps, the order exists but the work was never scheduled. Publishing first is also unsafe: a consumer may act on an order whose database transaction later rolls back.
The outbox changes what happens atomically. Transaction B stores both the accepted order and an event describing that committed fact. A worker discovers the event afterward. This is the central idea of the transactional outbox pattern. The outbox preserves intent to deliver work; it does not make the external world part of the database transaction.
Fieldnote's original event envelope is intentionally small:
{
"eventId": "evt_104",
"eventType": "OrderAccepted",
"schemaVersion": 1,
"orderId": "order_104",
"aggregateVersion": 2,
"occurredAt": "2026-10-27T10:00:00Z"
}
eventId identifies this fact across retries. schemaVersion describes its shape; aggregateVersion identifies the order revision represented by the event. The timestamp is an illustrative event time recorded in the accepting transaction, not an exact commit timestamp or global ordering guarantee.
Keep card details and delivery addresses out of the envelope. Authorized handlers can obtain the minimum required details through module interfaces. Less copied personal data makes the message easier to evolve and limits unnecessary distribution; an opaque order ID is still data that needs appropriate access controls.
Live model · Sequence view
Follow durable work after acceptance
The accepted order and OrderAccepted event share a local commit. A database-backed worker tracks Fulfillment and Notifications independently; each handler commits a processed marker with its local task or job. The later email-gateway call crosses an external boundary where a lost acknowledgement may still produce a duplicate send.
Explore scenarios in this model
- Architecture view
- Checkout: preparation, payment and acceptance
- Accepted order: durable packing and receipt work
- Payment timeout: same identity, authoritative recovery
These links open the same public model in a selected view. They do not change its source.
Open this viewer on its own ↗ if the frame is unavailable. The explanation remains readable without the viewer.
Exploration is local to your viewer. Reload or Reset view returns to the source layout.
Start with a database-backed worker
Fieldnote does not need a broker for two local consumers. A background process from the same codebase reads the committed outbox and tracks deliveries separately for Fulfillment and Notifications. KitDesk's local Observer callbacks illustrate a different boundary: synchronous reactions without persistence or replay cannot replace this durable handoff.
The worker claims a delivery with an expiring lease. In a local transaction, Fulfillment records (Fulfillment, evt_104) and creates the packing task with unique business key fulfill_order_104. Notifications independently records (Notifications, evt_104) and creates receipt job receipt_order_104_v1 in its own transaction.
If a transaction fails, neither its processed marker nor its task is committed. If the worker crashes after commit, the next delivery sees the marker and does not create another task. The unique business key also protects against two distinct events accidentally requesting the same task.
A lease reduces overlapping work but is not the correctness guarantee: a slow worker can continue after its lease expires. Database constraints and guarded state transitions must still handle that overlap.
Do not mark the whole event “done” when only Fulfillment finishes. Each named consumer has its own delivery state. Outbox retention and cleanup must preserve unfinished deliveries and whatever replay history the team promises.
Distinguish a receipt job from a sent email
The Notifications transaction creates one local job. A sender later calls the email gateway. If the gateway accepts the email and the sender crashes before recording completion, a retry may send it again.
The local processed marker cannot close that external gap. Use the gateway's documented idempotency or authoritative lookup facility if available. If neither exists, acknowledge the possibility of duplicate receipts and choose a product policy for ambiguous sends. Preventing a duplicate packing task and preventing a duplicate email are different guarantees.
This is also why “exactly once” is a poor unqualified label for the entire flow. Name the effect and boundary: one committed local task per order is a claim you can enforce here; one external delivery requires additional provider behavior.
Introduce a broker only for a concrete need
Independent deployments, multiple remote consumers, or operational requirements may justify a broker later. Then an outbox relay publishes events and marks progress after the broker's documented acceptance condition. A crash in that gap can cause duplicate publication, so consumers keep their deduplication logic.
Broker acceptance and consumer completion are separate acknowledgements. RabbitMQ's documentation makes that distinction and describes redelivery after consumer failures. A broker does not remove the need to define retention, retries, or business invariants.
For this route, keep the database-backed worker. Observe oldest unfinished event age, per-consumer failures, and oldest pending receipt job. NightLens develops the next operational decision: how to drain a backlog without overwhelming a recovering provider. A low queue length alone can hide one permanently stuck order.
Try it in Unograph
Extend Fieldnote's architecture with the outbox and worker inside the owned system. Draw a sequence from transaction B's commit to each consumer's task transaction. Then add the separate email-gateway call and mark its crash gap.
Your diagram passes the practical check when a worker restart cannot lose the packing task, and the email limitation remains visible rather than being hidden behind an “exactly once” note.
Next, Handle Checkout Failures with Idempotency and Explicit Trade-offs combines retries, reconciliation, and business decisions. Revisit the synchronous checkout lesson for the acceptance boundary, or the route hub for all five lessons.