Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design)  Module 14 – Case studies: money and commerce

Low-level design: food ordering and delivery

Design a food ordering and delivery app in code: restaurants and menus, one order lifecycle across three parties, refunds by stage and riders as a strategy.

  • Advanced
  • 40 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free core, Pro depth: 15 of 40 minutes free
Free: The prompt and the questions that shape it · Requirements and what is out of scope · Classes, and who owns which rule · 3 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 25 minutes.

What you will learn

  • Model restaurants, menus, orders, kitchens and delivery partners
  • Implement the order lifecycle across customer, restaurant and rider
  • Choose riders with a pluggable strategy and handle cancellations
  • Calculate the refund owed when an order is cancelled at each stage
  • Test the design with a lunch-hour simulation and failure drills

Before you start

For a low-level design (LLD) round, a food ordering and delivery app comes down to a few classes: they take an order from a customer, have a restaurant cook it and a rider carry it, and keep the money right when anyone changes their mind. Four decisions carry the design. One Order object holds the whole lifecycle, and a transition table says which party may move it from which state: the customer places and cancels, the restaurant accepts, cooks and marks the food ready, the rider picks up and delivers, and the system rejects an order nobody accepts in time. The refund for a cancellation comes from a table keyed by the state the order was in. The rider is chosen by a dispatch strategy. And time comes from a clock the code is given, so a whole lunch hour runs in a test.

The steps follow the answer framework. City-wide dispatch with many servers is the system design track’s food delivery case; this model stays in one process.

Not affiliated

Based on public information about Swiggy and Zomato. MySmartCoPilot is not affiliated with Swiggy and Zomato, and Swiggy and Zomato’s real design may differ from what this lesson describes.

The prompt and the questions that shape it

The prompt is one line: “Design a food delivery app like Swiggy or Zomato.” Customers, restaurants and riders all touch the same order, so the first questions are about who may do what, and when:

  1. Can an order hold dishes from two restaurants? No: one restaurant per order, one kitchen and one pick-up.
  2. Must a restaurant accept every order? No. It accepts or rejects, and an order not accepted within 5 minutes is rejected for it.
  3. Who can cancel, and what does it cost? The customer, until a rider has the food; the restaurant, between accepting and handing over. The refund depends on how far the order got.
  4. How is a rider chosen? The nearest free rider, for now; the rule must be replaceable.
  5. What if no rider is free? The order waits in a queue, and the next free rider takes the oldest order.

Requirements and what is out of scope

  • R1 A restaurant has a place, a menu of items with prices in paise and a preparation time.
  • R2 An order belongs to one customer and one restaurant; its lines copy each item’s price when it is placed.
  • R3 An order moves through placed, accepted, preparing, ready, picked up and delivered, and ends rejected or cancelled if it does not get there.
  • R4 Each move is allowed only to its party: the restaurant accepts, rejects, starts, marks ready and may cancel while accepted or preparing; the rider picks up and delivers; the customer may cancel until the pick-up.
  • R5 An order not accepted within 5 minutes is rejected by the system.
  • R6 A cancellation refunds what the refund table gives for the order’s state at that moment, once.
  • R7 Every move is recorded with its minute and its party.
  • R8 A move the table does not allow is refused, and the order does not change.
  • R9 A rider is chosen by a strategy when the restaurant accepts; with no rider free, the order waits, oldest first.
  • R10 A rider carries one order at a time.
  • N1 Time comes from an injected clock, never from the system. N2 Money is whole paise. N3 Every change to one order happens under that order’s lock.

Out of scope: the payment itself (the model records what to refund), search, ratings and real street maps.

Classes, and who owns which rule

  • Order (entity): the lines, the fee, the state, the rider and the history. It owns R3, R4 and R8 through one method, move(event, actor, now), that looks the move up in the transition table.
  • Restaurant, MenuItem and Line: the menu and what was bought at which price (R1, R2).
  • Rider (entity): a place on the street grid and a state of its own: available, riding, waiting or offline.
  • OrderService: every use case of the three parties, the queue of orders without a rider and the clock (R5, R9).
  • RefundPolicy (a table of data) and Dispatch (a strategy): what differs between businesses.
Class diagram: an order service runs orders of one restaurant, with lines, a rider, a dispatch strategy and a refund policy.OrderService+waitingqueue of Order+place(...)Order+accept(id)+start(id)+cancel(id, actor)refundOrder+stateState+historylist[(minute, State, Actor)]+delivery_fee, refundpaise+move(event, actor, now)StateLine+itemMenuItem+qtyintRestaurant+placePlace+menucode → MenuItem+prep_minutesintRider+placePlace+stateRiderState«interface»Dispatch+choose(order, riders, now)RiderNearestAvailableRefundPolicy+customer_rulesState → RefundRule+refund(order, stage, actor)paiseruns0..*11..*from1carried by0..1uses1uses1

The food-delivery model's class diagram

Text description of the diagram

The diagram shows eight classes in UML notation. Each box names a class, then lists its attributes with their types and, below a line, its operations.

  • OrderService has waiting, a queue of orders without a rider, and the operations place, which returns an Order, accept(id), start(id) and cancel(id, actor), which returns the refund. An arrow labelled "runs" leads from it to zero or more Orders, and two arrows labelled "uses" lead to exactly one Dispatch and exactly one RefundPolicy.
  • Order has a state, a history of (minute, state, actor) entries, a delivery fee and a refund in paise, and the operation move(event, actor, now), which returns the state it left. A filled diamond shows that an order is composed of one or more Lines; each Line has a menu item and a quantity.
  • An arrow labelled "from" leads from Order to exactly one Restaurant, which has a place, a menu from code to menu item, and its preparation minutes. An arrow labelled "carried by" leads from Order to zero or one Rider, which has a place and a rider state.
  • Dispatch is an interface with the operation choose(order, riders, now), which returns a Rider. NearestAvailable realises it, shown by a dashed line with a hollow triangle.
  • RefundPolicy has customer_rules, a map from state to refund rule, and the operation refund(order, stage, actor), which returns paise.

