~learn.

LLD 1513 min+60 XP

Design a Food Delivery App.

Design carts, orders, partners and refunds, then cancel one fifty times

6 code

Lesson · 13 min

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#

Interviewer

"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."

A good 90 minutes
  • 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
Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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#

DiagramFood delivery: 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 <|.. NearestIdlePartner

The order's states, and who moves it between them:

DiagramFood delivery: order states
%% 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:

pythonMenuItem · 46 lines
# 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:

pythonOrderStatus · 42 lines
# 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:

pythonPaymentGateway · 52 lines
# 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.
Edge cases
  • 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 refunded set; 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:

pythonFoodService · 94 lines
# 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 amount

Three orders from two restaurants, two partners, and a cancellation storm:

pythoncancel_o3 · 55 lines
# 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)
text8 lines
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 -> ready

Riya'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 AssignmentStrategy that 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 PricingStrategy with decorators for coupons, surge delivery fees and packaging charges. checkout asks 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.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • 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
Quick check3 questions · +5 XP each

Food delivery

  1. 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.

  2. 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.

  3. 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.

End of LLD 15 · Design a Food Delivery App
Finish LLD 15Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…