How it’s built

Why a catering job is a saga that only moves forward

A catering job takes weeks and touches three objects and Stripe. No transaction can hold it. This is how the bakery runs it as a chain of small ones, how it undoes a step without rolling anything back, and why a step that comes in the wrong order is refused.

A breakfast for forty, over three weeks

An inquiry arrives for a breakfast spread for 40 people at a made-up venue, on a Saturday more than seven open days ahead. Staff send a quote. A few days later the customer accepts it and has 15 minutes of bakery time to pay a deposit of a quarter, or the day’s place goes back. Two days before the event a reminder is due. The kitchen bakes, a van delivers, and a balance invoice falls due seven days later. In the smoke test this job comes to 58,000 cents, deposit and balance together.

Along the way the job holds one of the day’s two catering places in that day’s Schedule, takes two payments through Billing and Stripe, and has to notice time passing when nobody touches it.

What a simpler design does, and where it breaks

One transaction around the whole job would hold its locks for weeks, and no transaction spans two Durable Objects, let alone Stripe. A process that waits in memory for the next step is evicted long before the next step comes. A choreography where each object reacts to the others’ events works until something has to be undone, and then nobody owns the question of what “undone” means for this job.

The 1987 paper “Sagas”, by Hector Garcia-Molina and Kenneth Salem, gives the shape that survives: a long transaction written as a sequence of short ones, each committed on its own, with a compensating transaction for each step that undoes its effect in business terms. No lab page shows this one yet, and the lab is where the family of techniques is explained.

One object, one step at a time

Each catering job is a CateringEvent object, one per inquiry. Its states are the twelve of CateringStatus in crates/domain/src/catering.rs, from inquiry through quoted, accepted, confirmed, in_production, delivered, invoiced and paid to closed, with declined, expired and cancelled as the other three ends. Every step is one command, decided against the state and committed with its events, its outbox rows and its key in one transactionSync. A step is started by a person (a route), by an event from the Queue, such as Billing’s PaymentSucceededbecoming RecordDeposit, or by a timer.

The timers are a pure function of the state, timers() in the same file: the follow-up reminder and the quote’s expiry while quoted, the deposit’s lapse while accepted, the event_soon reminder while confirmed or in production, and invoice_due while invoiced. The object rewrites its timers rows from that list in the transaction of every change, so there are never more than two saga rows at once. Each row holds an id such as quote-expiry, a purpose, a wall time and a business time. Cloudflare’s documentation gives an object a single alarm at a time, so the object arms it for its earliest row, and when the clock is moved in the console every wall time is worked out again from its business time.

An alarm runs at least once, the same documentation says, and is retried when it fails. So each timer runs its command under the key timer:<row>:<due>, and a second run finds the key and appends nothing. A timer that fires when its condition no longer holds, a lapse after the deposit was paid, emits nothing.

That makes the object the job’s process manager, in Hohpe and Woolf’s sense: one place that keeps the state of the sequence and decides the next step. Because it is a single writer, two steps never race inside it, and no other service has to coordinate them.

Undone by a step of its own

A saga cannot roll back what already committed in another object, so each failure has a compensation that is a new step. When the deposit does not come in time, the deposit-lapse row runs LapseDeposit: the job goes from accepted back to quoted, and CateringDepositLapsed tells the day’s Schedule to release the place. When a confirmed job is cancelled, CateringCancelled releases the place, and Billing refunds the deposit if the event was more than seven calendar days away.

A compensation can itself be a call to another system that fails. A deposit that lands after the quote expired cannot confirm anything, so the object asks Billing to refund it in full at Stripe, under the delivery’s id and the reason goodwill. While Billing cannot be reached the object answers 503, the Queue delivers the event again, and the id makes the repeat ask for the same refund. The undo is retried until it lands, the way the step was.

Some steps have no undo. Cancellation is taken from confirmed only, so once the kitchen has started the job runs to delivery and its invoice. No saga step voids an invoice: as built on 2026-11-06, only the nightly wipe does, when Billing voids every open one at Stripe before it deletes itself.

Only forward

The log only grows. Going from accepted back to quoted is a new event, not the acceptance removed. A command that does not fit the current state is refused with 409 and the rule out-of-order, and nothing is written: a start of production before the deposit, a delivery before production, a cancellation after it. The bakery does not hold such a command for later, because a step that arrives early was usually sent from an old page. Repeating a step in the state it leads to is not an error and emits nothing.

Each of these refusals is proven over HTTP. On 2026-11-06 scripts/smoke-catering.sh passed 238 checks, among them the five 409s of decline, start of production, delivery, cancellation and staff cancellation, each from two states, each leaving the event byte for byte as it was (docs/roadmap.md, PR 7).

What it costs

Try it

Sign in and send an inquiry from the catering page. Quote it in the console, accept it, then advance the clock by 15 minutes before paying: the place is released and the job is quoted again. Accept again and pay the deposit with the test card 4242 4242 4242 4242. Start production, then try to cancel: 409 out-of-order. Mark it delivered and pay the balance on the event’s page, and the console’s list of reminders shows each one the job fired.

Sources

Back to sagas by alarm on How it’s built, to the decision that chose them, ADR 0004, or to how an event reaches the next object.