The order decides its own moves; the service decides what involves more than one object: finding a rider, timing out, refunding. A status string with setters lets any caller write any state, so a delivered order could be cancelled; a table of allowed moves makes every illegal move fail the same way.

The order lifecycle across three parties

State diagram of a food order: placed, accepted, preparing, ready, picked up, delivered, with rejection and cancel paths.placedacceptedpreparingreadypicked updeliveredrejectedcancelledcustomer:placerestaurant:acceptrestaurant: rejectsystem: no answerin 5 minrestaurant:startrestaurant:readyrider:pick uprider:delivercustomer:cancelcustomer orrestaurant:cancelcustomer orrestaurant:cancelcustomer:cancel

A food order's lifecycle, and who moves it

Text description of the diagram

The diagram is a UML state machine with eight states drawn as rounded boxes: placed, accepted, preparing, ready, picked up, delivered, rejected and cancelled. Each arrow is labelled with who makes the move and the event.

  1. A filled dot marks the start. The arrow "customer: place" leads from it to placed.
  2. From placed, "restaurant: accept" leads to accepted. "Restaurant: reject" and "system: no answer in 5 min" both lead to rejected. "Customer: cancel" leads to cancelled.
  3. From accepted, "restaurant: start" leads to preparing, and "customer or restaurant: cancel" leads to cancelled.
  4. From preparing, "restaurant: ready" leads to ready, and "customer or restaurant: cancel" leads to cancelled.
  5. From ready, "rider: pick up" leads to picked up, and "customer: cancel" leads to cancelled.
  6. From picked up, "rider: deliver" leads to delivered. There is no cancel arrow from picked up.
  7. No arrow leaves delivered, rejected or cancelled: they are final.

Cancelling is the move with the most rules, so its refund is data, not code:

State when the customer cancels Food refunded Delivery fee refunded
placed 100 % 100 %
accepted 100 % 100 %
preparing 50 % 100 %
ready 0 % 0 %
picked up or later the cancel is refused the cancel is refused

When the restaurant rejects or cancels, or the order times out, the customer gets everything back. Amounts are rounded down to the paisa.

The first complete design, run on a lunch hour

The script plays one lunch hour in a city of 250-metre blocks, where a rider covers one block a minute: three restaurants, four riders and 13 orders. It says only what people do and when; kitchens and riders move by themselves, through a scheduler that runs events in time order with heapq.

A lunch hour with a cancellation at every stage

delivery/lunch.py

"""A lunch hour of the food-delivery model: three restaurants, four riders and 13 orders, with a cancellation at
every stage, a rejection, an order that times out, a cancel refused after pick-up and an order that waits for a
rider. The kitchens and the riders move by themselves; the script below only says what people do, and when.
Every refusal is printed, and the hour goes on. Then each order's history and money are listed.
"""

from model import Actor, MenuItem, Place, Refused, Restaurant, Rider, clock_text, rupees
from policies import NearestAvailable, RefundPolicy
from service import OrderService, Scheduler


def menu(*items: MenuItem) -> dict[str, MenuItem]:
    return {i.code: i for i in items}


TANDOOR = Restaurant("T", "Tandoor House", Place(2, 3), menu(
    MenuItem("PT", "paneer tikka", 28900), MenuItem("DM", "dal makhani", 24900), MenuItem("NA", "naan", 4900)), prep_minutes=15)
DOSA = Restaurant("D", "Dosa Corner", Place(8, 1), menu(
    MenuItem("MD", "masala dosa", 12900), MenuItem("ID", "idli", 8900), MenuItem("FC", "filter coffee", 4900)), prep_minutes=10)
BOWL = Restaurant("G", "Green Bowl", Place(5, 7), menu(
    MenuItem("SB", "salad bowl", 23900), MenuItem("SM", "smoothie", 14900)), prep_minutes=12)
RIDERS = [Rider("r1", "Imran", Place(0, 0)), Rider("r2", "Sunita", Place(6, 2)), Rider("r3", "Joseph", Place(4, 8)),
          Rider("r4", "Lakshmi", Place(13, 12))]
FEE = 3500

clock = Scheduler()
app = OrderService([TANDOOR, DOSA, BOWL], RIDERS, NearestAvailable(), RefundPolicy(), clock)

script = [
    (0, lambda: app.place("O1", "asha", "T", {"PT": 1, "NA": 2}, Place(1, 9), FEE)),
    (1, lambda: app.accept("O1")),
    (2, lambda: app.start("O1")),
    (2, lambda: app.place("O2", "ravi", "D", {"MD": 2, "FC": 2}, Place(11, 4), FEE)),
    (3, lambda: app.accept("O2")),
    (3, lambda: app.start("O2")),
    (4, lambda: app.place("O3", "meera", "G", {"SB": 1, "SM": 1}, Place(7, 11), FEE)),
    (5, lambda: app.cancel("O3")),  # while placed
    (5, lambda: app.place("O4", "kabir", "T", {"DM": 1, "NA": 2}, Place(3, 0), FEE)),
    (6, lambda: app.accept("O4")),
    (7, lambda: app.cancel("O4")),  # while accepted
    (8, lambda: app.place("O5", "leela", "D", {"ID": 2, "FC": 1}, Place(10, 0), FEE)),
    (9, lambda: app.accept("O5")),
    (10, lambda: app.start("O5")),
    (10, lambda: app.place("O6", "omar", "G", {"SB": 2}, Place(9, 9), FEE)),
    (11, lambda: app.accept("O6")),
    (11, lambda: app.start("O6")),
    (12, lambda: app.place("O7", "priya", "T", {"PT": 2}, Place(0, 6), FEE)),
    (13, lambda: app.reject("O7")),  # the restaurant has run out of paneer
    (14, lambda: app.cancel("O5")),  # while preparing
    (15, lambda: app.place("O8", "dev", "D", {"MD": 1}, Place(12, 3), FEE)),  # the restaurant never answers
    (18, lambda: app.place("O9", "farah", "G", {"SB": 1, "SM": 2}, Place(2, 10), FEE)),
    (19, lambda: app.accept("O9")),
    (19, lambda: app.start("O9")),
    (20, lambda: app.place("O10", "hari", "T", {"DM": 2, "NA": 4}, Place(4, 1), FEE)),
    (21, lambda: app.accept("O10")),
    (22, lambda: app.start("O10")),
    (23, lambda: app.cancel("O6")),  # while ready, with a rider on the way
    (24, lambda: app.place("O11", "jaya", "D", {"MD": 1, "FC": 1}, Place(9, 4), FEE)),
    (24, lambda: app.accept("O11")),
    (24, lambda: app.start("O11")),
    (25, lambda: app.place("O12", "kiran", "D", {"ID": 3}, Place(6, 0), FEE)),
    (25, lambda: app.accept("O12")),
    (26, lambda: app.start("O12")),
    (26, lambda: app.place("O13", "ira", "T", {"PT": 1, "NA": 1}, Place(5, 4), FEE)),
    (27, lambda: app.accept("O13")),  # every rider is busy
    (27, lambda: app.start("O13")),
    (30, lambda: app.cancel("O10", Actor.RESTAURANT)),  # the kitchen ran out of dal
    (34, lambda: app.cancel("O9")),  # too late: the food is on its way
]

