🍔 Food Delivery — Implementation¶
Python 3.10+, stdlib only. Run the demo with
python3 food_delivery.py; run the tests withpython3 -m unittest test_food_deliveryfrom this directory.
Map of the file¶
| Section | Key types | What to look at |
|---|---|---|
| Money & errors | money(), FoodDeliveryError subclasses |
money() rejects float; one exception per failure the caller handles differently |
| State machine | OrderStatus, Actor, TRANSITIONS, DISPATCHABLE |
Policy as a table: (from, to) -> allowed actors |
| Catalogue | Location, MenuItem, Restaurant, Customer |
MenuItem.available models stock-outs |
| Cart | Cart |
Single-restaurant rule; replace_cart=True is the "Start a new cart?" dialog |
| Pricing | PricingRule + 6 rules, PricingEngine, PriceBreakdown, Coupon |
Ordered chain; each rule appends a line |
| Order | Order, OrderEvent, OrderLine |
transition() and attach_partner() are the only mutators, both under the order's lock |
| Dispatch | DeliveryPartner, PartnerSnapshot, AssignmentStrategy, NearestAvailableStrategy, BatchingStrategy, DispatchService |
Rank from snapshots, claim with CAS |
| Ports | PaymentGateway, FakePaymentGateway |
Fake dedups on idempotency key like a real PSP |
| Observers | OrderObserver, NotificationObserver |
Template per status, recipient resolved from the event |
| Facade | OrderService |
Orchestration, idempotency, side effects after transitions |
Key design decisions¶
1. The transition table is the policy¶
TRANSITIONS = {
(PLACED, ACCEPTED): {RESTAURANT},
(PLACED, REJECTED): {RESTAURANT, SYSTEM},
...
(PLACED, CANCELLED): {CUSTOMER, SYSTEM},
(ACCEPTED, CANCELLED): {CUSTOMER, RESTAURANT, SYSTEM},
(PREPARING, CANCELLED): {RESTAURANT, SYSTEM},
(PICKED_UP, CANCELLED): {SYSTEM},
}
Order.transition(new, actor, actor_id) checks, under self._lock: the edge exists (InvalidTransitionError), the actor is allowed (NotAuthorizedError), and for PARTNER that actor_id is the assigned partner. On CANCELLED/REJECTED it detaches the partner in the same critical section and returns them, so the caller can release them. Changing who may cancel is a one-line data change, reviewable by product.
2. Idempotent place_order without a global critical section¶
OrderService._idem maps (customer_id, key) to an _IdempotencySlot (fingerprint, threading.Event, order, error). The service lock is held only to look up or insert the slot; pricing and the payment call happen outside it. Waiters block on slot.done. On failure the slot is deleted so the client can retry with the same key; on success it stays (in production it would carry a TTL, typically 24 h).
The payment is charged with f"{customer_id}:{key}" as its idempotency key, so the PSP also dedups.
3. Pricing as an ordered chain¶
PricingEngine.default() = [ItemSubtotalRule(), PackagingFeeRule(), DeliveryFeeRule(),
SurgeRule(), CouponRule(), TaxRule()]
Each rule reads the PricingContext and the lines added so far (bill.amount_of("DELIVERY")) and appends its own. Dependencies between rules are therefore positional and visible in one list. DeliveryFeeRule charges every started km beyond free_km (ROUND_CEILING); SurgeRule scales only the delivery fee; Coupon.discount_for caps at max_discount and at the food subtotal; TaxRule taxes food after discount. Each amount is quantised when added, and the total is the sum of quantised lines, floored at zero.
To extend: a SmallOrderFeeRule, a MembershipFreeDeliveryRule (adds a negative DELIVERY line) or a restaurant-funded discount are new classes inserted at the right position. No existing rule changes.
4. Dispatch: rank, then claim¶
snapshots = [p.snapshot() for p in partners] # each under the partner's lock
for pid in strategy.rank(order, restaurant, snapshots):
if not partner.try_claim(order_id, restaurant_id, strategy.max_orders_per_partner):
continue # lost the race, try the next
if order.attach_partner(pid):
return partner
partner.release(order_id) # order cancelled meanwhile
return None
- Strategies are pure ranking functions over immutable
PartnerSnapshots, trivially testable. try_claimis the compare-and-set. It re-checks online, capacity, same restaurant for a batch, and that the batch hasn't left the restaurant (_picked_up).attach_partnercloses the cancel-vs-dispatch race: it only succeeds while the order is inDISPATCHABLEand unassigned. Either the cancel runs first (attach fails, claim is released) or the attach runs first (cancel sees the partner and releases it). Locks are never nested (partner lock and order lock are taken one after another), so there is no lock-ordering deadlock.BatchingStrategyputs partners already assigned to the same restaurant (with spare capacity, not yet picked up) first, then falls back to nearest idle.max_orders_per_partnerflows into the claim, so capacity is enforced at the CAS, not just in the ranking.
5. Side effects after the lock¶
_move() runs the transition, then, outside the order lock: releases a detached partner, refunds on CANCELLED/REJECTED, and publishes the event. Holding a lock across a PSP call or a notification fan-out would turn a slow dependency into lock contention. Ordering is preserved for consumers through OrderEvent.seq.
6. Price snapshot¶
OrderLine copies name and unit_price at placement. A restaurant changing its menu price five minutes later must not change an order that is already paid.
Where to extend¶
| Requirement | Change |
|---|---|
| Auto-reject if the restaurant doesn't accept in 3 min | A scheduler calls reject(order_id, Actor.SYSTEM); it simply loses if the restaurant already accepted (InvalidTransitionError) |
| Partner goes offline before pickup | unassign(order_id, partner_id): under the order lock clear partner_id if it matches and status < PICKED_UP; release the partner; call dispatch() |
| Scored assignment (ETA, rating, acceptance rate) | New AssignmentStrategy.rank; the claim logic is unchanged |
| Partner must accept the offer | Claim becomes a time-boxed offer; on decline/timeout release and try the next ranked partner |
Cancellation fee after ACCEPTED |
A RefundPolicy strategy computing the refund from (status_at_cancel, actor) |
| COD | PaymentGateway implementation that records a receivable instead of charging |
| Coupon usage limits | Coupon redemption becomes a conditional increment (uses < limit) inside the same idempotent placement |
Tests¶
test_food_delivery.py (28 tests, < 0.1 s) covers: the cart rule; exact pricing including partial-km delivery, surge, coupon cap/min/over-discount; idempotent retry, key conflict, retry after payment failure and 16 concurrent duplicate submissions → one order, one charge; full happy path; illegal jumps; wrong-partner pickup; per-actor cancel rules with refund and partner release; concurrent cancel vs start-preparing → exactly one wins; a deterministic cancel-between-rank-and-claim test; nearest/offline/radius dispatch; 20 concurrent accepts over 5 partners → 5 distinct assignments; batching up to capacity and closing at pickup; observer ordering and failure isolation.
Full source¶
"""
Food Delivery (Swiggy / Zomato / DoorDash) — Low-Level Design
==============================================================
Scope (what an LLD round expects in 60-90 minutes):
* Restaurants with menus; a cart that can only hold items from ONE restaurant.
* Order placement that is idempotent on a client-supplied key (a retried
"Place order" tap must never create two orders or two charges).
* An explicit order state machine with per-actor rules:
PLACED -> ACCEPTED -> PREPARING -> READY -> PICKED_UP -> DELIVERED
PLACED -> REJECTED (restaurant / system timeout)
* -> CANCELLED (who may cancel depends on state)
* Delivery-partner assignment as a Strategy (nearest available; batching as
an extension) that can never double-assign a partner under concurrency.
* Decimal pricing built by an ordered chain of rules (fees, surge, coupon, tax).
* Observer for status notifications.
Patterns: Strategy (assignment), Chain/pipeline (pricing rules), State machine
(transition table), Observer (status events), Facade (OrderService).
Concurrency model:
* Each Order has its own lock; every transition is check-and-set under it.
* Each DeliveryPartner has its own lock; claiming is a compare-and-set
(`try_claim`). Strategies only RANK candidates from a snapshot; the claim
re-validates, so a stale snapshot can cost a retry but never a double-assign.
* Idempotency uses an in-flight slot per key, so concurrent duplicates wait for
the first attempt instead of serialising all order placement behind one lock.
* Observers are notified AFTER the order lock is released; every event carries
a per-order sequence number so consumers can drop out-of-order deliveries.
Python 3.10+, stdlib only.
"""
from __future__ import annotations
import itertools
import math
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal
from enum import Enum
from typing import Iterable, Optional, Sequence
PAISE = Decimal("0.01")
def money(value: Decimal | int | str) -> Decimal:
"""Normalise to 2 dp with half-up rounding. Never construct money from float."""
if isinstance(value, float):
raise TypeError("money must not be built from float; pass str/int/Decimal")
return Decimal(value).quantize(PAISE, rounding=ROUND_HALF_UP)
# ─── Errors ──────────────────────────────────────────────────────────────────
class FoodDeliveryError(Exception):
"""Base class for domain errors."""
class CartError(FoodDeliveryError):
pass
class DifferentRestaurantError(CartError):
"""Raised when adding an item from a second restaurant to a non-empty cart."""
class ItemUnavailableError(FoodDeliveryError):
pass
class RestaurantClosedError(FoodDeliveryError):
pass
class InvalidCouponError(FoodDeliveryError):
pass
class PaymentFailedError(FoodDeliveryError):
pass
class IdempotencyConflictError(FoodDeliveryError):
"""Same idempotency key reused with a different request body."""
class InvalidTransitionError(FoodDeliveryError):
pass
class NotAuthorizedError(FoodDeliveryError):
pass
class OrderNotFoundError(FoodDeliveryError):
pass
# ─── Enums ───────────────────────────────────────────────────────────────────
class OrderStatus(Enum):
PLACED = "PLACED"
ACCEPTED = "ACCEPTED"
PREPARING = "PREPARING"
READY = "READY"
PICKED_UP = "PICKED_UP"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
REJECTED = "REJECTED"
@property
def is_terminal(self) -> bool:
return self in (OrderStatus.DELIVERED, OrderStatus.CANCELLED, OrderStatus.REJECTED)
class Actor(Enum):
CUSTOMER = "CUSTOMER"
RESTAURANT = "RESTAURANT"
PARTNER = "PARTNER"
SYSTEM = "SYSTEM" # timeouts, support agents, ops tooling
class CouponType(Enum):
PERCENT = "PERCENT"
FLAT = "FLAT"
# Who may move an order from one state to another. Anything not listed is illegal.
# Cancellation rules encode the business policy:
# - customer: only before food is being made (PLACED, ACCEPTED)
# - restaurant: after accepting but before handing over (e.g. ran out of stock)
# - partner: never cancels an order; they drop it and it is re-dispatched
# - system/support: any non-terminal state, including after pickup (lost food)
_S, _A = OrderStatus, Actor
TRANSITIONS: dict[tuple[OrderStatus, OrderStatus], frozenset[Actor]] = {
(_S.PLACED, _S.ACCEPTED): frozenset({_A.RESTAURANT}),
(_S.PLACED, _S.REJECTED): frozenset({_A.RESTAURANT, _A.SYSTEM}),
(_S.ACCEPTED, _S.PREPARING): frozenset({_A.RESTAURANT}),
(_S.PREPARING, _S.READY): frozenset({_A.RESTAURANT}),
(_S.READY, _S.PICKED_UP): frozenset({_A.PARTNER}),
(_S.PICKED_UP, _S.DELIVERED): frozenset({_A.PARTNER}),
(_S.PLACED, _S.CANCELLED): frozenset({_A.CUSTOMER, _A.SYSTEM}),
(_S.ACCEPTED, _S.CANCELLED): frozenset({_A.CUSTOMER, _A.RESTAURANT, _A.SYSTEM}),
(_S.PREPARING, _S.CANCELLED): frozenset({_A.RESTAURANT, _A.SYSTEM}),
(_S.READY, _S.CANCELLED): frozenset({_A.RESTAURANT, _A.SYSTEM}),
(_S.PICKED_UP, _S.CANCELLED): frozenset({_A.SYSTEM}),
}
# States in which a delivery partner may be attached to the order.
DISPATCHABLE = frozenset({OrderStatus.ACCEPTED, OrderStatus.PREPARING, OrderStatus.READY})
# ─── Value objects & catalogue ───────────────────────────────────────────────
@dataclass(frozen=True)
class Location:
lat: float
lng: float
def distance_km(self, other: Location) -> float:
"""Great-circle (haversine) distance. Road distance would come from a routing service."""
r = 6371.0
p1, p2 = math.radians(self.lat), math.radians(other.lat)
dp = p2 - p1
dl = math.radians(other.lng - self.lng)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
@dataclass
class MenuItem:
item_id: str
name: str
price: Decimal
available: bool = True
def __post_init__(self) -> None:
self.price = money(self.price)
if self.price <= 0:
raise ValueError("price must be positive")
class Restaurant:
def __init__(self, restaurant_id: str, name: str, location: Location,
packaging_fee: Decimal | str = "0", is_open: bool = True) -> None:
self.restaurant_id = restaurant_id
self.name = name
self.location = location
self.packaging_fee = money(packaging_fee)
self.is_open = is_open
self._menu: dict[str, MenuItem] = {}
def add_item(self, item: MenuItem) -> None:
self._menu[item.item_id] = item
def item(self, item_id: str) -> MenuItem:
try:
return self._menu[item_id]
except KeyError:
raise ItemUnavailableError(f"{item_id} is not on {self.name}'s menu") from None
def set_availability(self, item_id: str, available: bool) -> None:
self.item(item_id).available = available
@dataclass(frozen=True)
class Customer:
customer_id: str
name: str
location: Location
# ─── Cart ────────────────────────────────────────────────────────────────────
class Cart:
"""Client-side-ish draft of an order. One restaurant per cart (the classic rule)."""
def __init__(self, customer: Customer) -> None:
self.customer = customer
self._restaurant: Optional[Restaurant] = None
self._quantities: dict[str, int] = {}
@property
def restaurant(self) -> Optional[Restaurant]:
return self._restaurant
@property
def quantities(self) -> dict[str, int]:
return dict(self._quantities)
def add(self, restaurant: Restaurant, item_id: str, qty: int = 1,
*, replace_cart: bool = False) -> None:
if qty <= 0:
raise CartError("quantity must be positive")
if self._restaurant is not None and self._restaurant is not restaurant:
if not replace_cart:
raise DifferentRestaurantError(
f"cart holds items from {self._restaurant.name}; "
f"clear it or pass replace_cart=True")
self.clear()
restaurant.item(item_id) # validates the item exists
self._restaurant = restaurant
self._quantities[item_id] = self._quantities.get(item_id, 0) + qty
def remove(self, item_id: str, qty: int = 1) -> None:
current = self._quantities.get(item_id, 0)
if current == 0:
raise CartError(f"{item_id} not in cart")
if qty >= current:
del self._quantities[item_id]
else:
self._quantities[item_id] = current - qty
if not self._quantities:
self._restaurant = None
def clear(self) -> None:
self._restaurant = None
self._quantities.clear()
@property
def is_empty(self) -> bool:
return not self._quantities
# ─── Pricing: an ordered chain of rules ──────────────────────────────────────
@dataclass(frozen=True)
class OrderLine:
"""Snapshot of a menu item at order time; later menu price changes don't touch it."""
item_id: str
name: str
unit_price: Decimal
qty: int
@property
def amount(self) -> Decimal:
return money(self.unit_price * self.qty)
@dataclass(frozen=True)
class Coupon:
code: str
kind: CouponType
value: Decimal # percent (e.g. 20) or flat amount
min_subtotal: Decimal = Decimal("0")
max_discount: Optional[Decimal] = None
def discount_for(self, subtotal: Decimal) -> Decimal:
if subtotal < self.min_subtotal:
raise InvalidCouponError(
f"{self.code} needs a subtotal of at least {self.min_subtotal}")
if self.kind is CouponType.PERCENT:
raw = subtotal * self.value / Decimal(100)
else:
raw = self.value
if self.max_discount is not None:
raw = min(raw, self.max_discount)
return money(min(raw, subtotal)) # never discount more than the food costs
@dataclass(frozen=True)
class PricingContext:
restaurant: Restaurant
customer: Customer
lines: tuple[OrderLine, ...]
coupon: Optional[Coupon] = None
surge_multiplier: Decimal = Decimal("1")
@property
def subtotal(self) -> Decimal:
return money(sum((line.amount for line in self.lines), Decimal("0")))
@property
def distance_km(self) -> Decimal:
km = self.restaurant.location.distance_km(self.customer.location)
return Decimal(str(round(km, 2)))
@dataclass(frozen=True)
class PriceLine:
code: str
amount: Decimal # negative for discounts
@dataclass
class PriceBreakdown:
lines: list[PriceLine] = field(default_factory=list)
def add(self, code: str, amount: Decimal) -> None:
amount = money(amount)
if amount != 0:
self.lines.append(PriceLine(code, amount))
def amount_of(self, code: str) -> Decimal:
return sum((l.amount for l in self.lines if l.code == code), Decimal("0.00"))
@property
def total(self) -> Decimal:
return money(max(sum((l.amount for l in self.lines), Decimal("0")), Decimal("0")))
class PricingRule(ABC):
"""One step in the price chain. Rules run in order and may read earlier lines."""
@abstractmethod
def apply(self, ctx: PricingContext, bill: PriceBreakdown) -> None: ...
class ItemSubtotalRule(PricingRule):
def apply(self, ctx: PricingContext, bill: PriceBreakdown) -> None:
bill.add("ITEMS", ctx.subtotal)
class PackagingFeeRule(PricingRule):
def apply(self, ctx: PricingContext, bill: PriceBreakdown) -> None:
bill.add("PACKAGING", ctx.restaurant.packaging_fee)
class DeliveryFeeRule(PricingRule):
"""Base fee covers the first `free_km`; each extra km (or part of one) costs `per_km`."""
def __init__(self, base: str = "25", per_km: str = "8", free_km: int = 2) -> None:
self.base, self.per_km, self.free_km = money(base), money(per_km), free_km
def apply(self, ctx: PricingContext, bill: PriceBreakdown) -> None:
extra_km = max(Decimal(0), ctx.distance_km - self.free_km)
billable_km = extra_km.to_integral_value(rounding=ROUND_CEILING)
bill.add("DELIVERY", self.base + self.per_km * billable_km)
class SurgeRule(PricingRule):
"""Surge scales the delivery fee only, never food. Must run after DeliveryFeeRule."""
def apply(self, ctx: PricingContext, bill: PriceBreakdown) -> None:
if ctx.surge_multiplier > 1:
bill.add("SURGE", bill.amount_of("DELIVERY") * (ctx.surge_multiplier - 1))
class CouponRule(PricingRule):
def apply(self, ctx: PricingContext, bill: PriceBreakdown) -> None:
if ctx.coupon is not None:
bill.add("COUPON", -ctx.coupon.discount_for(bill.amount_of("ITEMS")))
class TaxRule(PricingRule):
"""Tax on food after discount (fees are taxed separately in reality; kept simple)."""
def __init__(self, rate_percent: str = "5") -> None:
self.rate = Decimal(rate_percent) / Decimal(100)
def apply(self, ctx: PricingContext, bill: PriceBreakdown) -> None:
taxable = bill.amount_of("ITEMS") + bill.amount_of("COUPON")
bill.add("TAX", max(taxable, Decimal(0)) * self.rate)
class PricingEngine:
def __init__(self, rules: Sequence[PricingRule]) -> None:
self._rules = list(rules)
@classmethod
def default(cls) -> PricingEngine:
return cls([ItemSubtotalRule(), PackagingFeeRule(), DeliveryFeeRule(),
SurgeRule(), CouponRule(), TaxRule()])
def price(self, ctx: PricingContext) -> PriceBreakdown:
bill = PriceBreakdown()
for rule in self._rules:
rule.apply(ctx, bill)
return bill
# ─── Order + state machine ───────────────────────────────────────────────────
@dataclass(frozen=True)
class OrderEvent:
order_id: str
seq: int # per-order, monotonically increasing
old_status: Optional[OrderStatus]
new_status: OrderStatus
actor: Actor
customer_id: str
restaurant_id: str
partner_id: Optional[str]
class Order:
def __init__(self, order_id: str, customer_id: str, restaurant_id: str,
lines: tuple[OrderLine, ...], bill: PriceBreakdown, payment_id: str) -> None:
self.order_id = order_id
self.customer_id = customer_id
self.restaurant_id = restaurant_id
self.lines = lines
self.bill = bill
self.payment_id = payment_id
self._status = OrderStatus.PLACED
self._partner_id: Optional[str] = None
self._seq = 0
self._history: list[tuple[OrderStatus, Actor]] = [(OrderStatus.PLACED, Actor.CUSTOMER)]
self._lock = threading.Lock()
@property
def status(self) -> OrderStatus:
return self._status
@property
def partner_id(self) -> Optional[str]:
return self._partner_id
@property
def total(self) -> Decimal:
return self.bill.total
@property
def history(self) -> list[tuple[OrderStatus, Actor]]:
with self._lock:
return list(self._history)
def placed_event(self) -> OrderEvent:
return OrderEvent(self.order_id, 0, None, OrderStatus.PLACED, Actor.CUSTOMER,
self.customer_id, self.restaurant_id, None)
def transition(self, new: OrderStatus, actor: Actor,
actor_id: Optional[str] = None) -> tuple[OrderEvent, Optional[str]]:
"""Atomic check-and-set. Returns the event and, on cancellation, the partner
that was holding the order (so the caller can release them)."""
with self._lock:
old = self._status
allowed = TRANSITIONS.get((old, new))
if allowed is None:
raise InvalidTransitionError(f"{self.order_id}: {old.value} -> {new.value}")
if actor not in allowed:
raise NotAuthorizedError(
f"{actor.value} may not move {self.order_id} {old.value} -> {new.value}")
if actor is Actor.PARTNER and actor_id != self._partner_id:
raise NotAuthorizedError(f"partner {actor_id} is not assigned to {self.order_id}")
released = None
if new.is_terminal and new is not OrderStatus.DELIVERED:
released, self._partner_id = self._partner_id, None
self._status = new
self._seq += 1
self._history.append((new, actor))
event = OrderEvent(self.order_id, self._seq, old, new, actor,
self.customer_id, self.restaurant_id,
self._partner_id if released is None else released)
return event, released
def attach_partner(self, partner_id: str) -> bool:
"""Attach only if still dispatchable and unassigned; guards the cancel/assign race."""
with self._lock:
if self._status not in DISPATCHABLE or self._partner_id is not None:
return False
self._partner_id = partner_id
return True
# ─── Delivery partners & assignment strategy ─────────────────────────────────
@dataclass(frozen=True)
class PartnerSnapshot:
partner_id: str
location: Location
online: bool
active_restaurants: tuple[str, ...] # one entry per active order
picked_up: bool
@property
def is_idle(self) -> bool:
return self.online and not self.active_restaurants
class DeliveryPartner:
"""Holds its own lock; `try_claim` is the only way to take on an order."""
def __init__(self, partner_id: str, name: str, location: Location) -> None:
self.partner_id = partner_id
self.name = name
self._location = location
self._online = True
self._active: dict[str, str] = {} # order_id -> restaurant_id
self._picked_up = False # once out of the restaurant, the batch is closed
self._lock = threading.Lock()
def snapshot(self) -> PartnerSnapshot:
with self._lock:
return PartnerSnapshot(self.partner_id, self._location, self._online,
tuple(self._active.values()), self._picked_up)
def update_location(self, location: Location) -> None:
with self._lock:
self._location = location
def set_online(self, online: bool) -> None:
with self._lock:
self._online = online
def try_claim(self, order_id: str, restaurant_id: str, max_orders: int) -> bool:
"""Compare-and-set: re-checks everything the strategy saw in its snapshot."""
with self._lock:
if not self._online or order_id in self._active:
return False
if self._active:
if (len(self._active) >= max_orders or self._picked_up
or any(r != restaurant_id for r in self._active.values())):
return False
self._active[order_id] = restaurant_id
return True
def mark_picked_up(self, order_id: str) -> None:
with self._lock:
if order_id in self._active:
self._picked_up = True
def release(self, order_id: str) -> None:
with self._lock:
self._active.pop(order_id, None)
if not self._active:
self._picked_up = False
@property
def active_orders(self) -> list[str]:
with self._lock:
return list(self._active)
class AssignmentStrategy(ABC):
"""Ranks candidates; it never mutates partners. The dispatcher does the claim."""
max_orders_per_partner: int = 1
@abstractmethod
def rank(self, order: Order, restaurant: Restaurant,
partners: Sequence[PartnerSnapshot]) -> list[str]: ...
class NearestAvailableStrategy(AssignmentStrategy):
def __init__(self, radius_km: float = 5.0) -> None:
self.radius_km = radius_km
def rank(self, order: Order, restaurant: Restaurant,
partners: Sequence[PartnerSnapshot]) -> list[str]:
scored = []
for p in partners:
if not p.is_idle:
continue
d = p.location.distance_km(restaurant.location)
if d <= self.radius_km:
scored.append((d, p.partner_id))
return [pid for _, pid in sorted(scored)]
class BatchingStrategy(AssignmentStrategy):
"""Prefer a partner already heading to the same restaurant (not yet picked up)
with spare capacity; otherwise fall back to the nearest idle partner."""
def __init__(self, max_orders: int = 2, radius_km: float = 5.0) -> None:
self.max_orders_per_partner = max_orders
self._fallback = NearestAvailableStrategy(radius_km)
def rank(self, order: Order, restaurant: Restaurant,
partners: Sequence[PartnerSnapshot]) -> list[str]:
rid = restaurant.restaurant_id
batchable = sorted(
(p.location.distance_km(restaurant.location), p.partner_id)
for p in partners
if p.online and p.active_restaurants and not p.picked_up
and len(p.active_restaurants) < self.max_orders_per_partner
and all(r == rid for r in p.active_restaurants)
)
return [pid for _, pid in batchable] + self._fallback.rank(order, restaurant, partners)
class DispatchService:
def __init__(self, strategy: AssignmentStrategy) -> None:
self.strategy = strategy
self._partners: dict[str, DeliveryPartner] = {}
self._registry_lock = threading.Lock()
def register(self, partner: DeliveryPartner) -> None:
with self._registry_lock:
self._partners[partner.partner_id] = partner
def partner(self, partner_id: str) -> DeliveryPartner:
return self._partners[partner_id]
def assign(self, order: Order, restaurant: Restaurant) -> Optional[DeliveryPartner]:
with self._registry_lock:
partners = list(self._partners.values())
snapshots = [p.snapshot() for p in partners]
for pid in self.strategy.rank(order, restaurant, snapshots):
partner = self._partners[pid]
if not partner.try_claim(order.order_id, restaurant.restaurant_id,
self.strategy.max_orders_per_partner):
continue # lost the race for this partner; try the next one
if order.attach_partner(pid):
return partner
partner.release(order.order_id) # order was cancelled / already assigned
return None
return None
# ─── Payments (port) ─────────────────────────────────────────────────────────
class PaymentGateway(ABC):
@abstractmethod
def charge(self, customer_id: str, amount: Decimal, idempotency_key: str) -> str: ...
@abstractmethod
def refund(self, payment_id: str, amount: Decimal) -> None: ...
class FakePaymentGateway(PaymentGateway):
"""Dedups on idempotency key, as real PSPs (Stripe, Razorpay) do."""
def __init__(self, fail_customers: Iterable[str] = ()) -> None:
self._fail = set(fail_customers)
self._by_key: dict[str, str] = {}
self.charges: dict[str, Decimal] = {}
self.refunds: dict[str, Decimal] = {}
self._ids = itertools.count(1)
self._lock = threading.Lock()
def charge(self, customer_id: str, amount: Decimal, idempotency_key: str) -> str:
with self._lock:
if customer_id in self._fail:
raise PaymentFailedError(f"card declined for {customer_id}")
if idempotency_key in self._by_key:
return self._by_key[idempotency_key]
pid = f"pay-{next(self._ids)}"
self._by_key[idempotency_key] = pid
self.charges[pid] = amount
return pid
def refund(self, payment_id: str, amount: Decimal) -> None:
with self._lock:
self.refunds.setdefault(payment_id, amount) # idempotent per payment
# ─── Observers ───────────────────────────────────────────────────────────────
class OrderObserver(ABC):
@abstractmethod
def on_order_event(self, event: OrderEvent) -> None: ...
class NotificationObserver(OrderObserver):
"""Turns status changes into user-facing messages. In production this publishes
to a notification service; here it records (recipient, text)."""
TEMPLATES = {
OrderStatus.PLACED: ("customer", "Order {id} placed"),
OrderStatus.ACCEPTED: ("customer", "Restaurant accepted order {id}"),
OrderStatus.READY: ("partner", "Order {id} is ready for pickup"),
OrderStatus.PICKED_UP: ("customer", "Order {id} is on the way"),
OrderStatus.DELIVERED: ("customer", "Order {id} delivered. Enjoy!"),
OrderStatus.CANCELLED: ("customer", "Order {id} was cancelled; refund initiated"),
OrderStatus.REJECTED: ("customer", "Restaurant could not take order {id}; refund initiated"),
}
def __init__(self) -> None:
self.sent: list[tuple[str, str]] = []
self._lock = threading.Lock()
def on_order_event(self, event: OrderEvent) -> None:
template = self.TEMPLATES.get(event.new_status)
if template is None:
return
who, text = template
recipient = event.customer_id if who == "customer" else event.partner_id
if recipient is None:
return
with self._lock:
self.sent.append((recipient, text.format(id=event.order_id)))
# ─── Facade ──────────────────────────────────────────────────────────────────
@dataclass
class _IdempotencySlot:
fingerprint: tuple
done: threading.Event = field(default_factory=threading.Event)
order: Optional[Order] = None
error: Optional[BaseException] = None
class OrderService:
def __init__(self, dispatch: DispatchService, payments: PaymentGateway,
pricing: Optional[PricingEngine] = None) -> None:
self._dispatch = dispatch
self._payments = payments
self._pricing = pricing or PricingEngine.default()
self._restaurants: dict[str, Restaurant] = {}
self._coupons: dict[str, Coupon] = {}
self._orders: dict[str, Order] = {}
self._observers: list[OrderObserver] = []
self._idem: dict[tuple[str, str], _IdempotencySlot] = {}
self._lock = threading.Lock() # guards the dicts above, never held during I/O
self._order_ids = itertools.count(1)
self.surge_multiplier = Decimal("1")
# -- registration --------------------------------------------------------
def add_restaurant(self, restaurant: Restaurant) -> None:
self._restaurants[restaurant.restaurant_id] = restaurant
def add_coupon(self, coupon: Coupon) -> None:
self._coupons[coupon.code] = coupon
def subscribe(self, observer: OrderObserver) -> None:
self._observers.append(observer)
def get(self, order_id: str) -> Order:
try:
return self._orders[order_id]
except KeyError:
raise OrderNotFoundError(order_id) from None
# -- quoting & placement -------------------------------------------------
def quote(self, cart: Cart, coupon_code: Optional[str] = None) -> PriceBreakdown:
return self._pricing.price(self._pricing_context(cart, coupon_code))
def place_order(self, cart: Cart, idempotency_key: str,
coupon_code: Optional[str] = None) -> Order:
"""Idempotent on (customer, key). A retry with the same body returns the same
order; a different body under the same key is rejected."""
if cart.is_empty or cart.restaurant is None:
raise CartError("cart is empty")
key = (cart.customer.customer_id, idempotency_key)
fingerprint = (cart.restaurant.restaurant_id,
tuple(sorted(cart.quantities.items())), coupon_code)
with self._lock:
slot = self._idem.get(key)
owner = slot is None
if owner:
slot = self._idem[key] = _IdempotencySlot(fingerprint)
if slot.fingerprint != fingerprint:
raise IdempotencyConflictError(f"key {idempotency_key} reused with a different cart")
if not owner:
slot.done.wait()
if slot.order is None:
raise slot.error # type: ignore[misc]
return slot.order
try:
order = self._create_order(cart, coupon_code, idempotency_key)
except BaseException as exc:
slot.error = exc
with self._lock:
del self._idem[key] # failed attempts are retryable with the same key
slot.done.set()
raise
slot.order = order
slot.done.set()
self._publish(order.placed_event())
return order
def _pricing_context(self, cart: Cart, coupon_code: Optional[str]) -> PricingContext:
restaurant = cart.restaurant
assert restaurant is not None
lines = []
for item_id, qty in sorted(cart.quantities.items()):
item = restaurant.item(item_id)
if not item.available:
raise ItemUnavailableError(f"{item.name} is out of stock")
lines.append(OrderLine(item.item_id, item.name, item.price, qty))
coupon = None
if coupon_code is not None:
coupon = self._coupons.get(coupon_code)
if coupon is None:
raise InvalidCouponError(f"unknown coupon {coupon_code}")
return PricingContext(restaurant, cart.customer, tuple(lines), coupon,
self.surge_multiplier)
def _create_order(self, cart: Cart, coupon_code: Optional[str], idem_key: str) -> Order:
restaurant = cart.restaurant
assert restaurant is not None
if not restaurant.is_open:
raise RestaurantClosedError(restaurant.name)
ctx = self._pricing_context(cart, coupon_code)
bill = self._pricing.price(ctx)
customer_id = cart.customer.customer_id
payment_id = self._payments.charge(customer_id, bill.total, f"{customer_id}:{idem_key}")
with self._lock:
order_id = f"ORD-{next(self._order_ids):04d}"
order = Order(order_id, customer_id, restaurant.restaurant_id,
ctx.lines, bill, payment_id)
self._orders[order_id] = order
return order
# -- restaurant actions --------------------------------------------------
def accept(self, order_id: str) -> Optional[DeliveryPartner]:
"""Accepting triggers dispatch so the partner travels while food is cooked."""
self._move(order_id, OrderStatus.ACCEPTED, Actor.RESTAURANT)
return self.dispatch(order_id)
def reject(self, order_id: str, actor: Actor = Actor.RESTAURANT) -> None:
self._move(order_id, OrderStatus.REJECTED, actor)
def start_preparing(self, order_id: str) -> None:
self._move(order_id, OrderStatus.PREPARING, Actor.RESTAURANT)
def mark_ready(self, order_id: str) -> None:
self._move(order_id, OrderStatus.READY, Actor.RESTAURANT)
# -- partner actions -----------------------------------------------------
def pick_up(self, order_id: str, partner_id: str) -> None:
self._move(order_id, OrderStatus.PICKED_UP, Actor.PARTNER, partner_id)
self._dispatch.partner(partner_id).mark_picked_up(order_id)
def deliver(self, order_id: str, partner_id: str) -> None:
self._move(order_id, OrderStatus.DELIVERED, Actor.PARTNER, partner_id)
self._dispatch.partner(partner_id).release(order_id)
# -- cancellation & dispatch --------------------------------------------
def cancel(self, order_id: str, actor: Actor) -> None:
self._move(order_id, OrderStatus.CANCELLED, actor)
def dispatch(self, order_id: str) -> Optional[DeliveryPartner]:
"""Safe to call repeatedly (e.g. a retry loop while no partner is free)."""
order = self.get(order_id)
if order.partner_id is not None or order.status not in DISPATCHABLE:
return None
return self._dispatch.assign(order, self._restaurants[order.restaurant_id])
def _move(self, order_id: str, new: OrderStatus, actor: Actor,
actor_id: Optional[str] = None) -> None:
order = self.get(order_id)
event, released_partner = order.transition(new, actor, actor_id)
# Side effects run after the order lock is released.
if released_partner is not None:
self._dispatch.partner(released_partner).release(order_id)
if new in (OrderStatus.CANCELLED, OrderStatus.REJECTED):
self._payments.refund(order.payment_id, order.total)
self._publish(event)
def _publish(self, event: OrderEvent) -> None:
for observer in list(self._observers):
try:
observer.on_order_event(event)
except Exception: # one broken observer must not fail the order flow
pass
# ─── Demo ────────────────────────────────────────────────────────────────────
def build_demo() -> tuple[OrderService, DispatchService, FakePaymentGateway,
NotificationObserver, Restaurant, Customer]:
restaurant = Restaurant("R1", "Biryani House", Location(12.9716, 77.5946), packaging_fee="15")
restaurant.add_item(MenuItem("I1", "Chicken Biryani", Decimal("249.00")))
restaurant.add_item(MenuItem("I2", "Raita", Decimal("39.50")))
customer = Customer("C1", "Asha", Location(12.9900, 77.5946)) # ~2.05 km north
dispatch = DispatchService(NearestAvailableStrategy(radius_km=5))
dispatch.register(DeliveryPartner("P1", "Ravi", Location(12.9800, 77.5946)))
dispatch.register(DeliveryPartner("P2", "Meera", Location(12.9720, 77.5950)))
payments = FakePaymentGateway()
service = OrderService(dispatch, payments)
service.add_restaurant(restaurant)
service.add_coupon(Coupon("SAVE20", CouponType.PERCENT, Decimal("20"),
min_subtotal=Decimal("300"), max_discount=Decimal("100")))
notifier = NotificationObserver()
service.subscribe(notifier)
return service, dispatch, payments, notifier, restaurant, customer
def main() -> None:
service, dispatch, payments, notifier, restaurant, customer = build_demo()
cart = Cart(customer)
cart.add(restaurant, "I1", 2)
cart.add(restaurant, "I2", 1)
print("Quote:")
for line in service.quote(cart, "SAVE20").lines:
print(f" {line.code:<10} {line.amount:>8}")
order = service.place_order(cart, idempotency_key="tap-1", coupon_code="SAVE20")
retry = service.place_order(cart, idempotency_key="tap-1", coupon_code="SAVE20")
assert retry is order and len(payments.charges) == 1
print(f"\nPlaced {order.order_id}, total {order.total} (retry returned same order)")
partner = service.accept(order.order_id)
assert partner is not None
print(f"Assigned partner {partner.partner_id} (nearest to restaurant)")
service.start_preparing(order.order_id)
service.mark_ready(order.order_id)
service.pick_up(order.order_id, partner.partner_id)
service.deliver(order.order_id, partner.partner_id)
print(f"Final status: {order.status.value}")
# A customer may not cancel once food is being prepared.
cart2 = Cart(customer)
cart2.add(restaurant, "I2", 1)
order2 = service.place_order(cart2, idempotency_key="tap-2")
service.accept(order2.order_id)
service.start_preparing(order2.order_id)
try:
service.cancel(order2.order_id, Actor.CUSTOMER)
except NotAuthorizedError as exc:
print(f"Customer cancel refused: {exc}")
service.cancel(order2.order_id, Actor.RESTAURANT)
print(f"{order2.order_id} cancelled by restaurant; refunds: {payments.refunds}")
print(f"Partners idle again: {[dispatch.partner(p).active_orders for p in ('P1', 'P2')]}")
print("\nNotifications:")
for recipient, text in notifier.sent:
print(f" -> {recipient}: {text}")
if __name__ == "__main__":
main()