Low Level DesignWorked problems
Design a food delivery app
Design a food delivery app like Swiggy, Zomato or DoorDash step by step for a senior low level design interview. Grow from an order with a free-form status to a table-driven state machine that checks who may act, partner dispatch with a swappable strategy and a waiting line, listeners and an order timeline, and a shared platform where racing cancels and accepts have one winner and notifications are sent after the lock, with code in Java, Python, C#, Go and TypeScript.
- Read first
- StateObserverThread safety basics
“Design a food delivery app like Swiggy or DoorDash: customers order, restaurants cook, delivery partners carry it.” Then: “the customer cancels at the same moment the restaurant accepts. What happens?”
In a food delivery app, a customer orders from a restaurant, the restaurant cooks the food, and a delivery partner (a driver or rider) carries it to the customer. Three kinds of people touch every order, and each may do only certain things, at certain points. The customer can cancel until the kitchen accepts, but not after. The partner can pick up only food that’s ready, and only their own order.
That’s a state machine with permissions: a list of the order’s possible states, and who may move it from one to the next. Getting that explicit is most of the problem. Around it sit two more questions: which partner gets which order, and how everyone’s phone learns what happened, without a slow phone holding up the kitchen.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- The order’s life is one table: state, event, who may send it, next state. Anything not in the
table is refused, and every change goes through one
handlemethod. - An event is refused for one of two reasons: it’s not allowed in this state
(
NOT_ALLOWED), or it’s not sent by this order’s own customer, restaurant or partner (NOT_YOURS). - A partner is assigned when the restaurant accepts, nearest first, through a swappable strategy. If nobody is free, the order waits in line for the next partner to finish.
- Every change becomes an event, sent to listeners and added to the order’s timeline.
- Under many threads, one lock makes each change one step, and events are queued under the lock but sent after it, so slow phones never hold the lock.
We’ll build this in four stages, then handle many phones acting at once.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a food delivery app.
Whether you scope it to the order flow. Search, menus, ratings, payments and maps are each their own system. The red flag is spending the first ten minutes on restaurant search and never reaching the order’s lifecycle.
Here are the questions that matter:
- Which part: browsing, ordering, or delivery? The order, from placing it to delivering it.
- Can the customer cancel, and until when? Until the restaurant accepts.
- Who picks the partner? The platform, automatically.
- One order per partner at a time? Yes.
- How do people learn about changes? A push message to each party’s app.
These answers give us one promise that matters more than the others: an order only ever moves along an allowed path, moved by the party allowed to move it. It must still hold when two parties act at the same instant. Two smaller promises come with it. A partner carries one order at a time, never two by mistake. And a slow or failing notification never delays or undoes a change to an order.
We leave out search, menus, payments and maps.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Payments? | Out of scope | A paid order adds a refund on every path to cancelled |
| Live partner location? | Out of scope | A stream of location updates, and an arrival time from it |
| Partner can decline an offer? | No | An offered state, a time limit, and offering the next partner |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram). Time comes from a clock the platform is given, so the tests can control it.
Stage 1: An order with a status
Section titled “Stage 1: An order with a status”Start simple: place an order and track its status.
Whether you can get something working fast, and then show exactly why a status string isn’t a lifecycle.
Start with the smallest thing that works. An Order has a customer, a restaurant, and a status
string. The Platform places orders and sets their status.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
class Order { final String id; final String customer; final String restaurant; String status = "PLACED";
Order(String id, String customer, String restaurant) { this.id = id; this.customer = customer; this.restaurant = restaurant; }}
class Platform { private final Map<String, Order> orders = new HashMap<>(); private int nextId = 1;
Order placeOrder(String customer, String restaurant) { Order o = new Order("O" + nextId++, customer, restaurant); orders.put(o.id, o); return o; }
// flaw 1: any status can follow any other: an order can be delivered before it's cooked, // or cancelled after it's eaten. // flaw 2: anyone can change any order. Nothing checks who is asking. // flaw 3: nobody is assigned to carry the food. void setStatus(String orderId, String status) { orders.get(orderId).status = status; }
Order get(String id) { return orders.get(id); }}@dataclass(eq=False)class Order: id: str customer: str restaurant: str status: str = "PLACED"
class Platform: def __init__(self): self._orders: dict[str, Order] = {} self._next_id = 1
def place_order(self, customer: str, restaurant: str) -> Order: o = Order(f"O{self._next_id}", customer, restaurant) self._next_id += 1 self._orders[o.id] = o return o
# flaw 1: any status can follow any other: an order can be delivered before it's cooked, # or cancelled after it's eaten. # flaw 2: anyone can change any order. Nothing checks who is asking. # flaw 3: nobody is assigned to carry the food. def set_status(self, order_id: str, status: str) -> None: self._orders[order_id].status = status
def get(self, id: str) -> Order | None: return self._orders.get(id)public sealed class Order{ public Order(string id, string customer, string restaurant) => (Id, Customer, Restaurant) = (id, customer, restaurant);
public string Id { get; } public string Customer { get; } public string Restaurant { get; } public string Status { get; set; } = "PLACED";}
public sealed class Platform{ private readonly Dictionary<string, Order> _orders = new(); private int _nextId = 1;
public Order PlaceOrder(string customer, string restaurant) { var o = new Order($"O{_nextId++}", customer, restaurant); _orders[o.Id] = o; return o; }
// flaw 1: any status can follow any other: an order can be delivered before it's cooked, // or cancelled after it's eaten. // flaw 2: anyone can change any order. Nothing checks who is asking. // flaw 3: nobody is assigned to carry the food. public void SetStatus(string orderId, string status) => _orders[orderId].Status = status;
public Order? Get(string id) => _orders.GetValueOrDefault(id);}type Order struct { ID, Customer, Restaurant string Status string}
type Platform struct { orders map[string]*Order nextID int}
func NewPlatform() *Platform { return &Platform{orders: map[string]*Order{}, nextID: 1} }
func (p *Platform) PlaceOrder(customer, restaurant string) *Order { o := &Order{ID: fmt.Sprintf("O%d", p.nextID), Customer: customer, Restaurant: restaurant, Status: "PLACED"} p.nextID++ p.orders[o.ID] = o return o}
// flaw 1: any status can follow any other: an order can be delivered before it's cooked, or// cancelled after it's eaten.// flaw 2: anyone can change any order. Nothing checks who is asking.// flaw 3: nobody is assigned to carry the food.func (p *Platform) SetStatus(orderID, status string) { p.orders[orderID].Status = status }
func (p *Platform) Get(id string) *Order { return p.orders[id] }class Order { status = 'PLACED';
constructor( readonly id: string, readonly customer: string, readonly restaurant: string, ) {}}
class Platform { private readonly orders = new Map<string, Order>(); private nextId = 1;
placeOrder(customer: string, restaurant: string): Order { const o = new Order(`O${this.nextId++}`, customer, restaurant); this.orders.set(o.id, o); return o; }
// flaw 1: any status can follow any other: an order can be delivered before it's cooked, or // cancelled after it's eaten. // flaw 2: anyone can change any order. Nothing checks who is asking. // flaw 3: nobody is assigned to carry the food. setStatus(orderId: string, status: string): void { this.orders.get(orderId)!.status = status; }
get(id: string): Order | undefined { return this.orders.get(id); }}It works, but it has three problems, and we leave them in on purpose. Any status can follow any other, so an order can be delivered before it’s cooked, or cancelled after it’s eaten. Anyone can change any order. And nobody is assigned to carry the food.
Stage 2: The lifecycle as a table
Section titled “Stage 2: The lifecycle as a table”Make it impossible to deliver an order that hasn’t been picked up, and impossible for one restaurant to accept another’s orders.
Whether you make the states and the moves between them explicit, and tie each move to who may make it. A candidate who writes a separate chain of ifs in each method has the rules, but nowhere to read them.
The order gets a State from a fixed set, and changes happen through Events: accept,
reject, cancel, mark ready, pick up, deliver. Each allowed change is a Transition: from this
state, on this event, sent by this kind of Actor (customer, restaurant or partner), to that
state. The whole lifecycle is six rows:
| From | Event | Sent by | To |
|---|---|---|---|
PLACED |
accept | restaurant | ACCEPTED |
PLACED |
reject | restaurant | REJECTED |
PLACED |
cancel | customer | CANCELLED |
ACCEPTED |
mark ready | restaurant | READY |
READY |
pick up | partner | PICKED_UP |
PICKED_UP |
deliver | partner | DELIVERED |
That’s the whole rulebook. Anyone can read it, including people who don’t read code, and a new rule is a new row.
- Color key
- State
- Fails or stops
- Ends well
Every change goes through one method, handle(order, event, sender), which does three things
in order:
- Find the row for the order’s current state and the event. If there’s no row, the answer is
NOT_ALLOWED. - Check the sender. The row says which kind of actor may send this event. The sender must be
this order’s own party of that kind: its customer, its restaurant, or its assigned partner.
Otherwise, the answer is
NOT_YOURS. - Only then change the state.
The friendly methods, accept, cancel, pickUp and the rest, are one line each that call
handle. A lifecycle where each state allows only some moves is the State pattern; here it’s
written as a table.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Every state an order can be in. DELIVERED, CANCELLED and REJECTED are final: no row of the// table leaves them.enum State { PLACED, ACCEPTED, READY, PICKED_UP, DELIVERED, CANCELLED, REJECTED }
// What someone asks the platform to do. Each event is one button in one of the three apps.enum Event { ACCEPT, REJECT, CANCEL, MARK_READY, PICK_UP, DELIVER }
// Who may cause an event. Each event belongs to exactly one kind of actor.enum Actor { CUSTOMER, RESTAURANT, PARTNER }
// What a request gets back. The app can show why it failed, not just that it failed.enum Result { OK, NOT_FOUND, NOT_YOURS, NOT_ALLOWED }
// One row of the state machine: in this state, this event, sent by this kind of actor, moves the// order to that state.record Transition(State from, Event event, Actor actor, State to) {}
final class Lifecycle { // The whole order lifecycle in one table. A pair missing from it is not allowed: a customer // can't cancel once the kitchen has started, and nothing leaves DELIVERED. static final List<Transition> TABLE = List.of( new Transition(State.PLACED, Event.ACCEPT, Actor.RESTAURANT, State.ACCEPTED), new Transition(State.PLACED, Event.REJECT, Actor.RESTAURANT, State.REJECTED), new Transition(State.PLACED, Event.CANCEL, Actor.CUSTOMER, State.CANCELLED), new Transition(State.ACCEPTED, Event.MARK_READY, Actor.RESTAURANT, State.READY), new Transition(State.READY, Event.PICK_UP, Actor.PARTNER, State.PICKED_UP), new Transition(State.PICKED_UP, Event.DELIVER, Actor.PARTNER, State.DELIVERED));
static Transition find(State from, Event event) { for (Transition t : TABLE) if (t.from() == from && t.event() == event) return t; return null; }}
class Order { final String id; final String customer; final String restaurant; String partner; // assigned later State state = State.PLACED;
Order(String id, String customer, String restaurant) { this.id = id; this.customer = customer; this.restaurant = restaurant; }
// The one person of that kind who may act on this order. String party(Actor actor) { return switch (actor) { case CUSTOMER -> customer; case RESTAURANT -> restaurant; case PARTNER -> partner; }; }}
class Platform { private final Map<String, Order> orders = new HashMap<>(); private int nextId = 1;
Order placeOrder(String customer, String restaurant) { Order o = new Order("O" + nextId++, customer, restaurant); orders.put(o.id, o); return o; }
Result accept(String restaurant, String orderId) { return handle(orderId, Event.ACCEPT, restaurant); }
Result reject(String restaurant, String orderId) { return handle(orderId, Event.REJECT, restaurant); }
Result cancel(String customer, String orderId) { return handle(orderId, Event.CANCEL, customer); }
Result markReady(String restaurant, String orderId) { return handle(orderId, Event.MARK_READY, restaurant); }
Result pickUp(String partner, String orderId) { return handle(orderId, Event.PICK_UP, partner); }
Result deliver(String partner, String orderId) { return handle(orderId, Event.DELIVER, partner); }
Order get(String id) { return orders.get(id); }
// Every change to an order goes through here: is this event allowed in this state, and is the // sender the order's own customer, restaurant or partner? Result handle(String orderId, Event event, String sender) { Order o = orders.get(orderId); if (o == null) return Result.NOT_FOUND; Transition t = Lifecycle.find(o.state, event); if (t == null) return Result.NOT_ALLOWED; if (!sender.equals(o.party(t.actor()))) return Result.NOT_YOURS; o.state = t.to(); return Result.OK; }}# Every state an order can be in. DELIVERED, CANCELLED and REJECTED are final: no row of# the table leaves them.class State(Enum): PLACED = auto() ACCEPTED = auto() READY = auto() PICKED_UP = auto() DELIVERED = auto() CANCELLED = auto() REJECTED = auto()
# What someone asks the platform to do. Each event is one button in one of the three apps.class Event(Enum): ACCEPT = auto() REJECT = auto() CANCEL = auto() MARK_READY = auto() PICK_UP = auto() DELIVER = auto()
# Who may cause an event. Each event belongs to exactly one kind of actor.class Actor(Enum): CUSTOMER = auto() RESTAURANT = auto() PARTNER = auto()
# What a request gets back. The app can show why it failed, not just that it failed.class Result(Enum): OK = auto() NOT_FOUND = auto() NOT_YOURS = auto() NOT_ALLOWED = auto()
# One row of the state machine: in this state, this event, sent by this kind of actor,# moves the order to that state.@dataclass(frozen=True)class Transition: frm: State event: Event actor: Actor to: State
# The whole order lifecycle in one table. A pair missing from it is not allowed: a customer# can't cancel once the kitchen has started, and nothing leaves DELIVERED.LIFECYCLE = { (t.frm, t.event): t for t in [ Transition(State.PLACED, Event.ACCEPT, Actor.RESTAURANT, State.ACCEPTED), Transition(State.PLACED, Event.REJECT, Actor.RESTAURANT, State.REJECTED), Transition(State.PLACED, Event.CANCEL, Actor.CUSTOMER, State.CANCELLED), Transition(State.ACCEPTED, Event.MARK_READY, Actor.RESTAURANT, State.READY), Transition(State.READY, Event.PICK_UP, Actor.PARTNER, State.PICKED_UP), Transition(State.PICKED_UP, Event.DELIVER, Actor.PARTNER, State.DELIVERED), ]}
@dataclass(eq=False)class Order: id: str customer: str restaurant: str partner: str | None = None # assigned later state: State = State.PLACED
# The one person of that kind who may act on this order. def party(self, actor: Actor) -> str | None: return {Actor.CUSTOMER: self.customer, Actor.RESTAURANT: self.restaurant, Actor.PARTNER: self.partner}[actor]
class Platform: def __init__(self): self._orders: dict[str, Order] = {} self._next_id = 1
def place_order(self, customer: str, restaurant: str) -> Order: o = Order(f"O{self._next_id}", customer, restaurant) self._next_id += 1 self._orders[o.id] = o return o
def accept(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.ACCEPT, restaurant)
def reject(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.REJECT, restaurant)
def cancel(self, customer: str, order_id: str) -> Result: return self.handle(order_id, Event.CANCEL, customer)
def mark_ready(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.MARK_READY, restaurant)
def pick_up(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.PICK_UP, partner)
def deliver(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.DELIVER, partner)
def get(self, id: str) -> Order | None: return self._orders.get(id)
# Every change to an order goes through here: is this event allowed in this state, and # is the sender the order's own customer, restaurant or partner? def handle(self, order_id: str, event: Event, sender: str) -> Result: o = self._orders.get(order_id) if o is None: return Result.NOT_FOUND t = LIFECYCLE.get((o.state, event)) if t is None: return Result.NOT_ALLOWED if sender != o.party(t.actor): return Result.NOT_YOURS o.state = t.to return Result.OK// Every state an order can be in. Delivered, Cancelled and Rejected are final: no row of the// table leaves them.public enum State { Placed, Accepted, Ready, PickedUp, Delivered, Cancelled, Rejected }
// What someone asks the platform to do. Each event is one button in one of the three apps.public enum Event { Accept, Reject, Cancel, MarkReady, PickUp, Deliver }
// Who may cause an event. Each event belongs to exactly one kind of actor.public enum Actor { Customer, Restaurant, Partner }
// What a request gets back. The app can show why it failed, not just that it failed.public enum Result { Ok, NotFound, NotYours, NotAllowed }
// One row of the state machine: in this state, this event, sent by this kind of actor, moves// the order to that state.public sealed record Transition(State From, Event Event, Actor Actor, State To);
public static class Lifecycle{ // The whole order lifecycle in one table. A pair missing from it is not allowed: a customer // can't cancel once the kitchen has started, and nothing leaves Delivered. private static readonly Dictionary<(State, Event), Transition> Table = new Transition[] { new(State.Placed, Event.Accept, Actor.Restaurant, State.Accepted), new(State.Placed, Event.Reject, Actor.Restaurant, State.Rejected), new(State.Placed, Event.Cancel, Actor.Customer, State.Cancelled), new(State.Accepted, Event.MarkReady, Actor.Restaurant, State.Ready), new(State.Ready, Event.PickUp, Actor.Partner, State.PickedUp), new(State.PickedUp, Event.Deliver, Actor.Partner, State.Delivered), }.ToDictionary(t => (t.From, t.Event));
public static Transition? Find(State from, Event e) => Table.GetValueOrDefault((from, e));}
public sealed class Order{ public Order(string id, string customer, string restaurant) => (Id, Customer, Restaurant) = (id, customer, restaurant);
public string Id { get; } public string Customer { get; } public string Restaurant { get; } public string? Partner { get; set; } // assigned later public State State { get; set; } = State.Placed;
// The one person of that kind who may act on this order. public string? Party(Actor actor) => actor switch { Actor.Customer => Customer, Actor.Restaurant => Restaurant, _ => Partner, };}
public sealed class Platform{ private readonly Dictionary<string, Order> _orders = new(); private int _nextId = 1;
public Order PlaceOrder(string customer, string restaurant) { var o = new Order($"O{_nextId++}", customer, restaurant); _orders[o.Id] = o; return o; }
public Result Accept(string restaurant, string orderId) => Handle(orderId, Event.Accept, restaurant); public Result Reject(string restaurant, string orderId) => Handle(orderId, Event.Reject, restaurant); public Result Cancel(string customer, string orderId) => Handle(orderId, Event.Cancel, customer); public Result MarkReady(string restaurant, string orderId) => Handle(orderId, Event.MarkReady, restaurant); public Result PickUp(string partner, string orderId) => Handle(orderId, Event.PickUp, partner); public Result Deliver(string partner, string orderId) => Handle(orderId, Event.Deliver, partner); public Order? Get(string id) => _orders.GetValueOrDefault(id);
// Every change to an order goes through here: is this event allowed in this state, and is // the sender the order's own customer, restaurant or partner? public Result Handle(string orderId, Event e, string sender) { if (!_orders.TryGetValue(orderId, out var o)) return Result.NotFound; var t = Lifecycle.Find(o.State, e); if (t is null) return Result.NotAllowed; if (sender != o.Party(t.Actor)) return Result.NotYours; o.State = t.To; return Result.Ok; }}// State is every state an order can be in. Delivered, Cancelled and Rejected are final: no row// of the table leaves them.type State int
const ( Placed State = iota Accepted Ready PickedUp Delivered Cancelled Rejected)
func (s State) String() string { return [...]string{"placed", "accepted", "ready", "picked up", "delivered", "cancelled", "rejected"}[s]}
// Event is what someone asks the platform to do. Each event is one button in one of the three// apps.type Event int
const ( Accept Event = iota Reject Cancel MarkReady PickUp Deliver)
// Actor is who may cause an event. Each event belongs to exactly one kind of actor.type Actor int
const ( Customer Actor = iota Restaurant Courier)
// Result is what a request gets back. The app can show why it failed, not just that it failed.type Result int
const ( OK Result = iota NotFound NotYours NotAllowed)
// Transition is one row of the state machine: in this state, this event, sent by this kind of// actor, moves the order to that state.type Transition struct { From State Event Event Actor Actor To State}
type step struct { from State event Event}
// lifecycle is the whole order lifecycle in one table. A pair missing from it is not allowed: a// customer can't cancel once the kitchen has started, and nothing leaves Delivered.var lifecycle = map[step]Transition{}
func init() { for _, t := range []Transition{ {Placed, Accept, Restaurant, Accepted}, {Placed, Reject, Restaurant, Rejected}, {Placed, Cancel, Customer, Cancelled}, {Accepted, MarkReady, Restaurant, Ready}, {Ready, PickUp, Courier, PickedUp}, {PickedUp, Deliver, Courier, Delivered}, } { lifecycle[step{t.From, t.Event}] = t }}
type Order struct { ID, Customer, Restaurant string Partner string // assigned later State State}
// Party is the one person of that kind who may act on this order.func (o *Order) Party(a Actor) string { switch a { case Customer: return o.Customer case Restaurant: return o.Restaurant default: return o.Partner }}
type Platform struct { orders map[string]*Order nextID int}
func NewPlatform() *Platform { return &Platform{orders: map[string]*Order{}, nextID: 1} }
func (p *Platform) Get(id string) *Order { return p.orders[id] }
func (p *Platform) PlaceOrder(customer, restaurant string) *Order { o := &Order{ID: fmt.Sprintf("O%d", p.nextID), Customer: customer, Restaurant: restaurant} p.nextID++ p.orders[o.ID] = o return o}
func (p *Platform) Accept(restaurant, orderID string) Result { return p.Handle(orderID, Accept, restaurant)}
func (p *Platform) Reject(restaurant, orderID string) Result { return p.Handle(orderID, Reject, restaurant)}
func (p *Platform) Cancel(customer, orderID string) Result { return p.Handle(orderID, Cancel, customer)}
func (p *Platform) MarkReady(restaurant, orderID string) Result { return p.Handle(orderID, MarkReady, restaurant)}
func (p *Platform) PickUp(partner, orderID string) Result { return p.Handle(orderID, PickUp, partner) }
func (p *Platform) Deliver(partner, orderID string) Result { return p.Handle(orderID, Deliver, partner)}
// Handle is where every change to an order goes: is this event allowed in this state, and is// the sender the order's own customer, restaurant or partner?func (p *Platform) Handle(orderID string, e Event, sender string) Result { o, ok := p.orders[orderID] if !ok { return NotFound } t, ok := lifecycle[step{o.State, e}] if !ok { return NotAllowed } if sender != o.Party(t.Actor) { return NotYours } o.State = t.To return OK}// Every state an order can be in. Delivered, cancelled and rejected are final: no row of the// table leaves them.type State = 'placed' | 'accepted' | 'ready' | 'picked-up' | 'delivered' | 'cancelled' | 'rejected';
// What someone asks the platform to do. Each event is one button in one of the three apps.type OrderEventName = 'accept' | 'reject' | 'cancel' | 'mark-ready' | 'pick-up' | 'deliver';
// Who may cause an event. Each event belongs to exactly one kind of actor.type Actor = 'customer' | 'restaurant' | 'partner';
// What a request gets back. The app can show why it failed, not just that it failed.type Result = 'ok' | 'not-found' | 'not-yours' | 'not-allowed';
// One row of the state machine: in this state, this event, sent by this kind of actor, moves the// order to that state.interface Transition { readonly from: State; readonly event: OrderEventName; readonly actor: Actor; readonly to: State;}
// The whole order lifecycle in one table. A pair missing from it is not allowed: a customer can't// cancel once the kitchen has started, and nothing leaves delivered.const LIFECYCLE: Transition[] = [ { from: 'placed', event: 'accept', actor: 'restaurant', to: 'accepted' }, { from: 'placed', event: 'reject', actor: 'restaurant', to: 'rejected' }, { from: 'placed', event: 'cancel', actor: 'customer', to: 'cancelled' }, { from: 'accepted', event: 'mark-ready', actor: 'restaurant', to: 'ready' }, { from: 'ready', event: 'pick-up', actor: 'partner', to: 'picked-up' }, { from: 'picked-up', event: 'deliver', actor: 'partner', to: 'delivered' },];
const findTransition = (from: State, event: OrderEventName) => LIFECYCLE.find((t) => t.from === from && t.event === event);
class Order { partner: string | null = null; // assigned later state: State = 'placed';
constructor( readonly id: string, readonly customer: string, readonly restaurant: string, ) {}
// The one person of that kind who may act on this order. party(actor: Actor): string | null { return { customer: this.customer, restaurant: this.restaurant, partner: this.partner }[actor]; }}
class Platform { private readonly orders = new Map<string, Order>(); private nextId = 1;
placeOrder(customer: string, restaurant: string): Order { const o = new Order(`O${this.nextId++}`, customer, restaurant); this.orders.set(o.id, o); return o; }
accept(restaurant: string, orderId: string): Result { return this.handle(orderId, 'accept', restaurant); }
reject(restaurant: string, orderId: string): Result { return this.handle(orderId, 'reject', restaurant); }
cancel(customer: string, orderId: string): Result { return this.handle(orderId, 'cancel', customer); }
markReady(restaurant: string, orderId: string): Result { return this.handle(orderId, 'mark-ready', restaurant); }
pickUp(partner: string, orderId: string): Result { return this.handle(orderId, 'pick-up', partner); }
deliver(partner: string, orderId: string): Result { return this.handle(orderId, 'deliver', partner); }
get(id: string): Order | undefined { return this.orders.get(id); }
// Every change to an order goes through here: is this event allowed in this state, and is the // sender the order's own customer, restaurant or partner? handle(orderId: string, event: OrderEventName, sender: string): Result { const o = this.orders.get(orderId); if (!o) return 'not-found'; const t = findTransition(o.state, event); if (!t) return 'not-allowed'; if (sender !== o.party(t.actor)) return 'not-yours'; o.state = t.to; return 'ok'; }}Go deeperA transition table, or a class per state1 min
| A class per state | A table (our choice) | |
|---|---|---|
| Where the rules are | Spread over seven classes | Six rows in one place |
| Adding a state | A new class, and edits to others | New rows |
| Behaviour that differs by state | Natural: each class has its own | Needs a separate hook, as stage 3 adds |
| Showing the rules to the business | Read the code | Print the table; it’s the diagram above |
When states mostly differ in which moves they allow, a table is clearer. When each state does very different work, a class per state earns its keep. The State pattern page covers both.
Stage 3: Assigning a partner
Section titled “Stage 3: Assigning a partner”When do you assign a delivery partner, and which one? What if none is free?
Whether you pick a moment with a reason (while the food cooks), keep the choice swappable, and handle having no partner without losing the order. The red flag is assigning at placement, before the restaurant has even said yes.
A partner is assigned when the restaurant accepts. Any earlier wastes a partner on an order that may be rejected. Any later, when the food is ready, leaves cooked food waiting while someone drives over. Accepting gives the partner the cooking time to arrive.
The Dispatcher keeps the Partners, and a line of orders waiting for one. To assign, it
gives the free partners to an AssignmentStrategy, and lets it choose. The first strategy is
Nearest: the free partner closest to the restaurant. Real apps weigh much more, such as
ratings, how long each partner has been waiting, and grouping two orders from one restaurant. That’s
why the choice sits behind an interface, which is the Strategy pattern.
If no partner is free, the order waits in line. When a partner delivers, the dispatcher frees them and gives them the oldest waiting order, so no order waits forever behind newer ones.
The platform starts all this from handle, after the state changes. Reaching ACCEPTED asks the
dispatcher to assign a partner. Reaching DELIVERED frees the partner. The state machine decides
what’s allowed, and what a new state sets off happens after it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in// for road distance.record Location(int x, int y) { int distanceTo(Location o) { return Math.abs(x - o.x) + Math.abs(y - o.y); }}
// A delivery partner. A partner carries one order at a time, which keeps the design simple.// Batching orders would turn `current` into a list.class Partner { final String id; final Location location; Order current; // null while free
Partner(String id, Location location) { this.id = id; this.location = location; }}
// How a free partner is chosen for an order. Nearest is the start; a real app weighs ratings,// batching and how long each partner has been idle.interface AssignmentStrategy { Partner choose(Order order, List<Partner> free);}
// Picks the free partner closest to the restaurant, so cooked food waits the least. Returns// null when nobody is free, and the dispatcher then puts the order in line.class Nearest implements AssignmentStrategy { public Partner choose(Order order, List<Partner> free) { return free.stream().min(Comparator.comparingInt(p -> p.location.distanceTo(order.pickup))).orElse(null); }}
// Keeps the partners and the orders waiting for one. An order is offered a partner when the// restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it// waits in line, and the next partner to finish a delivery takes the oldest waiting order.class Dispatcher { private final List<Partner> partners = new ArrayList<>(); private final Deque<Order> waiting = new ArrayDeque<>(); private final AssignmentStrategy strategy;
Dispatcher(AssignmentStrategy strategy) { this.strategy = strategy; }
void addPartner(String id, Location at) { partners.add(new Partner(id, at)); }
// Called when the restaurant accepts the order. void assign(Order o) { List<Partner> free = partners.stream().filter(p -> p.current == null).toList(); Partner p = strategy.choose(o, free); if (p == null) { waiting.addLast(o); return; } p.current = o; o.partner = p.id; }
// Called when the partner delivers. The oldest waiting order goes first, so no order waits // forever behind newer ones. void release(String partnerId) { Partner p = partners.stream().filter(x -> x.id.equals(partnerId)).findFirst().orElseThrow(); p.current = null; Order next = waiting.pollFirst(); if (next != null) { p.current = next; next.partner = p.id; } }
int waitingCount() { return waiting.size(); }}
// One order and the three people tied to it. Its state changes only through Platform.handle,// so every change passes the same checks.class Order { final String id; final String customer; final String restaurant; final Location pickup; // where the restaurant is String partner; // null until a partner is assigned State state = State.PLACED;
Order(String id, String customer, String restaurant, Location pickup) { this.id = id; this.customer = customer; this.restaurant = restaurant; this.pickup = pickup; }
// The one person of that kind who may act on this order. String party(Actor actor) { return switch (actor) { case CUSTOMER -> customer; case RESTAURANT -> restaurant; case PARTNER -> partner; }; }}
class Platform { private final Map<String, Order> orders = new HashMap<>(); private final Dispatcher dispatcher; private int nextId = 1;
Platform(Dispatcher dispatcher) { this.dispatcher = dispatcher; }
Order placeOrder(String customer, String restaurant, Location pickup) { Order o = new Order("O" + nextId++, customer, restaurant, pickup); orders.put(o.id, o); return o; }
Result accept(String restaurant, String orderId) { return handle(orderId, Event.ACCEPT, restaurant); }
Result reject(String restaurant, String orderId) { return handle(orderId, Event.REJECT, restaurant); }
Result cancel(String customer, String orderId) { return handle(orderId, Event.CANCEL, customer); }
Result markReady(String restaurant, String orderId) { return handle(orderId, Event.MARK_READY, restaurant); }
Result pickUp(String partner, String orderId) { return handle(orderId, Event.PICK_UP, partner); }
Result deliver(String partner, String orderId) { return handle(orderId, Event.DELIVER, partner); }
Order get(String id) { return orders.get(id); }
// The state machine decides what is allowed; what each new state sets off happens after. Result handle(String orderId, Event event, String sender) { Order o = orders.get(orderId); if (o == null) return Result.NOT_FOUND; Transition t = Lifecycle.find(o.state, event); if (t == null) return Result.NOT_ALLOWED; if (!sender.equals(o.party(t.actor()))) return Result.NOT_YOURS; o.state = t.to(); if (t.to() == State.ACCEPTED) dispatcher.assign(o); if (t.to() == State.DELIVERED) dispatcher.release(o.partner); return Result.OK; }}# A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in# for road distance.@dataclass(frozen=True)class Location: x: int y: int
def distance_to(self, o: Location) -> int: return abs(self.x - o.x) + abs(self.y - o.y)
# A delivery partner. A partner carries one order at a time, which keeps the design simple.# Batching orders would turn `current` into a list.@dataclass(eq=False)class Partner: id: str location: Location current: Order | None = None # None while free
# How a free partner is chosen for an order. Nearest is the start; a real app weighs# ratings, batching and how long each partner has been idle.class AssignmentStrategy(Protocol): def choose(self, order: Order, free: list[Partner]) -> Partner | None: ...
# Picks the free partner closest to the restaurant, so cooked food waits the least. Returns# None when nobody is free, and the dispatcher then puts the order in line.class Nearest: def choose(self, order: Order, free: list[Partner]) -> Partner | None: return min(free, key=lambda p: p.location.distance_to(order.pickup), default=None)
# Keeps the partners and the orders waiting for one. An order is offered a partner when the# restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it# waits in line, and the next partner to finish a delivery takes the oldest waiting order.class Dispatcher: def __init__(self, strategy: AssignmentStrategy): self._partners: list[Partner] = [] self._waiting: deque[Order] = deque() self._strategy = strategy
def add_partner(self, id: str, at: Location) -> None: self._partners.append(Partner(id, at))
# Called when the restaurant accepts the order. def assign(self, o: Order) -> None: p = self._strategy.choose(o, [p for p in self._partners if p.current is None]) if p is None: self._waiting.append(o) return p.current = o o.partner = p.id
# Called when the partner delivers. The oldest waiting order goes first, so no order # waits forever behind newer ones. def release(self, partner_id: str) -> None: p = next(x for x in self._partners if x.id == partner_id) p.current = None if self._waiting: nxt = self._waiting.popleft() p.current = nxt nxt.partner = p.id
@property def waiting_count(self) -> int: return len(self._waiting)
# One order and the three people tied to it. Its state changes only through# Platform.handle, so every change passes the same checks.@dataclass(eq=False)class Order: id: str customer: str restaurant: str pickup: Location # where the restaurant is partner: str | None = None # None until a partner is assigned state: State = State.PLACED
# The one person of that kind who may act on this order. def party(self, actor: Actor) -> str | None: return {Actor.CUSTOMER: self.customer, Actor.RESTAURANT: self.restaurant, Actor.PARTNER: self.partner}[actor]
class Platform: def __init__(self, dispatcher: Dispatcher): self._orders: dict[str, Order] = {} self._dispatcher = dispatcher self._next_id = 1
def place_order(self, customer: str, restaurant: str, pickup: Location) -> Order: o = Order(f"O{self._next_id}", customer, restaurant, pickup) self._next_id += 1 self._orders[o.id] = o return o
def accept(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.ACCEPT, restaurant)
def reject(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.REJECT, restaurant)
def cancel(self, customer: str, order_id: str) -> Result: return self.handle(order_id, Event.CANCEL, customer)
def mark_ready(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.MARK_READY, restaurant)
def pick_up(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.PICK_UP, partner)
def deliver(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.DELIVER, partner)
def get(self, id: str) -> Order | None: return self._orders.get(id)
# The state machine decides what is allowed; what each new state sets off happens after. def handle(self, order_id: str, event: Event, sender: str) -> Result: o = self._orders.get(order_id) if o is None: return Result.NOT_FOUND t = LIFECYCLE.get((o.state, event)) if t is None: return Result.NOT_ALLOWED if sender != o.party(t.actor): return Result.NOT_YOURS o.state = t.to if t.to is State.ACCEPTED: self._dispatcher.assign(o) if t.to is State.DELIVERED: self._dispatcher.release(o.partner) return Result.OK// A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in// for road distance.public readonly record struct Location(int X, int Y){ public int DistanceTo(Location o) => Math.Abs(X - o.X) + Math.Abs(Y - o.Y);}
// A delivery partner. A partner carries one order at a time, which keeps the design simple.// Batching orders would turn Current into a list.public sealed class Partner{ public Partner(string id, Location location) => (Id, Location) = (id, location);
public string Id { get; } public Location Location { get; } public Order? Current { get; set; } // null while free}
// How a free partner is chosen for an order. Nearest is the start; a real app weighs ratings,// batching and how long each partner has been idle.public interface IAssignmentStrategy{ Partner? Choose(Order order, IReadOnlyList<Partner> free);}
// Picks the free partner closest to the restaurant, so cooked food waits the least. Returns// null when nobody is free, and the dispatcher then puts the order in line.public sealed class Nearest : IAssignmentStrategy{ public Partner? Choose(Order order, IReadOnlyList<Partner> free) => free.MinBy(p => p.Location.DistanceTo(order.Pickup));}
// Keeps the partners and the orders waiting for one. An order is offered a partner when the// restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it// waits in line, and the next partner to finish a delivery takes the oldest waiting order.public sealed class Dispatcher{ private readonly List<Partner> _partners = new(); private readonly Queue<Order> _waiting = new(); private readonly IAssignmentStrategy _strategy;
public Dispatcher(IAssignmentStrategy strategy) => _strategy = strategy;
public int WaitingCount => _waiting.Count;
public void AddPartner(string id, Location at) => _partners.Add(new Partner(id, at));
// Called when the restaurant accepts the order. public void Assign(Order o) { var p = _strategy.Choose(o, _partners.Where(p => p.Current is null).ToList()); if (p is null) { _waiting.Enqueue(o); return; } p.Current = o; o.Partner = p.Id; }
// Called when the partner delivers. The oldest waiting order goes first, so no order waits // forever behind newer ones. public void Release(string partnerId) { var p = _partners.First(x => x.Id == partnerId); p.Current = null; if (_waiting.TryDequeue(out var next)) { p.Current = next; next.Partner = p.Id; } }}
// One order and the three people tied to it. Its state changes only through Platform.Handle,// so every change passes the same checks.public sealed class Order{ public Order(string id, string customer, string restaurant, Location pickup) => (Id, Customer, Restaurant, Pickup) = (id, customer, restaurant, pickup);
public string Id { get; } public string Customer { get; } public string Restaurant { get; } public Location Pickup { get; } // where the restaurant is public string? Partner { get; set; } // null until a partner is assigned public State State { get; set; } = State.Placed;
// The one person of that kind who may act on this order. public string? Party(Actor actor) => actor switch { Actor.Customer => Customer, Actor.Restaurant => Restaurant, _ => Partner, };}
public sealed class Platform{ private readonly Dictionary<string, Order> _orders = new(); private readonly Dispatcher _dispatcher; private int _nextId = 1;
public Platform(Dispatcher dispatcher) => _dispatcher = dispatcher;
public Order PlaceOrder(string customer, string restaurant, Location pickup) { var o = new Order($"O{_nextId++}", customer, restaurant, pickup); _orders[o.Id] = o; return o; }
public Result Accept(string restaurant, string orderId) => Handle(orderId, Event.Accept, restaurant); public Result Reject(string restaurant, string orderId) => Handle(orderId, Event.Reject, restaurant); public Result Cancel(string customer, string orderId) => Handle(orderId, Event.Cancel, customer); public Result MarkReady(string restaurant, string orderId) => Handle(orderId, Event.MarkReady, restaurant); public Result PickUp(string partner, string orderId) => Handle(orderId, Event.PickUp, partner); public Result Deliver(string partner, string orderId) => Handle(orderId, Event.Deliver, partner); public Order? Get(string id) => _orders.GetValueOrDefault(id);
// The state machine decides what is allowed; what each new state sets off happens after. public Result Handle(string orderId, Event e, string sender) { if (!_orders.TryGetValue(orderId, out var o)) return Result.NotFound; var t = Lifecycle.Find(o.State, e); if (t is null) return Result.NotAllowed; if (sender != o.Party(t.Actor)) return Result.NotYours; o.State = t.To; if (t.To == State.Accepted) _dispatcher.Assign(o); if (t.To == State.Delivered) _dispatcher.Release(o.Partner!); return Result.Ok; }}// Location is a point on a simple grid. Distance counts blocks across plus blocks up, a cheap// stand-in for road distance.type Location struct{ X, Y int }
func (l Location) DistanceTo(o Location) int { return abs(l.X-o.X) + abs(l.Y-o.Y) }
func abs(x int) int { return max(x, -x) }
// Partner is a delivery partner. A partner carries one order at a time, which keeps the design// simple. Batching orders would turn Current into a slice.type Partner struct { ID string Location Location Current *Order // nil while free}
// AssignmentStrategy chooses a free partner for an order. Nearest is the start; a real app// weighs ratings, batching and how long each partner has been idle.type AssignmentStrategy interface { Choose(o *Order, free []*Partner) *Partner}
// Nearest picks the free partner closest to the restaurant, so cooked food waits the least. It// returns nil when nobody is free, and the dispatcher then puts the order in line.type Nearest struct{}
func (Nearest) Choose(o *Order, free []*Partner) *Partner { var best *Partner for _, p := range free { if best == nil || p.Location.DistanceTo(o.Pickup) < best.Location.DistanceTo(o.Pickup) { best = p } } return best}
// Dispatcher keeps the partners and the orders waiting for one. An order is offered a partner// when the restaurant accepts it, so a partner is on the way while the food cooks; if nobody is// free it waits in line, and the next partner to finish a delivery takes the oldest waiting// order.type Dispatcher struct { partners []*Partner waiting []*Order strategy AssignmentStrategy}
func NewDispatcher(s AssignmentStrategy) *Dispatcher { return &Dispatcher{strategy: s} }
func (d *Dispatcher) AddPartner(id string, at Location) { d.partners = append(d.partners, &Partner{ID: id, Location: at})}
// Assign is called when the restaurant accepts the order.func (d *Dispatcher) Assign(o *Order) { var free []*Partner for _, p := range d.partners { if p.Current == nil { free = append(free, p) } } p := d.strategy.Choose(o, free) if p == nil { d.waiting = append(d.waiting, o) return } p.Current = o o.Partner = p.ID}
// Release is called when the partner delivers. The oldest waiting order goes first, so no order// waits forever behind newer ones.func (d *Dispatcher) Release(partnerID string) { for _, p := range d.partners { if p.ID != partnerID { continue } p.Current = nil if len(d.waiting) > 0 { next := d.waiting[0] d.waiting = d.waiting[1:] p.Current = next next.Partner = p.ID } }}
func (d *Dispatcher) WaitingCount() int { return len(d.waiting) }
// Order is one order and the three people tied to it. Its state changes only through// Platform.Handle, so every change passes the same checks.type Order struct { ID, Customer, Restaurant string Pickup Location // where the restaurant is Partner string // empty until a partner is assigned State State}
// Party is the one person of that kind who may act on this order.func (o *Order) Party(a Actor) string { switch a { case Customer: return o.Customer case Restaurant: return o.Restaurant default: return o.Partner }}
type Platform struct { orders map[string]*Order dispatcher *Dispatcher nextID int}
func NewPlatform(d *Dispatcher) *Platform { return &Platform{orders: map[string]*Order{}, dispatcher: d, nextID: 1}}
func (p *Platform) PlaceOrder(customer, restaurant string, pickup Location) *Order { o := &Order{ID: fmt.Sprintf("O%d", p.nextID), Customer: customer, Restaurant: restaurant, Pickup: pickup} p.nextID++ p.orders[o.ID] = o return o}
func (p *Platform) Accept(restaurant, orderID string) Result { return p.Handle(orderID, Accept, restaurant)}
func (p *Platform) Reject(restaurant, orderID string) Result { return p.Handle(orderID, Reject, restaurant)}
func (p *Platform) Cancel(customer, orderID string) Result { return p.Handle(orderID, Cancel, customer)}
func (p *Platform) MarkReady(restaurant, orderID string) Result { return p.Handle(orderID, MarkReady, restaurant)}
func (p *Platform) PickUp(partner, orderID string) Result { return p.Handle(orderID, PickUp, partner) }
func (p *Platform) Deliver(partner, orderID string) Result { return p.Handle(orderID, Deliver, partner)}
func (p *Platform) Get(id string) *Order { return p.orders[id] }
// Handle lets the state machine decide what is allowed; what each new state sets off happens// after.func (p *Platform) Handle(orderID string, e Event, sender string) Result { o, ok := p.orders[orderID] if !ok { return NotFound } t, ok := lifecycle[step{o.State, e}] if !ok { return NotAllowed } if sender != o.Party(t.Actor) { return NotYours } o.State = t.To if t.To == Accepted { p.dispatcher.Assign(o) } if t.To == Delivered { p.dispatcher.Release(o.Partner) } return OK}// A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in for// road distance.class Location { constructor( readonly x: number, readonly y: number, ) {}
distanceTo(o: Location): number { return Math.abs(this.x - o.x) + Math.abs(this.y - o.y); }}
// A delivery partner. A partner carries one order at a time, which keeps the design simple.// Batching orders would turn `current` into a list.class Partner { current: Order | null = null; // null while free
constructor( readonly id: string, readonly location: Location, ) {}}
// How a free partner is chosen for an order. Nearest is the start; a real app weighs ratings,// batching and how long each partner has been idle.interface AssignmentStrategy { choose(order: Order, free: Partner[]): Partner | null;}
// Picks the free partner closest to the restaurant, so cooked food waits the least. Returns null// when nobody is free, and the dispatcher then puts the order in line.class Nearest implements AssignmentStrategy { choose(order: Order, free: Partner[]): Partner | null { let best: Partner | null = null; for (const p of free) { if (!best || p.location.distanceTo(order.pickup) < best.location.distanceTo(order.pickup)) best = p; } return best; }}
// Keeps the partners and the orders waiting for one. An order is offered a partner when the// restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it// waits in line, and the next partner to finish a delivery takes the oldest waiting order.class Dispatcher { private readonly partners: Partner[] = []; private readonly waiting: Order[] = [];
constructor(private readonly strategy: AssignmentStrategy) {}
addPartner(id: string, at: Location): void { this.partners.push(new Partner(id, at)); }
// Called when the restaurant accepts the order. assign(o: Order): void { const p = this.strategy.choose( o, this.partners.filter((p) => p.current === null), ); if (!p) { this.waiting.push(o); return; } p.current = o; o.partner = p.id; }
// Called when the partner delivers. The oldest waiting order goes first, so no order waits // forever behind newer ones. release(partnerId: string): void { const p = this.partners.find((x) => x.id === partnerId)!; p.current = null; const next = this.waiting.shift(); if (next) { p.current = next; next.partner = p.id; } }
get waitingCount(): number { return this.waiting.length; }}
// One order and the three people tied to it. Its state changes only through Platform.handle, so// every change passes the same checks.class Order { partner: string | null = null; // null until a partner is assigned state: State = 'placed';
constructor( readonly id: string, readonly customer: string, readonly restaurant: string, readonly pickup: Location, // where the restaurant is ) {}
// The one person of that kind who may act on this order. party(actor: Actor): string | null { return { customer: this.customer, restaurant: this.restaurant, partner: this.partner }[actor]; }}
class Platform { private readonly orders = new Map<string, Order>(); private nextId = 1;
constructor(private readonly dispatcher: Dispatcher) {}
placeOrder(customer: string, restaurant: string, pickup: Location): Order { const o = new Order(`O${this.nextId++}`, customer, restaurant, pickup); this.orders.set(o.id, o); return o; }
accept(restaurant: string, orderId: string): Result { return this.handle(orderId, 'accept', restaurant); }
reject(restaurant: string, orderId: string): Result { return this.handle(orderId, 'reject', restaurant); }
cancel(customer: string, orderId: string): Result { return this.handle(orderId, 'cancel', customer); }
markReady(restaurant: string, orderId: string): Result { return this.handle(orderId, 'mark-ready', restaurant); }
pickUp(partner: string, orderId: string): Result { return this.handle(orderId, 'pick-up', partner); }
deliver(partner: string, orderId: string): Result { return this.handle(orderId, 'deliver', partner); }
get(id: string): Order | undefined { return this.orders.get(id); }
// The state machine decides what is allowed; what each new state sets off happens after. handle(orderId: string, event: OrderEventName, sender: string): Result { const o = this.orders.get(orderId); if (!o) return 'not-found'; const t = findTransition(o.state, event); if (!t) return 'not-allowed'; if (sender !== o.party(t.actor)) return 'not-yours'; o.state = t.to; if (t.to === 'accepted') this.dispatcher.assign(o); if (t.to === 'delivered') this.dispatcher.release(o.partner!); return 'ok'; }}Go deeperAn order from placement to delivery2 min
Let’s follow one order through every step, with who sends each event and what the table checks.
- Ana places an order at Pasta Place. The order is placed. Nobody is assigned yet.
- Pasta Place accepts. The table has a row “placed, accept, restaurant, accepted”, and the sender is this order’s restaurant, so the state changes. Then the platform asks the dispatcher for a partner. Pat is the nearest free partner, so Pat is assigned and is no longer free.
- Ana taps cancel. The table has no row for “accepted, cancel”, so the answer is
NOT_ALLOWED. Nothing changes. - Pasta Place marks it ready. The order is ready.
- Sam, another partner, taps pick up. The row exists, and it needs a partner, but Sam is not
this order’s partner.
NOT_YOURS. - Pat picks up, then delivers. The order is delivered, and the platform frees Pat. If an order is waiting in line, Pat gets the oldest one right away.
- Color key
- Entity
- Facade
Notice that steps 3 and 5 needed no special code. The table and the sender check refused them, the same way they’d refuse any other wrong move.
Go deeperHow Nearest chooses, and what real apps add2 min
In our code, every place is a point on a grid, and distance is how many blocks you’d walk: the
difference across plus the difference up and down. Nearest takes the free partners, and picks the
one with the smallest distance to the restaurant. That’s it: a few lines behind one interface.
Real apps replace that one number with a score. Common parts of it:
- Travel time, not distance. A partner two blocks away across a river is further than one five blocks away on the same road. This comes from a maps service.
- Time until the food is ready. If the kitchen needs 20 minutes, a partner 15 minutes away is better than one 2 minutes away, who would stand around waiting.
- Fairness. A partner who has waited 40 minutes for an order gets a bonus over one who just finished, so earnings spread more evenly.
- Grouping. A partner already heading to the same restaurant can take a second order that’s going the same way.
Each of these is a new strategy, or a new part of one strategy’s score. The Dispatcher, the
waiting line and the order’s table don’t change. That’s what putting the choice behind an interface
buys.
Stage 4: Everyone hears about every change
Section titled “Stage 4: Everyone hears about every change”The customer’s app shows “your food is being prepared”, the restaurant’s tablet beeps for new orders, and support needs to see exactly when each step happened. How?
Whether the platform publishes changes without knowing who listens. The red flag is the platform
calling the push service, the SMS gateway and the tablet API by name inside accept.
Each change becomes an OrderEvent: the order, the state it left, the state it entered, and the
time. The platform does two things with it.
- It adds it to the order’s timeline, which support reads when food is late.
- It tells every
OrderListener. The customer’s push service, the restaurant’s tablet feed and analytics each subscribe, and the platform doesn’t know any of them by name. This is the Observer pattern.
Only accepted changes become events. A refused cancel changes nothing, so nobody hears about it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Something that happened to an order, at a time. The customer's app, the restaurant's tablet// and the partner's phone all learn about orders from these.record OrderEvent(String orderId, State from, State to, long at) {}
// Anyone who wants to hear about order changes. A new channel, such as SMS, is a new listener;// the platform does not change.interface OrderListener { void onEvent(OrderEvent e);}
// The front door for every action on an order. It checks the rules, moves the order, starts or// ends the delivery, and tells the listeners.class Platform { private final Map<String, Order> orders = new HashMap<>(); private final Map<String, List<OrderEvent>> timelines = new HashMap<>(); private final Dispatcher dispatcher; private final List<OrderListener> listeners; private final LongSupplier clock; // passed in, so tests can control time private int nextId = 1;
Platform(Dispatcher dispatcher, LongSupplier clock, List<OrderListener> listeners) { this.dispatcher = dispatcher; this.clock = clock; this.listeners = listeners; }
Order placeOrder(String customer, String restaurant, Location pickup) { Order o = new Order("O" + nextId++, customer, restaurant, pickup); orders.put(o.id, o); timelines.put(o.id, new ArrayList<>()); record(o, null, State.PLACED); return o; }
Result accept(String restaurant, String orderId) { return handle(orderId, Event.ACCEPT, restaurant); }
Result reject(String restaurant, String orderId) { return handle(orderId, Event.REJECT, restaurant); }
Result cancel(String customer, String orderId) { return handle(orderId, Event.CANCEL, customer); }
Result markReady(String restaurant, String orderId) { return handle(orderId, Event.MARK_READY, restaurant); }
Result pickUp(String partner, String orderId) { return handle(orderId, Event.PICK_UP, partner); }
Result deliver(String partner, String orderId) { return handle(orderId, Event.DELIVER, partner); }
Order get(String id) { return orders.get(id); }
// Every state the order has been in, and when: what support looks at when food is late. List<OrderEvent> timeline(String orderId) { return List.copyOf(timelines.get(orderId)); }
// First the checks: does the order exist, is this event allowed now, and is the sender the // right person? Then the effects: record the change, and start or end a delivery. Result handle(String orderId, Event event, String sender) { Order o = orders.get(orderId); if (o == null) return Result.NOT_FOUND; Transition t = Lifecycle.find(o.state, event); if (t == null) return Result.NOT_ALLOWED; if (!sender.equals(o.party(t.actor()))) return Result.NOT_YOURS; o.state = t.to(); record(o, t.from(), t.to()); if (t.to() == State.ACCEPTED) dispatcher.assign(o); if (t.to() == State.DELIVERED) dispatcher.release(o.partner); return Result.OK; }
// The platform doesn't know who is listening or how they're told: push, SMS, a tablet. private void record(Order o, State from, State to) { OrderEvent e = new OrderEvent(o.id, from, to, clock.getAsLong()); timelines.get(o.id).add(e); for (OrderListener l : listeners) l.onEvent(e); }}# Something that happened to an order, at a time. The customer's app, the restaurant's# tablet and the partner's phone all learn about orders from these.@dataclass(frozen=True)class OrderEvent: order_id: str frm: State | None to: State at: int
# Anyone who wants to hear about order changes. A new channel, such as SMS, is a new# listener; the platform does not change.class OrderListener(Protocol): def on_event(self, e: OrderEvent) -> None: ...
# The front door for every action on an order. It checks the rules, moves the order,# starts or ends the delivery, and tells the listeners.class Platform: def __init__(self, dispatcher: Dispatcher, clock: Callable[[], int], listeners: list[OrderListener]): self._orders: dict[str, Order] = {} self._timelines: dict[str, list[OrderEvent]] = {} self._dispatcher = dispatcher self._clock = clock # passed in, so tests can control time self._listeners = listeners self._next_id = 1
def place_order(self, customer: str, restaurant: str, pickup: Location) -> Order: o = Order(f"O{self._next_id}", customer, restaurant, pickup) self._next_id += 1 self._orders[o.id] = o self._timelines[o.id] = [] self._record(o, None, State.PLACED) return o
def accept(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.ACCEPT, restaurant)
def reject(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.REJECT, restaurant)
def cancel(self, customer: str, order_id: str) -> Result: return self.handle(order_id, Event.CANCEL, customer)
def mark_ready(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.MARK_READY, restaurant)
def pick_up(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.PICK_UP, partner)
def deliver(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.DELIVER, partner)
def get(self, id: str) -> Order | None: return self._orders.get(id)
# Every state the order has been in, and when: what support looks at when food is late. def timeline(self, order_id: str) -> list[OrderEvent]: return list(self._timelines[order_id])
# First the checks: does the order exist, is this event allowed now, and is the sender # the right person? Then the effects: record the change, and start or end a delivery. def handle(self, order_id: str, event: Event, sender: str) -> Result: o = self._orders.get(order_id) if o is None: return Result.NOT_FOUND t = LIFECYCLE.get((o.state, event)) if t is None: return Result.NOT_ALLOWED if sender != o.party(t.actor): return Result.NOT_YOURS o.state = t.to self._record(o, t.frm, t.to) if t.to is State.ACCEPTED: self._dispatcher.assign(o) if t.to is State.DELIVERED: self._dispatcher.release(o.partner) return Result.OK
# The platform doesn't know who is listening or how they're told: push, SMS, a tablet. def _record(self, o: Order, frm: State | None, to: State) -> None: e = OrderEvent(o.id, frm, to, self._clock()) self._timelines[o.id].append(e) for listener in self._listeners: listener.on_event(e)// Something that happened to an order, at a time. The customer's app, the restaurant's tablet// and the partner's phone all learn about orders from these.public sealed record OrderEvent(string OrderId, State? From, State To, long At);
// Anyone who wants to hear about order changes. A new channel, such as SMS, is a new listener;// the platform does not change.public interface IOrderListener{ void OnEvent(OrderEvent e);}
// The front door for every action on an order. It checks the rules, moves the order, starts or// ends the delivery, and tells the listeners.public sealed class Platform{ private readonly Dictionary<string, Order> _orders = new(); private readonly Dictionary<string, List<OrderEvent>> _timelines = new(); private readonly Dispatcher _dispatcher; private readonly Func<long> _clock; // passed in, so tests can control time private readonly IReadOnlyList<IOrderListener> _listeners; private int _nextId = 1;
public Platform(Dispatcher dispatcher, Func<long> clock, IReadOnlyList<IOrderListener> listeners) => (_dispatcher, _clock, _listeners) = (dispatcher, clock, listeners);
public Order PlaceOrder(string customer, string restaurant, Location pickup) { var o = new Order($"O{_nextId++}", customer, restaurant, pickup); _orders[o.Id] = o; _timelines[o.Id] = new List<OrderEvent>(); Record(o, null, State.Placed); return o; }
public Result Accept(string restaurant, string orderId) => Handle(orderId, Event.Accept, restaurant); public Result Reject(string restaurant, string orderId) => Handle(orderId, Event.Reject, restaurant); public Result Cancel(string customer, string orderId) => Handle(orderId, Event.Cancel, customer); public Result MarkReady(string restaurant, string orderId) => Handle(orderId, Event.MarkReady, restaurant); public Result PickUp(string partner, string orderId) => Handle(orderId, Event.PickUp, partner); public Result Deliver(string partner, string orderId) => Handle(orderId, Event.Deliver, partner); public Order? Get(string id) => _orders.GetValueOrDefault(id);
// Every state the order has been in, and when: what support looks at when food is late. public IReadOnlyList<OrderEvent> Timeline(string orderId) => _timelines[orderId].ToList();
// First the checks: does the order exist, is this event allowed now, and is the sender the // right person? Then the effects: record the change, and start or end a delivery. public Result Handle(string orderId, Event e, string sender) { if (!_orders.TryGetValue(orderId, out var o)) return Result.NotFound; var t = Lifecycle.Find(o.State, e); if (t is null) return Result.NotAllowed; if (sender != o.Party(t.Actor)) return Result.NotYours; o.State = t.To; Record(o, t.From, t.To); if (t.To == State.Accepted) _dispatcher.Assign(o); if (t.To == State.Delivered) _dispatcher.Release(o.Partner!); return Result.Ok; }
// The platform doesn't know who is listening or how they're told: push, SMS, a tablet. private void Record(Order o, State? from, State to) { var e = new OrderEvent(o.Id, from, to, _clock()); _timelines[o.Id].Add(e); foreach (var l in _listeners) l.OnEvent(e); }}// OrderEvent is something that happened to an order, at a time. The customer's app, the// restaurant's tablet and the partner's phone all learn about orders from these. From is -1 for// the first event.type OrderEvent struct { OrderID string From, To State At int64}
// OrderListener is anyone who wants to hear about order changes. A new channel, such as SMS, is// a new listener; the platform does not change.type OrderListener interface { OnEvent(e OrderEvent)}
// Platform is the front door for every action on an order. It checks the rules, moves the// order, starts or ends the delivery, and tells the listeners.type Platform struct { orders map[string]*Order timelines map[string][]OrderEvent dispatcher *Dispatcher clock func() int64 // passed in, so tests can control time listeners []OrderListener nextID int}
func NewPlatform(d *Dispatcher, clock func() int64, listeners ...OrderListener) *Platform { return &Platform{ orders: map[string]*Order{}, timelines: map[string][]OrderEvent{}, dispatcher: d, clock: clock, listeners: listeners, nextID: 1, }}
func (p *Platform) PlaceOrder(customer, restaurant string, pickup Location) *Order { o := &Order{ID: fmt.Sprintf("O%d", p.nextID), Customer: customer, Restaurant: restaurant, Pickup: pickup} p.nextID++ p.orders[o.ID] = o p.record(o, -1, Placed) return o}
func (p *Platform) Accept(restaurant, orderID string) Result { return p.Handle(orderID, Accept, restaurant)}
func (p *Platform) Reject(restaurant, orderID string) Result { return p.Handle(orderID, Reject, restaurant)}
func (p *Platform) Cancel(customer, orderID string) Result { return p.Handle(orderID, Cancel, customer)}
func (p *Platform) MarkReady(restaurant, orderID string) Result { return p.Handle(orderID, MarkReady, restaurant)}
func (p *Platform) PickUp(partner, orderID string) Result { return p.Handle(orderID, PickUp, partner) }
func (p *Platform) Deliver(partner, orderID string) Result { return p.Handle(orderID, Deliver, partner)}
func (p *Platform) Get(id string) *Order { return p.orders[id] }
// Timeline is every state the order has been in, and when: what support looks at when food is// late.func (p *Platform) Timeline(orderID string) []OrderEvent { return slices.Clone(p.timelines[orderID]) }
// Handle runs the checks first: does the order exist, is this event allowed now, and is the// sender the right person? Then the effects: record the change, and start or end a delivery.func (p *Platform) Handle(orderID string, e Event, sender string) Result { o, ok := p.orders[orderID] if !ok { return NotFound } t, ok := lifecycle[step{o.State, e}] if !ok { return NotAllowed } if sender != o.Party(t.Actor) { return NotYours } o.State = t.To p.record(o, t.From, t.To) if t.To == Accepted { p.dispatcher.Assign(o) } if t.To == Delivered { p.dispatcher.Release(o.Partner) } return OK}
// record doesn't know who is listening or how they're told: push, SMS, a tablet.func (p *Platform) record(o *Order, from, to State) { e := OrderEvent{o.ID, from, to, p.clock()} p.timelines[o.ID] = append(p.timelines[o.ID], e) for _, l := range p.listeners { l.OnEvent(e) }}// Something that happened to an order, at a time. The customer's app, the restaurant's tablet and// the partner's phone all learn about orders from these.interface OrderEvent { readonly orderId: string; readonly from: State | null; readonly to: State; readonly at: number;}
// Anyone who wants to hear about order changes. A new channel, such as SMS, is a new listener;// the platform does not change.interface OrderListener { onEvent(e: OrderEvent): void;}
// The front door for every action on an order. It checks the rules, moves the order, starts or// ends the delivery, and tells the listeners.class Platform { private readonly orders = new Map<string, Order>(); private readonly timelines = new Map<string, OrderEvent[]>(); private nextId = 1;
constructor( private readonly dispatcher: Dispatcher, private readonly clock: () => number, // passed in, so tests can control time private readonly listeners: OrderListener[], ) {}
placeOrder(customer: string, restaurant: string, pickup: Location): Order { const o = new Order(`O${this.nextId++}`, customer, restaurant, pickup); this.orders.set(o.id, o); this.timelines.set(o.id, []); this.record(o, null, 'placed'); return o; }
accept(restaurant: string, orderId: string): Result { return this.handle(orderId, 'accept', restaurant); }
reject(restaurant: string, orderId: string): Result { return this.handle(orderId, 'reject', restaurant); }
cancel(customer: string, orderId: string): Result { return this.handle(orderId, 'cancel', customer); }
markReady(restaurant: string, orderId: string): Result { return this.handle(orderId, 'mark-ready', restaurant); }
pickUp(partner: string, orderId: string): Result { return this.handle(orderId, 'pick-up', partner); }
deliver(partner: string, orderId: string): Result { return this.handle(orderId, 'deliver', partner); }
get(id: string): Order | undefined { return this.orders.get(id); }
// Every state the order has been in, and when: what support looks at when food is late. timeline(orderId: string): OrderEvent[] { return [...this.timelines.get(orderId)!]; }
// First the checks: does the order exist, is this event allowed now, and is the sender the // right person? Then the effects: record the change, and start or end a delivery. handle(orderId: string, event: OrderEventName, sender: string): Result { const o = this.orders.get(orderId); if (!o) return 'not-found'; const t = findTransition(o.state, event); if (!t) return 'not-allowed'; if (sender !== o.party(t.actor)) return 'not-yours'; o.state = t.to; this.record(o, t.from, t.to); if (t.to === 'accepted') this.dispatcher.assign(o); if (t.to === 'delivered') this.dispatcher.release(o.partner!); return 'ok'; }
// The platform doesn't know who is listening or how they're told: push, SMS, a tablet. private record(o: Order, from: State | null, to: State): void { const e = { orderId: o.id, from, to, at: this.clock() }; this.timelines.get(o.id)!.push(e); for (const l of this.listeners) l.onEvent(e); }}Go deeperA timeline, and what each listener does with it1 min
After the order from stage 3 is delivered, its timeline might read like this:
| Time | From | To |
|---|---|---|
| 12:00 | PLACED |
ACCEPTED |
| 12:18 | ACCEPTED |
READY |
| 12:21 | READY |
PICKED_UP |
| 12:39 | PICKED_UP |
DELIVERED |
Ana’s refused cancel at 12:02 isn’t there, because it changed nothing.
Each listener gets the same events and picks what it cares about. The customer’s push listener
turns “entered PICKED_UP” into “Pat is on the way”. The restaurant’s tablet listener only cares
about new orders and cancels. Analytics stores everything, so it can work out how long kitchens take
to cook. Support reads the timeline, and can see at a glance that the food sat ready for three
minutes before pickup.
This is enough for a mid-level answer. Explicit states with checks on each change, a partner assigned with a fallback when none is free, and listeners for every change. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Apps call SharedPlatform. Platform runs every change through the
Lifecycle table, starts dispatch on the states that need it, and records each change as an event.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.function.*;
// One order and the three people tied to it. Its state changes only through Platform.handle,// so every change passes the same checks.class Order { final String id; final String customer; final String restaurant; final Location pickup; // where the restaurant is String partner; // null until a partner is assigned State state = State.PLACED;
Order(String id, String customer, String restaurant, Location pickup) { this.id = id; this.customer = customer; this.restaurant = restaurant; this.pickup = pickup; }
// The one person of that kind who may act on this order. String party(Actor actor) { return switch (actor) { case CUSTOMER -> customer; case RESTAURANT -> restaurant; case PARTNER -> partner; }; }}
// The front door for every action on an order. It checks the rules, moves the order, starts or// ends the delivery, and tells the listeners.class Platform { private final Map<String, Order> orders = new HashMap<>(); private final Map<String, List<OrderEvent>> timelines = new HashMap<>(); private final Dispatcher dispatcher; private final List<OrderListener> listeners; private final LongSupplier clock; // passed in, so tests can control time private int nextId = 1;
Platform(Dispatcher dispatcher, LongSupplier clock, List<OrderListener> listeners) { this.dispatcher = dispatcher; this.clock = clock; this.listeners = listeners; }
Order placeOrder(String customer, String restaurant, Location pickup) { Order o = new Order("O" + nextId++, customer, restaurant, pickup); orders.put(o.id, o); timelines.put(o.id, new ArrayList<>()); record(o, null, State.PLACED); return o; }
Result accept(String restaurant, String orderId) { return handle(orderId, Event.ACCEPT, restaurant); }
Result reject(String restaurant, String orderId) { return handle(orderId, Event.REJECT, restaurant); }
Result cancel(String customer, String orderId) { return handle(orderId, Event.CANCEL, customer); }
Result markReady(String restaurant, String orderId) { return handle(orderId, Event.MARK_READY, restaurant); }
Result pickUp(String partner, String orderId) { return handle(orderId, Event.PICK_UP, partner); }
Result deliver(String partner, String orderId) { return handle(orderId, Event.DELIVER, partner); }
Order get(String id) { return orders.get(id); }
// Every state the order has been in, and when: what support looks at when food is late. List<OrderEvent> timeline(String orderId) { return List.copyOf(timelines.get(orderId)); }
// First the checks: does the order exist, is this event allowed now, and is the sender the // right person? Then the effects: record the change, and start or end a delivery. Result handle(String orderId, Event event, String sender) { Order o = orders.get(orderId); if (o == null) return Result.NOT_FOUND; Transition t = Lifecycle.find(o.state, event); if (t == null) return Result.NOT_ALLOWED; if (!sender.equals(o.party(t.actor()))) return Result.NOT_YOURS; o.state = t.to(); record(o, t.from(), t.to()); if (t.to() == State.ACCEPTED) dispatcher.assign(o); if (t.to() == State.DELIVERED) dispatcher.release(o.partner); return Result.OK; }
// The platform doesn't know who is listening or how they're told: push, SMS, a tablet. private void record(Order o, State from, State to) { OrderEvent e = new OrderEvent(o.id, from, to, clock.getAsLong()); timelines.get(o.id).add(e); for (OrderListener l : listeners) l.onEvent(e); }}
// Every state an order can be in. DELIVERED, CANCELLED and REJECTED are final: no row of the// table leaves them.enum State { PLACED, ACCEPTED, READY, PICKED_UP, DELIVERED, CANCELLED, REJECTED }
// What someone asks the platform to do. Each event is one button in one of the three apps.enum Event { ACCEPT, REJECT, CANCEL, MARK_READY, PICK_UP, DELIVER }
// Who may cause an event. Each event belongs to exactly one kind of actor.enum Actor { CUSTOMER, RESTAURANT, PARTNER }
// What a request gets back. The app can show why it failed, not just that it failed.enum Result { OK, NOT_FOUND, NOT_YOURS, NOT_ALLOWED }
// One row of the state machine: in this state, this event, sent by this kind of actor, moves the// order to that state.record Transition(State from, Event event, Actor actor, State to) {}
final class Lifecycle { // The whole order lifecycle in one table. A pair missing from it is not allowed: a customer // can't cancel once the kitchen has started, and nothing leaves DELIVERED. static final List<Transition> TABLE = List.of( new Transition(State.PLACED, Event.ACCEPT, Actor.RESTAURANT, State.ACCEPTED), new Transition(State.PLACED, Event.REJECT, Actor.RESTAURANT, State.REJECTED), new Transition(State.PLACED, Event.CANCEL, Actor.CUSTOMER, State.CANCELLED), new Transition(State.ACCEPTED, Event.MARK_READY, Actor.RESTAURANT, State.READY), new Transition(State.READY, Event.PICK_UP, Actor.PARTNER, State.PICKED_UP), new Transition(State.PICKED_UP, Event.DELIVER, Actor.PARTNER, State.DELIVERED));
static Transition find(State from, Event event) { for (Transition t : TABLE) if (t.from() == from && t.event() == event) return t; return null; }}
// A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in// for road distance.record Location(int x, int y) { int distanceTo(Location o) { return Math.abs(x - o.x) + Math.abs(y - o.y); }}
// A delivery partner. A partner carries one order at a time, which keeps the design simple.// Batching orders would turn `current` into a list.class Partner { final String id; final Location location; Order current; // null while free
Partner(String id, Location location) { this.id = id; this.location = location; }}
// How a free partner is chosen for an order. Nearest is the start; a real app weighs ratings,// batching and how long each partner has been idle.interface AssignmentStrategy { Partner choose(Order order, List<Partner> free);}
// Picks the free partner closest to the restaurant, so cooked food waits the least. Returns// null when nobody is free, and the dispatcher then puts the order in line.class Nearest implements AssignmentStrategy { public Partner choose(Order order, List<Partner> free) { return free.stream().min(Comparator.comparingInt(p -> p.location.distanceTo(order.pickup))).orElse(null); }}
// Keeps the partners and the orders waiting for one. An order is offered a partner when the// restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it// waits in line, and the next partner to finish a delivery takes the oldest waiting order.class Dispatcher { private final List<Partner> partners = new ArrayList<>(); private final Deque<Order> waiting = new ArrayDeque<>(); private final AssignmentStrategy strategy;
Dispatcher(AssignmentStrategy strategy) { this.strategy = strategy; }
void addPartner(String id, Location at) { partners.add(new Partner(id, at)); }
// Called when the restaurant accepts the order. void assign(Order o) { List<Partner> free = partners.stream().filter(p -> p.current == null).toList(); Partner p = strategy.choose(o, free); if (p == null) { waiting.addLast(o); return; } p.current = o; o.partner = p.id; }
// Called when the partner delivers. The oldest waiting order goes first, so no order waits // forever behind newer ones. void release(String partnerId) { Partner p = partners.stream().filter(x -> x.id.equals(partnerId)).findFirst().orElseThrow(); p.current = null; Order next = waiting.pollFirst(); if (next != null) { p.current = next; next.partner = p.id; } }
int waitingCount() { return waiting.size(); }}
// Something that happened to an order, at a time. The customer's app, the restaurant's tablet// and the partner's phone all learn about orders from these.record OrderEvent(String orderId, State from, State to, long at) {}
// Anyone who wants to hear about order changes. A new channel, such as SMS, is a new listener;// the platform does not change.interface OrderListener { void onEvent(OrderEvent e);}
// A listener that only queues events. The platform tells it about each change while the lock// is held, which is quick; the events are delivered to the real listeners after the lock is// released.class Outbox implements OrderListener { private final List<OrderEvent> pending = new ArrayList<>();
public void onEvent(OrderEvent e) { pending.add(e); }
List<OrderEvent> drain() { List<OrderEvent> out = List.copyOf(pending); pending.clear(); return out; }}
// The customer, the restaurant and the partner act on the same order from different phones,// at the same moment. One lock makes each change to the platform one step: a cancel and an// accept racing for the same order can't both succeed, and two orders accepted at once can't// get the same partner.//// Listeners are the slow part, sending push messages over the network, and may call back into// the platform. So they run after the lock is released. Each request's events arrive in order;// events from two requests may interleave, as they would over any network anyway.class SharedPlatform { private final Platform platform; private final Outbox outbox; private final List<OrderListener> listeners; private final Object lock = new Object();
SharedPlatform(Dispatcher dispatcher, LongSupplier clock, List<OrderListener> listeners) { this.outbox = new Outbox(); this.platform = new Platform(dispatcher, clock, List.of(outbox)); this.listeners = listeners; }
Order placeOrder(String customer, String restaurant, Location pickup) { Order o; List<OrderEvent> events; synchronized (lock) { o = platform.placeOrder(customer, restaurant, pickup); events = outbox.drain(); } publish(events); return o; }
Result accept(String restaurant, String orderId) { return run(() -> platform.accept(restaurant, orderId)); }
Result reject(String restaurant, String orderId) { return run(() -> platform.reject(restaurant, orderId)); }
Result cancel(String customer, String orderId) { return run(() -> platform.cancel(customer, orderId)); }
Result markReady(String restaurant, String orderId) { return run(() -> platform.markReady(restaurant, orderId)); }
Result pickUp(String partner, String orderId) { return run(() -> platform.pickUp(partner, orderId)); }
Result deliver(String partner, String orderId) { return run(() -> platform.deliver(partner, orderId)); }
// Reads take the lock too, so they never see an order halfway through a change. State state(String orderId) { synchronized (lock) { return platform.get(orderId).state; } }
String partner(String orderId) { synchronized (lock) { return platform.get(orderId).partner; } }
// Make the change and collect its events under the lock. Send them after. private Result run(Supplier<Result> change) { Result r; List<OrderEvent> events; synchronized (lock) { r = change.get(); events = outbox.drain(); } publish(events); return r; }
// Runs without the lock, so a slow listener holds up only this request, not everyone. private void publish(List<OrderEvent> events) { for (OrderEvent e : events) for (OrderListener l : listeners) l.onEvent(e); }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom collections import dequefrom dataclasses import dataclassfrom enum import Enum, autofrom typing import Callable, Protocol, TypeVar
T = TypeVar("T")
# Every state an order can be in. DELIVERED, CANCELLED and REJECTED are final: no row of# the table leaves them.class State(Enum): PLACED = auto() ACCEPTED = auto() READY = auto() PICKED_UP = auto() DELIVERED = auto() CANCELLED = auto() REJECTED = auto()
# One order and the three people tied to it. Its state changes only through# Platform.handle, so every change passes the same checks.@dataclass(eq=False)class Order: id: str customer: str restaurant: str pickup: Location # where the restaurant is partner: str | None = None # None until a partner is assigned state: State = State.PLACED
# The one person of that kind who may act on this order. def party(self, actor: Actor) -> str | None: return {Actor.CUSTOMER: self.customer, Actor.RESTAURANT: self.restaurant, Actor.PARTNER: self.partner}[actor]
# The front door for every action on an order. It checks the rules, moves the order,# starts or ends the delivery, and tells the listeners.class Platform: def __init__(self, dispatcher: Dispatcher, clock: Callable[[], int], listeners: list[OrderListener]): self._orders: dict[str, Order] = {} self._timelines: dict[str, list[OrderEvent]] = {} self._dispatcher = dispatcher self._clock = clock # passed in, so tests can control time self._listeners = listeners self._next_id = 1
def place_order(self, customer: str, restaurant: str, pickup: Location) -> Order: o = Order(f"O{self._next_id}", customer, restaurant, pickup) self._next_id += 1 self._orders[o.id] = o self._timelines[o.id] = [] self._record(o, None, State.PLACED) return o
def accept(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.ACCEPT, restaurant)
def reject(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.REJECT, restaurant)
def cancel(self, customer: str, order_id: str) -> Result: return self.handle(order_id, Event.CANCEL, customer)
def mark_ready(self, restaurant: str, order_id: str) -> Result: return self.handle(order_id, Event.MARK_READY, restaurant)
def pick_up(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.PICK_UP, partner)
def deliver(self, partner: str, order_id: str) -> Result: return self.handle(order_id, Event.DELIVER, partner)
def get(self, id: str) -> Order | None: return self._orders.get(id)
# Every state the order has been in, and when: what support looks at when food is late. def timeline(self, order_id: str) -> list[OrderEvent]: return list(self._timelines[order_id])
# First the checks: does the order exist, is this event allowed now, and is the sender # the right person? Then the effects: record the change, and start or end a delivery. def handle(self, order_id: str, event: Event, sender: str) -> Result: o = self._orders.get(order_id) if o is None: return Result.NOT_FOUND t = LIFECYCLE.get((o.state, event)) if t is None: return Result.NOT_ALLOWED if sender != o.party(t.actor): return Result.NOT_YOURS o.state = t.to self._record(o, t.frm, t.to) if t.to is State.ACCEPTED: self._dispatcher.assign(o) if t.to is State.DELIVERED: self._dispatcher.release(o.partner) return Result.OK
# The platform doesn't know who is listening or how they're told: push, SMS, a tablet. def _record(self, o: Order, frm: State | None, to: State) -> None: e = OrderEvent(o.id, frm, to, self._clock()) self._timelines[o.id].append(e) for listener in self._listeners: listener.on_event(e)
# What someone asks the platform to do. Each event is one button in one of the three apps.class Event(Enum): ACCEPT = auto() REJECT = auto() CANCEL = auto() MARK_READY = auto() PICK_UP = auto() DELIVER = auto()
# Who may cause an event. Each event belongs to exactly one kind of actor.class Actor(Enum): CUSTOMER = auto() RESTAURANT = auto() PARTNER = auto()
# What a request gets back. The app can show why it failed, not just that it failed.class Result(Enum): OK = auto() NOT_FOUND = auto() NOT_YOURS = auto() NOT_ALLOWED = auto()
# One row of the state machine: in this state, this event, sent by this kind of actor,# moves the order to that state.@dataclass(frozen=True)class Transition: frm: State event: Event actor: Actor to: State
# The whole order lifecycle in one table. A pair missing from it is not allowed: a customer# can't cancel once the kitchen has started, and nothing leaves DELIVERED.LIFECYCLE = { (t.frm, t.event): t for t in [ Transition(State.PLACED, Event.ACCEPT, Actor.RESTAURANT, State.ACCEPTED), Transition(State.PLACED, Event.REJECT, Actor.RESTAURANT, State.REJECTED), Transition(State.PLACED, Event.CANCEL, Actor.CUSTOMER, State.CANCELLED), Transition(State.ACCEPTED, Event.MARK_READY, Actor.RESTAURANT, State.READY), Transition(State.READY, Event.PICK_UP, Actor.PARTNER, State.PICKED_UP), Transition(State.PICKED_UP, Event.DELIVER, Actor.PARTNER, State.DELIVERED), ]}
# A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in# for road distance.@dataclass(frozen=True)class Location: x: int y: int
def distance_to(self, o: Location) -> int: return abs(self.x - o.x) + abs(self.y - o.y)
# A delivery partner. A partner carries one order at a time, which keeps the design simple.# Batching orders would turn `current` into a list.@dataclass(eq=False)class Partner: id: str location: Location current: Order | None = None # None while free
# How a free partner is chosen for an order. Nearest is the start; a real app weighs# ratings, batching and how long each partner has been idle.class AssignmentStrategy(Protocol): def choose(self, order: Order, free: list[Partner]) -> Partner | None: ...
# Picks the free partner closest to the restaurant, so cooked food waits the least. Returns# None when nobody is free, and the dispatcher then puts the order in line.class Nearest: def choose(self, order: Order, free: list[Partner]) -> Partner | None: return min(free, key=lambda p: p.location.distance_to(order.pickup), default=None)
# Keeps the partners and the orders waiting for one. An order is offered a partner when the# restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it# waits in line, and the next partner to finish a delivery takes the oldest waiting order.class Dispatcher: def __init__(self, strategy: AssignmentStrategy): self._partners: list[Partner] = [] self._waiting: deque[Order] = deque() self._strategy = strategy
def add_partner(self, id: str, at: Location) -> None: self._partners.append(Partner(id, at))
# Called when the restaurant accepts the order. def assign(self, o: Order) -> None: p = self._strategy.choose(o, [p for p in self._partners if p.current is None]) if p is None: self._waiting.append(o) return p.current = o o.partner = p.id
# Called when the partner delivers. The oldest waiting order goes first, so no order # waits forever behind newer ones. def release(self, partner_id: str) -> None: p = next(x for x in self._partners if x.id == partner_id) p.current = None if self._waiting: nxt = self._waiting.popleft() p.current = nxt nxt.partner = p.id
@property def waiting_count(self) -> int: return len(self._waiting)
# Something that happened to an order, at a time. The customer's app, the restaurant's# tablet and the partner's phone all learn about orders from these.@dataclass(frozen=True)class OrderEvent: order_id: str frm: State | None to: State at: int
# Anyone who wants to hear about order changes. A new channel, such as SMS, is a new# listener; the platform does not change.class OrderListener(Protocol): def on_event(self, e: OrderEvent) -> None: ...
# A listener that only queues events. The platform tells it about each change while the# lock is held, which is quick; the events are delivered to the real listeners after the lock# is released.class Outbox: def __init__(self): self._pending: list[OrderEvent] = []
def on_event(self, e: OrderEvent) -> None: self._pending.append(e)
def drain(self) -> list[OrderEvent]: out, self._pending = self._pending, [] return out
# The customer, the restaurant and the partner act on the same order from different phones,# at the same moment. One lock makes each change to the platform one step: a cancel and an# accept racing for the same order can't both succeed, and two orders accepted at once can't# get the same partner.## Listeners are the slow part, sending push messages over the network, and may call back into# the platform. So they run after the lock is released. Each request's events arrive in order;# events from two requests may interleave, as they would over any network anyway.class SharedPlatform: def __init__(self, dispatcher: Dispatcher, clock: Callable[[], int], listeners: list[OrderListener]): self._outbox = Outbox() self._platform = Platform(dispatcher, clock, [self._outbox]) self._listeners = listeners self._lock = threading.Lock()
def place_order(self, customer: str, restaurant: str, pickup: Location) -> Order: return self._run(lambda: self._platform.place_order(customer, restaurant, pickup))
def accept(self, restaurant: str, order_id: str) -> Result: return self._run(lambda: self._platform.accept(restaurant, order_id))
def reject(self, restaurant: str, order_id: str) -> Result: return self._run(lambda: self._platform.reject(restaurant, order_id))
def cancel(self, customer: str, order_id: str) -> Result: return self._run(lambda: self._platform.cancel(customer, order_id))
def mark_ready(self, restaurant: str, order_id: str) -> Result: return self._run(lambda: self._platform.mark_ready(restaurant, order_id))
def pick_up(self, partner: str, order_id: str) -> Result: return self._run(lambda: self._platform.pick_up(partner, order_id))
def deliver(self, partner: str, order_id: str) -> Result: return self._run(lambda: self._platform.deliver(partner, order_id))
# Reads take the lock too, so they never see an order halfway through a change. def state(self, order_id: str) -> State: with self._lock: return self._platform.get(order_id).state
def partner(self, order_id: str) -> str | None: with self._lock: return self._platform.get(order_id).partner
# Make the change and collect its events under the lock. Send them after, without the # lock, so a slow listener holds up only this request, not everyone. def _run(self, change: Callable[[], T]) -> T: with self._lock: result = change() events = self._outbox.drain() for e in events: for listener in self._listeners: listener.on_event(e) return result// The complete design, built from every stage on this page.
// One order and the three people tied to it. Its state changes only through Platform.Handle,// so every change passes the same checks.public sealed class Order{ public Order(string id, string customer, string restaurant, Location pickup) => (Id, Customer, Restaurant, Pickup) = (id, customer, restaurant, pickup);
public string Id { get; } public string Customer { get; } public string Restaurant { get; } public Location Pickup { get; } // where the restaurant is public string? Partner { get; set; } // null until a partner is assigned public State State { get; set; } = State.Placed;
// The one person of that kind who may act on this order. public string? Party(Actor actor) => actor switch { Actor.Customer => Customer, Actor.Restaurant => Restaurant, _ => Partner, };}
// The front door for every action on an order. It checks the rules, moves the order, starts or// ends the delivery, and tells the listeners.public sealed class Platform{ private readonly Dictionary<string, Order> _orders = new(); private readonly Dictionary<string, List<OrderEvent>> _timelines = new(); private readonly Dispatcher _dispatcher; private readonly Func<long> _clock; // passed in, so tests can control time private readonly IReadOnlyList<IOrderListener> _listeners; private int _nextId = 1;
public Platform(Dispatcher dispatcher, Func<long> clock, IReadOnlyList<IOrderListener> listeners) => (_dispatcher, _clock, _listeners) = (dispatcher, clock, listeners);
public Order PlaceOrder(string customer, string restaurant, Location pickup) { var o = new Order($"O{_nextId++}", customer, restaurant, pickup); _orders[o.Id] = o; _timelines[o.Id] = new List<OrderEvent>(); Record(o, null, State.Placed); return o; }
public Result Accept(string restaurant, string orderId) => Handle(orderId, Event.Accept, restaurant); public Result Reject(string restaurant, string orderId) => Handle(orderId, Event.Reject, restaurant); public Result Cancel(string customer, string orderId) => Handle(orderId, Event.Cancel, customer); public Result MarkReady(string restaurant, string orderId) => Handle(orderId, Event.MarkReady, restaurant); public Result PickUp(string partner, string orderId) => Handle(orderId, Event.PickUp, partner); public Result Deliver(string partner, string orderId) => Handle(orderId, Event.Deliver, partner); public Order? Get(string id) => _orders.GetValueOrDefault(id);
// Every state the order has been in, and when: what support looks at when food is late. public IReadOnlyList<OrderEvent> Timeline(string orderId) => _timelines[orderId].ToList();
// First the checks: does the order exist, is this event allowed now, and is the sender the // right person? Then the effects: record the change, and start or end a delivery. public Result Handle(string orderId, Event e, string sender) { if (!_orders.TryGetValue(orderId, out var o)) return Result.NotFound; var t = Lifecycle.Find(o.State, e); if (t is null) return Result.NotAllowed; if (sender != o.Party(t.Actor)) return Result.NotYours; o.State = t.To; Record(o, t.From, t.To); if (t.To == State.Accepted) _dispatcher.Assign(o); if (t.To == State.Delivered) _dispatcher.Release(o.Partner!); return Result.Ok; }
// The platform doesn't know who is listening or how they're told: push, SMS, a tablet. private void Record(Order o, State? from, State to) { var e = new OrderEvent(o.Id, from, to, _clock()); _timelines[o.Id].Add(e); foreach (var l in _listeners) l.OnEvent(e); }}
// Every state an order can be in. Delivered, Cancelled and Rejected are final: no row of the// table leaves them.public enum State { Placed, Accepted, Ready, PickedUp, Delivered, Cancelled, Rejected }
// What someone asks the platform to do. Each event is one button in one of the three apps.public enum Event { Accept, Reject, Cancel, MarkReady, PickUp, Deliver }
// Who may cause an event. Each event belongs to exactly one kind of actor.public enum Actor { Customer, Restaurant, Partner }
// What a request gets back. The app can show why it failed, not just that it failed.public enum Result { Ok, NotFound, NotYours, NotAllowed }
// One row of the state machine: in this state, this event, sent by this kind of actor, moves// the order to that state.public sealed record Transition(State From, Event Event, Actor Actor, State To);
public static class Lifecycle{ // The whole order lifecycle in one table. A pair missing from it is not allowed: a customer // can't cancel once the kitchen has started, and nothing leaves Delivered. private static readonly Dictionary<(State, Event), Transition> Table = new Transition[] { new(State.Placed, Event.Accept, Actor.Restaurant, State.Accepted), new(State.Placed, Event.Reject, Actor.Restaurant, State.Rejected), new(State.Placed, Event.Cancel, Actor.Customer, State.Cancelled), new(State.Accepted, Event.MarkReady, Actor.Restaurant, State.Ready), new(State.Ready, Event.PickUp, Actor.Partner, State.PickedUp), new(State.PickedUp, Event.Deliver, Actor.Partner, State.Delivered), }.ToDictionary(t => (t.From, t.Event));
public static Transition? Find(State from, Event e) => Table.GetValueOrDefault((from, e));}
// A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in// for road distance.public readonly record struct Location(int X, int Y){ public int DistanceTo(Location o) => Math.Abs(X - o.X) + Math.Abs(Y - o.Y);}
// A delivery partner. A partner carries one order at a time, which keeps the design simple.// Batching orders would turn Current into a list.public sealed class Partner{ public Partner(string id, Location location) => (Id, Location) = (id, location);
public string Id { get; } public Location Location { get; } public Order? Current { get; set; } // null while free}
// How a free partner is chosen for an order. Nearest is the start; a real app weighs ratings,// batching and how long each partner has been idle.public interface IAssignmentStrategy{ Partner? Choose(Order order, IReadOnlyList<Partner> free);}
// Picks the free partner closest to the restaurant, so cooked food waits the least. Returns// null when nobody is free, and the dispatcher then puts the order in line.public sealed class Nearest : IAssignmentStrategy{ public Partner? Choose(Order order, IReadOnlyList<Partner> free) => free.MinBy(p => p.Location.DistanceTo(order.Pickup));}
// Keeps the partners and the orders waiting for one. An order is offered a partner when the// restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it// waits in line, and the next partner to finish a delivery takes the oldest waiting order.public sealed class Dispatcher{ private readonly List<Partner> _partners = new(); private readonly Queue<Order> _waiting = new(); private readonly IAssignmentStrategy _strategy;
public Dispatcher(IAssignmentStrategy strategy) => _strategy = strategy;
public int WaitingCount => _waiting.Count;
public void AddPartner(string id, Location at) => _partners.Add(new Partner(id, at));
// Called when the restaurant accepts the order. public void Assign(Order o) { var p = _strategy.Choose(o, _partners.Where(p => p.Current is null).ToList()); if (p is null) { _waiting.Enqueue(o); return; } p.Current = o; o.Partner = p.Id; }
// Called when the partner delivers. The oldest waiting order goes first, so no order waits // forever behind newer ones. public void Release(string partnerId) { var p = _partners.First(x => x.Id == partnerId); p.Current = null; if (_waiting.TryDequeue(out var next)) { p.Current = next; next.Partner = p.Id; } }}
// Something that happened to an order, at a time. The customer's app, the restaurant's tablet// and the partner's phone all learn about orders from these.public sealed record OrderEvent(string OrderId, State? From, State To, long At);
// Anyone who wants to hear about order changes. A new channel, such as SMS, is a new listener;// the platform does not change.public interface IOrderListener{ void OnEvent(OrderEvent e);}
// A listener that only queues events. The platform tells it about each change while the lock is// held, which is quick; the events are delivered to the real listeners after the lock is// released.public sealed class Outbox : IOrderListener{ private readonly List<OrderEvent> _pending = new();
public void OnEvent(OrderEvent e) => _pending.Add(e);
public List<OrderEvent> Drain() { var output = _pending.ToList(); _pending.Clear(); return output; }}
// The customer, the restaurant and the partner act on the same order from different phones, at// the same moment. One lock makes each change to the platform one step: a cancel and an accept// racing for the same order can't both succeed, and two orders accepted at once can't get the// same partner.//// Listeners are the slow part, sending push messages over the network, and may call back into// the platform. So they run after the lock is released. Each request's events arrive in order;// events from two requests may interleave, as they would over any network anyway.public sealed class SharedPlatform{ private readonly object _lock = new(); private readonly Outbox _outbox = new(); private readonly Platform _platform; private readonly IReadOnlyList<IOrderListener> _listeners;
public SharedPlatform(Dispatcher dispatcher, Func<long> clock, IReadOnlyList<IOrderListener> listeners) { _platform = new Platform(dispatcher, clock, new IOrderListener[] { _outbox }); _listeners = listeners; }
public Order PlaceOrder(string customer, string restaurant, Location pickup) => Run(() => _platform.PlaceOrder(customer, restaurant, pickup));
public Result Handle(string orderId, Event e, string sender) => Run(() => _platform.Handle(orderId, e, sender));
// Reads take the lock too, so they never see an order halfway through a change. public State StateOf(string orderId) { lock (_lock) return _platform.Get(orderId)!.State; }
public string? PartnerOf(string orderId) { lock (_lock) return _platform.Get(orderId)!.Partner; }
// Make the change and collect its events under the lock. Send them after, without the lock, // so a slow listener holds up only this request, not everyone. private T Run<T>(Func<T> change) { T result; List<OrderEvent> events; lock (_lock) { result = change(); events = _outbox.Drain(); } foreach (var e in events) foreach (var l in _listeners) l.OnEvent(e); return result; }}// The complete design, built from every stage on this page.
package fooddelivery
import ( "fmt" "slices" "sync")
// State is every state an order can be in. Delivered, Cancelled and Rejected are final: no row// of the table leaves them.type State int
const ( Placed State = iota Accepted Ready PickedUp Delivered Cancelled Rejected)
func (s State) String() string { return [...]string{"placed", "accepted", "ready", "picked up", "delivered", "cancelled", "rejected"}[s]}
// Event is what someone asks the platform to do. Each event is one button in one of the three// apps.type Event int
const ( Accept Event = iota Reject Cancel MarkReady PickUp Deliver)
// Actor is who may cause an event. Each event belongs to exactly one kind of actor.type Actor int
const ( Customer Actor = iota Restaurant Courier)
// Result is what a request gets back. The app can show why it failed, not just that it failed.type Result int
const ( OK Result = iota NotFound NotYours NotAllowed)
// Transition is one row of the state machine: in this state, this event, sent by this kind of// actor, moves the order to that state.type Transition struct { From State Event Event Actor Actor To State}
type step struct { from State event Event}
// lifecycle is the whole order lifecycle in one table. A pair missing from it is not allowed: a// customer can't cancel once the kitchen has started, and nothing leaves Delivered.var lifecycle = map[step]Transition{}
// Location is a point on a simple grid. Distance counts blocks across plus blocks up, a cheap// stand-in for road distance.type Location struct{ X, Y int }
func (l Location) DistanceTo(o Location) int { return abs(l.X-o.X) + abs(l.Y-o.Y) }
func abs(x int) int { return max(x, -x) }
// Partner is a delivery partner. A partner carries one order at a time, which keeps the design// simple. Batching orders would turn Current into a slice.type Partner struct { ID string Location Location Current *Order // nil while free}
// AssignmentStrategy chooses a free partner for an order. Nearest is the start; a real app// weighs ratings, batching and how long each partner has been idle.type AssignmentStrategy interface { Choose(o *Order, free []*Partner) *Partner}
// Nearest picks the free partner closest to the restaurant, so cooked food waits the least. It// returns nil when nobody is free, and the dispatcher then puts the order in line.type Nearest struct{}
func (Nearest) Choose(o *Order, free []*Partner) *Partner { var best *Partner for _, p := range free { if best == nil || p.Location.DistanceTo(o.Pickup) < best.Location.DistanceTo(o.Pickup) { best = p } } return best}
// Dispatcher keeps the partners and the orders waiting for one. An order is offered a partner// when the restaurant accepts it, so a partner is on the way while the food cooks; if nobody is// free it waits in line, and the next partner to finish a delivery takes the oldest waiting// order.type Dispatcher struct { partners []*Partner waiting []*Order strategy AssignmentStrategy}
func NewDispatcher(s AssignmentStrategy) *Dispatcher { return &Dispatcher{strategy: s} }
func (d *Dispatcher) AddPartner(id string, at Location) { d.partners = append(d.partners, &Partner{ID: id, Location: at})}
// Assign is called when the restaurant accepts the order.func (d *Dispatcher) Assign(o *Order) { var free []*Partner for _, p := range d.partners { if p.Current == nil { free = append(free, p) } } p := d.strategy.Choose(o, free) if p == nil { d.waiting = append(d.waiting, o) return } p.Current = o o.Partner = p.ID}
// Release is called when the partner delivers. The oldest waiting order goes first, so no order// waits forever behind newer ones.func (d *Dispatcher) Release(partnerID string) { for _, p := range d.partners { if p.ID != partnerID { continue } p.Current = nil if len(d.waiting) > 0 { next := d.waiting[0] d.waiting = d.waiting[1:] p.Current = next next.Partner = p.ID } }}
func (d *Dispatcher) WaitingCount() int { return len(d.waiting) }
// Order is one order and the three people tied to it. Its state changes only through// Platform.Handle, so every change passes the same checks.type Order struct { ID, Customer, Restaurant string Pickup Location // where the restaurant is Partner string // empty until a partner is assigned State State}
// Party is the one person of that kind who may act on this order.func (o *Order) Party(a Actor) string { switch a { case Customer: return o.Customer case Restaurant: return o.Restaurant default: return o.Partner }}
// OrderEvent is something that happened to an order, at a time. The customer's app, the// restaurant's tablet and the partner's phone all learn about orders from these. From is -1 for// the first event.type OrderEvent struct { OrderID string From, To State At int64}
// OrderListener is anyone who wants to hear about order changes. A new channel, such as SMS, is// a new listener; the platform does not change.type OrderListener interface { OnEvent(e OrderEvent)}
// Platform is the front door for every action on an order. It checks the rules, moves the// order, starts or ends the delivery, and tells the listeners.type Platform struct { orders map[string]*Order timelines map[string][]OrderEvent dispatcher *Dispatcher clock func() int64 // passed in, so tests can control time listeners []OrderListener nextID int}
func NewPlatform(d *Dispatcher, clock func() int64, listeners ...OrderListener) *Platform { return &Platform{ orders: map[string]*Order{}, timelines: map[string][]OrderEvent{}, dispatcher: d, clock: clock, listeners: listeners, nextID: 1, }}
func (p *Platform) PlaceOrder(customer, restaurant string, pickup Location) *Order { o := &Order{ID: fmt.Sprintf("O%d", p.nextID), Customer: customer, Restaurant: restaurant, Pickup: pickup} p.nextID++ p.orders[o.ID] = o p.record(o, -1, Placed) return o}
func (p *Platform) Accept(restaurant, orderID string) Result { return p.Handle(orderID, Accept, restaurant)}
func (p *Platform) Reject(restaurant, orderID string) Result { return p.Handle(orderID, Reject, restaurant)}
func (p *Platform) Cancel(customer, orderID string) Result { return p.Handle(orderID, Cancel, customer)}
func (p *Platform) MarkReady(restaurant, orderID string) Result { return p.Handle(orderID, MarkReady, restaurant)}
func (p *Platform) PickUp(partner, orderID string) Result { return p.Handle(orderID, PickUp, partner) }
func (p *Platform) Deliver(partner, orderID string) Result { return p.Handle(orderID, Deliver, partner)}
func (p *Platform) Get(id string) *Order { return p.orders[id] }
// Timeline is every state the order has been in, and when: what support looks at when food is// late.func (p *Platform) Timeline(orderID string) []OrderEvent { return slices.Clone(p.timelines[orderID]) }
// Handle runs the checks first: does the order exist, is this event allowed now, and is the// sender the right person? Then the effects: record the change, and start or end a delivery.func (p *Platform) Handle(orderID string, e Event, sender string) Result { o, ok := p.orders[orderID] if !ok { return NotFound } t, ok := lifecycle[step{o.State, e}] if !ok { return NotAllowed } if sender != o.Party(t.Actor) { return NotYours } o.State = t.To p.record(o, t.From, t.To) if t.To == Accepted { p.dispatcher.Assign(o) } if t.To == Delivered { p.dispatcher.Release(o.Partner) } return OK}
// record doesn't know who is listening or how they're told: push, SMS, a tablet.func (p *Platform) record(o *Order, from, to State) { e := OrderEvent{o.ID, from, to, p.clock()} p.timelines[o.ID] = append(p.timelines[o.ID], e) for _, l := range p.listeners { l.OnEvent(e) }}
// Outbox is a listener that only queues events. The platform tells it about each change while// the mutex is held, which is quick; the events are delivered to the real listeners after the// mutex is released.type Outbox struct{ pending []OrderEvent }
func (b *Outbox) OnEvent(e OrderEvent) { b.pending = append(b.pending, e) }
func (b *Outbox) Drain() []OrderEvent { out := b.pending b.pending = nil return out}
// SharedPlatform is used by the customer, the restaurant and the partner on different phones,// at the same moment. One mutex makes each change to the platform one step: a cancel and an// accept racing for the same order can't both succeed, and two orders accepted at once can't get// the same partner.//// Listeners are the slow part, sending push messages over the network, and may call back into// the platform. So they run after the mutex is released; a Go mutex isn't reentrant, so a// listener called under it would deadlock. Each request's events arrive in order; events from// two requests may interleave, as they would over any network anyway.type SharedPlatform struct { mu sync.Mutex platform *Platform outbox *Outbox listeners []OrderListener}
func NewSharedPlatform(d *Dispatcher, clock func() int64, listeners ...OrderListener) *SharedPlatform { outbox := &Outbox{} return &SharedPlatform{platform: NewPlatform(d, clock, outbox), outbox: outbox, listeners: listeners}}
func (s *SharedPlatform) PlaceOrder(customer, restaurant string, pickup Location) *Order { s.mu.Lock() o := s.platform.PlaceOrder(customer, restaurant, pickup) events := s.outbox.Drain() s.mu.Unlock() s.publish(events) return o}
// Handle makes the change and collects its events under the lock, then sends them after.func (s *SharedPlatform) Handle(orderID string, e Event, sender string) Result { s.mu.Lock() r := s.platform.Handle(orderID, e, sender) events := s.outbox.Drain() s.mu.Unlock() s.publish(events) return r}
// State takes the lock too, so a read never sees an order halfway through a change.func (s *SharedPlatform) State(orderID string) State { s.mu.Lock() defer s.mu.Unlock() return s.platform.Get(orderID).State}
func (s *SharedPlatform) Partner(orderID string) string { s.mu.Lock() defer s.mu.Unlock() return s.platform.Get(orderID).Partner}
// publish runs without the lock, so a slow listener holds up only this request, not everyone.func (s *SharedPlatform) publish(events []OrderEvent) { for _, e := range events { for _, l := range s.listeners { l.OnEvent(e) } }}// The complete design, built from every stage on this page.
// One order and the three people tied to it. Its state changes only through Platform.handle, so// every change passes the same checks.class Order { partner: string | null = null; // null until a partner is assigned state: State = 'placed';
constructor( readonly id: string, readonly customer: string, readonly restaurant: string, readonly pickup: Location, // where the restaurant is ) {}
// The one person of that kind who may act on this order. party(actor: Actor): string | null { return { customer: this.customer, restaurant: this.restaurant, partner: this.partner }[actor]; }}
// The front door for every action on an order. It checks the rules, moves the order, starts or// ends the delivery, and tells the listeners.class Platform { private readonly orders = new Map<string, Order>(); private readonly timelines = new Map<string, OrderEvent[]>(); private nextId = 1;
constructor( private readonly dispatcher: Dispatcher, private readonly clock: () => number, // passed in, so tests can control time private readonly listeners: OrderListener[], ) {}
placeOrder(customer: string, restaurant: string, pickup: Location): Order { const o = new Order(`O${this.nextId++}`, customer, restaurant, pickup); this.orders.set(o.id, o); this.timelines.set(o.id, []); this.record(o, null, 'placed'); return o; }
accept(restaurant: string, orderId: string): Result { return this.handle(orderId, 'accept', restaurant); }
reject(restaurant: string, orderId: string): Result { return this.handle(orderId, 'reject', restaurant); }
cancel(customer: string, orderId: string): Result { return this.handle(orderId, 'cancel', customer); }
markReady(restaurant: string, orderId: string): Result { return this.handle(orderId, 'mark-ready', restaurant); }
pickUp(partner: string, orderId: string): Result { return this.handle(orderId, 'pick-up', partner); }
deliver(partner: string, orderId: string): Result { return this.handle(orderId, 'deliver', partner); }
get(id: string): Order | undefined { return this.orders.get(id); }
// Every state the order has been in, and when: what support looks at when food is late. timeline(orderId: string): OrderEvent[] { return [...this.timelines.get(orderId)!]; }
// First the checks: does the order exist, is this event allowed now, and is the sender the // right person? Then the effects: record the change, and start or end a delivery. handle(orderId: string, event: OrderEventName, sender: string): Result { const o = this.orders.get(orderId); if (!o) return 'not-found'; const t = findTransition(o.state, event); if (!t) return 'not-allowed'; if (sender !== o.party(t.actor)) return 'not-yours'; o.state = t.to; this.record(o, t.from, t.to); if (t.to === 'accepted') this.dispatcher.assign(o); if (t.to === 'delivered') this.dispatcher.release(o.partner!); return 'ok'; }
// The platform doesn't know who is listening or how they're told: push, SMS, a tablet. private record(o: Order, from: State | null, to: State): void { const e = { orderId: o.id, from, to, at: this.clock() }; this.timelines.get(o.id)!.push(e); for (const l of this.listeners) l.onEvent(e); }}
// Every state an order can be in. Delivered, cancelled and rejected are final: no row of the// table leaves them.type State = 'placed' | 'accepted' | 'ready' | 'picked-up' | 'delivered' | 'cancelled' | 'rejected';
// What someone asks the platform to do. Each event is one button in one of the three apps.type OrderEventName = 'accept' | 'reject' | 'cancel' | 'mark-ready' | 'pick-up' | 'deliver';
// Who may cause an event. Each event belongs to exactly one kind of actor.type Actor = 'customer' | 'restaurant' | 'partner';
// What a request gets back. The app can show why it failed, not just that it failed.type Result = 'ok' | 'not-found' | 'not-yours' | 'not-allowed';
// One row of the state machine: in this state, this event, sent by this kind of actor, moves the// order to that state.interface Transition { readonly from: State; readonly event: OrderEventName; readonly actor: Actor; readonly to: State;}
// The whole order lifecycle in one table. A pair missing from it is not allowed: a customer can't// cancel once the kitchen has started, and nothing leaves delivered.const LIFECYCLE: Transition[] = [ { from: 'placed', event: 'accept', actor: 'restaurant', to: 'accepted' }, { from: 'placed', event: 'reject', actor: 'restaurant', to: 'rejected' }, { from: 'placed', event: 'cancel', actor: 'customer', to: 'cancelled' }, { from: 'accepted', event: 'mark-ready', actor: 'restaurant', to: 'ready' }, { from: 'ready', event: 'pick-up', actor: 'partner', to: 'picked-up' }, { from: 'picked-up', event: 'deliver', actor: 'partner', to: 'delivered' },];
const findTransition = (from: State, event: OrderEventName) => LIFECYCLE.find((t) => t.from === from && t.event === event);
// A point on a simple grid. Distance counts blocks across plus blocks up, a cheap stand-in for// road distance.class Location { constructor( readonly x: number, readonly y: number, ) {}
distanceTo(o: Location): number { return Math.abs(this.x - o.x) + Math.abs(this.y - o.y); }}
// A delivery partner. A partner carries one order at a time, which keeps the design simple.// Batching orders would turn `current` into a list.class Partner { current: Order | null = null; // null while free
constructor( readonly id: string, readonly location: Location, ) {}}
// How a free partner is chosen for an order. Nearest is the start; a real app weighs ratings,// batching and how long each partner has been idle.interface AssignmentStrategy { choose(order: Order, free: Partner[]): Partner | null;}
// Picks the free partner closest to the restaurant, so cooked food waits the least. Returns null// when nobody is free, and the dispatcher then puts the order in line.class Nearest implements AssignmentStrategy { choose(order: Order, free: Partner[]): Partner | null { let best: Partner | null = null; for (const p of free) { if (!best || p.location.distanceTo(order.pickup) < best.location.distanceTo(order.pickup)) best = p; } return best; }}
// Keeps the partners and the orders waiting for one. An order is offered a partner when the// restaurant accepts it, so a partner is on the way while the food cooks; if nobody is free it// waits in line, and the next partner to finish a delivery takes the oldest waiting order.class Dispatcher { private readonly partners: Partner[] = []; private readonly waiting: Order[] = [];
constructor(private readonly strategy: AssignmentStrategy) {}
addPartner(id: string, at: Location): void { this.partners.push(new Partner(id, at)); }
// Called when the restaurant accepts the order. assign(o: Order): void { const p = this.strategy.choose( o, this.partners.filter((p) => p.current === null), ); if (!p) { this.waiting.push(o); return; } p.current = o; o.partner = p.id; }
// Called when the partner delivers. The oldest waiting order goes first, so no order waits // forever behind newer ones. release(partnerId: string): void { const p = this.partners.find((x) => x.id === partnerId)!; p.current = null; const next = this.waiting.shift(); if (next) { p.current = next; next.partner = p.id; } }
get waitingCount(): number { return this.waiting.length; }}
// Something that happened to an order, at a time. The customer's app, the restaurant's tablet and// the partner's phone all learn about orders from these.interface OrderEvent { readonly orderId: string; readonly from: State | null; readonly to: State; readonly at: number;}
// Anyone who wants to hear about order changes. A new channel, such as SMS, is a new listener;// the platform does not change.interface OrderListener { onEvent(e: OrderEvent): void;}
// A listener that only queues events. The platform tells it about each change as it happens;// the events are delivered to the real listeners once the change is complete.class Outbox implements OrderListener { private pending: OrderEvent[] = [];
onEvent(e: OrderEvent): void { this.pending.push(e); }
drain(): OrderEvent[] { const out = this.pending; this.pending = []; return out; }}
// Sending a push message is asynchronous, so these listeners return a promise.interface AsyncOrderListener { onEvent(e: OrderEvent): Promise<void>;}
// The customer, the restaurant and the partner act on the same order from different phones, at// the same moment. JavaScript runs one callback at a time, and each change to the platform runs// with no await inside it, so a cancel and an accept for the same order can't interleave, and// two accepts can't both take the same partner. No lock is needed.//// Listeners send messages over the network, which awaits. They run after the change is// complete, so a listener that's slow, or that calls back into the platform, never sees or// leaves a half-made change.class SharedPlatform { private readonly outbox = new Outbox(); private readonly platform: Platform;
constructor( dispatcher: Dispatcher, clock: () => number, private readonly listeners: AsyncOrderListener[], ) { this.platform = new Platform(dispatcher, clock, [this.outbox]); }
async placeOrder(customer: string, restaurant: string, pickup: Location): Promise<Order> { return this.run(() => this.platform.placeOrder(customer, restaurant, pickup)); }
async handle(orderId: string, event: OrderEventName, sender: string): Promise<Result> { return this.run(() => this.platform.handle(orderId, event, sender)); }
state(orderId: string): State { return this.platform.get(orderId)!.state; }
partner(orderId: string): string | null { return this.platform.get(orderId)!.partner; }
// The change runs to the end before any listener is called. Listeners are awaited one at a // time, so each request's events arrive in order. private async run<T>(change: () => T): Promise<T> { const result = change(); for (const e of this.outbox.drain()) { for (const l of this.listeners) await l.onEvent(e); } return result; }}Three ideas hold it together:
- One path for every change. All changes go through
handleand the table (State), so the rules are in one place, and checked the same way for every actor. - Decide, then react. The state machine decides. Dispatch (Strategy) and notifications react to the new state, and never decide whether it was allowed.
- The platform knows no listeners. Push, SMS, tablets and analytics subscribe (Observer), so adding one changes nothing in the platform. Apps call one object, safely, from many threads (Facade).
Stress it
Section titled “Stress it”So far, one call happens at a time. Now customers, restaurants and partners all act at once.
A cancel and an accept at the same instant
Section titled “A cancel and an accept at the same instant”The customer taps cancel at the same instant the restaurant taps accept. And at lunchtime, forty orders are accepted in the same second, with ten partners free. What can go wrong?
Whether you find both races, the cancel against the accept on one order, and two orders claiming one partner, and fix both with the same step. Then whether you keep notifications out of the lock. Calling a push service while holding the platform’s lock is the red flag.
There are two races.
A cancel and an accept for the same order. Both read the state as PLACED, both find their row
in the table, and both write. The order ends up cancelled or accepted, depending on which write
lands last, and both callers were told they succeeded. The customer thinks they cancelled, and the
restaurant starts cooking.
Two orders, one partner. Two accepts run dispatch at once. Both see Pat as the nearest free partner, and both assign Pat.
Both are the same mistake: a check and a change that must be one step. The clearly correct fix is
one lock around each call into the platform, which makes each handle, with its dispatch, one
step. Work in memory like this takes microseconds, so one lock for a city’s orders is rarely the
bottleneck. At larger scale, the platform splits by city.
The better fix also sends notifications outside the lock. Listeners send push messages over the network, which can take hundreds of milliseconds. And a listener may call back into the platform: a restaurant’s auto-accept bot does exactly that. Calling listeners under the lock would make every order in the city wait on one slow phone. A callback would also deadlock (wait forever for a lock its own thread holds) wherever locks can’t be taken twice by the same thread, as in Go.
So inside the lock, the platform’s only listener is an Outbox that queues events. After the
lock is released, the queued events go to the real listeners.
- Color key
- Implementation
- Facade
| Option | How | Correct? | Cost |
|---|---|---|---|
| 1. One lock, listeners after it (our choice) | An outbox, queued under the lock | Yes | Every change in the city goes one at a time |
| 2. One lock, listeners inside it | Simplest | Yes | A slow phone stalls everyone; callbacks deadlock |
| 3. A lock per order, plus the dispatcher | Orders change in parallel | Yes | Two locks to take in a careful order on accept |
| 4. Compare-and-set on the state | Write only if the state is still what was read | Partly | Dispatch still needs its own protection |
| 5. An actor per order | Each order’s changes go through one queue | Yes | More machinery; the usual shape at real scale |
Go deeperThe cancel and the accept, under the lock1 min
Let’s replay the race with the lock.
- The restaurant’s request takes the lock first. The order is
PLACED. The row for accept exists, the sender is this order’s restaurant, so the state becomesACCEPTED. Dispatch assigns Pat. An event “placed to accepted” goes into the outbox. The lock is released. - The customer’s request has been waiting for the lock. Now it takes it. The order is
ACCEPTED. There’s no row for “accepted, cancel”, so the answer isNOT_ALLOWED. Nothing goes into the outbox. The lock is released. - The outbox sends “accepted” to the listeners. Ana’s phone shows “Pasta Place is cooking your order”, and her cancel button is refused with a clear reason.
If the customer had won the lock first, the order would be CANCELLED, and the restaurant’s accept
would get NOT_ALLOWED. Either way there’s exactly one winner, and both callers are told the truth.
// A listener that only queues events. The platform tells it about each change while the lock// is held, which is quick; the events are delivered to the real listeners after the lock is// released.class Outbox implements OrderListener { private final List<OrderEvent> pending = new ArrayList<>();
public void onEvent(OrderEvent e) { pending.add(e); }
List<OrderEvent> drain() { List<OrderEvent> out = List.copyOf(pending); pending.clear(); return out; }}
// The customer, the restaurant and the partner act on the same order from different phones,// at the same moment. One lock makes each change to the platform one step: a cancel and an// accept racing for the same order can't both succeed, and two orders accepted at once can't// get the same partner.//// Listeners are the slow part, sending push messages over the network, and may call back into// the platform. So they run after the lock is released. Each request's events arrive in order;// events from two requests may interleave, as they would over any network anyway.class SharedPlatform { private final Platform platform; private final Outbox outbox; private final List<OrderListener> listeners; private final Object lock = new Object();
SharedPlatform(Dispatcher dispatcher, LongSupplier clock, List<OrderListener> listeners) { this.outbox = new Outbox(); this.platform = new Platform(dispatcher, clock, List.of(outbox)); this.listeners = listeners; }
Order placeOrder(String customer, String restaurant, Location pickup) { Order o; List<OrderEvent> events; synchronized (lock) { o = platform.placeOrder(customer, restaurant, pickup); events = outbox.drain(); } publish(events); return o; }
Result accept(String restaurant, String orderId) { return run(() -> platform.accept(restaurant, orderId)); }
Result reject(String restaurant, String orderId) { return run(() -> platform.reject(restaurant, orderId)); }
Result cancel(String customer, String orderId) { return run(() -> platform.cancel(customer, orderId)); }
Result markReady(String restaurant, String orderId) { return run(() -> platform.markReady(restaurant, orderId)); }
Result pickUp(String partner, String orderId) { return run(() -> platform.pickUp(partner, orderId)); }
Result deliver(String partner, String orderId) { return run(() -> platform.deliver(partner, orderId)); }
// Reads take the lock too, so they never see an order halfway through a change. State state(String orderId) { synchronized (lock) { return platform.get(orderId).state; } }
String partner(String orderId) { synchronized (lock) { return platform.get(orderId).partner; } }
// Make the change and collect its events under the lock. Send them after. private Result run(Supplier<Result> change) { Result r; List<OrderEvent> events; synchronized (lock) { r = change.get(); events = outbox.drain(); } publish(events); return r; }
// Runs without the lock, so a slow listener holds up only this request, not everyone. private void publish(List<OrderEvent> events) { for (OrderEvent e : events) for (OrderListener l : listeners) l.onEvent(e); }}# A listener that only queues events. The platform tells it about each change while the# lock is held, which is quick; the events are delivered to the real listeners after the lock# is released.class Outbox: def __init__(self): self._pending: list[OrderEvent] = []
def on_event(self, e: OrderEvent) -> None: self._pending.append(e)
def drain(self) -> list[OrderEvent]: out, self._pending = self._pending, [] return out
# The customer, the restaurant and the partner act on the same order from different phones,# at the same moment. One lock makes each change to the platform one step: a cancel and an# accept racing for the same order can't both succeed, and two orders accepted at once can't# get the same partner.## Listeners are the slow part, sending push messages over the network, and may call back into# the platform. So they run after the lock is released. Each request's events arrive in order;# events from two requests may interleave, as they would over any network anyway.class SharedPlatform: def __init__(self, dispatcher: Dispatcher, clock: Callable[[], int], listeners: list[OrderListener]): self._outbox = Outbox() self._platform = Platform(dispatcher, clock, [self._outbox]) self._listeners = listeners self._lock = threading.Lock()
def place_order(self, customer: str, restaurant: str, pickup: Location) -> Order: return self._run(lambda: self._platform.place_order(customer, restaurant, pickup))
def accept(self, restaurant: str, order_id: str) -> Result: return self._run(lambda: self._platform.accept(restaurant, order_id))
def reject(self, restaurant: str, order_id: str) -> Result: return self._run(lambda: self._platform.reject(restaurant, order_id))
def cancel(self, customer: str, order_id: str) -> Result: return self._run(lambda: self._platform.cancel(customer, order_id))
def mark_ready(self, restaurant: str, order_id: str) -> Result: return self._run(lambda: self._platform.mark_ready(restaurant, order_id))
def pick_up(self, partner: str, order_id: str) -> Result: return self._run(lambda: self._platform.pick_up(partner, order_id))
def deliver(self, partner: str, order_id: str) -> Result: return self._run(lambda: self._platform.deliver(partner, order_id))
# Reads take the lock too, so they never see an order halfway through a change. def state(self, order_id: str) -> State: with self._lock: return self._platform.get(order_id).state
def partner(self, order_id: str) -> str | None: with self._lock: return self._platform.get(order_id).partner
# Make the change and collect its events under the lock. Send them after, without the # lock, so a slow listener holds up only this request, not everyone. def _run(self, change: Callable[[], T]) -> T: with self._lock: result = change() events = self._outbox.drain() for e in events: for listener in self._listeners: listener.on_event(e) return result// A listener that only queues events. The platform tells it about each change while the lock is// held, which is quick; the events are delivered to the real listeners after the lock is// released.public sealed class Outbox : IOrderListener{ private readonly List<OrderEvent> _pending = new();
public void OnEvent(OrderEvent e) => _pending.Add(e);
public List<OrderEvent> Drain() { var output = _pending.ToList(); _pending.Clear(); return output; }}
// The customer, the restaurant and the partner act on the same order from different phones, at// the same moment. One lock makes each change to the platform one step: a cancel and an accept// racing for the same order can't both succeed, and two orders accepted at once can't get the// same partner.//// Listeners are the slow part, sending push messages over the network, and may call back into// the platform. So they run after the lock is released. Each request's events arrive in order;// events from two requests may interleave, as they would over any network anyway.public sealed class SharedPlatform{ private readonly object _lock = new(); private readonly Outbox _outbox = new(); private readonly Platform _platform; private readonly IReadOnlyList<IOrderListener> _listeners;
public SharedPlatform(Dispatcher dispatcher, Func<long> clock, IReadOnlyList<IOrderListener> listeners) { _platform = new Platform(dispatcher, clock, new IOrderListener[] { _outbox }); _listeners = listeners; }
public Order PlaceOrder(string customer, string restaurant, Location pickup) => Run(() => _platform.PlaceOrder(customer, restaurant, pickup));
public Result Handle(string orderId, Event e, string sender) => Run(() => _platform.Handle(orderId, e, sender));
// Reads take the lock too, so they never see an order halfway through a change. public State StateOf(string orderId) { lock (_lock) return _platform.Get(orderId)!.State; }
public string? PartnerOf(string orderId) { lock (_lock) return _platform.Get(orderId)!.Partner; }
// Make the change and collect its events under the lock. Send them after, without the lock, // so a slow listener holds up only this request, not everyone. private T Run<T>(Func<T> change) { T result; List<OrderEvent> events; lock (_lock) { result = change(); events = _outbox.Drain(); } foreach (var e in events) foreach (var l in _listeners) l.OnEvent(e); return result; }}// Outbox is a listener that only queues events. The platform tells it about each change while// the mutex is held, which is quick; the events are delivered to the real listeners after the// mutex is released.type Outbox struct{ pending []OrderEvent }
func (b *Outbox) OnEvent(e OrderEvent) { b.pending = append(b.pending, e) }
func (b *Outbox) Drain() []OrderEvent { out := b.pending b.pending = nil return out}
// SharedPlatform is used by the customer, the restaurant and the partner on different phones,// at the same moment. One mutex makes each change to the platform one step: a cancel and an// accept racing for the same order can't both succeed, and two orders accepted at once can't get// the same partner.//// Listeners are the slow part, sending push messages over the network, and may call back into// the platform. So they run after the mutex is released; a Go mutex isn't reentrant, so a// listener called under it would deadlock. Each request's events arrive in order; events from// two requests may interleave, as they would over any network anyway.type SharedPlatform struct { mu sync.Mutex platform *Platform outbox *Outbox listeners []OrderListener}
func NewSharedPlatform(d *Dispatcher, clock func() int64, listeners ...OrderListener) *SharedPlatform { outbox := &Outbox{} return &SharedPlatform{platform: NewPlatform(d, clock, outbox), outbox: outbox, listeners: listeners}}
func (s *SharedPlatform) PlaceOrder(customer, restaurant string, pickup Location) *Order { s.mu.Lock() o := s.platform.PlaceOrder(customer, restaurant, pickup) events := s.outbox.Drain() s.mu.Unlock() s.publish(events) return o}
// Handle makes the change and collects its events under the lock, then sends them after.func (s *SharedPlatform) Handle(orderID string, e Event, sender string) Result { s.mu.Lock() r := s.platform.Handle(orderID, e, sender) events := s.outbox.Drain() s.mu.Unlock() s.publish(events) return r}
// State takes the lock too, so a read never sees an order halfway through a change.func (s *SharedPlatform) State(orderID string) State { s.mu.Lock() defer s.mu.Unlock() return s.platform.Get(orderID).State}
func (s *SharedPlatform) Partner(orderID string) string { s.mu.Lock() defer s.mu.Unlock() return s.platform.Get(orderID).Partner}
// publish runs without the lock, so a slow listener holds up only this request, not everyone.func (s *SharedPlatform) publish(events []OrderEvent) { for _, e := range events { for _, l := range s.listeners { l.OnEvent(e) } }}// A listener that only queues events. The platform tells it about each change as it happens;// the events are delivered to the real listeners once the change is complete.class Outbox implements OrderListener { private pending: OrderEvent[] = [];
onEvent(e: OrderEvent): void { this.pending.push(e); }
drain(): OrderEvent[] { const out = this.pending; this.pending = []; return out; }}
// Sending a push message is asynchronous, so these listeners return a promise.interface AsyncOrderListener { onEvent(e: OrderEvent): Promise<void>;}
// The customer, the restaurant and the partner act on the same order from different phones, at// the same moment. JavaScript runs one callback at a time, and each change to the platform runs// with no await inside it, so a cancel and an accept for the same order can't interleave, and// two accepts can't both take the same partner. No lock is needed.//// Listeners send messages over the network, which awaits. They run after the change is// complete, so a listener that's slow, or that calls back into the platform, never sees or// leaves a half-made change.class SharedPlatform { private readonly outbox = new Outbox(); private readonly platform: Platform;
constructor( dispatcher: Dispatcher, clock: () => number, private readonly listeners: AsyncOrderListener[], ) { this.platform = new Platform(dispatcher, clock, [this.outbox]); }
async placeOrder(customer: string, restaurant: string, pickup: Location): Promise<Order> { return this.run(() => this.platform.placeOrder(customer, restaurant, pickup)); }
async handle(orderId: string, event: OrderEventName, sender: string): Promise<Result> { return this.run(() => this.platform.handle(orderId, event, sender)); }
state(orderId: string): State { return this.platform.get(orderId)!.state; }
partner(orderId: string): string | null { return this.platform.get(orderId)!.partner; }
// The change runs to the end before any listener is called. Listeners are awaited one at a // time, so each request's events arrive in order. private async run<T>(change: () => T): Promise<T> { const result = change(); for (const e of this.outbox.drain()) { for (const l of this.listeners) await l.onEvent(e); } return result; }}Why is this correct? Under the lock, a cancel and an accept run one after the other. The second
one sees the state the first one left, finds no row, and gets NOT_ALLOWED. In the same way, the
second accept’s dispatch sees Pat already busy, and picks someone else, or waits in line.
What does it cost? All changes in a city go one at a time, but each takes microseconds. The slow part, notifications, runs after the lock, in parallel.
Go deeperHow each language does it1 min
In Java, Python, C# and Go, requests run on their own threads, so the lock is needed. TypeScript
runs one callback at a time, and each change runs with no await inside it, so a cancel and an
accept can’t interleave, and no lock is needed. Its listeners are asynchronous, and are awaited only
after the change is complete, which is the same outbox idea.
Go deeperIs one lock for a whole city too slow?2 min
Let’s put numbers on it. A big city at lunchtime might see 50 orders a minute, and each order has about six changes. That’s 300 changes a minute, or five a second. Each change, done in memory, takes a few microseconds under the lock. So the lock is busy for a tiny fraction of each second, and nobody waits in any way they’d notice. The slow parts, push messages and saving to a database, run outside it.
When does it stop being enough? When one lock covers far more than a city, or when each change must also write to a database under the lock. Then there are three steps up, in order of effort:
- One platform per city. Orders never cross cities, and neither do partners, so each city gets its own lock. This is usually all you need.
- A lock per order, plus one for the dispatcher. Changes to different orders run in parallel. Accept must take the order’s lock, then the dispatcher’s, always in that order, so two threads can never each hold one lock and wait for the other.
- One worker per order. Each order’s events go into its own queue, handled one at a time, often with the order stored in a database row with a version number. This is what large delivery companies run, and it’s a system design question.
Start with one lock, say why it’s enough, and name the next step. That’s the senior answer.
How you’d prove it works
Section titled “How you’d prove it works”The lifecycle is plain data, so most tests are a few calls and an expected result. The concurrency tests are built to fail without the fix, not just “run a few times”. The tests that matter:
- An order moves from placed to delivered, and gets a partner on accept.
- Events out of order are refused, and leave the state unchanged.
- Another restaurant, customer or partner gets
NOT_YOURS. - Only a placed order can be cancelled or rejected, and a rejected order gets no partner.
- The nearest free partner is chosen, and the next nearest when they’re busy.
- With no partner free, orders wait, and the oldest is served first.
- Listeners and the timeline see every accepted change, with the clock’s times, and nothing for a refused one.
- A listener that accepts orders by calling back into the platform works.
- A cancel and an accept raced 200 times always have exactly one winner, and the state matches.
- Eight orders accepted at once with three partners get three different partners.
Go deeperThe tests in five languages2 min
The last two tests fail in Java, Python and Go if the lock is removed: both racers succeed, or a partner is assigned twice.
static final Location KITCHEN = new Location(0, 0);
long now = 0; // the test's clockList<OrderEvent> seen = new ArrayList<>();
Dispatcher partners(String... idsAtDistances) { Dispatcher d = new Dispatcher(new Nearest()); for (String s : idsAtDistances) { String[] parts = s.split("@"); d.addPartner(parts[0], new Location(Integer.parseInt(parts[1]), 0)); } return d;}
Platform platform(Dispatcher d) { return new Platform(d, () -> now, List.of(seen::add)); }
@Testvoid anOrderMovesThroughItsStates() { Platform p = platform(partners("pat@1")); Order o = p.placeOrder("ana", "dosa-hut", KITCHEN); assertEquals(Result.OK, p.accept("dosa-hut", o.id)); assertEquals("pat", o.partner); assertEquals(Result.OK, p.markReady("dosa-hut", o.id)); assertEquals(Result.OK, p.pickUp("pat", o.id)); assertEquals(Result.OK, p.deliver("pat", o.id)); assertEquals(State.DELIVERED, o.state);}
@Testvoid eventsOutOfOrderAreRefused() { Platform p = platform(partners("pat@1")); Order o = p.placeOrder("ana", "dosa-hut", KITCHEN); assertEquals(Result.NOT_ALLOWED, p.markReady("dosa-hut", o.id)); // not accepted yet p.accept("dosa-hut", o.id); assertEquals(Result.NOT_ALLOWED, p.deliver("pat", o.id)); // not picked up yet assertEquals(Result.NOT_FOUND, p.accept("dosa-hut", "O99")); assertEquals(State.ACCEPTED, o.state);}
@Testvoid onlyTheOrdersOwnPartiesCanAct() { Platform p = platform(partners("pat@1", "sam@5")); Order o = p.placeOrder("ana", "dosa-hut", KITCHEN); assertEquals(Result.NOT_YOURS, p.accept("pizza-place", o.id)); assertEquals(Result.NOT_YOURS, p.cancel("ben", o.id)); p.accept("dosa-hut", o.id); p.markReady("dosa-hut", o.id); assertEquals(Result.NOT_YOURS, p.pickUp("sam", o.id)); // pat was assigned}
@Testvoid onlyAPlacedOrderCanBeCancelledOrRejected() { Platform p = platform(partners("pat@1")); Order a = p.placeOrder("ana", "dosa-hut", KITCHEN); assertEquals(Result.OK, p.cancel("ana", a.id)); assertEquals(Result.NOT_ALLOWED, p.accept("dosa-hut", a.id)); Order b = p.placeOrder("ana", "dosa-hut", KITCHEN); assertEquals(Result.OK, p.reject("dosa-hut", b.id)); assertNull(b.partner); Order c = p.placeOrder("ana", "dosa-hut", KITCHEN); p.accept("dosa-hut", c.id); assertEquals(Result.NOT_ALLOWED, p.cancel("ana", c.id)); // the kitchen has started}
@Testvoid theNearestFreePartnerIsAssigned() { Platform p = platform(partners("far@9", "near@2", "mid@5")); Order a = p.placeOrder("ana", "dosa-hut", KITCHEN); Order b = p.placeOrder("ben", "dosa-hut", KITCHEN); p.accept("dosa-hut", a.id); p.accept("dosa-hut", b.id); assertEquals("near", a.partner); assertEquals("mid", b.partner); // near is busy}
@Testvoid ordersWaitInLineForTheNextFreePartner() { Dispatcher d = partners("pat@1"); Platform p = platform(d); Order a = p.placeOrder("ana", "dosa-hut", KITCHEN); Order b = p.placeOrder("ben", "dosa-hut", KITCHEN); Order c = p.placeOrder("cy", "dosa-hut", KITCHEN); p.accept("dosa-hut", a.id); p.accept("dosa-hut", b.id); p.accept("dosa-hut", c.id); assertNull(b.partner); assertEquals(2, d.waitingCount()); p.markReady("dosa-hut", a.id); p.pickUp("pat", a.id); p.deliver("pat", a.id); assertEquals("pat", b.partner); // the oldest waiting order goes first assertNull(c.partner);}
@Testvoid listenersAndTheTimelineSeeEveryChange() { Platform p = platform(partners("pat@1")); Order o = p.placeOrder("ana", "dosa-hut", KITCHEN); now = 60; p.accept("dosa-hut", o.id); now = 900; p.markReady("dosa-hut", o.id); p.cancel("ana", o.id); // refused, so not recorded assertEquals(List.of(State.PLACED, State.ACCEPTED, State.READY), seen.stream().map(OrderEvent::to).toList()); assertEquals(List.of(0L, 60L, 900L), p.timeline(o.id).stream().map(OrderEvent::at).toList());}
@Testvoid aListenerCanActOnThePlatform() { // A restaurant that accepts every order automatically, from its own listener. List<SharedPlatform> holder = new ArrayList<>(); OrderListener autoAccept = e -> { if (e.to() == State.PLACED) holder.get(0).accept("dosa-hut", e.orderId()); }; SharedPlatform p = new SharedPlatform(partners("pat@1"), () -> now, List.of(autoAccept)); holder.add(p); Order o = p.placeOrder("ana", "dosa-hut", KITCHEN); assertEquals(State.ACCEPTED, p.state(o.id));}
@Testvoid aRacingCancelAndAcceptHaveOneWinner() throws Exception { SharedPlatform p = new SharedPlatform(partners("pat@1"), () -> now, List.of()); for (int round = 0; round < 200; round++) { Order o = p.placeOrder("ana", "dosa-hut", KITCHEN); Result[] results = new Result[2]; Thread cancel = new Thread(() -> results[0] = p.cancel("ana", o.id)); Thread accept = new Thread(() -> results[1] = p.accept("dosa-hut", o.id)); cancel.start(); accept.start(); cancel.join(); accept.join(); assertTrue((results[0] == Result.OK) != (results[1] == Result.OK)); assertEquals(results[0] == Result.OK ? State.CANCELLED : State.ACCEPTED, p.state(o.id)); if (results[1] == Result.OK) { // free pat for the next round p.markReady("dosa-hut", o.id); p.pickUp("pat", o.id); p.deliver("pat", o.id); } }}
@Testvoid ordersAcceptedAtOnceNeverShareAPartner() throws Exception { for (int round = 0; round < 50; round++) { SharedPlatform p = new SharedPlatform(partners("a@1", "b@2", "c@3"), () -> now, List.of()); List<Order> orders = new ArrayList<>(); for (int i = 0; i < 8; i++) orders.add(p.placeOrder("c" + i, "dosa-hut", KITCHEN)); CountDownLatch start = new CountDownLatch(1); List<Thread> threads = new ArrayList<>(); for (Order o : orders) { Thread t = new Thread(() -> { try { start.await(); } catch (InterruptedException e) { return; } p.accept("dosa-hut", o.id); }); t.start(); threads.add(t); } start.countDown(); for (Thread t : threads) t.join(); List<String> assigned = orders.stream().map(o -> p.partner(o.id)).filter(Objects::nonNull).toList(); assertEquals(3, assigned.size()); assertEquals(3, new HashSet<>(assigned).size()); }}import sys
KITCHEN = Location(0, 0)
def partners(*ids_at_distances: str) -> Dispatcher: d = Dispatcher(Nearest()) for s in ids_at_distances: id, x = s.split("@") d.add_partner(id, Location(int(x), 0)) return d
class World: def __init__(self, dispatcher: Dispatcher): self.now = 0 # the test's clock self.seen: list[OrderEvent] = [] self.platform = Platform(dispatcher, lambda: self.now, [self])
def on_event(self, e: OrderEvent) -> None: self.seen.append(e)
class Listener: def __init__(self, fn: Callable[[OrderEvent], None]): self.on_event = fn
def test_an_order_moves_through_its_states(): p = World(partners("pat@1")).platform o = p.place_order("ana", "dosa-hut", KITCHEN) assert p.accept("dosa-hut", o.id) is Result.OK assert o.partner == "pat" assert p.mark_ready("dosa-hut", o.id) is Result.OK assert p.pick_up("pat", o.id) is Result.OK assert p.deliver("pat", o.id) is Result.OK assert o.state is State.DELIVERED
def test_events_out_of_order_are_refused(): p = World(partners("pat@1")).platform o = p.place_order("ana", "dosa-hut", KITCHEN) assert p.mark_ready("dosa-hut", o.id) is Result.NOT_ALLOWED # not accepted yet p.accept("dosa-hut", o.id) assert p.deliver("pat", o.id) is Result.NOT_ALLOWED # not picked up yet assert p.accept("dosa-hut", "O99") is Result.NOT_FOUND assert o.state is State.ACCEPTED
def test_only_the_orders_own_parties_can_act(): p = World(partners("pat@1", "sam@5")).platform o = p.place_order("ana", "dosa-hut", KITCHEN) assert p.accept("pizza-place", o.id) is Result.NOT_YOURS assert p.cancel("ben", o.id) is Result.NOT_YOURS p.accept("dosa-hut", o.id) p.mark_ready("dosa-hut", o.id) assert p.pick_up("sam", o.id) is Result.NOT_YOURS # pat was assigned
def test_only_a_placed_order_can_be_cancelled_or_rejected(): p = World(partners("pat@1")).platform a = p.place_order("ana", "dosa-hut", KITCHEN) assert p.cancel("ana", a.id) is Result.OK assert p.accept("dosa-hut", a.id) is Result.NOT_ALLOWED b = p.place_order("ana", "dosa-hut", KITCHEN) assert p.reject("dosa-hut", b.id) is Result.OK assert b.partner is None c = p.place_order("ana", "dosa-hut", KITCHEN) p.accept("dosa-hut", c.id) assert p.cancel("ana", c.id) is Result.NOT_ALLOWED # the kitchen has started
def test_the_nearest_free_partner_is_assigned(): p = World(partners("far@9", "near@2", "mid@5")).platform a = p.place_order("ana", "dosa-hut", KITCHEN) b = p.place_order("ben", "dosa-hut", KITCHEN) p.accept("dosa-hut", a.id) p.accept("dosa-hut", b.id) assert a.partner == "near" assert b.partner == "mid" # near is busy
def test_orders_wait_in_line_for_the_next_free_partner(): d = partners("pat@1") p = World(d).platform a, b, c = (p.place_order(n, "dosa-hut", KITCHEN) for n in ("ana", "ben", "cy")) for o in (a, b, c): p.accept("dosa-hut", o.id) assert b.partner is None assert d.waiting_count == 2 p.mark_ready("dosa-hut", a.id) p.pick_up("pat", a.id) p.deliver("pat", a.id) assert b.partner == "pat" # the oldest waiting order goes first assert c.partner is None
def test_listeners_and_the_timeline_see_every_change(): w = World(partners("pat@1")) p = w.platform o = p.place_order("ana", "dosa-hut", KITCHEN) w.now = 60 p.accept("dosa-hut", o.id) w.now = 900 p.mark_ready("dosa-hut", o.id) p.cancel("ana", o.id) # refused, so not recorded assert [e.to for e in w.seen] == [State.PLACED, State.ACCEPTED, State.READY] assert [e.at for e in p.timeline(o.id)] == [0, 60, 900]
def test_a_listener_can_act_on_the_platform(): # A restaurant that accepts every order automatically, from its own listener. holder: list[SharedPlatform] = []
def auto_accept(e: OrderEvent) -> None: if e.to is State.PLACED: holder[0].accept("dosa-hut", e.order_id)
p = SharedPlatform(partners("pat@1"), lambda: 0, [Listener(auto_accept)]) holder.append(p) o = p.place_order("ana", "dosa-hut", KITCHEN) assert p.state(o.id) is State.ACCEPTED
def test_a_racing_cancel_and_accept_have_one_winner(): old = sys.getswitchinterval() sys.setswitchinterval(1e-6) # switch threads often, so a missing lock shows up try: p = SharedPlatform(partners("pat@1"), lambda: 0, []) with ThreadPoolExecutor(2) as pool: for _ in range(200): o = p.place_order("ana", "dosa-hut", KITCHEN) cancel = pool.submit(p.cancel, "ana", o.id) accept = pool.submit(p.accept, "dosa-hut", o.id) cancelled, accepted = cancel.result() is Result.OK, accept.result() is Result.OK assert cancelled != accepted assert p.state(o.id) is (State.CANCELLED if cancelled else State.ACCEPTED) if accepted: # free pat for the next round p.mark_ready("dosa-hut", o.id) p.pick_up("pat", o.id) p.deliver("pat", o.id) finally: sys.setswitchinterval(old)
def test_orders_accepted_at_once_never_share_a_partner(): old = sys.getswitchinterval() sys.setswitchinterval(1e-6) try: for _ in range(30): p = SharedPlatform(partners("a@1", "b@2", "c@3"), lambda: 0, []) orders = [p.place_order(f"c{i}", "dosa-hut", KITCHEN) for i in range(8)] start = threading.Barrier(8)
def accept(o: Order) -> None: start.wait() p.accept("dosa-hut", o.id)
with ThreadPoolExecutor(8) as pool: list(pool.map(accept, orders)) assigned = [p.partner(o.id) for o in orders if p.partner(o.id)] assert len(assigned) == 3 assert len(set(assigned)) == 3 finally: sys.setswitchinterval(old)private static readonly Location Kitchen = new(0, 0);
private long _now; // the test's clockprivate readonly List<OrderEvent> _seen = new();
private sealed class FuncListener : IOrderListener{ private readonly Action<OrderEvent> _fn; public FuncListener(Action<OrderEvent> fn) => _fn = fn; public void OnEvent(OrderEvent e) => _fn(e);}
private static Dispatcher Partners(params string[] idsAtDistances){ var d = new Dispatcher(new Nearest()); foreach (var s in idsAtDistances) { var parts = s.Split('@'); d.AddPartner(parts[0], new Location(int.Parse(parts[1]), 0)); } return d;}
private Platform NewPlatform(Dispatcher d) => new(d, () => _now, new IOrderListener[] { new FuncListener(_seen.Add) });
[Fact]public void AnOrderMovesThroughItsStates(){ var p = NewPlatform(Partners("pat@1")); var o = p.PlaceOrder("ana", "dosa-hut", Kitchen); Assert.Equal(Result.Ok, p.Accept("dosa-hut", o.Id)); Assert.Equal("pat", o.Partner); Assert.Equal(Result.Ok, p.MarkReady("dosa-hut", o.Id)); Assert.Equal(Result.Ok, p.PickUp("pat", o.Id)); Assert.Equal(Result.Ok, p.Deliver("pat", o.Id)); Assert.Equal(State.Delivered, o.State);}
[Fact]public void EventsOutOfOrderAreRefused(){ var p = NewPlatform(Partners("pat@1")); var o = p.PlaceOrder("ana", "dosa-hut", Kitchen); Assert.Equal(Result.NotAllowed, p.MarkReady("dosa-hut", o.Id)); // not accepted yet p.Accept("dosa-hut", o.Id); Assert.Equal(Result.NotAllowed, p.Deliver("pat", o.Id)); // not picked up yet Assert.Equal(Result.NotFound, p.Accept("dosa-hut", "O99")); Assert.Equal(State.Accepted, o.State);}
[Fact]public void OnlyTheOrdersOwnPartiesCanAct(){ var p = NewPlatform(Partners("pat@1", "sam@5")); var o = p.PlaceOrder("ana", "dosa-hut", Kitchen); Assert.Equal(Result.NotYours, p.Accept("pizza-place", o.Id)); Assert.Equal(Result.NotYours, p.Cancel("ben", o.Id)); p.Accept("dosa-hut", o.Id); p.MarkReady("dosa-hut", o.Id); Assert.Equal(Result.NotYours, p.PickUp("sam", o.Id)); // pat was assigned}
[Fact]public void OnlyAPlacedOrderCanBeCancelledOrRejected(){ var p = NewPlatform(Partners("pat@1")); var a = p.PlaceOrder("ana", "dosa-hut", Kitchen); Assert.Equal(Result.Ok, p.Cancel("ana", a.Id)); Assert.Equal(Result.NotAllowed, p.Accept("dosa-hut", a.Id)); var b = p.PlaceOrder("ana", "dosa-hut", Kitchen); Assert.Equal(Result.Ok, p.Reject("dosa-hut", b.Id)); Assert.Null(b.Partner); var c = p.PlaceOrder("ana", "dosa-hut", Kitchen); p.Accept("dosa-hut", c.Id); Assert.Equal(Result.NotAllowed, p.Cancel("ana", c.Id)); // the kitchen has started}
[Fact]public void TheNearestFreePartnerIsAssigned(){ var p = NewPlatform(Partners("far@9", "near@2", "mid@5")); var a = p.PlaceOrder("ana", "dosa-hut", Kitchen); var b = p.PlaceOrder("ben", "dosa-hut", Kitchen); p.Accept("dosa-hut", a.Id); p.Accept("dosa-hut", b.Id); Assert.Equal("near", a.Partner); Assert.Equal("mid", b.Partner); // near is busy}
[Fact]public void OrdersWaitInLineForTheNextFreePartner(){ var d = Partners("pat@1"); var p = NewPlatform(d); var orders = new[] { "ana", "ben", "cy" }.Select(n => p.PlaceOrder(n, "dosa-hut", Kitchen)).ToList(); foreach (var o in orders) p.Accept("dosa-hut", o.Id); Assert.Null(orders[1].Partner); Assert.Equal(2, d.WaitingCount); p.MarkReady("dosa-hut", orders[0].Id); p.PickUp("pat", orders[0].Id); p.Deliver("pat", orders[0].Id); Assert.Equal("pat", orders[1].Partner); // the oldest waiting order goes first Assert.Null(orders[2].Partner);}
[Fact]public void ListenersAndTheTimelineSeeEveryChange(){ var p = NewPlatform(Partners("pat@1")); var o = p.PlaceOrder("ana", "dosa-hut", Kitchen); _now = 60; p.Accept("dosa-hut", o.Id); _now = 900; p.MarkReady("dosa-hut", o.Id); p.Cancel("ana", o.Id); // refused, so not recorded Assert.Equal(new[] { State.Placed, State.Accepted, State.Ready }, _seen.Select(e => e.To)); Assert.Equal(new long[] { 0, 60, 900 }, p.Timeline(o.Id).Select(e => e.At));}
[Fact]public void AListenerCanActOnThePlatform(){ // A restaurant that accepts every order automatically, from its own listener. SharedPlatform? p = null; var autoAccept = new FuncListener(e => { if (e.To == State.Placed) p!.Handle(e.OrderId, Event.Accept, "dosa-hut"); }); p = new SharedPlatform(Partners("pat@1"), () => _now, new IOrderListener[] { autoAccept }); var o = p.PlaceOrder("ana", "dosa-hut", Kitchen); Assert.Equal(State.Accepted, p.StateOf(o.Id));}
[Fact]public void ARacingCancelAndAcceptHaveOneWinner(){ var p = new SharedPlatform(Partners("pat@1"), () => _now, Array.Empty<IOrderListener>()); for (int round = 0; round < 200; round++) { var o = p.PlaceOrder("ana", "dosa-hut", Kitchen); var cancel = Task.Run(() => p.Handle(o.Id, Event.Cancel, "ana")); var accept = Task.Run(() => p.Handle(o.Id, Event.Accept, "dosa-hut")); bool cancelled = cancel.Result == Result.Ok, accepted = accept.Result == Result.Ok; Assert.NotEqual(cancelled, accepted); Assert.Equal(cancelled ? State.Cancelled : State.Accepted, p.StateOf(o.Id)); if (accepted) // free pat for the next round { p.Handle(o.Id, Event.MarkReady, "dosa-hut"); p.Handle(o.Id, Event.PickUp, "pat"); p.Handle(o.Id, Event.Deliver, "pat"); } }}
[Fact]public void OrdersAcceptedAtOnceNeverShareAPartner(){ for (int round = 0; round < 30; round++) { var p = new SharedPlatform(Partners("a@1", "b@2", "c@3"), () => _now, Array.Empty<IOrderListener>()); var orders = Enumerable.Range(0, 8).Select(i => p.PlaceOrder($"c{i}", "dosa-hut", Kitchen)).ToList(); using var start = new ManualResetEventSlim(); var threads = orders.Select(o => new Thread(() => { start.Wait(); p.Handle(o.Id, Event.Accept, "dosa-hut"); })).ToList(); threads.ForEach(t => t.Start()); start.Set(); threads.ForEach(t => t.Join()); var assigned = orders.Select(o => p.PartnerOf(o.Id)).Where(x => x is not null).ToList(); Assert.Equal(3, assigned.Count); Assert.Equal(3, assigned.Distinct().Count()); }}var kitchen = Location{0, 0}
func partners(idsAtDistances ...string) *Dispatcher { d := NewDispatcher(Nearest{}) for _, s := range idsAtDistances { id, x, _ := strings.Cut(s, "@") d.AddPartner(id, Location{int(x[0] - '0'), 0}) } return d}
type listenerFunc func(OrderEvent)
func (f listenerFunc) OnEvent(e OrderEvent) { f(e) }
func zero() int64 { return 0 }
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestAnOrderMovesThroughItsStates(t *testing.T) { p := NewPlatform(partners("pat@1"), zero) o := p.PlaceOrder("ana", "dosa-hut", kitchen) expect(t, OK, p.Handle(o.ID, Accept, "dosa-hut")) expect(t, "pat", o.Partner) expect(t, OK, p.Handle(o.ID, MarkReady, "dosa-hut")) expect(t, OK, p.Handle(o.ID, PickUp, "pat")) expect(t, OK, p.Handle(o.ID, Deliver, "pat")) expect(t, Delivered, o.State)}
func TestEventsOutOfOrderAreRefused(t *testing.T) { p := NewPlatform(partners("pat@1"), zero) o := p.PlaceOrder("ana", "dosa-hut", kitchen) expect(t, NotAllowed, p.Handle(o.ID, MarkReady, "dosa-hut")) // not accepted yet p.Handle(o.ID, Accept, "dosa-hut") expect(t, NotAllowed, p.Handle(o.ID, Deliver, "pat")) // not picked up yet expect(t, NotFound, p.Handle("O99", Accept, "dosa-hut")) expect(t, Accepted, o.State)}
func TestOnlyTheOrdersOwnPartiesCanAct(t *testing.T) { p := NewPlatform(partners("pat@1", "sam@5"), zero) o := p.PlaceOrder("ana", "dosa-hut", kitchen) expect(t, NotYours, p.Handle(o.ID, Accept, "pizza-place")) expect(t, NotYours, p.Handle(o.ID, Cancel, "ben")) p.Handle(o.ID, Accept, "dosa-hut") p.Handle(o.ID, MarkReady, "dosa-hut") expect(t, NotYours, p.Handle(o.ID, PickUp, "sam")) // pat was assigned}
func TestOnlyAPlacedOrderCanBeCancelledOrRejected(t *testing.T) { p := NewPlatform(partners("pat@1"), zero) a := p.PlaceOrder("ana", "dosa-hut", kitchen) expect(t, OK, p.Handle(a.ID, Cancel, "ana")) expect(t, NotAllowed, p.Handle(a.ID, Accept, "dosa-hut")) b := p.PlaceOrder("ana", "dosa-hut", kitchen) expect(t, OK, p.Handle(b.ID, Reject, "dosa-hut")) expect(t, "", b.Partner) c := p.PlaceOrder("ana", "dosa-hut", kitchen) p.Handle(c.ID, Accept, "dosa-hut") expect(t, NotAllowed, p.Handle(c.ID, Cancel, "ana")) // the kitchen has started}
func TestTheNearestFreePartnerIsAssigned(t *testing.T) { p := NewPlatform(partners("far@9", "near@2", "mid@5"), zero) a := p.PlaceOrder("ana", "dosa-hut", kitchen) b := p.PlaceOrder("ben", "dosa-hut", kitchen) p.Handle(a.ID, Accept, "dosa-hut") p.Handle(b.ID, Accept, "dosa-hut") expect(t, "near", a.Partner) expect(t, "mid", b.Partner) // near is busy}
func TestOrdersWaitInLineForTheNextFreePartner(t *testing.T) { d := partners("pat@1") p := NewPlatform(d, zero) a := p.PlaceOrder("ana", "dosa-hut", kitchen) b := p.PlaceOrder("ben", "dosa-hut", kitchen) c := p.PlaceOrder("cy", "dosa-hut", kitchen) for _, o := range []*Order{a, b, c} { p.Handle(o.ID, Accept, "dosa-hut") } expect(t, "", b.Partner) expect(t, 2, d.WaitingCount()) p.Handle(a.ID, MarkReady, "dosa-hut") p.Handle(a.ID, PickUp, "pat") p.Handle(a.ID, Deliver, "pat") expect(t, "pat", b.Partner) // the oldest waiting order goes first expect(t, "", c.Partner)}
func TestListenersAndTheTimelineSeeEveryChange(t *testing.T) { var now int64 var seen []State p := NewPlatform(partners("pat@1"), func() int64 { return now }, listenerFunc(func(e OrderEvent) { seen = append(seen, e.To) })) o := p.PlaceOrder("ana", "dosa-hut", kitchen) now = 60 p.Handle(o.ID, Accept, "dosa-hut") now = 900 p.Handle(o.ID, MarkReady, "dosa-hut") p.Handle(o.ID, Cancel, "ana") // refused, so not recorded expect(t, fmt.Sprint([]State{Placed, Accepted, Ready}), fmt.Sprint(seen)) var at []int64 for _, e := range p.Timeline(o.ID) { at = append(at, e.At) } expect(t, "[0 60 900]", fmt.Sprint(at))}
func TestAListenerCanActOnThePlatform(t *testing.T) { // A restaurant that accepts every order automatically, from its own listener. var p *SharedPlatform autoAccept := listenerFunc(func(e OrderEvent) { if e.To == Placed { p.Handle(e.OrderID, Accept, "dosa-hut") } }) p = NewSharedPlatform(partners("pat@1"), zero, autoAccept) o := p.PlaceOrder("ana", "dosa-hut", kitchen) expect(t, Accepted, p.State(o.ID))}
func TestARacingCancelAndAcceptHaveOneWinner(t *testing.T) { p := NewSharedPlatform(partners("pat@1"), zero) for range 200 { o := p.PlaceOrder("ana", "dosa-hut", kitchen) var cancelled, accepted Result var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); cancelled = p.Handle(o.ID, Cancel, "ana") }() go func() { defer wg.Done(); accepted = p.Handle(o.ID, Accept, "dosa-hut") }() wg.Wait() expect(t, true, (cancelled == OK) != (accepted == OK)) if accepted == OK { // free pat for the next round expect(t, Accepted, p.State(o.ID)) p.Handle(o.ID, MarkReady, "dosa-hut") p.Handle(o.ID, PickUp, "pat") p.Handle(o.ID, Deliver, "pat") } else { expect(t, Cancelled, p.State(o.ID)) } }}
func TestOrdersAcceptedAtOnceNeverShareAPartner(t *testing.T) { for range 30 { p := NewSharedPlatform(partners("a@1", "b@2", "c@3"), zero) var ids []string for i := range 8 { ids = append(ids, p.PlaceOrder(fmt.Sprint("c", i), "dosa-hut", kitchen).ID) } var wg sync.WaitGroup for _, id := range ids { wg.Add(1) go func() { defer wg.Done(); p.Handle(id, Accept, "dosa-hut") }() } wg.Wait() assigned := map[string]int{} for _, id := range ids { if partner := p.Partner(id); partner != "" { assigned[partner]++ } } expect(t, 3, len(assigned)) for _, n := range assigned { expect(t, 1, n) } }}import { test } from 'node:test';import assert from 'node:assert/strict';
const KITCHEN = new Location(0, 0);
function partners(...idsAtDistances: string[]): Dispatcher { const d = new Dispatcher(new Nearest()); for (const s of idsAtDistances) { const [id, x] = s.split('@'); d.addPartner(id, new Location(Number(x), 0)); } return d;}
const zero = () => 0;
test('an order moves through its states', () => { const p = new Platform(partners('pat@1'), zero, []); const o = p.placeOrder('ana', 'dosa-hut', KITCHEN); assert.equal(p.accept('dosa-hut', o.id), 'ok'); assert.equal(o.partner, 'pat'); assert.equal(p.markReady('dosa-hut', o.id), 'ok'); assert.equal(p.pickUp('pat', o.id), 'ok'); assert.equal(p.deliver('pat', o.id), 'ok'); assert.equal(o.state, 'delivered');});
test('events out of order are refused', () => { const p = new Platform(partners('pat@1'), zero, []); const o = p.placeOrder('ana', 'dosa-hut', KITCHEN); assert.equal(p.markReady('dosa-hut', o.id), 'not-allowed'); // not accepted yet p.accept('dosa-hut', o.id); assert.equal(p.deliver('pat', o.id), 'not-allowed'); // not picked up yet assert.equal(p.accept('dosa-hut', 'O99'), 'not-found'); assert.equal(o.state, 'accepted');});
test("only the order's own parties can act", () => { const p = new Platform(partners('pat@1', 'sam@5'), zero, []); const o = p.placeOrder('ana', 'dosa-hut', KITCHEN); assert.equal(p.accept('pizza-place', o.id), 'not-yours'); assert.equal(p.cancel('ben', o.id), 'not-yours'); p.accept('dosa-hut', o.id); p.markReady('dosa-hut', o.id); assert.equal(p.pickUp('sam', o.id), 'not-yours'); // pat was assigned});
test('only a placed order can be cancelled or rejected', () => { const p = new Platform(partners('pat@1'), zero, []); const a = p.placeOrder('ana', 'dosa-hut', KITCHEN); assert.equal(p.cancel('ana', a.id), 'ok'); assert.equal(p.accept('dosa-hut', a.id), 'not-allowed'); const b = p.placeOrder('ana', 'dosa-hut', KITCHEN); assert.equal(p.reject('dosa-hut', b.id), 'ok'); assert.equal(b.partner, null); const c = p.placeOrder('ana', 'dosa-hut', KITCHEN); p.accept('dosa-hut', c.id); assert.equal(p.cancel('ana', c.id), 'not-allowed'); // the kitchen has started});
test('the nearest free partner is assigned', () => { const p = new Platform(partners('far@9', 'near@2', 'mid@5'), zero, []); const a = p.placeOrder('ana', 'dosa-hut', KITCHEN); const b = p.placeOrder('ben', 'dosa-hut', KITCHEN); p.accept('dosa-hut', a.id); p.accept('dosa-hut', b.id); assert.equal(a.partner, 'near'); assert.equal(b.partner, 'mid'); // near is busy});
test('orders wait in line for the next free partner', () => { const d = partners('pat@1'); const p = new Platform(d, zero, []); const [a, b, c] = ['ana', 'ben', 'cy'].map((n) => p.placeOrder(n, 'dosa-hut', KITCHEN)); for (const o of [a, b, c]) p.accept('dosa-hut', o.id); assert.equal(b.partner, null); assert.equal(d.waitingCount, 2); p.markReady('dosa-hut', a.id); p.pickUp('pat', a.id); p.deliver('pat', a.id); assert.equal(b.partner, 'pat'); // the oldest waiting order goes first assert.equal(c.partner, null);});
test('listeners and the timeline see every change', () => { let now = 0; const seen: State[] = []; const p = new Platform(partners('pat@1'), () => now, [{ onEvent: (e) => seen.push(e.to) }]); const o = p.placeOrder('ana', 'dosa-hut', KITCHEN); now = 60; p.accept('dosa-hut', o.id); now = 900; p.markReady('dosa-hut', o.id); p.cancel('ana', o.id); // refused, so not recorded assert.deepEqual(seen, ['placed', 'accepted', 'ready']); assert.deepEqual( p.timeline(o.id).map((e) => e.at), [0, 60, 900], );});
test('a listener can act on the platform', async () => { // A restaurant that accepts every order automatically, from its own listener. const p: SharedPlatform = new SharedPlatform(partners('pat@1'), zero, [ { onEvent: async (e) => { if (e.to === 'placed') await p.handle(e.orderId, 'accept', 'dosa-hut'); }, }, ]); const o = await p.placeOrder('ana', 'dosa-hut', KITCHEN); assert.equal(p.state(o.id), 'accepted');});
test('a racing cancel and accept have one winner', async () => { const slowPush: AsyncOrderListener = { onEvent: () => new Promise((r) => setTimeout(r, 1)) }; const p = new SharedPlatform(partners('pat@1'), zero, [slowPush]); const o = await p.placeOrder('ana', 'dosa-hut', KITCHEN); const [cancelled, accepted] = await Promise.all([p.handle(o.id, 'cancel', 'ana'), p.handle(o.id, 'accept', 'dosa-hut')]); assert.equal(cancelled, 'ok'); assert.equal(accepted, 'not-allowed'); assert.equal(p.state(o.id), 'cancelled');});
test('orders accepted at once never share a partner', async () => { const p = new SharedPlatform(partners('a@1', 'b@2', 'c@3'), zero, []); const orders = await Promise.all(Array.from({ length: 8 }, (_, i) => p.placeOrder(`c${i}`, 'dosa-hut', KITCHEN))); await Promise.all(orders.map((o) => p.handle(o.id, 'accept', 'dosa-hut'))); const assigned = orders.map((o) => p.partner(o.id)).filter((x) => x !== null); assert.equal(assigned.length, 3); assert.equal(new Set(assigned).size, 3);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| An order id that doesn’t exist | NOT_FOUND |
handle |
| An event out of order | NOT_ALLOWED |
Lifecycle.find |
| Another restaurant accepts | NOT_YOURS |
Order.party |
| Another partner picks up | NOT_YOURS |
Order.party |
| Pickup before any partner is assigned | NOT_YOURS: the order has no partner |
Order.party |
| Customer cancels after accept | NOT_ALLOWED |
the table |
| Rejected or cancelled order | Never assigned a partner | dispatch only on accept |
| No partner free | The order waits; the next free partner takes the oldest | Dispatcher |
| Cancel and accept at once | One wins, the other is refused | SharedPlatform lock |
| Two accepts, one free partner | One assigned, one waits | SharedPlatform lock |
| A listener calls back into the platform | Works: listeners run after the lock | Outbox |
| A refused event | Not recorded, not sent | handle |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| The lifecycle | One table of transitions, each with its actor | A class per state |
| Permissions | The sender must be the order’s own party for that row | Roles checked separately from states |
| When to assign | At accept, while the food cooks | A partner waiting at a kitchen that rejects |
| Choosing a partner | A strategy, nearest first; a fair waiting line | Partners choosing their own orders |
| Telling people | Events to listeners, and a timeline | Calling each channel directly |
| Concurrency | One lock per call; events queued, sent after | Parallel changes to different orders |
Go deeperGenuinely different designs, and when to pick them2 min
Partners claim orders. Instead of the platform choosing, every nearby partner sees the order, and the first to tap claims it. It’s simpler to build, and lets partners choose, at the cost of a race between partners (solved the same way) and less control over fairness.
Event sourcing. Store only the events, and rebuild each order’s state by replaying them. The timeline from stage 4 becomes the source of truth, instead of a by-product. It gives a perfect record of what happened, which delivery disputes need, and makes “what was the state at 12:03” a simple query.
A workflow engine. At large companies, each order is a long-running workflow in a tool like Temporal or Step Functions, with each step, time limit and retry declared. It suits flows with many calls to other systems and long waits, and is far more than an interview needs.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. Scope to the order flow; the cancel window; who picks partners; notifications.
- 3 to 5. The promises. Name “only allowed paths, by the allowed party” as the hard one.
- 5 to 22. A status string and its problems, the lifecycle table with actors, dispatch at accept
with a strategy and a waiting line, then events and listeners. Draw the state diagram and code
handle. - 22 to 24. Walk through the full diagram: one path for every change; decide, then react.
- 24 to 30. The two races, one lock, and the outbox for notifications.
- 30 to 45. Partner offers and time limits, then your own questions.
If time is short, describe the listeners in one sentence, and spend the time on the table and the races. Those decide this question for most interviewers.
Follow-up questions
Section titled “Follow-up questions”Partners should be able to decline an offer, and if nobody accepts within a minute, it goes to the next one. Where does that fit?
Whether the extension lands in the right place: a new state on the partner side, not new ifs in
handle. And whether you see the time limit as just another event.
Most extensions fit into one place:
| Extension | What changes | Touches the table? |
|---|---|---|
| Partners decline or time out | An offer lifecycle in the dispatcher, and a scheduled time limit | No |
| Cancel after accept, with a fee | A row from ACCEPTED to CANCELLED, and a refund listener |
One row |
| Grouping two orders | A strategy that picks a partner already heading to that kitchen | No |
| Live tracking and arrival time | Location updates from partners, a listener that works out the time | No |
| Payments | Charge on accept, refund on cancel or reject, as listeners | No |
| Many cities | One platform per city, each with its own lock | No |
Go deeperAdding partner offers, step by step2 min
- An offer gets its own small lifecycle, kept in the dispatcher: offered, then accepted, declined or expired.
- The dispatcher offers instead of assigning. On accept, the strategy picks Pat as before, but Pat gets an offer, not the order. Pat is marked as “has an offer”, so no second offer goes to Pat at the same time.
- Pat accepts: the offer becomes an assignment, and the order’s partner is filled in.
- Pat declines: the dispatcher asks the strategy again, with Pat left out, and offers the order to the next partner. If nobody is left, the order waits in line, as before.
- Pat does nothing for a minute: a scheduler sends an “offer expired” event. It’s handled under the same lock as any other event, and works exactly like a decline. If Pat’s accept and the expiry arrive together, the lock makes one of them win, the same way it settles a cancel and an accept.
- The order’s table doesn’t change. The order still moves to
READYandPICKED_UPthe same way. Only “who is the partner” is filled in a little later.
The payoff of keeping the order’s rules in one table is that a whole new flow on the partner’s side needs no change to it.
Go deeperFour more follow-up questions2 min
Interviewer asksWhat if a push notification fails?
The order has already changed, and it stays changed: a notification is a report, not part of the change. The push listener retries on its own with growing waits, and the app also fetches the order’s state when it opens, so a lost push only delays the news. The notification service page covers retries in detail.
Interviewer asksHow do you stop a partner from marking an order delivered while still at the restaurant?
The table already requires PICKED_UP first, and only the assigned partner can send either. For
location, the deliver event carries the partner’s position, and the platform refuses it, or flags
it, if they’re far from the customer. That check belongs in handle as one more condition on that
row, not in the partner’s app.
Interviewer asksWhy not let the restaurant's tablet set the state directly in the database?
Because then the rules live in every app that can write to the database, and the first buggy
tablet breaks them. Every change goes through handle, which is the only code that knows the
table. Apps send events, and are told whether they were accepted.
Interviewer asksHow do you show the customer an accurate status if events arrive out of order?
Each event carries the state it moved to, and the time, and the timeline orders them. The app shows the latest state by the platform’s order, not by when the push arrived. If a push for “picked up” arrives before “ready”, the app still shows “picked up”, which is the truth.
Further reading
Section titled “Further reading”- State pattern, tables and classes for lifecycles
- Observer pattern, the shape of the listeners
- Design a notification service, what the push listener does with a message
- Design a movie ticket booking system, another design where two people race for the same thing
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.