print("Tandoor House (15 min), Dosa Corner (10 min), Green Bowl (12 min); riders Imran, Sunita, Joseph, Lakshmi")
for minute, step in script:
    clock.run_until(minute)
    try:
        step()
    except Refused as refusal:
        print(f"{clock_text(clock.now)} refused: {refusal}")
clock.run_until(120)

print("\nEach order: its history, what was paid and what came back")
for order in app.orders.values():
    steps = ", ".join(f"{state.value} {clock_text(t)}" for t, state, _ in order.history)
    print(f"{order.id:>3} {order.customer:<6} {steps}")
    print(f"    paid {rupees(order.paid)}, refunded {rupees(order.refund)}")
busy = ", ".join(f"{r.name} {r.busy_minutes} min" for r in RIDERS)
print(f"riding time: {busy}")

Output

Tandoor House (15 min), Dosa Corner (10 min), Green Bowl (12 min); riders Imran, Sunita, Joseph, Lakshmi
12:00 O1 asha: placed at Tandoor House, 387.00 + 35.00 delivery = 422.00
12:01 O1 asha: accepted by Tandoor House
12:01 O1 asha: Imran assigned, 5 min from the restaurant
12:02 O1 asha: the kitchen starts; ready at 12:17
12:02 O2 ravi: placed at Dosa Corner, 356.00 + 35.00 delivery = 391.00
12:03 O2 ravi: accepted by Dosa Corner
12:03 O2 ravi: Sunita assigned, 3 min from the restaurant
12:03 O2 ravi: the kitchen starts; ready at 12:13
12:04 O3 meera: placed at Green Bowl, 388.00 + 35.00 delivery = 423.00
12:05 O3 meera: cancelled by the customer while placed; refund 423.00 of 423.00
12:05 O4 kabir: placed at Tandoor House, 347.00 + 35.00 delivery = 382.00
12:06 O1 asha: Imran waits at Tandoor House
12:06 O2 ravi: Sunita waits at Dosa Corner
12:06 O4 kabir: accepted by Tandoor House
12:06 O4 kabir: Joseph assigned, 7 min from the restaurant
12:07 O4 kabir: cancelled by the customer while accepted; refund 382.00 of 382.00
12:08 O5 leela: placed at Dosa Corner, 227.00 + 35.00 delivery = 262.00
12:09 O5 leela: accepted by Dosa Corner
12:09 O5 leela: Joseph assigned, 11 min from the restaurant
12:10 O5 leela: the kitchen starts; ready at 12:20
12:10 O6 omar: placed at Green Bowl, 478.00 + 35.00 delivery = 513.00
12:11 O6 omar: accepted by Green Bowl
12:11 O6 omar: Lakshmi assigned, 13 min from the restaurant
12:11 O6 omar: the kitchen starts; ready at 12:23
12:12 O7 priya: placed at Tandoor House, 578.00 + 35.00 delivery = 613.00
12:13 O2 ravi: ready; Sunita is already waiting
12:13 O2 ravi: picked up by Sunita, 6 min to the customer
12:13 O7 priya: rejected by Tandoor House; refund 613.00
12:14 O5 leela: cancelled by the customer while preparing; refund 148.50 of 262.00
12:15 O8 dev: placed at Dosa Corner, 129.00 + 35.00 delivery = 164.00
12:17 O1 asha: ready; Imran is already waiting
12:17 O1 asha: picked up by Imran, 7 min to the customer
12:18 O9 farah: placed at Green Bowl, 537.00 + 35.00 delivery = 572.00
12:19 O2 ravi: delivered by Sunita, 17 min after it was placed
12:19 O9 farah: accepted by Green Bowl
12:19 O9 farah: Joseph assigned, 2 min from the restaurant
12:19 O9 farah: the kitchen starts; ready at 12:31
12:20 O8 dev: not accepted within 5 min, so it is rejected; refund 164.00
12:20 O10 hari: placed at Tandoor House, 694.00 + 35.00 delivery = 729.00
12:21 O9 farah: Joseph waits at Green Bowl
12:21 O10 hari: accepted by Tandoor House
12:21 O10 hari: Sunita assigned, 10 min from the restaurant
12:22 O10 hari: the kitchen starts; ready at 12:37
12:23 O6 omar: ready; Lakshmi is on the way
12:23 O6 omar: cancelled by the customer while ready; refund 0.00 of 513.00
12:24 O1 asha: delivered by Imran, 24 min after it was placed
12:24 O11 jaya: placed at Dosa Corner, 178.00 + 35.00 delivery = 213.00
12:24 O11 jaya: accepted by Dosa Corner
12:24 O11 jaya: Imran assigned, 15 min from the restaurant
12:24 O11 jaya: the kitchen starts; ready at 12:34
12:25 O12 kiran: placed at Dosa Corner, 267.00 + 35.00 delivery = 302.00
12:25 O12 kiran: accepted by Dosa Corner
12:25 O12 kiran: Lakshmi assigned, 16 min from the restaurant
12:26 O12 kiran: the kitchen starts; ready at 12:36
12:26 O13 ira: placed at Tandoor House, 338.00 + 35.00 delivery = 373.00
12:27 O13 ira: accepted by Tandoor House
12:27 O13 ira: no rider is free; the order waits for one
12:27 O13 ira: the kitchen starts; ready at 12:42
12:30 O10 hari: cancelled by the restaurant while preparing; refund 729.00 of 729.00
12:30 O13 ira: Sunita assigned, 10 min from the restaurant
12:31 O9 farah: ready; Joseph is already waiting
12:31 O9 farah: picked up by Joseph, 6 min to the customer
12:34 O11 jaya: ready; Imran is on the way
12:34 refused: O9: a customer cannot cancel an order that is picked up
12:36 O12 kiran: ready; Lakshmi is on the way
12:37 O9 farah: delivered by Joseph, 19 min after it was placed
12:39 O11 jaya: picked up by Imran, 4 min to the customer

