If you remember five things
The whole answer in one glance. Everything below defends these five calls.
- Order is a State machine, not a status flag. Legal moves differ per state, so each state is a small class that knows only its own exits. Illegal moves become impossible to express, not merely discouraged.
- Strategy for the parts that change weekly. Pricing and matching get injected into the service, so surge, promos, and batch-matching swap without touching the order flow.
- The last-rider race is the interview. Two orders, one free rider. A per-rider compare-and-set picks exactly one winner, with no lock across the whole pool.
- Cart and Order are different animals. Cart is a mutable scratchpad that dies at checkout. Order is a durable record with a frozen item list and a lifecycle.
- Observer keeps the Order ignorant of listeners. It fires onStatusChange without knowing if a push, an SMS, or a restaurant dashboard is on the other end.
I will walk through it the way I would at a whiteboard in a 45 minute slot: 5 minutes clarifying, 10 on entities and the diagram, 20 on code, the rest on concurrency and follow-ups.
1Clarify the requirements#
Pin down what "food delivery" means for this interview before drawing a single box. The phrase covers everything from a menu app to a global logistics platform, so scope it out loud and let the interviewer redirect you.
What I scope in, and say so.
- The order lifecycle, not map routing or payment-gateway internals.
- If they wanted the dispatch optimizer or the Stripe clone, they can say so now.
What I ask, and the answers I push for.
- Who are the actors? Customer, Restaurant, Delivery Partner (the rider), plus the system itself as matchmaker. Yes to all four.
- One restaurant per order? Yes. A cart maps to a single restaurant. Multi-restaurant carts are a nasty extension parked for section 7.
- Do we assign a rider, or do riders accept? Assignment is pluggable. Default: the system offers an order to the nearest available rider, who can accept or reject.
- Do we handle payments? Model it as an interface. Not writing a payment processor at the whiteboard.
- Real-time tracking? Model rider location updates and order status transitions. The customer sees status, not a live map render.
Functional scope I commit to.
- Browse restaurants and menus, build a cart, place an order.
- Restaurant accepts or rejects, then prepares.
- System matches a delivery partner.
- Order moves through a strict state machine until delivered.
- Everyone gets notified on state changes.
Non-functional scope that actually shapes the design.
- Concurrency. Two customers grab the last available rider. One order, one rider, no double booking. This is the real meat, and where most candidates hand-wave. I will not.
- Extensibility. Pricing, matching, and notifications change often (surge on a rainy Friday night, a new promo shipped mid-week). They must be swappable without touching the order flow.
- Consistency over the order state. An order is never in two states at once, and never skips from PLACED straight to DELIVERED.
I am scoring this opening before you write a line of code. I want to hear you name the two hard parts (the order state machine and the last-rider race) on your own and promise to spend time there. If you start modeling a five-table menu schema, I have quietly decided you have not run a real order system.
2Identify the core entities#
Name the nouns first, then ask whether each earns its place. If an entity has no behavior and no lifecycle, it is a field, not a class.
- Customer places orders. Has a location and payment method.
- Restaurant owns a
Menuand decides accept or reject. Has a location. - MenuItem is a line in the menu with a price and availability flag.
- Cart holds
CartItems before the order is placed. Mutable. Dies at checkout. - Order is the center of gravity: an immutable list of items, a status, a customer, a restaurant, and eventually a rider.
- DeliveryPartner (rider) has a location and an availability status.
- OrderStatus is an enum, but the transitions between values are controlled by a State pattern. That distinction matters, and I defend it in section 5.
- MatchingStrategy decides which rider gets an order.
- PricingStrategy computes the bill (subtotal, delivery fee, surge, discounts).
- NotificationService tells actors about changes via Observer.
- FoodDeliveryService is the facade that ties it together and holds the locks.
Order and Cart look similar but are different animals. Cart is a scratchpad: mutable, throwaway. Order is a durable record with a lifecycle. Keeping them separate stops you from letting someone mutate a placed order's items, which is a real class of bug (customer edits the cart after the kitchen already started cooking).
3Class design#
The spine is the Order and its OrderState.
- Status transitions are a State pattern, not a pile of
if (status == X)checks. The legal moves differ in every state, and I want illegal moves impossible to express. - A
cancel()on an out-for-delivery order should fail to reach a legal path, not pass a runtime flag check. - Pricing and matching are Strategy objects hung off the service.
- Notifications flow through an Observer so the order never needs to know who is listening.
Order *-- OrderItem is composition: items are owned by the order and die with it. Order o-- OrderState is aggregation: the state object is swapped in and out as the order moves. Interviewers read those diamonds, so call the distinction out at the board.4Key implementation#
Start with the state machine, since it drives everything.
- The enum names the states.
- A separate
OrderStateobject enforces which transitions are legal from where.
public enum OrderStatus {
PLACED, ACCEPTED, PREPARING, READY_FOR_PICKUP,
OUT_FOR_DELIVERY, DELIVERED, CANCELLED, REJECTED
}
public interface OrderState {
OrderStatus status();
void next(Order order); // advance to the next legal state
void cancel(Order order); // may throw if not cancellable
}
Each concrete state knows only its own exits. No god-switch anywhere.
public class PlacedState implements OrderState {
public OrderStatus status() { return OrderStatus.PLACED; }
public void next(Order order) {
order.setState(new AcceptedState());
}
public void cancel(Order order) {
// free to cancel before the kitchen commits
order.setState(new CancelledState());
}
}
public class OutForDeliveryState implements OrderState {
public OrderStatus status() { return OrderStatus.OUT_FOR_DELIVERY; }
public void next(Order order) {
order.setState(new DeliveredState());
}
public void cancel(Order order) {
throw new IllegalStateException(
"Cannot cancel an order already on the way");
}
}
Figure. Two concrete states side by side. PlacedState allows a clean cancel; OutForDeliveryState throws on it. The legal exits live inside each state, so there is no central switch to keep in sync.
Order itself holds the state, the items, and the observer list.
- Items are copied in and handed out as an unmodifiable view, so nobody mutates a placed order.
public class Order {
private final String id;
private final Customer customer;
private final Restaurant restaurant;
private final List<OrderItem> items;
private volatile DeliveryPartner rider;
private OrderState state;
private Money total;
private final List<OrderObserver> observers = new CopyOnWriteArrayList<>();
public Order(String id, Customer c, Restaurant r,
List<OrderItem> items, Money total) {
this.id = id;
this.customer = c;
this.restaurant = r;
this.items = List.copyOf(items); // defensive copy, immutable
this.total = total;
this.state = new PlacedState();
}
public synchronized void advance() {
state.next(this);
}
public synchronized void cancel() {
state.cancel(this);
}
void setState(OrderState newState) { // package-private: states only
this.state = newState;
notifyObservers();
}
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
private void notifyObservers() {
for (OrderObserver o : observers) {
o.onStatusChange(this);
}
}
public void addObserver(OrderObserver o) { observers.add(o); }
}
Two deliberate choices here:
setStateis package-private, so only state classes inside the package can drive a transition, never arbitrary client code.itemsgoes throughList.copyOfat construction plusunmodifiableListon the way out: a belt-and-suspenders defense so a placed order's contents are frozen.
Pricing is a Strategy so surge, flat fee, or promo logic swaps cleanly.
public interface PricingStrategy {
Money price(Cart cart, Location riderToRestaurant);
}
public class SurgePricingStrategy implements PricingStrategy {
private final double surgeMultiplier;
public SurgePricingStrategy(double surge) {
this.surgeMultiplier = surge;
}
public Money price(Cart cart, Location distance) {
Money subtotal = cart.subtotal();
Money deliveryFee = Money.of(distance.km() * 8.0);
return subtotal.add(deliveryFee).scale(surgeMultiplier);
}
}
That 8.0 is a per-km rate placeholder, 8 rupees per km in my head. In a real review I would pull it into config, not a literal, but at the whiteboard it shows the shape of the math: subtotal plus distance-based fee, then multiplied by surge.
Reveal the answer
Now the piece that separates a passing answer from a strong one: assigning a rider without double booking. The service, not the order, owns this because the contended resource is the pool of riders.
public class FoodDeliveryService {
private final PricingStrategy pricer;
private final MatchingStrategy matcher;
private final RiderRegistry riders;
public Order placeOrder(Cart cart) {
Money total = pricer.price(cart, cart.estimatedDistance());
Order order = cart.checkout(total);
order.addObserver(new CustomerNotifier(cart.getCustomer()));
return order;
}
public boolean assignRider(Order order) {
List<DeliveryPartner> candidates = riders.availableNear(
order.getRestaurant().getLocation());
for (DeliveryPartner rider : candidates) {
// claim() is an atomic compare-and-set on the rider's status
if (rider.claim(order.getId())) {
order.attachRider(rider);
order.addObserver(new RiderNotifier(rider));
return true;
}
// lost the race, try the next candidate
}
return false; // caller re-queues the order
}
}
The rider's claim is the atomic gate. One winner, everyone else moves on.
public class DeliveryPartner {
private final AtomicReference<String> assignedOrderId =
new AtomicReference<>(null);
// returns true only for the thread that wins the slot
public boolean claim(String orderId) {
return assignedOrderId.compareAndSet(null, orderId);
}
public void release() {
assignedOrderId.set(null);
}
public boolean isAvailable() {
return assignedOrderId.get() == null;
}
}
Figure. The last-rider gate in two parts. The service loops candidates and tries to claim each; DeliveryPartner.claim is a single compare-and-set on the rider's own slot. The thread that flips null to an order id wins; every other thread falls through and tries the next rider.
Why AtomicReference and compare-and-set, not a synchronized block around the candidate loop?
- A lock on the rider pool serializes every assignment in the system.
- On a Friday dinner rush that is thousands of orders per minute queueing behind one lock.
- Per-rider CAS keeps contention down to the exact rider two orders are fighting over, and nothing else blocks.
Lock the contended resource, not the whole pool around it.
5Design decisions and patterns used#
The short version of why each pattern is here:
- State pattern for
OrderState. Legal transitions differ per state, and I want the type system to reject acancel()on an out-for-delivery order, not a runtime flag buried in a method. Each state is a small class that knows only its own exits. - Strategy pattern for
PricingStrategyandMatchingStrategy. These change constantly in a real business: surge on New Year's Eve, nearest-rider versus batch-matching, a flat promo fee. Inject them into the service so the order flow never changes when the math does. - Observer pattern for notifications. The
OrderfiresonStatusChangewithout knowing whether a push, an SMS, or a restaurant dashboard is listening. A new listener is a new observer, not an edit toOrder. - Factory (implied, not shown in full) for building
Orderfrom aCartat checkout. Keeps the construction rules (copy items, snapshot price) in one place. - Facade via
FoodDeliveryService. Clients callplaceOrderandassignRiderand never touch the state classes or the rider registry directly.
Why keep OrderStatus as a plain enum alongside the state objects?
- The enum is for persistence and display: the value you write to a database column and show on a screen.
- The state objects carry behavior.
- Mix the two into one and you get a 400-line enum with a
next()method full of switch arms that nobody wants to touch.
6Handling edge cases and concurrency#
A quick tour of the races and the traps, each with the defense.
- The last-rider race. Two orders, one free rider, two threads in
assignRider. ThecompareAndSetinclaimguarantees exactly one wins; the loser falls through to the next candidate or re-queues. No lock held across the loop, so no contention on the whole pool. I name this before anyone asks, because it is the single most likely question to decide the interview. - Double state advance. A flaky network makes the restaurant hit "mark ready" twice. Because
advance()issynchronizedon the order and each state only moves forward, the second call runs from the new state. If that state has no validnext, it throws rather than silently skipping ahead. Terminal states (DELIVERED,CANCELLED) throw on any transition. - Cancel mid-flight. Customer cancels while the rider is carrying the food.
OutForDeliveryState.cancelthrows. The product answer is "too late, contact support," and the type enforces it so no amount of client retrying sneaks a cancel through. - Rider rejects after claiming.
claimsucceeded but the rider taps reject. Callrelease()to CAS the slot back to null and re-run matching. Add a short claim timeout: if the rider does not confirm pickup within N seconds (30 to 60 in practice), a reaper releases the claim automatically so a tapped-and-walked-away rider never strands an order. - Menu item sells out between browse and checkout. Validate availability inside
checkout(), not at browse time. The order factory re-checks each item and rejects the checkout if anything is unavailable. Checking at browse time is a classic time-of-check to time-of-use bug: the item was there when you looked, gone when you paid. - Observer throws. One notifier failing should not break the transition or block the others. In production I dispatch notifications to an executor so a slow SMS provider cannot stall the state machine. The
CopyOnWriteArrayListkeeps iteration safe while listeners are added, so a notification firing mid-iteration never throws aConcurrentModificationException.
Guarding the rider pool with one big synchronized block and calling it done. It is correct and it is a disaster: every assignment in the system now queues behind a single lock exactly when traffic peaks. If you cannot explain why the lock lives on the individual rider and not the pool, I cannot tell you apart from someone who has only read about concurrency.
7Extensions and follow-ups#
If the interviewer pushes, here is where I go.
- Batch matching. Swap
MatchingStrategyfor one that gives a rider two nearby orders heading the same direction. The service code does not change, only the injected strategy. This is the payoff for making matching a Strategy up front. - Multi-restaurant cart. Split one checkout into multiple
Orderobjects under a parentOrderGroup, each with its own rider and lifecycle, aggregated for the customer view. - Scheduled orders. Add a
ScheduledStatebeforePLACEDand a scheduler that advances it at the target time. - Real-time rider tracking. Riders push location updates; the customer subscribes as an observer on location, not just status.
- Refunds and partial delivery. Hang a
PaymentStrategyoff the service and emit compensation events when an order lands in a failed terminal state.
"Why a State pattern and not just a status enum with a switch?"
"Two orders want the last rider at the same moment. Walk me through it."
"A rider claims an order, then walks away without confirming. Now what?"
"The item was available when added to cart but sold out by checkout. How do you avoid charging for it?"
"One of your notifiers is a slow SMS provider. Does it stall the order?"
8What the interviewer is really checking#
The three things that decide this one:
- Did you reach for a State machine instead of a status flag? That single choice tells them whether you have maintained a real order system.
- Can you tell the concurrency story on the shared rider pool in terms of a CAS or a lock, with the cost of the wrong choice, not hand-waved?
- Are your strategies injected, so the parts that change weekly in a real business do not force edits to the parts that should stay stable?
Draw the state machine clearly, name the last-rider race before they ask, and keep pricing and matching swappable, and you have shown all three.
What you can now explain in the room
- Why order transitions belong in State objects, not a switch over an enum.
- Why Cart and Order are separate, and what bug that separation prevents.
- How per-rider compare-and-set beats a lock on the whole pool, and the cost of getting it wrong.
- Why pricing and matching are Strategy objects injected into the service.
- How a claim timeout and reaper recover a rider who claims and walks away.
- Why availability is validated at checkout, not at browse, and what TOCTOU means here.
Related: see the State pattern write-up, the Strategy pattern guide, and other lifecycle-heavy problems like Design a Ride-Sharing System and Design an Elevator.
Get a prep plan built around your Design a Food Delivery System interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.