Skip to content

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.

Senior26 min core · 44 min full depth

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

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. 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 handle method.
  2. 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).
  3. 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.
  4. Every change becomes an event, sent to listeners and added to the order’s timeline.
  5. 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.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design a food delivery app.

What they are testing

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

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.

Interviewerminute 3

Start simple: place an order and track its status.

What they are testing

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.

Stage 1: an order and its status

Platform

-orders: Map<String, Order>

+placeOrder(customer, restaurant) : Order

+setStatus(orderId, status)

Order

+id

+customer

+restaurant

+status: String

Platform

-orders: Map<String, Order>

+placeOrder(customer, restaurant) : Order

+setStatus(orderId, status)

Order

+id

+customer

+restaurant

+status: String

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Platform.javaplatform.pyPlatform.csplatform.goplatform.ts
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); }
}

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.

Interviewerminute 5

Make it impossible to deliver an order that hasn’t been picked up, and impossible for one restaurant to accept another’s orders.

What they are testing

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.

Stage 2: the order's lifecycle

accept (restaurant)

reject (restaurant)

cancel (customer)

mark ready (restaurant)

pick up (partner)

deliver (partner)

Placed

Accepted

Rejected

Cancelled

Ready

PickedUp

Delivered

accept (restaurant)

reject (restaurant)

cancel (customer)

mark ready (restaurant)

pick up (partner)

deliver (partner)

Placed

Accepted

Rejected

Cancelled

Ready

PickedUp

Delivered

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
  • Fails or stops
  • Ends well

Every change goes through one method, handle(order, event, sender), which does three things in order:

  1. Find the row for the order’s current state and the event. If there’s no row, the answer is NOT_ALLOWED.
  2. 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.
  3. 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.

Stage 2: a state machine with permissions

Platform

+placeOrder(customer, restaurant) : Order

+accept(restaurant, orderId) : Result

+cancel(customer, orderId) : Result

+pickUp(partner, orderId) : Result

+handle(orderId, event, sender) : Result

Lifecycle

+TABLE: List<Transition>

+find(state, event) : Transition

«value»

Transition

+from: State

+event: Event

+actor: Actor

+to: State

Order

+state: State

+partner

+party(Actor) : String

«enum»

State

PLACED ACCEPTED READY PICKED_UP DELIVERED CANCELLED REJECTED

«enum»

Actor

CUSTOMER RESTAURANT PARTNER

Platform

+placeOrder(customer, restaurant) : Order

+accept(restaurant, orderId) : Result

+cancel(customer, orderId) : Result

+pickUp(partner, orderId) : Result

+handle(orderId, event, sender) : Result

Lifecycle

+TABLE: List<Transition>

+find(state, event) : Transition

«value»

Transition

+from: State

+event: Event

+actor: Actor

+to: State

Order

+state: State

+partner

+party(Actor) : String

«enum»

State

PLACED ACCEPTED READY PICKED_UP DELIVERED CANCELLED REJECTED

«enum»

Actor

CUSTOMER RESTAURANT PARTNER

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Platform.javaplatform.pyPlatform.csplatform.goplatform.ts
// 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;
}
}
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.

Interviewerminute 12

When do you assign a delivery partner, and which one? What if none is free?

What they are testing

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.

Stage 3: dispatch

Platform

+handle(orderId, event, sender) : Result

Dispatcher

-partners: List<Partner>

-waiting: Queue<Order>

+assign(Order)

+release(partnerId)

«interface»

AssignmentStrategy

+choose(Order, free) : Partner

Nearest

Partner

+id

+location: Location

+current: Order

«value»

Location

+x

+y

+distanceTo(Location)

Platform

+handle(orderId, event, sender) : Result

Dispatcher

-partners: List<Partner>

-waiting: Queue<Order>

+assign(Order)

+release(partnerId)

«interface»

AssignmentStrategy

+choose(Order, free) : Partner

Nearest

Partner

+id

+location: Location

+current: Order

«value»

Location

+x

+y

+distanceTo(Location)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Platform.javaplatform.pyDispatcher.csplatform.goplatform.ts
// 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;
}
}
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.

  1. Ana places an order at Pasta Place. The order is placed. Nobody is assigned yet.
  2. 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.
  3. Ana taps cancel. The table has no row for “accepted, cancel”, so the answer is NOT_ALLOWED. Nothing changes.
  4. Pasta Place marks it ready. The order is ready.
  5. Sam, another partner, taps pick up. The row exists, and it needs a partner, but Sam is not this order’s partner. NOT_YOURS.
  6. 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.
Sequence: an order from placement to delivery
PartnerDispatcherPlatformRestaurantCustomerPartnerDispatcherPlatformRestaurantCustomerPat takes the oldest waiting order, if anyplace orderacceptassign, nearest free partnerPatmark readypick up (only Pat may)deliverrelease PatPartnerDispatcherPlatformRestaurantCustomerPartnerDispatcherPlatformRestaurantCustomerPat takes the oldest waiting order, if anyplace orderacceptassign, nearest free partnerPatmark readypick up (only Pat may)deliverrelease Pat
Scroll or pinch to zoom, drag to pan
  • 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”