Text longer than 4,000 characters is cut here; run the example yourself to see all of it.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lunch.py

delivery/service.py

"""The order service of the food-delivery model: every use case of the customer, the restaurant and the rider goes
through it, and it moves time with a scheduler it is given (N1).

    Scheduler     a clock that runs events in time order (heapq), ties in the order they were added
    OrderService  place, accept, reject, start, cancel; the kitchen and the riders move by themselves
"""

from __future__ import annotations

from collections import deque
from heapq import heappop, heappush
from itertools import count

from model import Actor, Line, Order, Place, Refused, Restaurant, Rider, RiderState, State, clock_text, rupees
from policies import Dispatch, RefundPolicy


class Scheduler:
    def __init__(self) -> None:
        self.now = 0
        self._queue: list = []
        self._seq = count()

    def at(self, minute: int, action) -> None:
        heappush(self._queue, (minute, next(self._seq), action))

    def run_until(self, minute: int) -> None:
        while self._queue and self._queue[0][0] <= minute:
            self.now, _, action = heappop(self._queue)
            action()
        self.now = max(self.now, minute)


class OrderService:
    ACCEPT_WITHIN = 5  # R5: minutes a restaurant has to accept an order before it times out
    READY_ALERT = 10  # minutes food may wait at the counter with no rider before someone is told

    def __init__(self, restaurants: list[Restaurant], riders: list[Rider], dispatch: Dispatch, refunds: RefundPolicy,
                 scheduler: Scheduler, log=print) -> None:
        self.restaurants = {r.id: r for r in restaurants}
        self.riders = riders
        self.dispatch = dispatch
        self.refunds = refunds
        self.clock = scheduler
        self.orders: dict[str, Order] = {}
        self.waiting: deque[Order] = deque()  # accepted orders without a rider, oldest first (R9)
        self.alerts: list[str] = []
        self.log = log

    def _say(self, order: Order, text: str) -> None:
        self.log(f"{clock_text(self.clock.now)} {order.id} {order.customer}: {text}")

    # ---- the customer --------------------------------------------------------------------------------------------

    def place(self, order_id: str, customer: str, restaurant_id: str, items: dict[str, int], drop: Place, fee: int) -> Order:
        restaurant = self.restaurants[restaurant_id]
        unknown = [code for code in items if code not in restaurant.menu]
        if unknown or not items:
            raise Refused(f"{order_id}: {restaurant.name} has no {', '.join(unknown) or 'items in this order'}")
        lines = tuple(Line(restaurant.menu[code], qty) for code, qty in items.items())  # R2: prices copied now
        order = Order(order_id, customer, restaurant, drop, lines, fee, self.clock.now)
        order.history.append((self.clock.now, State.PLACED, Actor.CUSTOMER))
        self.orders[order_id] = order
        self._say(order, f"placed at {restaurant.name}, {rupees(order.items)} + {rupees(fee)} delivery = {rupees(order.paid)}")
        self.clock.at(self.clock.now + self.ACCEPT_WITHIN, lambda: self._time_out(order))
        return order

    def cancel(self, order_id: str, actor: Actor = Actor.CUSTOMER) -> int:
        order = self.orders[order_id]
        stage = order.state
        order.move("cancel", actor, self.clock.now)  # R4: refused once the food is on its way
        refund = self._refund(order, stage, actor)
        self._say(order, f"cancelled by the {actor.value} while {stage.value}; refund {rupees(refund)} of {rupees(order.paid)}")
        self._release_rider(order)
        return refund

    # ---- the restaurant ----------------------------------------------------------------------------------------

    def accept(self, order_id: str) -> None:
        order = self.orders[order_id]
        order.move("accept", Actor.RESTAURANT, self.clock.now)
        self._say(order, f"accepted by {order.restaurant.name}")
        self._find_rider(order)

    def reject(self, order_id: str) -> None:
        order = self.orders[order_id]
        order.move("reject", Actor.RESTAURANT, self.clock.now)
        refund = self._refund(order, State.PLACED, Actor.RESTAURANT)
        self._say(order, f"rejected by {order.restaurant.name}; refund {rupees(refund)}")

    def start(self, order_id: str) -> None:
        order = self.orders[order_id]
        order.move("start", Actor.RESTAURANT, self.clock.now)
        ready_at = self.clock.now + order.restaurant.prep_minutes
        self._say(order, f"the kitchen starts; ready at {clock_text(ready_at)}")
        self.clock.at(ready_at, lambda: self._ready(order))

    def _ready(self, order: Order) -> None:
        if order.state is not State.PREPARING:  # cancelled while it cooked: nothing to announce
            return
        order.move("ready", Actor.RESTAURANT, self.clock.now)
        self.clock.at(self.clock.now + self.READY_ALERT, lambda: self._still_waiting(order))
        rider = order.rider
        if rider and rider.state is RiderState.WAITING:
            self._say(order, f"ready; {rider.name} is already waiting")
            self._pick_up(rider, order)
        else:
            self._say(order, "ready" + (f"; {rider.name} is on the way" if rider else "; no rider yet"))

    # ---- riders ------------------------------------------------------------------------------------------------

    def _find_rider(self, order: Order) -> None:
        rider = self.dispatch.choose(order, self.riders, self.clock.now)
        if rider is None:
            self.waiting.append(order)
            self._say(order, "no rider is free; the order waits for one")
            return
        rider.state, rider.order, order.rider = RiderState.TO_RESTAURANT, order, rider
        minutes = rider.place.blocks_to(order.restaurant.place)
        rider.busy_minutes += minutes
        self._say(order, f"{rider.name} assigned, {minutes} min from the restaurant")
        self.clock.at(self.clock.now + minutes, lambda: self._at_restaurant(rider, order))

    def _at_restaurant(self, rider: Rider, order: Order) -> None:
        if rider.order is not order:  # released on the way
            return
        rider.place = order.restaurant.place
        if order.state is State.READY:
            self._pick_up(rider, order)
        else:
            rider.state = RiderState.WAITING
            self._say(order, f"{rider.name} waits at {order.restaurant.name}")

    def _pick_up(self, rider: Rider, order: Order) -> None:
        order.move("pick up", Actor.RIDER, self.clock.now)
        rider.state = RiderState.TO_CUSTOMER
        minutes = order.restaurant.place.blocks_to(order.drop)
        rider.busy_minutes += minutes
        self._say(order, f"picked up by {rider.name}, {minutes} min to the customer")
        self.clock.at(self.clock.now + minutes, lambda: self._deliver(rider, order))

    def _deliver(self, rider: Rider, order: Order) -> None:
        if rider.state is RiderState.OFFLINE:  # only the rider's app can report a delivery
            return
        order.move("deliver", Actor.RIDER, self.clock.now)
        rider.place = order.drop
        self._say(order, f"delivered by {rider.name}, {self.clock.now - order.placed_at} min after it was placed")
        self._free(rider)

    def _release_rider(self, order: Order) -> None:
        if order in self.waiting:
            self.waiting.remove(order)
        rider = order.rider
        if rider and rider.order is order:
            if rider.state is RiderState.WAITING:
                rider.place = order.restaurant.place
            self._free(rider)

    def _free(self, rider: Rider) -> None:
        rider.state, rider.order = RiderState.AVAILABLE, None
        if self.waiting:  # R9: the oldest order without a rider is served first
            self._find_rider(self.waiting.popleft())

    def rider_offline(self, rider_id: str) -> None:
        """A rider's app stops answering. Before pick-up the order goes back to the front of the queue; after it, the
        food is with that rider, so nobody else can deliver it and people are told instead."""
        rider = next(r for r in self.riders if r.id == rider_id)
        order, rider.order, rider.state = rider.order, None, RiderState.OFFLINE
        if order is None:
            return
        if order.state is State.PICKED_UP:
            self.alerts.append(f"{order.id}: {rider.name} went offline carrying the food")
            self._say(order, f"{rider.name} went offline with the food; support and the customer are told")
            return
        order.rider = None
        self._say(order, f"{rider.name} went offline before the pick-up; the order goes back to the front of the queue")
        self.waiting.appendleft(order)
        free = [r for r in self.riders if r.state is RiderState.AVAILABLE]
        if free:
            self._find_rider(self.waiting.popleft())

    def _still_waiting(self, order: Order) -> None:
        if order.state is State.READY and (order.rider is None or order.rider.state is not RiderState.WAITING):
            self.alerts.append(f"{order.id}: ready for {self.READY_ALERT} min with no rider at the counter")
            self._say(order, f"ready for {self.READY_ALERT} min with no rider at the counter; the customer is told")

    # ---- time and money ------------------------------------------------------------------------------------------

    def _time_out(self, order: Order) -> None:
        if order.state is State.PLACED:
            order.move("time out", Actor.SYSTEM, self.clock.now)
            refund = self._refund(order, State.PLACED, Actor.SYSTEM)
            self._say(order, f"not accepted within {self.ACCEPT_WITHIN} min, so it is rejected; refund {rupees(refund)}")

    def _refund(self, order: Order, stage: State, actor: Actor) -> int:
        if not order.refunded:  # R6: one refund per order, whatever happens to the request
            order.refund, order.refunded = self.refunds.refund(order, stage, actor), True
        return order.refund

