Design a food-ordering system: customers place orders with restaurants, and the system assigns a delivery partner. Walk me through the classes.
A food ordering design has two lifecycles: the order moves from placed to delivered, while delivery partner assignment runs alongside and can fail and restart. Keep them separate, claim a partner atomically, rank candidates behind a strategy, and write cancellation windows as rules keyed on order state.
5 min readUpdated 2026-10-10Target archetype: Product Startup
0
Answer
Short answer
A food ordering system has two lifecycles that run side by side. The order goes from placed to accepted by the restaurant, preparing, ready, picked up and delivered. Finding a delivery partner is a separate process that may start while the kitchen is cooking, and it can fail and restart without the order moving at all. Model them as two state machines, claim a partner with an atomic check-and-set, hide the ranking rule behind an AssignmentStrategy, and express cancellation windows as rules keyed on order state.
Was this answer helpful?
Likely follow-ups
→The assigned partner goes offline after the food is ready. Which state changes, and which stays put?
→How would you batch two orders from the same restaurant onto one partner without rewriting the dispatcher?
→A restaurant edits a dish price while an order containing it is being placed. What does the customer pay?
→Nobody accepts an offer for ten minutes. What does the system do, and what does the customer see?
In a food ordering system, Restaurant owns a menu of MenuItems and an accepting-orders flag. Order holds the customer, the restaurant, a list of OrderLines and its status. DeliveryPartner holds a location and an availability status. OrderService handles placement and status changes, and a Dispatcher runs assignment.
One detail is worth saying early. An OrderLine copies the item name and price at the moment of ordering instead of pointing at the live MenuItem. A restaurant that raises a price at lunchtime must not change what an already-placed order costs, and a receipt has to show what the customer agreed to.
Two lifecycles, not one enum
The common first food ordering design is a single enum: PLACED, ACCEPTED, PARTNER_ASSIGNED, PREPARING, READY, PICKED_UP, DELIVERED. It breaks at once. Partner assignment usually starts while the food is being prepared, so an order is both preparing and assigned, and the enum cannot say so. When an assigned partner goes offline, the order would have to move "backwards" from PARTNER_ASSIGNED, which a linear status does not allow.
So the order status describes the food and the hand-over, and assignment is a separate field with its own states: none, offered, accepted. The order machine looks like this.
Loading diagram…
The edge to study is the one missing from Ready. Once food is cooked there is no free cancellation, and pickup needs an accepted partner. The order machine therefore asks the assignment machine a question at exactly one transition, Ready to PickedUp, rather than the two being fused into one status. For how to enforce a transition table without a nest of if statements, see order states as a table; the same entry-point-and-no-setter rule applies here.
Claiming a delivery partner without a double booking
Assignment has a race that a single-threaded demo hides. Two orders become ready near the same moment, both rank the same nearby rider first, and both assign her. The claim has to be one atomic step on the partner, and the losing order moves to its next candidate.
The order id inside accept is the subtle part. An offer expires after, say, thirty seconds, and the partner returns to available. A second order offers to her, and only then does her phone deliver the tap that accepts the first. If acceptance only checked "status is offered", that late tap would put her on the wrong order. Matching the order id makes the stale acceptance fail safely.
Ranking is the strategy, claiming is not
AssignmentStrategy is where the business experiments live: nearest available partner first, a fairness rule that favours partners who have waited longest, or batching two orders from the same kitchen. Each is a new class with one method. The dispatcher does not change, because claiming, expiry and retry are the same whatever the ranking. Keeping the strategy free of side effects also makes it testable with a list of fake partners and no threads.
Timing is a policy too. Offering a partner the moment the restaurant accepts wastes her time if the dish takes forty minutes. Offering when the food is ready makes the food wait. A reasonable middle is to start assignment at an estimated ready time minus the partner's travel time, and to say that the estimate comes from data you would measure.
Cancellation windows as rules, not scattered checks
The rule depends on order state, so write it as a table the cancel entry point consults. A placed order the restaurant has not accepted cancels freely. An accepted order can cancel under a stated policy, and once it is preparing that policy decides who absorbs the cost of food already started. A ready or picked-up order cannot be cancelled by the customer at all; what happens then is a refund or dispute process with its own states. Pass a Clock into the service rather than calling the system clock directly, so a test can place an order, advance time and check the window.
A cancellation also has to reach the assignment machine. If a partner is offered or accepted, cancelling the order must release her, through the same order-id check, so that a cancelled order never leaves a rider stuck in ON_DELIVERY.
A food ordering design turns on the second lifecycle. Keep food status and partner assignment as separate state machines, claim a partner with one atomic step that names the order, and push every "which partner" rule into a strategy.
import java.util.*;
enumPartnerStatus { AVAILABLE, OFFERED, ON_DELIVERY, OFFLINE }
finalclassDeliveryPartner {
privatefinal String id;
privatePartnerStatusstatus= PartnerStatus.AVAILABLE; // guarded by thisprivate String orderId; // guarded by this
DeliveryPartner(String id) { this.id = id; }
synchronizedbooleantryOffer(String forOrder) {
if (status != PartnerStatus.AVAILABLE) returnfalse; // another order got here first
status = PartnerStatus.OFFERED;
orderId = forOrder;
returntrue;
}
// Acceptance names the order, so a late tap on an expired offer cannot land on a newer one.synchronizedbooleanaccept(String forOrder) {
if (status != PartnerStatus.OFFERED || !forOrder.equals(orderId)) returnfalse;
status = PartnerStatus.ON_DELIVERY;
returntrue;
}
synchronizedvoidexpireOffer(String forOrder) {
if (status == PartnerStatus.OFFERED && forOrder.equals(orderId)) {
status = PartnerStatus.AVAILABLE;
orderId = null;
}
}
}
interfaceAssignmentStrategy {
// Ranks candidates. It never claims them; claiming belongs to the dispatcher.
List<DeliveryPartner> rank(String orderId, Collection<DeliveryPartner> candidates);
}
finalclassDispatcher {
privatefinal AssignmentStrategy strategy;
Dispatcher(AssignmentStrategy strategy) { this.strategy = strategy; }
Optional<DeliveryPartner> offer(String orderId, Collection<DeliveryPartner> pool) {
for (DeliveryPartner p : strategy.rank(orderId, pool)) {
if (p.tryOffer(orderId)) return Optional.of(p); // lost the race? try the next one
}
return Optional.empty(); // nobody free: retry later, order unchanged
}
}