0

Design a food delivery system (object-oriented)

by · 1 day ago· ⑂ 0 remixes

Question: Design a food delivery system (object-oriented)

Companies: Amazon

Difficulty: Hard

Problem statement

Model restaurants, menus, orders, couriers and order states for a food delivery app, including assignment and cancellation.

Requirements

  • Restaurants have a menu of items with a price and an availability flag. A restaurant can be open or closed.
  • A customer orders from one restaurant: menu items with quantities and a drop-off location. Prices are copied into the order when it is placed, so later menu changes do not affect it.
  • Order lifecycle: PLACED → ACCEPTED → PREPARING → READY → PICKED_UP → DELIVERED, plus CANCELLED.
  • Each step has an owner. The restaurant accepts, prepares and marks ready. The courier picks up and delivers.
  • A courier is assigned when the restaurant accepts, so the courier can travel while the food cooks. The assignment rule is pluggable; the default is the nearest available courier within a radius.
  • A courier carries one order at a time, and two orders must never get the same courier, even under concurrent calls. If no courier is free, the order waits in a queue and is retried when a courier comes online or becomes free.
  • Cancellation is allowed until pickup, by the customer, the restaurant or the system. Cancelling frees the courier, and a refund policy decides the amount. Observers (notifications, ETA, analytics) hear about every status change.
  • Out of scope: payment capture, live GPS tracking and routing, ETA prediction, ratings, promotions, batching several orders per courier, multi-restaurant carts.

Core objects

  • Location — immutable coordinates with a distance function.
  • MenuItem — a dish with a price in cents and availability. Restaurant — location, open flag and its menu (item_id → MenuItem).
  • Courier — location, availability and the id of the order it carries.
  • OrderItem — immutable snapshot of a menu line (name, unit price, quantity) at order time.
  • OrderStatus (enum) — the lifecycle states; TRANSITIONS is the legal-move table and STEP_OWNER says which Actor (customer, restaurant, courier, system) may make each move.
  • Order — the aggregate. It holds the items, status, assigned courier and refund, and has its own lock. It rejects illegal transitions.
  • AssignmentStrategy (ABC) / NearestCourierStrategy — choose a courier for an order.
  • refund_policy (default default_refund) — a function (prior_status, total, actor) → refund_cents.
  • OrderService — the facade. It places, advances and cancels orders, dispatches couriers, keeps the waiting queue and notifies listeners.

Class design

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"svc","x":0,"y":0,"width":240,"height":170,"label":{"text":"OrderService\norders, waiting\nplace_order()\nadvance()\ncancel()\nadd_courier()"}},
 {"type":"rectangle","id":"order","x":440,"y":10,"width":200,"height":150,"label":{"text":"Order\nitems, status, courier\ntotal_cents()\ntransition()"}},
 {"type":"rectangle","id":"rest","x":820,"y":-135,"width":220,"height":100,"label":{"text":"Restaurant\nlocation, is_open,\nmenu"}},
 {"type":"rectangle","id":"menu","x":1200,"y":-120,"width":180,"height":70,"label":{"text":"MenuItem"}},
 {"type":"rectangle","id":"oitem","x":820,"y":50,"width":180,"height":70,"label":{"text":"OrderItem"}},
 {"type":"rectangle","id":"status","x":820,"y":210,"width":180,"height":70,"label":{"text":"OrderStatus\n(enum)"}},
 {"type":"rectangle","id":"courier","x":450,"y":300,"width":180,"height":70,"label":{"text":"Courier"}},
 {"type":"rectangle","id":"strat","x":0,"y":300,"width":240,"height":100,"label":{"text":"AssignmentStrategy\n(abstract)\npick()"}},
 {"type":"rectangle","id":"nearest","x":-10,"y":520,"width":260,"height":70,"label":{"text":"NearestCourierStrategy"}},
 {"type":"arrow","id":"svc-order","x":240,"y":85,"width":200,"height":0,"start":{"id":"svc"},"end":{"id":"order"},"label":{"text":"has"}},
 {"type":"arrow","id":"svc-courier","x":240,"y":125,"width":210,"height":210,"start":{"id":"svc"},"end":{"id":"courier"},"label":{"text":"has"}},
 {"type":"arrow","id":"svc-strat","x":120,"y":170,"width":1,"height":130,"start":{"id":"svc"},"end":{"id":"strat"},"label":{"text":"uses"}},
 {"type":"arrow","id":"order-rest","x":640,"y":35,"width":180,"height":-120,"start":{"id":"order"},"end":{"id":"rest"},"label":{"text":"from"}},
 {"type":"arrow","id":"order-oitem","x":640,"y":85,"width":180,"height":0,"start":{"id":"order"},"end":{"id":"oitem"},"label":{"text":"has many"}},
 {"type":"arrow","id":"order-status","x":640,"y":125,"width":180,"height":120,"start":{"id":"order"},"end":{"id":"status"},"label":{"text":"has"}},
 {"type":"arrow","id":"order-courier","x":540,"y":160,"width":1,"height":140,"start":{"id":"order"},"end":{"id":"courier"},"label":{"text":"0..1"}},
 {"type":"arrow","id":"rest-menu","x":1040,"y":-85,"width":160,"height":0,"start":{"id":"rest"},"end":{"id":"menu"},"label":{"text":"has many"}},
 {"type":"arrow","id":"nearest-strat","x":120,"y":520,"width":1,"height":-120,"start":{"id":"nearest"},"end":{"id":"strat"},"label":{"text":"extends"}}
]}