delivery/model.py

"""Restaurants, menus, orders and riders of the food-delivery model (lesson lld/food-ordering-and-delivery), and the
order's lifecycle as a transition table. Each rule names its requirement (R1 to R10 in the lesson).

Money is whole paise and time is whole minutes from an injected clock (N1, N2). A place is a point on a street grid
of 250-metre blocks, and a rider covers one block a minute, so a distance in blocks is also a time in minutes.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum


def rupees(paise: int) -> str:
    sign = "-" if paise < 0 else ""
    return f"{sign}{abs(paise) // 100:,}.{abs(paise) % 100:02d}"


def clock_text(minute: int) -> str:
    """Minutes after 12:00 as a time of day: 7 -> "12:07"."""
    return f"{12 + minute // 60}:{minute % 60:02d}"


@dataclass(frozen=True)
class Place:
    x: int
    y: int

    def blocks_to(self, other: Place) -> int:
        return abs(self.x - other.x) + abs(self.y - other.y)  # streets on a grid: no diagonals


@dataclass(frozen=True)
class MenuItem:
    code: str
    name: str
    price: int  # paise


@dataclass
class Restaurant:
    id: str
    name: str
    place: Place
    menu: dict[str, MenuItem]
    prep_minutes: int


class State(Enum):
    PLACED = "placed"
    ACCEPTED = "accepted"
    PREPARING = "preparing"
    READY = "ready"
    PICKED_UP = "picked up"
    DELIVERED = "delivered"
    REJECTED = "rejected"
    CANCELLED = "cancelled"


class Actor(Enum):
    CUSTOMER = "customer"
    RESTAURANT = "restaurant"
    RIDER = "rider"
    SYSTEM = "system"


S, A = State, Actor
# R3, R4: the only moves an order can make, and who may make each one. Anything else is refused (R8).
TRANSITIONS: dict[tuple[State, str, Actor], State] = {
    (S.PLACED, "accept", A.RESTAURANT): S.ACCEPTED,
    (S.PLACED, "reject", A.RESTAURANT): S.REJECTED,
    (S.PLACED, "time out", A.SYSTEM): S.REJECTED,
    (S.ACCEPTED, "start", A.RESTAURANT): S.PREPARING,
    (S.PREPARING, "ready", A.RESTAURANT): S.READY,
    (S.READY, "pick up", A.RIDER): S.PICKED_UP,
    (S.PICKED_UP, "deliver", A.RIDER): S.DELIVERED,
    **{(s, "cancel", A.CUSTOMER): S.CANCELLED for s in (S.PLACED, S.ACCEPTED, S.PREPARING, S.READY)},
    **{(s, "cancel", A.RESTAURANT): S.CANCELLED for s in (S.ACCEPTED, S.PREPARING)},
}
FINAL = {S.DELIVERED, S.REJECTED, S.CANCELLED}


class Refused(Exception):
    """A request the rules refuse; the order is left exactly as it was."""


@dataclass(frozen=True)
class Line:
    item: MenuItem
    qty: int

    @property
    def amount(self) -> int:
        return self.item.price * self.qty


@dataclass
class Order:
    id: str
    customer: str
    restaurant: Restaurant
    drop: Place
    lines: tuple[Line, ...]
    delivery_fee: int
    placed_at: int
    state: State = State.PLACED
    rider: Rider | None = None
    history: list[tuple[int, State, Actor]] = field(default_factory=list)
    refund: int = 0
    refunded: bool = False

    @property
    def items(self) -> int:
        return sum(line.amount for line in self.lines)  # R2: prices copied into the lines when the order was placed

    @property
    def paid(self) -> int:
        return self.items + self.delivery_fee

    def move(self, event: str, actor: Actor, now: int) -> State:
        """R8: apply one event of the table, or refuse it and change nothing."""
        target = TRANSITIONS.get((self.state, event, actor))
        if target is None:
            raise Refused(f"{self.id}: a {actor.value} cannot {event} an order that is {self.state.value}")
        before = self.state
        self.state = target
        self.history.append((now, target, actor))  # R7
        return before


class RiderState(Enum):
    AVAILABLE = "available"
    TO_RESTAURANT = "riding to the restaurant"
    WAITING = "waiting for the food"
    TO_CUSTOMER = "riding to the customer"
    OFFLINE = "offline"


@dataclass(eq=False)
class Rider:
    id: str
    name: str
    place: Place
    state: RiderState = RiderState.AVAILABLE
    order: Order | None = None
    busy_minutes: int = 0

delivery/policies.py

"""What varies between food-delivery businesses, kept out of the order: the refund owed when an order is cancelled
(R6, a table of data) and the choice of a rider (R9, a strategy).
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol

