A cart, a kitchen, a rider on a scooter and a payment, each moving at its own speed. The design is the order's state machine that keeps them in step, and the rule that every rupee charged ends in either a delivered meal or a refund.
Most food delivery apps work the same way from the outside: browse a restaurant, fill a cart, pay, then watch the order go from "placed" to "preparing" to "on the way". It's a popular machine-coding prompt because three parties (customer, restaurant, delivery partner) change the same order, and a payment must stay consistent with it. Candidates who model the order as a bag of fields lose marks on the follow-ups: a cancel while the restaurant marks it ready, no free rider, a payment request sent twice.
This lesson builds the in-process core: the cart rules, an idempotent checkout, the order's states, partner assignment as a strategy, and refunds as a policy.
How the round runs#
"Design the ordering side of a food delivery app. Customers order from one restaurant at a time and pay online. The restaurant accepts and prepares; a delivery partner picks up and delivers. Customers can cancel, with refunds that depend on how far along the order is. Working code, 90 minutes. Search, ratings and live tracking are out of scope."
- 0–10 min — clarify: cart rules, when payment happens, order states, who can cancel when, refunds, partner assignment
- 10–25 min — the classes, the order state machine, the three seams (payments, refunds, assignment)
- 25–45 min — cart and checkout: price snapshot, idempotency key, charge outside the lock
- 45–65 min — the order's life: accept, assign a partner or queue, ready, pick up, deliver
- 65–80 min — cancellation and refunds; a demo with three orders and a cancellation storm
- 80–90 min — follow-ups: batching, ETAs, coupons, restaurant timeouts
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| Items from two restaurants in one cart? | No. The app offers to clear the cart. | Cart.add refuses a second restaurant |
| When do we charge? | At checkout, before the restaurant sees the order. | An order exists only after a successful charge |
| Can "Place order" be sent twice? | Yes: double taps and network retries. | An idempotency key on checkout and on the charge |
| The restaurant changes a price later? | Orders keep the price they were placed at. | Order lines copy the price at checkout |
| When is a partner assigned? | When the restaurant accepts, so they arrive as the food is ready. | Assignment on accept, a queue if nobody is free |
| Refunds on cancel? | Full before the kitchen accepts, half while cooking, none once packed; full if the restaurant cancels. | A RefundPolicy by stage and by who cancelled |
Why does the order need a state machine at all?
The customer cancels at the same moment the restaurant taps "Ready". With a plain status field, both writes succeed in some order. What can go wrong, and what fixes it?
One strong answer
Last write wins: the order may end up READY with the money refunded, so a partner collects food nobody paid for, or CANCELLED with no refund because the policy saw a stale status. The fix is to make every change a transition: check the current state against an allowed table, apply the change, and compute the refund, all under one lock. Then exactly one of the two calls succeeds and the other is told "cancelled → ready is not allowed", or the reverse.
The classes#
%% title: Food delivery: the classes
classDiagram
class Restaurant {
+restaurant_id str
+location tuple
+menu dict
+is_open bool
}
class MenuItem {
+item_id str
+price int
+available bool
}
class Cart {
+customer str
+restaurant_id str
+qty dict
+add(restaurant_id, item_id, n)
}
class Order {
+order_id str
+lines tuple
+total int
+payment_ref str
+status OrderStatus
+partner_id str
+refunded int
}
class PaymentGateway {
<<interface>>
+charge(customer, amount, key) str
+refund(payment_ref, amount)
}
class RefundPolicy {
<<interface>>
+refund_for(order, by_restaurant) int
}
class AssignmentStrategy {
<<interface>>
+choose(restaurant, idle) Partner
}
class FoodService {
+checkout(cart, key) Order
+accept(order_id)
+ready(order_id)
+picked_up(order_id)
+delivered(order_id)
+cancel(order_id, by_restaurant) int
}
Restaurant "1" *-- "many" MenuItem
FoodService --> PaymentGateway
FoodService --> RefundPolicy
FoodService --> AssignmentStrategy
FoodService "1" o-- "many" Order
FoodService "1" o-- "many" Partner
PaymentGateway <|.. FakeGateway
RefundPolicy <|.. StageRefund
AssignmentStrategy <|.. NearestIdlePartnerThe order's states, and who moves it between them:
%% title: Food delivery: order states stateDiagram-v2 [*] --> Placed: checkout, payment charged Placed --> Accepted: restaurant accepts, partner assigned or queued Accepted --> Ready: food packed Ready --> PickedUp: partner collects PickedUp --> Delivered: partner hands over Placed --> Cancelled: full refund Accepted --> Cancelled: half refund Ready --> Cancelled: no refund Delivered --> [*] Cancelled --> [*]
There is no way to cancel once the food is picked up: that's a support case with its own flow, not a button.
The interfaces#
The catalogue and the cart. The cart enforces one restaurant; it never holds prices, because prices are read at checkout:
# food.py, part 1: restaurants, menus and the cart
from __future__ import annotations
import itertools
import math
import threading
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
@dataclass
class MenuItem:
item_id: str
name: str
price: int # rupees
available: bool = True
@dataclass
class Restaurant:
restaurant_id: str
name: str
location: tuple[float, float]
menu: dict[str, MenuItem]
is_open: bool = True
class CartConflict(Exception): ...
@dataclass
class Cart:
customer: str
restaurant_id: str | None = None
qty: dict[str, int] = field(default_factory=dict) # item_id -> quantity
def add(self, restaurant_id: str, item_id: str, n: int = 1) -> None:
if self.restaurant_id not in (None, restaurant_id):
raise CartConflict("your cart has items from another restaurant; clear it first")
self.restaurant_id = restaurant_id
self.qty[item_id] = self.qty.get(item_id, 0) + n
def clear(self) -> None:
self.restaurant_id, self.qty = None, {}The order. OrderLine is frozen and copies the unit price, so the restaurant editing its menu tomorrow can't change what this customer paid today. history is for the demo and for support tooling:
# part 2: the order and its state machine
class OrderStatus(Enum):
PLACED = "placed"
ACCEPTED = "accepted"
READY = "ready"
PICKED_UP = "picked_up"
DELIVERED = "delivered"
CANCELLED = "cancelled"
ALLOWED = {
OrderStatus.PLACED: {OrderStatus.ACCEPTED, OrderStatus.CANCELLED},
OrderStatus.ACCEPTED: {OrderStatus.READY, OrderStatus.CANCELLED},
OrderStatus.READY: {OrderStatus.PICKED_UP, OrderStatus.CANCELLED},
OrderStatus.PICKED_UP: {OrderStatus.DELIVERED},
OrderStatus.DELIVERED: set(),
OrderStatus.CANCELLED: set(),
}
@dataclass(frozen=True)
class OrderLine:
name: str
unit_price: int # copied from the menu at checkout; later price changes don't touch it
qty: int
@dataclass
class Order:
order_id: str
customer: str
restaurant_id: str
lines: tuple[OrderLine, ...]
total: int
payment_ref: str
status: OrderStatus = OrderStatus.PLACED
partner_id: str | None = None
refunded: int = 0
history: list[str] = field(default_factory=lambda: ["placed"])
class InvalidTransition(Exception): ...Three seams, each a small interface. The gateway takes an idempotency key so a retried charge is not a second charge; the refund policy is a table you could hand to the finance team; and assignment picks a partner, or None if nobody is idle:
# part 3: the seams: payments, refund policy, partner assignment
class PaymentGateway(ABC):
@abstractmethod
def charge(self, customer: str, amount: int, idempotency_key: str) -> str: ...
@abstractmethod
def refund(self, payment_ref: str, amount: int) -> None: ...
class FakeGateway(PaymentGateway):
def __init__(self) -> None:
self.charges: dict[str, tuple[str, int]] = {} # key -> (payment_ref, amount)
self.refunds: list[tuple[str, int]] = []
def charge(self, customer: str, amount: int, idempotency_key: str) -> str:
if idempotency_key not in self.charges: # a retried charge with the same key is not a second charge
self.charges[idempotency_key] = (f"pay_{len(self.charges) + 1}", amount)
return self.charges[idempotency_key][0]
def refund(self, payment_ref: str, amount: int) -> None:
self.refunds.append((payment_ref, amount))
class RefundPolicy(ABC):
@abstractmethod
def refund_for(self, order: Order, by_restaurant: bool) -> int: ...
class StageRefund(RefundPolicy):
"""Full refund before the kitchen starts, half while it cooks, nothing once the food is packed."""
SHARE = {OrderStatus.PLACED: 1.0, OrderStatus.ACCEPTED: 0.5, OrderStatus.READY: 0.0}
def refund_for(self, order: Order, by_restaurant: bool) -> int:
return order.total if by_restaurant else round(order.total * self.SHARE[order.status])
@dataclass
class Partner:
partner_id: str
location: tuple[float, float]
order_id: str | None = None
class AssignmentStrategy(ABC):
@abstractmethod
def choose(self, restaurant: Restaurant, idle: list[Partner]) -> Partner | None: ...
class NearestIdlePartner(AssignmentStrategy):
def choose(self, restaurant: Restaurant, idle: list[Partner]) -> Partner | None:
return min(idle, key=lambda p: (math.dist(p.location, restaurant.location), p.partner_id), default=None)The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| State (as a transition table) | OrderStatus + ALLOWED |
Three parties change one order; the table is the single place that says which changes are legal. |
| Strategy | AssignmentStrategy, RefundPolicy |
"Nearest idle partner" becomes "partner already heading this way"; refund rules change with every policy review. Each is a new class. |
| Adapter | PaymentGateway |
The real gateway's SDK hides behind two methods; the demo uses a fake that records calls. |
| Idempotency key | checkout(cart, key) and charge(..., key) |
A double tap or a retry finds the first result instead of creating a second order or charge. |
| Facade | FoodService |
The customer app, the restaurant tablet and the partner app each call a few methods; none touches the order's fields. |
Concurrency and edge cases#
One service lock guards every order transition and the partner pool. Every operation follows the same shape: decide and change state under the lock, call the outside world after it.
- Checkout prices the cart under the lock, releases it, charges the gateway, then takes the lock again to create the order. A slow gateway never blocks other customers. If both taps of a double tap reach the gateway, they carry the same key, so it charges once, and the second tap finds the order already created.
- Cancel computes the refund and moves the order to CANCELLED in one critical section, then calls
refund()outside it. Fifty concurrent cancels: the first passes_move, the other forty-nine find CANCELLED and are refused, so the refund happens once. - Partner assignment happens inside the same lock as the transition that triggers it, so two orders accepted at once can't both get the last idle partner. When a partner frees up, the oldest waiting order gets them.
- An item goes out of stock between cart and checkout: refuse checkout and name the item; never charge for food the kitchen can't make
- The restaurant closes while the cart is open: same, at checkout
- No idle partner: the order is accepted anyway and waits in a FIFO queue; a cancelled order in the queue is skipped
- The restaurant cancels (out of an ingredient): always a full refund, whatever the stage
- Pick-up before a partner is assigned: refused; nobody is there to pick it up
- The refund call fails: the order is already CANCELLED with
refundedset; a retry job re-sends refunds that aren't confirmed
The code#
The service. Every public method is a transition; _assign and _free_partner are the partner pool:
# part 4: the service: checkout, the order's life, cancellation
@dataclass
class FoodService:
restaurants: dict[str, Restaurant]
partners: dict[str, Partner]
payments: PaymentGateway
assigner: AssignmentStrategy
refunds: RefundPolicy
delivery_fee: int = 40
orders: dict[str, Order] = field(default_factory=dict)
_by_key: dict[str, str] = field(default_factory=dict) # idempotency key -> order id
_waiting: deque = field(default_factory=deque) # accepted orders with no partner yet, oldest first
_ids: itertools.count = field(default_factory=lambda: itertools.count(1))
_mu: threading.Lock = field(default_factory=threading.Lock)
def checkout(self, cart: Cart, idempotency_key: str) -> Order:
with self._mu:
if idempotency_key in self._by_key: # "Place order" tapped twice
return self.orders[self._by_key[idempotency_key]]
r = self.restaurants[cart.restaurant_id]
if not r.is_open or not cart.qty:
raise ValueError("restaurant closed or cart empty")
items = [(r.menu[i], n) for i, n in cart.qty.items()]
if gone := [m.name for m, _ in items if not m.available]:
raise ValueError(f"no longer available: {gone}")
lines = tuple(OrderLine(m.name, m.price, n) for m, n in items) # the price snapshot
total = sum(line.unit_price * line.qty for line in lines) + self.delivery_fee
ref = self.payments.charge(cart.customer, total, idempotency_key) # slow: outside the lock
with self._mu:
if idempotency_key in self._by_key: # the other tap got here first; the key charged once
return self.orders[self._by_key[idempotency_key]]
order = Order(f"O{next(self._ids):04d}", cart.customer, r.restaurant_id, lines, total, ref)
self.orders[order.order_id] = order
self._by_key[idempotency_key] = order.order_id
cart.clear()
return order
def _move(self, order: Order, to: OrderStatus) -> None: # caller holds _mu
if to not in ALLOWED[order.status]:
raise InvalidTransition(f"{order.order_id}: {order.status.value} -> {to.value}")
order.status = to
order.history.append(to.value)
def _assign(self, order: Order) -> None: # caller holds _mu
idle = [p for p in self.partners.values() if p.order_id is None]
p = self.assigner.choose(self.restaurants[order.restaurant_id], idle)
if p is None:
self._waiting.append(order.order_id)
return
p.order_id, order.partner_id = order.order_id, p.partner_id
def _free_partner(self, order: Order) -> None: # caller holds _mu
if order.partner_id is None:
return
self.partners[order.partner_id].order_id = None
while self._waiting: # hand the free partner the oldest waiting order still alive
nxt = self.orders[self._waiting.popleft()]
if nxt.status is not OrderStatus.CANCELLED:
self._assign(nxt)
break
def accept(self, order_id: str) -> None: # the restaurant takes the order
with self._mu:
o = self.orders[order_id]
self._move(o, OrderStatus.ACCEPTED)
self._assign(o) # a partner rides over while the food cooks
def ready(self, order_id: str) -> None:
with self._mu:
self._move(self.orders[order_id], OrderStatus.READY)
def picked_up(self, order_id: str) -> None:
with self._mu:
o = self.orders[order_id]
if o.partner_id is None:
raise InvalidTransition("no partner has this order")
self._move(o, OrderStatus.PICKED_UP)
def delivered(self, order_id: str) -> None:
with self._mu:
o = self.orders[order_id]
self._move(o, OrderStatus.DELIVERED)
self._free_partner(o)
def cancel(self, order_id: str, by_restaurant: bool = False) -> int:
with self._mu: # decide under the lock; at most one caller gets past _move
o = self.orders[order_id]
amount = self.refunds.refund_for(o, by_restaurant) if o.status in StageRefund.SHARE else 0
self._move(o, OrderStatus.CANCELLED)
o.refunded = amount
self._free_partner(o)
if amount:
self.payments.refund(o.payment_ref, amount) # slow network call, outside the lock
return amountThree orders from two restaurants, two partners, and a cancellation storm:
# part 5: three orders, two partners, a cancellation storm
if __name__ == "__main__":
r1 = Restaurant("R1", "Annapurna Mess", (0, 0), {"bir": MenuItem("bir", "Veg biryani", 250),
"rai": MenuItem("rai", "Raita", 40), "keb": MenuItem("keb", "Paneer kebab", 180)})
r2 = Restaurant("R2", "Pizza Point", (6, 6), {"mar": MenuItem("mar", "Margherita", 300)})
gw = FakeGateway()
svc = FoodService({"R1": r1, "R2": r2}, {"P1": Partner("P1", (1, 1)), "P2": Partner("P2", (5, 5))},
gw, NearestIdlePartner(), StageRefund())
cart = Cart("riya")
cart.add("R1", "bir", 2)
cart.add("R1", "rai")
try:
cart.add("R2", "mar")
except CartConflict as e:
print("cart:", e)
o1 = svc.checkout(cart, idempotency_key="riya-7f3a")
again = svc.checkout(cart, idempotency_key="riya-7f3a") # the double tap
print(o1.order_id, o1.total, o1.payment_ref, "| double tap ->", again.order_id, "| charges:", len(gw.charges))
r1.menu["bir"].price = 270 # the restaurant raises a price after the order
print("order still totals", sum(line.unit_price * line.qty for line in o1.lines) + svc.delivery_fee)
c2, c3 = Cart("kabir"), Cart("dev")
c2.add("R2", "mar")
c3.add("R1", "keb")
o2, o3 = svc.checkout(c2, "kabir-91c2"), svc.checkout(c3, "dev-44be")
for o in (o1, o2, o3):
svc.accept(o.order_id)
print("partners:", {o.order_id: o.partner_id for o in (o1, o2, o3)}) # O0003 waits: both are busy
svc.ready(o1.order_id)
svc.picked_up(o1.order_id)
svc.delivered(o1.order_id)
print(o1.order_id, " -> ".join(o1.history), "| O0003 now has", o3.partner_id)
print("kabir cancels while the pizza bakes, refund", svc.cancel(o2.order_id)) # 50% of 340
outcomes: list[str] = []
def cancel_o3() -> None: # the customer's double tap, a support agent, an auto-cancel timer...
try:
outcomes.append(f"refund {svc.cancel(o3.order_id)}")
except InvalidTransition:
outcomes.append("rejected")
ts = [threading.Thread(target=cancel_o3) for _ in range(50)]
for t in ts:
t.start()
for t in ts:
t.join()
print(sorted(set(outcomes)), outcomes.count("rejected"), "rejected | refunds issued:", gw.refunds)
try:
svc.ready(o3.order_id)
except InvalidTransition as e:
print("rejected:", e)cart: your cart has items from another restaurant; clear it first
O0001 580 pay_1 | double tap -> O0001 | charges: 1
order still totals 580
partners: {'O0001': 'P1', 'O0002': 'P2', 'O0003': None}
O0001 placed -> accepted -> ready -> picked_up -> delivered | O0003 now has P1
kabir cancels while the pizza bakes, refund 170
['refund 110', 'rejected'] 49 rejected | refunds issued: [('pay_2', 170), ('pay_3', 110)]
rejected: O0003: cancelled -> readyRiya's cart refuses the pizza; her double tap returns the same order and one charge of ₹580 (two biryanis, a raita, the ₹40 fee), which the price change doesn't touch. P1 is nearest to Annapurna Mess and P2 to Pizza Point, so Dev's order waits until P1 delivers Riya's and is then handed to P1. Kabir cancels while his pizza bakes and gets half of ₹340. Fifty threads cancel Dev's order: one wins a refund of half of ₹220, forty-nine are refused, and the gateway sees exactly two refunds in total.
Extending it: the follow-ups#
- "One partner, two orders from the same restaurant." Batching: an
AssignmentStrategythat prefers a partner already assigned to that restaurant whose bag has room. A partner gets a list of orders instead of one. - "The restaurant doesn't respond." A timer on PLACED: after 5 minutes, cancel with
by_restaurant=True, so the customer gets a full refund automatically. - "Coupons and fees." Move pricing into a
PricingStrategywith decorators for coupons, surge delivery fees and packaging charges.checkoutasks it for the total. - "Pay on delivery." A second payment adapter whose charge happens at DELIVERED, plus a rule that cash orders can't be cancelled after pickup.
- "Many servers." Each transition becomes a conditional update (
UPDATE orders SET status = 'cancelled' WHERE id = ? AND status IN (...)); the refund is sent by an outbox worker after the row commits. The transition table is unchanged.
Common mistakes#
- A status field with no transition rules, so a cancelled order can still be marked ready
- Reading prices at delivery time, so a menu change alters what the customer is charged
- Charging inside the lock, so one slow gateway call blocks every order
- No idempotency key, so a double tap creates two orders and two charges
- Refunding before checking the state, so concurrent cancels refund twice
- Partner assignment logic inside
Order, with no way to swap the rule
Food delivery
Q1/3 Fifty requests cancel the same accepted order at once. How does the design make sure only one refund is sent?
Answer & explanation
Answer: C
Check-then-refund without a lock lets several requests see ACCEPTED. Deciding the refund and moving the state together, under the lock, lets exactly one through; the refund call itself happens after.
Q2/3 Why does each order line copy the item's unit price at checkout?
Answer & explanation
Answer: B
An order is a record of a sale. The menu is today's price list; the order line is what was agreed and charged.
Q3/3 The customer taps "Place order" twice and both requests reach the gateway. What prevents a double charge?
Answer & explanation
Answer: D
The key travels with the charge. The gateway returns the first payment for a repeated key, and the service returns the order already stored under it.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.