Key flows

1. Place an order. OrderService.place_order checks that the restaurant is open. It checks that each line refers to an available item on that restaurant's menu and that the quantity is positive. It snapshots each line into an OrderItem and creates the Order in PLACED. Then it notifies listeners. No courier is reserved yet, because the restaurant may still reject the order.

2. Restaurant accepts, and a courier is dispatched. advance(id, ACCEPTED, RESTAURANT) checks that the actor owns this step. It locks the order and applies the transition. After it releases the order lock, it calls _dispatch. _dispatch takes the dispatch lock and then the order lock. It skips the order if it was cancelled or already has a courier. It asks the AssignmentStrategy for a courier, marks that courier busy and links both sides. If no courier qualifies, the order goes into waiting.

3. Progress to delivery. The restaurant moves the order to PREPARING and then READY. The courier moves it to PICKED_UP, which is refused when no courier is assigned, and then to DELIVERED. On delivery the courier is freed and _retry_waiting dispatches the queued orders in FIFO order.

4. Cancel. cancel(id, actor) takes the dispatch lock and then the order lock, in the same order as _dispatch, so the two cannot deadlock. The transition table refuses a cancel from PICKED_UP or later. The refund is computed from the status before the cancel. The order leaves the waiting queue, and its courier is freed and given to a waiting order.

From Allowed next Who makes the forward move
PLACED ACCEPTED, CANCELLED restaurant
ACCEPTED PREPARING, CANCELLED restaurant
PREPARING READY, CANCELLED restaurant
READY PICKED_UP, CANCELLED courier
PICKED_UP DELIVERED courier
DELIVERED / CANCELLED — (terminal) —

Any actor may take a CANCELLED edge. Refunds: a cancel by the restaurant or the system is always a full refund. A customer gets a full refund before PREPARING, half during PREPARING, and nothing once the food is READY.

Code

from __future__ import annotations
import math, threading, uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional

class OrderStatus(Enum):
    PLACED = 1; ACCEPTED = 2; PREPARING = 3; READY = 4; PICKED_UP = 5; DELIVERED = 6; CANCELLED = 7
class Actor(Enum):
    CUSTOMER = 1; RESTAURANT = 2; COURIER = 3; SYSTEM = 4

S = OrderStatus
TRANSITIONS = {S.PLACED: {S.ACCEPTED, S.CANCELLED}, S.ACCEPTED: {S.PREPARING, S.CANCELLED},
               S.PREPARING: {S.READY, S.CANCELLED}, S.READY: {S.PICKED_UP, S.CANCELLED},
               S.PICKED_UP: {S.DELIVERED}, S.DELIVERED: set(), S.CANCELLED: set()}
STEP_OWNER = {S.ACCEPTED: Actor.RESTAURANT, S.PREPARING: Actor.RESTAURANT, S.READY: Actor.RESTAURANT,
              S.PICKED_UP: Actor.COURIER, S.DELIVERED: Actor.COURIER}

@dataclass(frozen=True)
class Location:
    lat: float; lng: float
    def distance_to(self, o: Location) -> float: return math.hypot(self.lat - o.lat, self.lng - o.lng)
@dataclass
class MenuItem:
    id: str; name: str; price_cents: int; available: bool = True
@dataclass
class Restaurant:
    id: str; name: str; location: Location
    menu: dict[str, MenuItem] = field(default_factory=dict); is_open: bool = True