from model import Actor, Order, Rider, RiderState, State


@dataclass(frozen=True)
class RefundRule:
    items_percent: int
    fee_percent: int


# R6: what a customer who cancels gets back, by the order's state at that moment. Rounded down to the paisa.
CUSTOMER_CANCELS = {
    State.PLACED: RefundRule(100, 100),
    State.ACCEPTED: RefundRule(100, 100),  # the kitchen has not started
    State.PREPARING: RefundRule(50, 100),  # half the food is paid for; no rider has carried anything
    State.READY: RefundRule(0, 0),  # the food is made and a rider is on the way or waiting
}
# R6: when the restaurant rejects or cancels, or the order times out, the customer gets everything back.
NOT_THE_CUSTOMER = RefundRule(100, 100)


class RefundPolicy:
    def __init__(self, customer_rules: dict[State, RefundRule] | None = None) -> None:
        self.customer_rules = customer_rules or CUSTOMER_CANCELS

    def refund(self, order: Order, stage: State, actor: Actor) -> int:
        rule = self.customer_rules[stage] if actor is Actor.CUSTOMER else NOT_THE_CUSTOMER
        return order.items * rule.items_percent // 100 + order.delivery_fee * rule.fee_percent // 100


class Dispatch(Protocol):
    name: str

    def choose(self, order: Order, riders: list[Rider], now: int) -> Rider | None: ...


class NearestAvailable:
    """R9: the available rider closest to the restaurant, by blocks; a tie goes to the rider listed first."""

    name = "nearest available rider"

    def choose(self, order: Order, riders: list[Rider], now: int) -> Rider | None:
        free = [r for r in riders if r.state is RiderState.AVAILABLE]
        return min(free, key=lambda r: r.place.blocks_to(order.restaurant.place), default=None)

What to read in the output:

  • O1 and O2 arrive in 24 and 17 minutes. Imran waits 11 minutes at Tandoor House: he was sent at acceptance, and the kitchen needed 15 minutes.
  • O3 to O6 are cancelled at every stage: placed and accepted give back everything, preparing gives back 148.50 of 262.00, and ready gives back nothing.
  • O7 is rejected by the restaurant and O8 times out after 5 minutes; both are refunded in full.
  • O9 cannot be cancelled after the pick-up, and O10 is cancelled by the restaurant while it cooks.
  • O13 finds every rider busy and waits; when O10 is cancelled at 12:30, Sunita is freed and takes it at once.