Interviewerminute 18

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?

What they are testing

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.

Stage 4: events, listeners and a timeline

Platform

-timelines

-listeners: List<OrderListener>

-clock

+handle(orderId, event, sender) : Result

+timeline(orderId) : List<OrderEvent>

-record(Order, from, to)

«value»

OrderEvent

+orderId

+from: State

+to: State

+at

«interface»

OrderListener

+onEvent(OrderEvent)

Platform

-timelines

-listeners: List<OrderListener>

-clock

+handle(orderId, event, sender) : Result

+timeline(orderId) : List<OrderEvent>

-record(Order, from, to)

«value»

OrderEvent

+orderId

+from: State

+to: State

+at

«interface»

OrderListener

+onEvent(OrderEvent)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Platform.javaplatform.pyPlatform.csplatform.goplatform.ts
// 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);
}
}
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.

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.

Food delivery: full class diagram

SharedPlatform

-lock

+handle(orderId, event, sender) : Result

Outbox

+onEvent(OrderEvent)

+drain() : List<OrderEvent>

Platform

+placeOrder(customer, restaurant, pickup) : Order

+handle(orderId, event, sender) : Result

+timeline(orderId) : List<OrderEvent>

Lifecycle

+find(state, event) : Transition

«value»

Transition

Order

+state: State

+partner

+pickup: Location

Dispatcher

+assign(Order)

+release(partnerId)

«interface»

AssignmentStrategy

Nearest

Partner

«interface»

OrderListener

+onEvent(OrderEvent)

«value»

OrderEvent

«enum»

State

SharedPlatform

-lock

+handle(orderId, event, sender) : Result

Outbox

+onEvent(OrderEvent)

+drain() : List<OrderEvent>

Platform

+placeOrder(customer, restaurant, pickup) : Order

+handle(orderId, event, sender) : Result

+timeline(orderId) : List<OrderEvent>

Lifecycle

+find(state, event) : Transition

«value»

Transition

Order

+state: State

+partner

+pickup: Location

Dispatcher

+assign(Order)

+release(partnerId)

«interface»

AssignmentStrategy

Nearest

Partner

«interface»

OrderListener

+onEvent(OrderEvent)

«value»

OrderEvent

«enum»

State

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
food-delivery· read only
AllInOne.java
AllInOne.java
// 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);
}
}
Read onlyJava 21Every file builds in CI18 files300 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI18 files318 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI18 files267 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI18 files364 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI18 files302 lines

Three ideas hold it together:

  • One path for every change. All changes go through handle and 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).

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”
Interviewerminute 24

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?

What they are testing

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.

Sequence: changes under the lock, notifications after it
Push listenerOutboxSharedPlatformRestaurantCustomerPush listenerOutboxSharedPlatformRestaurantCustomerthe lock lets one in at a timecancel O7accept O7cancel wins, PLACED to CANCELLEDqueue the cancelled eventaccept finds no row from CANCELLED, NOT_ALLOWEDafter unlocking, send the cancelled eventyour order was cancelledPush listenerOutboxSharedPlatformRestaurantCustomerPush listenerOutboxSharedPlatformRestaurantCustomerthe lock lets one in at a timecancel O7accept O7cancel wins, PLACED to CANCELLEDqueue the cancelled eventaccept finds no row from CANCELLED, NOT_ALLOWEDafter unlocking, send the cancelled eventyour order was cancelled
Scroll or pinch to zoom, drag to pan
  • 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.

  1. 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 becomes ACCEPTED. Dispatch assigns Pat. An event “placed to accepted” goes into the outbox. The lock is released.
  2. 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 is NOT_ALLOWED. Nothing goes into the outbox. The lock is released.
  3. 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.

SharedPlatform.javashared_platform.pySharedPlatform.csshared_platform.goshared-platform.ts
// 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);
}
}

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:

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

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.

Main.javalistener.pyFuncListener.cslistener_func.goexample.ts
static final Location KITCHEN = new Location(0, 0);
long now = 0; // the test's clock
List<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)); }
@Test
void 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);
}
@Test
void 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);
}
@Test
void 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
}
@Test
void 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
}
@Test
void 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
}
@Test
void 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);
}
@Test
void 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());
}
@Test
void 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));
}
@Test
void 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);
}
}
}
@Test
void 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());
}
}
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
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.

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

Interviewerminute 30

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?

What they are testing

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
  1. An offer gets its own small lifecycle, kept in the dispatcher: offered, then accepted, declined or expired.
  2. 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.
  3. Pat accepts: the offer becomes an assignment, and the order’s partner is filled in.
  4. 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.
  5. 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.
  6. The order’s table doesn’t change. The order still moves to READY and PICKED_UP the 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.