Food delivery, where three parties must agree and none of them is your software
An order is a promise made to a customer, a restaurant and a courier, each of whom can back out, go silent or be late. The system's job is to keep the three views consistent enough that nobody is cooking food nobody will collect.
The brief
Customers browse restaurants near them, place an order, pay, and watch it move from accepted to cooked to picked up to delivered.
Restaurants accept or reject orders and say when food is ready. Couriers are offered deliveries and accept them. The customer's estimate must be honest and the order must never be charged for and not delivered without a refund.
Requirements
Functional
- List restaurants that deliver to the customer's location with their current menus and availability
- Place an order and pay; the restaurant accepts or rejects within a short window
- Assign a courier close to the restaurant near the time the food is ready
- Show live status and an estimated arrival time to the customer
- Cancel with the right money movement at every stage
Non-functional
- A restaurant must never see an order the customer was not charged for, and a customer must never be charged for an order the restaurant did not accept — the consistency rule
- Menu browsing is the bulk of the traffic and must be fast and cacheable
- The system must keep working when the payment provider or the maps provider is slow
- Every state change is auditable: disputes are about who did what when
Back-of-envelope
Assume
- 2 million orders a day across all cities, peaking at 4× the average in the two dinner hours
- 40 browsing sessions per order, each loading 10 restaurant cards and 2 full menus
- 100,000 restaurants, each menu about 50 KB as JSON; menus change a few times a day
- 200,000 couriers online at the dinner peak, reporting position every 5 seconds
- An order's lifetime is about 45 minutes with 10 state changes
Therefore
- Orders: 2M a day is 23 a second averaged; at the 4× dinner peak about 95 a second, each starting a saga of 10 steps. The write path is small. Every one of those writes is money or a promise, so it is the path where correctness costs are paid.
- Browsing: 80 million sessions × 12 payloads = 960 million reads a day, 11,000 a second averaged and 44,000 at peak. That is 500 times the order rate and it is all cacheable by (restaurant, menu version); the read path is a cache problem, not a database one.
- Menu storage: 100,000 × 50 KB = 5 GB. It fits in a cache entirely; the database is the source of truth and is read on a cache miss and on edits.
- Courier positions: 200,000 / 5 = 40,000 writes a second, superseded in 5 seconds — the same shape as the ride-matching brief, and the same answer: an in-memory geospatial index, not rows.
- State changes: 2M × 10 = 20 million events a day, 230 a second averaged, about 1,000 at peak. Each is an outbox row and a Kafka message; the topic is small and the consumers are many (customer app, courier app, restaurant tablet, analytics, the ETA model).
If the peak is 6× instead of 4× — a rainy Friday — browsing reads go to 66,000 a second and orders to 140 a second. The cache absorbs the first; the second is where the restaurant's acceptance window and the courier supply, not your servers, become the bottleneck.
The interface
What is stored
order_id · customer_id · restaurant_id · courier_id · state · amount_paise · payment_ref · menu_version · placed_at · versionThe saga's state, with a version column so every transition is a conditional update (PENDING_ACCEPT → ACCEPTED only if still PENDING_ACCEPT), which is what makes a restaurant tapping accept twice, or accept racing the timeout, resolve to exactly one outcome.
order_id · seq · event (PLACED, PAID, ACCEPTED, COOKING, READY, COURIER_ASSIGNED, COLLECTED, DELIVERED, CANCELLED, REFUNDED) · actor · at · detailsThe audit trail and the source of the customer's timeline. Written in the same transaction as the state change, and it is also the outbox: the relay publishes each row and marks it. Disputes are answered from this table, never from the current state.
restaurant_id · version · items JSON · published_atVersioned, immutable per version: the cache key includes the version, an order records the version it was priced against, and a restaurant's edit publishes a new version rather than mutating the one 40 customers are looking at.
geospatial index: courier_id → (lat, lng) · status (AVAILABLE | OFFERED | DELIVERING) · updated_atSame as ride matching: the value expires in seconds, the only query is spatial, and assignment is a conditional status update that prevents one courier being offered two deliveries.
The design
The decisions
Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.
What breaks first
In order. Each names what you would actually observe, and each fix carries its cost.
When something fails
Scaling it
Each step is triggered by a number, not a feeling — and carries what it costs.
What gets probed
The design is the easy half. These are where the conversation goes, and each has a defensible answer above.
- The restaurant accepts and the capture fails. Who has the money, who has the food, and what does the system do?
- A customer taps 'place order' twice on a slow connection. Show every layer that stops the second one.
- Draw the state machine, and for each state say what a cancellation refunds and why.
- The orchestrator is restarted at the dinner peak. What happens to the orders that were mid-saga?
- Menu browsing is 500 times the order rate. Which single number in your design decides whether the database survives the peak?