Imran’s 11 minutes at the counter come from the simplest of three dispatch timings: this run sends a rider the moment a restaurant accepts. Sending a rider just in time means waiting until the rider would reach the counter about when the food is ready. Batching puts two orders from one restaurant on a single trip.

Python Online Compiler Paste the four files, then add a fifth rider and see which orders arrive sooner.

Self-check and an extension exercise

The quiz checks the edges of the run. The exercise builds such a batch for one rider, as long as both orders still arrive within the promised time.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 Leela's order costs 227.00 for the food and 35.00 for delivery. She cancels while the kitchen is preparing it. How much does she get back?

    Choose one answer.

    Show the answer to question 1

    Answer: 148.50, half of the food and all of the delivery fee.

    The refund table gives back half of the food once the kitchen has started (227.00 × 50 % = 113.50) and the whole delivery fee, because no rider has carried anything yet: 113.50 + 35.00 = 148.50. That is O5 in the lunch-hour run.

  2. Question 2 of 5 The food of an order is ready at the counter. Who may still cancel the order?

    Choose one answer.

    Show the answer to question 2

    Answer: The customer only

    The transition table lets the customer cancel from placed, accepted, preparing and ready, and the restaurant only from accepted and preparing. Once the food is ready, the restaurant has nothing left to stop, and after the pick-up nobody may cancel: the food is on its way.

  3. Question 3 of 5 Imran reached Tandoor House at 12:06 for asha's order but left at 12:17. Why?

    Choose one answer.

    Show the answer to question 3

    Answer: He was assigned when the restaurant accepted, and the kitchen needed 15 minutes from 12:02.

    The nearest free rider is assigned the moment the restaurant accepts. Imran was 5 minutes away, so he arrived at 12:06, but the food was only ready at 12:17. A rider standing at a counter is a rider who cannot take another order, which is the cost this first design accepts.

  4. Question 4 of 5 Ira's order O13 is accepted at 12:27, when every rider is busy. At how many minutes past 12 is a rider assigned to it?

    Type a number.

    Show the answer to question 4

    Answer: 30 minutes past 12

    O13 waits in the queue of orders without a rider. At 12:30 the restaurant cancels hari's order O10, which frees Sunita on her way to it, and the oldest waiting order, O13, gets her at once.

  5. Question 5 of 5 Which of these requests does the model refuse?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • The customer cancels an order that has been picked up.
    • The restaurant cancels an order that is ready.

    Only the moves in the transition table are allowed. Cancelling after the pick-up is refused for everyone, and the restaurant may cancel only while the order is accepted or preparing. A customer may cancel while the food is cooking, at the refund the table gives, and the system's time-out is one of the table's rows.

Exercise · Medium · Python

Batch two orders for one rider without breaking a delivery promise

A rider who is going to a restaurant anyway could carry a second order from it. Write plan_batch(first, second, rider_at, now, promise=40) in batching.py. It returns a Plan for one rider to carry both orders, or None when the batch is not allowed. Places are blocks of a street grid, and a rider covers one block a minute, so blocks_to() is also a number of minutes.

  • E1 Both orders come from the same restaurant (the same Place); otherwise there is no batch.
  • E2 Their ready times are at most 5 minutes apart; otherwise there is no batch.
  • E3 The rider leaves rider_at at minute now and picks both orders up at the later of its arrival and the two ready times: that minute is pick_up_at.
  • E4 Of the two orders in which the drops can be made, prefer the one with the shorter ride from the restaurant. When both are equally long, deliver first the order that was placed first.
  • E5 Each order must arrive within its promise: delivered_at <= placed_at + promise. If the preferred drop order breaks a promise, use the other one; if neither keeps both promises, return None.

A Plan lists the order ids in the order they are delivered (drops), pick_up_at, and delivered_at, a dict from order id to the minute it arrives.

Starter code · batching.py

"""Batching for the food-delivery model of lld/food-ordering-and-delivery: may one rider carry a second order, and
in which order are the two drops made? Places are blocks of a street grid, and a rider covers one block a minute.

Write plan_batch(): the rules E1 to E5 are in the exercise's prompt, and the sample tests name them.
"""

from dataclasses import dataclass

PROMISE = 40  # minutes from placing an order to its delivery


@dataclass(frozen=True)
class Place:
    x: int
    y: int

    def blocks_to(self, other: "Place") -> int:
        return abs(self.x - other.x) + abs(self.y - other.y)


@dataclass(frozen=True)
class Order:
    id: str
    restaurant: Place
    drop: Place
    placed_at: int
    ready_at: int


@dataclass(frozen=True)
class Plan:
    drops: tuple[str, ...]  # order ids in the order they are delivered
    pick_up_at: int
    delivered_at: dict[str, int]  # order id -> the minute it is delivered


def plan_batch(first: Order, second: Order, rider_at: Place, now: int, promise: int = PROMISE) -> Plan | None:
    """E1 to E5: a plan for one rider to carry both orders, or None."""
    raise NotImplementedError
The sample tests · test_batching.py
from batching import Order, Place, Plan, plan_batch

KITCHEN = Place(5, 5)


def order(oid, placed, ready, drop, restaurant=KITCHEN):
    return Order(oid, restaurant, drop, placed_at=placed, ready_at=ready)


def test_e4_the_shorter_route_is_chosen():
    """A's drop is 5 blocks away and B's 4: delivering B first is 13 blocks, A first 14"""
    a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 14, Place(9, 5))
    assert plan_batch(a, b, rider_at=Place(5, 8), now=10) == Plan(("B", "A"), 14, {"B": 18, "A": 27})


def test_e1_two_restaurants_are_not_a_batch():
    """the second order comes from another kitchen"""
    a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 14, Place(9, 5), restaurant=Place(6, 5))
    assert plan_batch(a, b, rider_at=Place(5, 8), now=10) is None