@dataclass(eq=False)
class Courier:
    id: str; location: Location; available: bool = True; order_id: Optional[str] = None
@dataclass(frozen=True)
class OrderItem:
    item_id: str; name: str; unit_price_cents: int; quantity: int
@dataclass(eq=False)  # identity equality: orders are entities
class Order:
    id: str; customer_id: str; restaurant: Restaurant; items: list[OrderItem]; drop_off: Location
    status: OrderStatus = S.PLACED; courier: Optional[Courier] = None; refund_cents: int = 0
    lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
    @property
    def total_cents(self) -> int: return sum(i.unit_price_cents * i.quantity for i in self.items)
    def transition(self, new: OrderStatus) -> None:  # caller holds self.lock
        if new not in TRANSITIONS[self.status]:
            raise ValueError(f"{self.status.name} -> {new.name} not allowed")
        self.status = new

class AssignmentStrategy(ABC):
    @abstractmethod
    def pick(self, order: Order, couriers: list[Courier]) -> Optional[Courier]: ...
class NearestCourierStrategy(AssignmentStrategy):
    def __init__(self, max_distance: float = 5.0): self.max_distance = max_distance
    def pick(self, order: Order, couriers: list[Courier]) -> Optional[Courier]:
        origin = order.restaurant.location
        free = [c for c in couriers if c.available and c.location.distance_to(origin) <= self.max_distance]
        return min(free, key=lambda c: c.location.distance_to(origin), default=None)
def default_refund(prior: OrderStatus, total: int, actor: Actor) -> int:
    if actor is not Actor.CUSTOMER or prior in (S.PLACED, S.ACCEPTED): return total
    return total // 2 if prior is S.PREPARING else 0

class OrderService:
    def __init__(self, strategy: AssignmentStrategy, refund_policy=default_refund):
        self.strategy, self.refund_policy = strategy, refund_policy
        self.couriers: dict[str, Courier] = {}
        self.orders: dict[str, Order] = {}
        self.waiting: list[Order] = []            # accepted orders with no courier yet
        self.dispatch_lock = threading.Lock()     # lock order: dispatch_lock, then order.lock
        self.listeners: list[Callable[[Order], None]] = []

    def add_courier(self, courier: Courier) -> None:
        with self.dispatch_lock: self.couriers[courier.id] = courier
        self._retry_waiting()

    def place_order(self, customer_id: str, restaurant: Restaurant, lines: dict[str, int],
                    drop_off: Location) -> Order:
        if not restaurant.is_open or not lines: raise ValueError("restaurant closed or empty order")
        items = []
        for item_id, qty in lines.items():
            m = restaurant.menu.get(item_id)
            if m is None or not m.available or qty <= 0: raise ValueError(f"cannot order {item_id} x{qty}")
            items.append(OrderItem(m.id, m.name, m.price_cents, qty))   # price snapshot
        order = Order(str(uuid.uuid4()), customer_id, restaurant, items, drop_off)
        self.orders[order.id] = order
        self._notify(order)
        return order

    def advance(self, order_id: str, new: OrderStatus, actor: Actor) -> None:
        order = self.orders[order_id]
        if STEP_OWNER.get(new) is not actor: raise PermissionError(f"{actor.name} cannot set {new.name}")
        with order.lock:
            if new is S.PICKED_UP and order.courier is None: raise ValueError("no courier assigned")
            order.transition(new)
            courier = order.courier
        if new is S.ACCEPTED:
            self._dispatch(order)
        elif new is S.DELIVERED and courier:
            with self.dispatch_lock: courier.available, courier.order_id = True, None
            self._retry_waiting()
        self._notify(order)

    def cancel(self, order_id: str, actor: Actor) -> Order:
        order = self.orders[order_id]
        with self.dispatch_lock, order.lock:
            prior = order.status
            order.transition(S.CANCELLED)          # raises once picked up or terminal
            order.refund_cents = self.refund_policy(prior, order.total_cents, actor)
            if order in self.waiting: self.waiting.remove(order)
            courier = order.courier
            if courier: courier.available, courier.order_id = True, None
        if courier: self._retry_waiting()
        self._notify(order)
        return order

    def _dispatch(self, order: Order) -> None:
        with self.dispatch_lock, order.lock:
            if order.status in (S.CANCELLED, S.DELIVERED) or order.courier: return
            courier = self.strategy.pick(order, list(self.couriers.values()))
            if courier is None: self.waiting.append(order); return
            courier.available, courier.order_id, order.courier = False, order.id, courier

    def _retry_waiting(self) -> None:
        with self.dispatch_lock: pending, self.waiting = self.waiting, []
        for order in pending: self._dispatch(order)   # FIFO; unmatched orders re-queue

    def _notify(self, order: Order) -> None:      # outside locks: listeners may be slow
        for listener in self.listeners: listener(order)

