Low Level Design (LLD) Interview Questions
Design a Food Ordering and Delivery Service
Tier: EssentialDifficulty: HardAsked of: Mid, SeniorAsked at: Zomato, Swiggy, DoorDash, Uber Eats, Amazon
The spine of this design is the order status transition map. Write it down early, guard every move through it, and most of the rest of the answer falls out.
This is the low level design round, so the answer is classes, responsibilities and code inside one system. The system design counterpart on this site is the client side of the same flow, design a checkout screen, which covers the screens, the APIs and the duplicate charge problem. Read that one for the client. Read this one for the objects behind the order.
What this really tests
Whether you model a lifecycle as data rather than as a scatter of boolean flags, and whether you notice that three separate parties are all trying to move the same order at the same time. This problem has more nouns than any other in the set, so it also tests whether you can keep the code narrow while the class list is wide.
What to clarify first
- Are all three sides in scope. Customer, restaurant and courier. Sometimes the courier side is dropped, and that halves the work.
- One restaurant per order. Confirm it. It is the usual rule and it keeps the cart simple.
- Is search and discovery in scope, or does the customer arrive with a restaurant already chosen. Search can eat the hour on its own.
- Who assigns the courier, and by what policy. Nearest to the kitchen is the default and the policy is a follow up question.
- When can an order be cancelled. Before the restaurant accepts, before the food is cooked, never. This one answer writes several rows of your transition table.
- Can a restaurant mark an item unavailable while it is in somebody's cart. Yes, and that is the reason checkout validates rather than trusting the cart.
- Are coupons in scope. If yes, ask whether they stack, because the order of application changes the total.
- Is live tracking in scope, or is a status trail enough.
Out of scope, said plainly. Payment card handling, ratings and reviews, restaurant onboarding, surge fees on delivery, and route optimisation for a courier carrying several orders.
The classes
The catalogue side.
- Restaurant owns its menu, its location and whether it is open. Open and closed lives here and not on the order, because it is a property of the shop and not of any one purchase.
- MenuItem is an id, a name, a price and an availability flag. That flag is the small thing that causes the interesting bug later.
- Location knows how to measure the distance to another Location, so nothing else in the system does geometry.
The buying side.
- Cart belongs to one customer and one restaurant. It records the price it saw when each item went in, and it holds the restaurant itself rather than a copy of the menu, so the menu it checks against at checkout is the live one. That is not bookkeeping, it is what lets checkout tell the customer what changed rather than silently charging a different number.
- OrderItem is a snapshot taken at order time. Name, unit price and quantity, copied, not referenced. A menu edited tomorrow must not be able to rewrite yesterday's receipt.
- Order holds the customer, the restaurant, the snapshotted items, the total, the assigned courier, and the status. The status is the only thing that ever changes, and it changes through exactly one guarded call.
- OrderStatus is the transition map. Placed, confirmed, preparing, ready for pickup, out for delivery, delivered, and cancelled reachable from the first three. Every legal move is written down once inside the enum.
The delivery side.
- DeliveryAgent is a courier. It owns its own location and its own availability, and it is the only class allowed to change either. Taking a job is a single call that wins or loses. This is the same class as a ride hailing driver in an apron, and saying that out loud is worth a mark.
- AssignmentStrategy ranks free couriers for an order. Nearest to the kitchen is the default, closest to the customer and least busy are the follow ups.
The listeners.
- OrderObserver is anything that wants to hear about a status change. There are genuinely three, the customer's tracking screen, the restaurant's kitchen board and the courier's app, and they each care about a different part of the same event.
The front door.
- OrderService is the only class the three apps talk to. Place, accept, cook, ready, assign, pick up, deliver, cancel. Every one of those methods is a guarded transition plus a side effect, which is what makes the state machine the spine of the design rather than a picture on a slide.
How an order actually moves
Follow one order through and the whole design explains itself.
A customer builds a cart from one restaurant's menu. Nothing is validated as items go in, deliberately. Then they hit checkout.
Checkout validates the entire cart at once against the live menu. Is the restaurant still open. Is every item still available. Has any price moved since it went in the cart. It returns a list of what is wrong rather than a single failure, because the app needs to show the customer exactly which two dishes changed. If everything checks out, each line is snapshotted into an OrderItem at the price live right now, the total is computed, and an Order is created in the placed state.
The restaurant sees it on the kitchen board and accepts, which moves the order from placed to confirmed. It starts cooking, which moves it to preparing. Every one of these moves names the status the caller believes the order is in, and the order refuses the move if it has already gone somewhere else.
While the food cooks, the service looks for a courier. It asks the assignment strategy to rank the free couriers by distance from the kitchen and then walks that ranked list, asking each one in turn to take the job. The first courier whose claim succeeds is the one who gets it. This is the same walk as ride hailing, for the same reason.
The kitchen marks the order ready. The courier collects it, which moves the order to out for delivery, and delivers it, which moves it to delivered and releases the courier back into the pool.
If the customer cancels at any point before the food is ready, the cancellation names the status they think the order is in. If the kitchen moved it first, the cancellation fails and the app tells the customer their food is already being made. That is the correct outcome, not an error.
Written out as a map, the whole lifecycle fits on a whiteboard, and it is worth drawing before any code.
Patterns actually used
A state machine, and it is the spine. The order lifecycle has real invariants. You cannot deliver an order nobody picked up. You cannot cancel food that is already in a bag on a motorbike.
There is a choice here worth voicing. You can implement this as an enum that knows its own legal moves, or as one class per state with the same method on each. The enum with a transition rule is more compact and it puts the whole map in one readable place, which is exactly what you want on a whiteboard. State classes earn their keep when each state has genuinely different behaviour for the same operation, and here they do not. Say that you considered both and why you picked the enum.
Strategy on delivery assignment. The policy for choosing a courier is the thing that changes. Nearest to the kitchen today, least busy tomorrow, zone based the week after. One seam, and each policy is a small class or a lambda.
Note the same split as ride hailing. The strategy ranks and stops. The service claims. Ranking is business policy written by people thinking about delivery times. Claiming is concurrency and has to be written once and correctly.
Observer, and here it is the textbook fit. One status change, three independent audiences. The customer's screen wants the whole trail. The kitchen board wants work appearing and disappearing. The courier app wants only the orders assigned to them. Put those three concerns inside the order and the order becomes a notification service. Publish and forget, and each of the three is a small class that subscribes.
What is deliberately not here. No five strategy interfaces. Search, delivery charge, discount and payment are all legitimate strategies and shipping all of them is noise, so one is implemented and the rest are named. No command pattern on the cart, because nobody asked for undo. No factory for payment or notification types, because a map of suppliers does the same job in one line. No coupon engine, because discount stacking is a follow up conversation and not a class hierarchy.
Java
com.androidinterview.foodordering.delivery.AssignmentStrategy.java
package com.androidinterview.foodordering.delivery;
import java.util.List;
import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;
// Ranking only. It orders the couriers and stops there, because claiming one
// has to be atomic and a policy author should not have to get that right again
// in every new strategy.
public interface AssignmentStrategy {
List<DeliveryAgent> rank(Order order, List<DeliveryAgent> candidates);
}
com.androidinterview.foodordering.delivery.NearestAgentStrategy.java
package com.androidinterview.foodordering.delivery;
import java.util.Comparator;
import java.util.List;
import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;
// Nearest to the restaurant, because the courier has to collect before they can
// deliver. A least busy or zone based policy is a different class and one line
// of wiring.
public final class NearestAgentStrategy implements AssignmentStrategy {
@Override
public List<DeliveryAgent> rank(Order order, List<DeliveryAgent> candidates) {
var kitchen = order.restaurant().location();
return candidates.stream()
.sorted(Comparator.comparingDouble(agent -> agent.location().distanceKmTo(kitchen)))
.toList();
}
}
com.androidinterview.foodordering.model.Cart.java
package com.androidinterview.foodordering.model;
import java.util.ArrayList;
import java.util.List;
// One restaurant per cart, which is the usual rule and worth confirming out
// loud. The cart records the price it saw when an item went in and holds the
// live restaurant rather than a copy of its menu, so checkout compares the two
// and can tell the customer what changed rather than silently charging more.
public final class Cart {
public record Line(String itemId, String name, Money priceWhenAdded, int quantity) {}
private final Customer customer;
private final Restaurant restaurant;
private final List<Line> lines = new ArrayList<>();
public Cart(Customer customer, Restaurant restaurant) {
this.customer = customer;
this.restaurant = restaurant;
}
public Customer customer() { return customer; }
public Restaurant restaurant() { return restaurant; }
public List<Line> lines() { return List.copyOf(lines); }
public void add(MenuItem item, int quantity) {
lines.add(new Line(item.id(), item.name(), item.price(), quantity));
}
public void remove(String itemId) {
lines.removeIf(line -> line.itemId().equals(itemId));
}
// Validate the whole cart at checkout, never at add time. An item can sell
// out or change price while the customer is still choosing, and the honest
// answer is a list of what changed rather than one unhelpful failure.
public List<String> problemsAtCheckout() {
List<String> problems = new ArrayList<>();
if (!restaurant.open()) {
problems.add("the restaurant is closed");
}
for (Line line : lines) {
MenuItem live = restaurant.itemById(line.itemId());
if (live == null || !live.available()) {
problems.add(line.name() + " is no longer available");
} else if (!live.price().equals(line.priceWhenAdded())) {
problems.add(line.name() + " has changed price");
}
}
return problems;
}
}
com.androidinterview.foodordering.model.Customer.java
package com.androidinterview.foodordering.model;
public record Customer(String id, String name, Location address) {
}
com.androidinterview.foodordering.model.DeliveryAgent.java
package com.androidinterview.foodordering.model;
// The same class as a ride hailing driver, wearing an apron. Two orders can
// reach the same courier at the same instant, so the check and the set have to
// be one operation. It is the identical bug in a different costume, and saying
// that out loud is worth a mark.
public final class DeliveryAgent {
private final String id;
private final String name;
private Location location;
private String currentOrderId;
private boolean online;
public DeliveryAgent(String id, String name, Location location) {
this.id = id;
this.name = name;
this.location = location;
}
public String id() { return id; }
public String name() { return name; }
public synchronized Location location() { return location; }
public synchronized boolean isFree() { return online && currentOrderId == null; }
public synchronized void goOnline(Location at) {
this.online = true;
this.location = at;
}
public synchronized boolean tryAssign(String orderId) {
if (!online || currentOrderId != null) {
return false;
}
currentOrderId = orderId;
return true;
}
// Owner checked, so a retry or a stale timer cannot free a courier who has
// already picked up somebody else's order.
public synchronized boolean releaseIfOn(String orderId) {
if (!orderId.equals(currentOrderId)) {
return false;
}
currentOrderId = null;
return true;
}
}
com.androidinterview.foodordering.model.Location.java
package com.androidinterview.foodordering.model;
// Distance sits on the value object, so nothing else has to know how it is
// measured. Straight line is fine for ranking couriers inside one city, which
// is why this is simpler than the haversine in the ride hailing answer. Real
// routing is a service call and is not this round.
public record Location(double latitude, double longitude) {
public double distanceKmTo(Location other) {
double dLat = other.latitude - latitude;
double dLng = other.longitude - longitude;
return Math.sqrt(dLat * dLat + dLng * dLng) * 111.0;
}
}
com.androidinterview.foodordering.model.MenuItem.java
package com.androidinterview.foodordering.model;
// Availability is on the item because a kitchen turns dishes off mid service.
// That single flag is what makes checkout validation necessary.
public record MenuItem(String id, String name, Money price, boolean available) {
}
com.androidinterview.foodordering.model.Money.java
package com.androidinterview.foodordering.model;
public record Money(String currency, long amount) {
public Money plus(Money other) {
return new Money(currency, amount + other.amount);
}
public Money times(int quantity) {
return new Money(currency, amount * quantity);
}
}
com.androidinterview.foodordering.model.Order.java
package com.androidinterview.foodordering.model;
import java.util.List;
// One order. The status is the only thing that changes, and it changes through
// one guarded call.
public final class Order {
private final String id;
private final Customer customer;
private final Restaurant restaurant;
private final List<OrderItem> items;
private final Money total;
private OrderStatus status = OrderStatus.PLACED;
private DeliveryAgent agent;
public Order(String id, Customer customer, Restaurant restaurant,
List<OrderItem> items, Money total) {
this.id = id;
this.customer = customer;
this.restaurant = restaurant;
this.items = List.copyOf(items);
this.total = total;
}
public String id() { return id; }
public Customer customer() { return customer; }
public Restaurant restaurant() { return restaurant; }
public List<OrderItem> items() { return items; }
public Money total() { return total; }
public synchronized OrderStatus status() { return status; }
public synchronized DeliveryAgent agent() { return agent; }
// A compare and set, not a plain setter. The restaurant marking an order
// ready and the customer cancelling it can arrive in the same millisecond.
// Both name the status they believe the order is in, so exactly one wins
// and the loser gets false instead of overwriting.
public synchronized boolean compareAndSetStatus(OrderStatus expected, OrderStatus next) {
if (status != expected || !status.canMoveTo(next)) {
return false;
}
status = next;
return true;
}
// First courier to arrive keeps the job. Overwriting here would orphan the
// courier who was already claimed, leaving them busy with no order.
public synchronized boolean attachAgent(DeliveryAgent assigned) {
if (this.agent != null) {
return false;
}
this.agent = assigned;
return true;
}
}
com.androidinterview.foodordering.model.OrderItem.java
package com.androidinterview.foodordering.model;
// A snapshot, not a reference to the live menu. The price the customer agreed
// to is fixed at order time, so a menu edit tomorrow cannot rewrite yesterday's
// receipt.
public record OrderItem(String itemId, String name, Money unitPrice, int quantity) {
public Money lineTotal() {
return unitPrice.times(quantity);
}
}
com.androidinterview.foodordering.model.OrderStatus.java
package com.androidinterview.foodordering.model;
// The spine of this problem. Every legal move is written down once, so the
// illegal ones are visible at a glance and no caller has to remember them.
//
// Cancellation is allowed while the kitchen has not finished. Once the food is
// ready somebody has already paid for ingredients, so a cancellation after that
// is a refund policy question rather than a status change.
public enum OrderStatus {
PLACED,
CONFIRMED,
PREPARING,
READY_FOR_PICKUP,
OUT_FOR_DELIVERY,
DELIVERED,
CANCELLED;
// A courier is worth claiming from the moment the restaurant accepts until
// the food is handed over. Claiming one for a delivered or cancelled order
// strands a courier in a busy state with nothing to carry.
public boolean acceptsCourier() {
return this == CONFIRMED || this == PREPARING || this == READY_FOR_PICKUP;
}
public boolean canMoveTo(OrderStatus next) {
return switch (this) {
case PLACED -> next == CONFIRMED || next == CANCELLED;
case CONFIRMED -> next == PREPARING || next == CANCELLED;
case PREPARING -> next == READY_FOR_PICKUP || next == CANCELLED;
case READY_FOR_PICKUP -> next == OUT_FOR_DELIVERY;
case OUT_FOR_DELIVERY -> next == DELIVERED;
case DELIVERED, CANCELLED -> false;
};
}
}
com.androidinterview.foodordering.model.Restaurant.java
package com.androidinterview.foodordering.model;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// The restaurant owns its menu, and the menu is live. A cart that copied the
// menu when it was opened could never notice a dish being turned off, which is
// the exact case checkout exists to catch, so the kitchen edits this object and
// every open cart reads the edit on its next lookup.
public final class Restaurant {
private final String id;
private final String name;
private final Location location;
private final Map<String, MenuItem> menu = new ConcurrentHashMap<>();
private volatile boolean open;
public Restaurant(String id, String name, Location location, boolean open, List<MenuItem> menu) {
this.id = id;
this.name = name;
this.location = location;
this.open = open;
for (MenuItem item : menu) {
this.menu.put(item.id(), item);
}
}
public String id() { return id; }
public String name() { return name; }
public Location location() { return location; }
public boolean open() { return open; }
public List<MenuItem> menu() { return List.copyOf(menu.values()); }
public MenuItem itemById(String itemId) {
return menu.get(itemId);
}
public void setOpen(boolean nowOpen) {
this.open = nowOpen;
}
// The kitchen turning a dish off or repricing it, mid service, while carts
// are open. One write, and the next checkout sees it.
public void updateItem(MenuItem item) {
menu.put(item.id(), item);
}
}
com.androidinterview.foodordering.notify.CourierApp.java
package com.androidinterview.foodordering.notify;
import java.util.ArrayList;
import java.util.List;
import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;
// The courier side of the same event, and the reason the fan out is three
// listeners rather than one. This one filters on the assigned courier, so a
// rider only ever sees their own jobs.
public final class CourierApp implements OrderObserver {
private final String agentId;
private final List<String> jobs = new ArrayList<>();
public CourierApp(String agentId) {
this.agentId = agentId;
}
@Override
public void onStatusChanged(Order order, OrderStatus from) {
DeliveryAgent assigned = order.agent();
if (assigned == null || !assigned.id().equals(agentId)) {
return;
}
switch (order.status()) {
case READY_FOR_PICKUP -> jobs.add(order.id());
case DELIVERED, CANCELLED -> jobs.remove(order.id());
default -> { }
}
}
public List<String> jobs() {
return List.copyOf(jobs);
}
}
com.androidinterview.foodordering.notify.CustomerTracker.java
package com.androidinterview.foodordering.notify;
import java.util.ArrayList;
import java.util.List;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;
// What the tracking screen reads. It keeps the trail rather than sending a
// push, because a domain object that talks to a push service is a domain object
// you cannot test.
public final class CustomerTracker implements OrderObserver {
private final List<String> trail = new ArrayList<>();
@Override
public void onStatusChanged(Order order, OrderStatus from) {
if (from == null) {
trail.add(order.id() + " placed");
return;
}
trail.add(order.id() + " moved from " + from + " to " + order.status());
}
public List<String> trail() {
return List.copyOf(trail);
}
}
com.androidinterview.foodordering.notify.KitchenBoard.java
package com.androidinterview.foodordering.notify;
import java.util.ArrayList;
import java.util.List;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;
// The restaurant side of the same event. It only cares about work arriving and
// work leaving, which is the argument for a fan out rather than one notifier
// with a switch inside it.
public final class KitchenBoard implements OrderObserver {
private final List<String> queue = new ArrayList<>();
@Override
public void onStatusChanged(Order order, OrderStatus from) {
switch (order.status()) {
case CONFIRMED -> queue.add(order.id());
case OUT_FOR_DELIVERY, CANCELLED -> queue.remove(order.id());
default -> { }
}
}
public List<String> queue() {
return List.copyOf(queue);
}
}
com.androidinterview.foodordering.notify.OrderObserver.java
package com.androidinterview.foodordering.notify;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;
// One status change, three audiences that care about different parts of it.
// The order service publishes and knows none of them.
//
// A brand new order came from nowhere, so from is null for the creation event.
// Reporting a move from placed to placed would be a lie about a transition that
// never happened.
@FunctionalInterface
public interface OrderObserver {
void onStatusChanged(Order order, OrderStatus from);
}
com.androidinterview.foodordering.service.CheckoutResult.java
package com.androidinterview.foodordering.service;
import java.util.List;
import com.androidinterview.foodordering.model.Order;
// Checkout either produces an order or a list of reasons it could not. An empty
// Optional would throw away the reasons, which are the only part the customer
// can act on.
public sealed interface CheckoutResult {
record Placed(Order order) implements CheckoutResult {}
record Rejected(List<String> reasons) implements CheckoutResult {
public Rejected {
reasons = List.copyOf(reasons);
}
}
}
com.androidinterview.foodordering.service.OrderService.java
package com.androidinterview.foodordering.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import com.androidinterview.foodordering.delivery.AssignmentStrategy;
import com.androidinterview.foodordering.model.Cart;
import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.MenuItem;
import com.androidinterview.foodordering.model.Money;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderItem;
import com.androidinterview.foodordering.model.OrderStatus;
import com.androidinterview.foodordering.notify.OrderObserver;
// The one class the three apps talk to. Every method is a guarded transition
// plus a side effect, which is what makes the state machine the spine of the
// design rather than a diagram in the slides.
public final class OrderService {
private final AssignmentStrategy assignment;
private final String currency;
private final List<OrderObserver> observers = new CopyOnWriteArrayList<>();
public OrderService(AssignmentStrategy assignment, String currency) {
this.assignment = assignment;
this.currency = currency;
}
public void subscribe(OrderObserver observer) {
observers.add(observer);
}
// Checkout validates the whole cart at once against the live menu and
// snapshots every price. Failing with a list of reasons lets the app show
// the customer exactly what changed.
public CheckoutResult placeOrder(Cart cart) {
List<String> problems = cart.problemsAtCheckout();
if (!problems.isEmpty()) {
return new CheckoutResult.Rejected(problems);
}
List<OrderItem> items = new ArrayList<>();
Money total = new Money(currency, 0);
for (Cart.Line line : cart.lines()) {
MenuItem live = cart.restaurant().itemById(line.itemId());
OrderItem item = new OrderItem(live.id(), live.name(), live.price(), line.quantity());
items.add(item);
total = total.plus(item.lineTotal());
}
Order order = new Order(UUID.randomUUID().toString(),
cart.customer(), cart.restaurant(), items, total);
publish(order, null);
return new CheckoutResult.Placed(order);
}
public boolean restaurantAccepts(Order order) {
return move(order, OrderStatus.PLACED, OrderStatus.CONFIRMED);
}
public boolean startCooking(Order order) {
return move(order, OrderStatus.CONFIRMED, OrderStatus.PREPARING);
}
public boolean markReady(Order order) {
return move(order, OrderStatus.PREPARING, OrderStatus.READY_FOR_PICKUP);
}
// Ranking is policy and the claim is concurrency, so they stay apart. The
// first courier whose claim succeeds is the one we get, and every loser
// costs a single step down the list.
//
// The status check keeps a courier off an order that is already delivered
// or cancelled, and the attach is a compare and set so a second call cannot
// overwrite the first courier and strand them.
public Optional<DeliveryAgent> assignAgent(Order order, List<DeliveryAgent> fleet) {
if (!order.status().acceptsCourier() || order.agent() != null) {
return Optional.empty();
}
for (DeliveryAgent agent : assignment.rank(order, fleet)) {
if (agent.tryAssign(order.id())) {
if (order.attachAgent(agent)) {
return Optional.of(agent);
}
agent.releaseIfOn(order.id());
return Optional.empty();
}
}
return Optional.empty();
}
public boolean pickUp(Order order) {
return move(order, OrderStatus.READY_FOR_PICKUP, OrderStatus.OUT_FOR_DELIVERY);
}
public boolean deliver(Order order) {
if (!move(order, OrderStatus.OUT_FOR_DELIVERY, OrderStatus.DELIVERED)) {
return false;
}
releaseAgent(order);
return true;
}
// Cancellation names the status the caller believed the order was in. If
// the kitchen moved it first, this returns false and the app tells the
// customer the food is already being made.
public boolean cancel(Order order, OrderStatus expected) {
if (!move(order, expected, OrderStatus.CANCELLED)) {
return false;
}
releaseAgent(order);
return true;
}
private void releaseAgent(Order order) {
DeliveryAgent agent = order.agent();
if (agent != null) {
agent.releaseIfOn(order.id());
}
}
private boolean move(Order order, OrderStatus expected, OrderStatus next) {
if (!order.compareAndSetStatus(expected, next)) {
return false;
}
publish(order, expected);
return true;
}
private void publish(Order order, OrderStatus from) {
for (OrderObserver observer : observers) {
observer.onStatusChanged(order, from);
}
}
}
Kotlin
com.androidinterview.foodordering.delivery.Assignment.kt
package com.androidinterview.foodordering.delivery
import com.androidinterview.foodordering.model.DeliveryAgent
import com.androidinterview.foodordering.model.Order
// Ranking only. It orders the couriers and stops there, because claiming one
// has to be atomic and a policy author should not have to get that right again
// in every new strategy.
//
// A function type rather than an interface, since a policy here has no state
// and no configuration.
typealias AssignmentStrategy = (Order, List<DeliveryAgent>) -> List<DeliveryAgent>
// Nearest to the kitchen, because a courier has to collect before they can
// deliver.
val nearestToKitchen: AssignmentStrategy = { order, candidates ->
candidates.sortedBy { it.location.distanceKmTo(order.restaurant.location) }
}
// The follow up policy an interviewer asks for. One line of wiring, no change
// anywhere else.
val closestToCustomer: AssignmentStrategy = { order, candidates ->
candidates.sortedBy { it.location.distanceKmTo(order.customer.address) }
}
com.androidinterview.foodordering.model.Cart.kt
package com.androidinterview.foodordering.model
// One restaurant per cart, which is the usual rule and worth confirming out
// loud. The cart records the price it saw when an item went in and holds the
// live restaurant rather than a copy of its menu, so checkout compares the two
// and can say what changed rather than silently charging more.
class Cart(val customer: Customer, val restaurant: Restaurant) {
data class Line(val itemId: String, val name: String, val priceWhenAdded: Money, val quantity: Int)
private val _lines = mutableListOf<Line>()
val lines: List<Line> get() = _lines.toList()
fun add(item: MenuItem, quantity: Int) {
_lines += Line(item.id, item.name, item.price, quantity)
}
fun remove(itemId: String) {
_lines.removeAll { it.itemId == itemId }
}
// Validate the whole cart at checkout, never at add time. An item can sell
// out or change price while the customer is still choosing, and the honest
// answer is a list of what changed rather than one unhelpful failure.
fun problemsAtCheckout(): List<String> = buildList {
if (!restaurant.open) add("the restaurant is closed")
for (line in lines) {
val live = restaurant.itemById(line.itemId)
when {
live == null || !live.available -> add("${line.name} is no longer available")
live.price != line.priceWhenAdded -> add("${line.name} has changed price")
}
}
}
}
com.androidinterview.foodordering.model.DeliveryAgent.kt
package com.androidinterview.foodordering.model
import java.util.concurrent.atomic.AtomicReference
// The same class as a ride hailing driver, wearing an apron. Two orders can
// reach the same courier at the same instant, so the check and the set have to
// be one operation. It is the identical bug in a different costume, and saying
// that out loud is worth a mark.
class DeliveryAgent(val id: String, val name: String, initialLocation: Location) {
private val currentOrderId = AtomicReference<String?>(null)
@Volatile
var location: Location = initialLocation
private set
@Volatile
var online: Boolean = false
private set
val isFree: Boolean get() = online && currentOrderId.get() == null
fun goOnline(at: Location) {
location = at
online = true
}
fun ping(at: Location) {
location = at
}
// The check and the set in one instruction. Two orders reaching this
// courier at the same moment, and exactly one of them gets true.
fun tryAssign(orderId: String): Boolean = online && currentOrderId.compareAndSet(null, orderId)
// Owner checked, and the compare is the owner check. A stale timer cannot
// free a courier who is already carrying somebody else's food.
fun releaseIfOn(orderId: String): Boolean = currentOrderId.compareAndSet(orderId, null)
}
com.androidinterview.foodordering.model.Domain.kt
package com.androidinterview.foodordering.model
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.sqrt
data class Money(val currency: String, val amount: Long) {
operator fun plus(other: Money) = copy(amount = amount + other.amount)
operator fun times(quantity: Int) = copy(amount = amount * quantity)
}
// Distance sits on the value object, so nothing else has to know how it is
// measured. Straight line is fine for ranking couriers inside one city, which
// is why this is simpler than the haversine in the ride hailing answer. Real
// routing is a service call and is not this round.
data class Location(val latitude: Double, val longitude: Double) {
fun distanceKmTo(other: Location): Double {
val dLat = other.latitude - latitude
val dLng = other.longitude - longitude
return sqrt(dLat * dLat + dLng * dLng) * 111.0
}
}
// Availability is on the item because a kitchen turns dishes off mid service.
// That single flag is what makes checkout validation necessary.
data class MenuItem(val id: String, val name: String, val price: Money, val available: Boolean)
// The restaurant owns its menu, and the menu is live. A cart that copied the
// menu when it was opened could never notice a dish being turned off, which is
// the exact case checkout exists to catch, so the kitchen edits this object and
// every open cart reads the edit on its next lookup.
class Restaurant(
val id: String,
val name: String,
val location: Location,
open: Boolean,
menu: List<MenuItem>,
) {
private val items = ConcurrentHashMap<String, MenuItem>().apply {
menu.forEach { put(it.id, it) }
}
@Volatile
var open: Boolean = open
val menu: List<MenuItem> get() = items.values.toList()
fun itemById(itemId: String): MenuItem? = items[itemId]
// The kitchen turning a dish off or repricing it, mid service, while carts
// are open. One write, and the next checkout sees it.
fun updateItem(item: MenuItem) {
items[item.id] = item
}
}
data class Customer(val id: String, val name: String, val address: Location)
// A snapshot, not a reference to the live menu. The price the customer agreed
// to is fixed at order time, so a menu edit tomorrow cannot rewrite yesterday's
// receipt.
data class OrderItem(val itemId: String, val name: String, val unitPrice: Money, val quantity: Int) {
val lineTotal: Money get() = unitPrice * quantity
}
com.androidinterview.foodordering.model.Order.kt
package com.androidinterview.foodordering.model
import java.util.concurrent.atomic.AtomicReference
// The spine of this problem. Every legal move is written down once, so the
// illegal ones are visible at a glance and no caller has to remember them.
//
// Cancellation is allowed while the kitchen has not finished. Once the food is
// ready somebody has paid for ingredients, so cancelling after that is a refund
// policy question rather than a status change.
enum class OrderStatus {
PLACED,
CONFIRMED,
PREPARING,
READY_FOR_PICKUP,
OUT_FOR_DELIVERY,
DELIVERED,
CANCELLED;
// A courier is worth claiming from the moment the restaurant accepts until
// the food is handed over. Claiming one for a delivered or cancelled order
// strands a courier in a busy state with nothing to carry.
val acceptsCourier: Boolean
get() = this == CONFIRMED || this == PREPARING || this == READY_FOR_PICKUP
fun canMoveTo(next: OrderStatus) = next in when (this) {
PLACED -> setOf(CONFIRMED, CANCELLED)
CONFIRMED -> setOf(PREPARING, CANCELLED)
PREPARING -> setOf(READY_FOR_PICKUP, CANCELLED)
READY_FOR_PICKUP -> setOf(OUT_FOR_DELIVERY)
OUT_FOR_DELIVERY -> setOf(DELIVERED)
DELIVERED, CANCELLED -> emptySet()
}
}
class Order(
val id: String,
val customer: Customer,
val restaurant: Restaurant,
val items: List<OrderItem>,
val total: Money,
) {
private val state = AtomicReference(OrderStatus.PLACED)
private val assigned = AtomicReference<DeliveryAgent?>(null)
val status: OrderStatus get() = state.get()
val agent: DeliveryAgent? get() = assigned.get()
// A compare and set, not a setter. The restaurant marking an order ready
// and the customer cancelling it can arrive in the same millisecond. Both
// name the status they believe the order is in, so exactly one wins and the
// loser gets false instead of overwriting.
fun compareAndSetStatus(expected: OrderStatus, next: OrderStatus): Boolean =
expected.canMoveTo(next) && state.compareAndSet(expected, next)
// First courier to arrive keeps the job. Overwriting here would orphan the
// courier who was already claimed, leaving them busy with no order.
fun attachAgent(agent: DeliveryAgent): Boolean = assigned.compareAndSet(null, agent)
}
com.androidinterview.foodordering.service.OrderService.kt
package com.androidinterview.foodordering.service
import com.androidinterview.foodordering.delivery.AssignmentStrategy
import com.androidinterview.foodordering.model.Cart
import com.androidinterview.foodordering.model.DeliveryAgent
import com.androidinterview.foodordering.model.Money
import com.androidinterview.foodordering.model.Order
import com.androidinterview.foodordering.model.OrderItem
import com.androidinterview.foodordering.model.OrderStatus
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
// Checkout either produces an order or a list of reasons it could not. A
// nullable Order would throw away the reasons, which are the only part the
// customer can act on.
sealed interface CheckoutResult {
data class Placed(val order: Order) : CheckoutResult
data class Rejected(val reasons: List<String>) : CheckoutResult
}
// The one class the three apps talk to. Every method is a guarded transition
// plus a side effect, which is what makes the state machine the spine of the
// design rather than a diagram on a slide.
//
// Observers are function types. The customer app, the kitchen board and the
// courier app all want the same event and none of them belong in the order.
class OrderService(
private val assignment: AssignmentStrategy,
private val currency: String = "INR",
) {
private val observers = CopyOnWriteArrayList<(Order, OrderStatus?) -> Unit>()
// A brand new order came from nowhere, so from is null for the creation
// event. Reporting a move from placed to placed would be a lie about a
// transition that never happened.
fun subscribe(observer: (order: Order, from: OrderStatus?) -> Unit) {
observers += observer
}
// Checkout validates the whole cart at once against the live menu and
// snapshots every price.
fun placeOrder(cart: Cart): CheckoutResult {
val problems = cart.problemsAtCheckout()
if (problems.isNotEmpty()) return CheckoutResult.Rejected(problems)
val items = cart.lines.mapNotNull { line ->
cart.restaurant.itemById(line.itemId)?.let {
OrderItem(it.id, it.name, it.price, line.quantity)
}
}
val total = items.fold(Money(currency, 0)) { running, item -> running + item.lineTotal }
val order = Order(UUID.randomUUID().toString(), cart.customer, cart.restaurant, items, total)
publish(order, null)
return CheckoutResult.Placed(order)
}
fun restaurantAccepts(order: Order) = move(order, OrderStatus.PLACED, OrderStatus.CONFIRMED)
fun startCooking(order: Order) = move(order, OrderStatus.CONFIRMED, OrderStatus.PREPARING)
fun markReady(order: Order) = move(order, OrderStatus.PREPARING, OrderStatus.READY_FOR_PICKUP)
fun pickUp(order: Order) = move(order, OrderStatus.READY_FOR_PICKUP, OrderStatus.OUT_FOR_DELIVERY)
// Ranking is policy and the claim is concurrency, so they stay apart. The
// claim is the predicate of firstOrNull, so the first courier whose
// compareAndSet wins is the one we get.
//
// The status check keeps a courier off an order that is already delivered
// or cancelled, and the attach is a compare and set so a second call cannot
// overwrite the first courier and strand them.
fun assignAgent(order: Order, fleet: List<DeliveryAgent>): DeliveryAgent? {
if (!order.status.acceptsCourier || order.agent != null) return null
val claimed = assignment(order, fleet).firstOrNull { it.tryAssign(order.id) } ?: return null
if (order.attachAgent(claimed)) return claimed
claimed.releaseIfOn(order.id)
return null
}
fun deliver(order: Order): Boolean =
move(order, OrderStatus.OUT_FOR_DELIVERY, OrderStatus.DELIVERED)
.also { if (it) order.agent?.releaseIfOn(order.id) }
// Cancellation names the status the caller believed the order was in. If
// the kitchen moved it first this returns false, and the app tells the
// customer the food is already being made.
fun cancel(order: Order, expected: OrderStatus): Boolean =
move(order, expected, OrderStatus.CANCELLED)
.also { if (it) order.agent?.releaseIfOn(order.id) }
private fun move(order: Order, expected: OrderStatus, next: OrderStatus): Boolean {
if (!order.compareAndSetStatus(expected, next)) return false
publish(order, expected)
return true
}
private fun publish(order: Order, from: OrderStatus?) = observers.forEach { it(order, from) }
}
Concurrency and edge cases
Two moves on the same order at the same instant. This is the race that matters here, and it is easy to miss because it does not look like a concurrency problem. The restaurant taps ready. In the same millisecond the customer taps cancel. If the status is a plain field with a setter, both writes land and the last one wins by accident. The order is now either cancelled with food sitting on a pass, or ready with a customer who thinks they cancelled.
The fix is that a transition names the status the caller believed the order was in. Compare, and set only if the comparison held. Exactly one of the two calls returns true. The loser learns it lost and the app says something honest. Every method on the service uses this, which is why none of them takes a plain new status.
Two orders offered to the same courier. Identical to the ride hailing race and worth pointing out as such. Checking whether a courier is free and then marking them busy is two operations. The courier has to expose one call that does both, and only one caller can win it.
The last portion of something. If an item has limited stock, two customers ordering it at once is the same shape again. The answer is an atomic conditional decrement on the stock count, decrement only where enough is left, then check the row count. It is the same primitive as room inventory, described in the Booking.com answer.
An item sells out while it is in a cart. This is why the cart validates nothing on add and everything at checkout. Validating on add gives a false promise, because the kitchen can turn a dish off a second later. Validating the whole cart at checkout and returning a list of reasons is both correct and better for the customer.
A price change between add and checkout. Same mechanism, different message. Snapshot at order time and show the delta. Never charge a price the customer has not seen.
Duplicate order submissions. The customer's phone loses the response and retries. Put an idempotency key on order creation so the second request returns the first order. The same key goes to the payment provider so a duplicated webhook cannot charge twice.
Be honest about the lock. Everything above is expressed here with a synchronized method or an atomic reference, and both protect one process. A real delivery platform runs many servers, so the same guarantees have to come from the database, as a conditional update that names the expected status in the where clause and then checks the row count. That statement is the compare and set, moved somewhere all the servers can see it. Say this rather than implying a synchronized block scales.
The smaller cases, worth naming quickly.
- The restaurant never accepts. A timer cancels the order and refunds, and the transition guard means the cancel is safe even if the restaurant accepts at the same moment.
- The courier goes offline mid delivery. The order stays out for delivery, and a human reassigns. Do not let an automatic rematch send a second courier to a kitchen that has nothing left to hand over.
- An unserviceable address. Reject at checkout, not after the food is cooked.
- Partial refunds for a missing item. That is a money workflow hanging off a delivered order, not a new status.
- Coupon stacking. If two discounts apply, the order they apply in changes the total, so the rule belongs somewhere a product person can read it.
Watch