def test_e2_ready_times_too_far_apart():
    """food ready at 12 and at 18 is 6 minutes apart: the first would wait too long"""
    a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 18, Place(9, 5))
    assert plan_batch(a, b, rider_at=Place(5, 8), now=10) is None


def test_e2_five_minutes_apart_is_allowed():
    """food ready at 12 and at 17 may still ride together"""
    a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 17, Place(9, 5))
    plan = plan_batch(a, b, rider_at=Place(5, 8), now=10)
    assert plan is not None and plan.pick_up_at == 17


def test_e3_the_rider_waits_for_the_later_food():
    """the rider is at the counter at 10; the food is ready at 12 and 16, so both leave at 16"""
    a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 16, Place(9, 5))
    assert plan_batch(a, b, rider_at=KITCHEN, now=10).pick_up_at == 16


def test_e3_the_food_waits_for_a_far_rider():
    """a rider 40 blocks away arrives at 50, long after the food is ready"""
    a, b = order("A", 0, 12, Place(5, 0)), order("B", 2, 14, Place(9, 5))
    plan = plan_batch(a, b, rider_at=Place(25, 25), now=10, promise=100)
    assert plan.pick_up_at == 50 and plan.delivered_at == {"B": 54, "A": 63}


def test_e4_a_tie_goes_to_the_order_placed_first():
    """both routes are 12 blocks, so B, placed at minute 1, is delivered before A, placed at 3"""
    a, b = order("A", 3, 12, Place(5, 9)), order("B", 1, 12, Place(5, 1))
    assert plan_batch(a, b, rider_at=KITCHEN, now=12).drops == ("B", "A")


def test_e5_the_shorter_route_would_break_a_promise():
    """B first is shorter, but A, placed at 0, would arrive at 41; A first keeps both promises"""
    a, b = order("A", 0, 28, Place(5, 0)), order("B", 25, 30, Place(5, 8))
    assert plan_batch(a, b, rider_at=KITCHEN, now=30) == Plan(("A", "B"), 30, {"A": 35, "B": 43})


def test_e5_no_route_keeps_both_promises():
    """leaving at 36, A arrives at 41 at the earliest: no batch"""
    a, b = order("A", 0, 28, Place(5, 0)), order("B", 25, 30, Place(5, 8))
    assert plan_batch(a, b, rider_at=KITCHEN, now=36) is None


def test_e5_a_longer_promise_allows_the_shorter_route():
    """with a promise of 50 minutes, B first is allowed again: B at 39, A at 47"""
    a, b = order("A", 0, 28, Place(5, 0)), order("B", 25, 30, Place(5, 8))
    assert plan_batch(a, b, rider_at=KITCHEN, now=36, promise=50) == Plan(("B", "A"), 36, {"B": 39, "A": 47})
A hint

Compute the pick-up minute once, then write a small helper that rides one order of drops and returns its length and the minute of each delivery: the first drop is pick_up_at plus the blocks from the restaurant, and the second adds the blocks between the two drops. Put the two orders in the order they were placed before you sort the two routes by length: a stable sort then keeps the earlier order first when the lengths tie. Try the routes in that order and return the first that keeps both promises.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Cancellations and refunds, with the race and the retries that break them Pro: opens with a Pro, Premium or Ultimate pass

A cancel is easy until it meets another request for the same order or a payment provider that does not answer. This part tests the refund table row by row, reproduces the race between a cancel and an accept on real threads, and shows a refund retried until the customer is paid three times, then once.

In this part:

  • The refund table, tested row by row
  • Cancel against accept: the race reproduced on threads
  • A cancel sent twice and a refund retried, paid once

2 runnable examples (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Low-level design: parking lot (the model)

Assigning riders: at acceptance, just in time or in batches, measured Pro: opens with a Pro, Premium or Ultimate pass

The nearest free rider is a fine first rule, but it is only one of three choices: which rider, when, and with which other orders. This part measures three policies on the same seeded lunch hour and reads what the numbers say about the design.

In this part:

  • Dispatch as a strategy, and when to call it
  • One lunch hour under three policies
  • What the numbers say, and what the model leaves out

1 runnable example (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Failure drills, follow-ups, a level rubric and a self-mock Pro: opens with a Pro, Premium or Ultimate pass

A delivery round usually closes with accidents and changed requirements. Three failure drills run against the service here, then the usual follow-ups are mapped to the classes they touch, and a rubric sorts answers by level.

In this part:

  • Three failure drills, run
  • Follow-up questions and where each change lands
  • Patterns used and rejected
  • What each level is expected to cover, as a rubric
  • A 45-minute self-mock

1 runnable example (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    Why model a food order's lifecycle as a table of allowed moves instead of a status field with setters?

    Because three parties and a timer change the same order, and each may make only some moves from some states. A status field with setters lets any caller write any value, so a delivered order can be cancelled or a rejected one picked up, and the rules end up scattered over every caller. A table keyed by the current state, the event and the party lists every allowed move in one place: one method looks the move up, refuses anything missing with the same error and leaves the order unchanged, and records each move with its minute and its party. Adding a state or a party becomes one new row, and a test can walk the table to check every pair.

  2. Mid to senior

    A customer taps cancel at the same moment the restaurant taps accept on the same order. What can go wrong, what should each side end up seeing, and how does the design make the outcome consistent?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    A restaurant rejects an order after the customer has paid, and the refund call to the payment provider times out. Design the refund path so that the customer is paid back exactly once, whatever fails.

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    At lunch, sending the nearest free rider the moment a restaurant accepts leaves riders waiting at counters while other orders wait for a rider. What would you change in the design, and how would you show that it helped?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Keep one Order with a transition table of moves and the party allowed to make each, so every illegal move fails the same way and every move is recorded.
  • Copy prices into the order’s lines when it is placed, and keep the refund for each state in a table.
  • Choose riders through a strategy, and let orders without a rider wait in a queue, oldest first.
  • Drive time from a clock the code is given, so a lunch hour runs as a test.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.