if __name__ == "__main__":
    r = Restaurant("r1", "Pho 88", Location(0, 0), {"pho": MenuItem("pho", "Beef pho", 1450)})
    svc = OrderService(NearestCourierStrategy(max_distance=3))
    svc.listeners.append(lambda o: print(o.id[:8], o.status.name))
    a = svc.place_order("c1", r, {"pho": 2}, Location(1, 1))
    b = svc.place_order("c2", r, {"pho": 1}, Location(2, 0))
    svc.advance(a.id, S.ACCEPTED, Actor.RESTAURANT)   # no courier yet: a waits
    svc.add_courier(Courier("k1", Location(1, 0)))      # k1 takes a
    svc.advance(b.id, S.ACCEPTED, Actor.RESTAURANT)   # b waits
    svc.advance(a.id, S.PREPARING, Actor.RESTAURANT)
    svc.cancel(a.id, Actor.CUSTOMER)                   # half refund; k1 moves to b
    print(a.refund_cents, b.courier.id)                # 1450 k1

Design patterns

  • State (table-driven). TRANSITIONS and STEP_OWNER hold the lifecycle as data, and Order.transition is the only way to change status. Adding a state, such as REFUNDED, means editing the table, not adding if branches throughout the service. A full State-class hierarchy would be overkill, because the states differ only in their legal moves.
  • Strategy. AssignmentStrategy keeps the matching rule apart from the order lifecycle. Nearest-courier, load-balanced and ETA-based matching can be swapped per city. The refund policy is a function-shaped strategy that serves the same purpose for money.
  • Observer. The listeners list and _notify let notifications, ETA updates and analytics react to status changes without the service knowing about them. Listeners run outside the locks, so a slow push call cannot block dispatch.
  • Facade. OrderService is the single entry point for the restaurant, courier and customer apps. It owns the invariants that span objects: one courier per order, release on cancel or delivery, and lock order.

Extensions

  • Batching (one courier, two orders from nearby restaurants). Change Courier.order_id to a list with a capacity, and let a BatchingStrategy pick a courier whose current route can absorb the new pickup. Freeing a courier becomes "remove this order id". The order lifecycle does not change.
  • Courier declines or times out. Add an OFFERED sub-state on the assignment rather than on the order: offer to the best courier, start a timer, and on decline or timeout exclude that courier and call _dispatch again. waiting already handles the case where nobody is left.
  • Scale beyond one process. Replace the in-memory dict scan with a geo index (geohash cells → available couriers). Make the "mark courier busy" step a conditional write (UPDATE ... WHERE available = true) instead of dispatch_lock, and partition dispatch by city. Order transitions become optimistic-concurrency updates on a version column. Observers become events on a queue.
  • Scheduled orders and restaurant capacity. Add scheduled_for to Order and a prep_minutes estimate. Dispatch when scheduled_for − travel_time − prep arrives instead of on accept. Restaurants with a capacity cap reject at place_order when too many orders are in PREPARING.

Interviewer expectations

Mid-level (L4)

  • Clean entities: restaurant/menu, order with line items, courier. Prices are snapshotted into the order.
  • An explicit status enum with legal transitions. The design rejects pickup without a courier and cancellation after pickup.
  • A working assignment of a nearest available courier, and freeing the courier on cancel or delivery.

Senior (L5)

  • Assignment behind a strategy, and refunds behind a policy that depends on the status before the cancel.
  • Correct concurrency: no double-assigned courier, no assignment to a cancelled order, and a stated lock order that rules out deadlock.
  • Handles "no courier available" with a queue and retry. Clear about which actor may perform each transition.

Staff (L6+)

  • Separates the order lifecycle from the courier-offer lifecycle (offers, declines, timeouts) and explains why they should not share one enum.
  • Maps the in-memory locks to distributed equivalents: conditional writes, version columns, per-city partitioning, events.
  • Discusses product trade-offs: assigning at accept versus at ready (courier idle time versus food waiting), batching, fairness among couriers, and who pays when a cancel happens mid-preparation.

Sign in to comment, vote or remix.