Advanced45 min readMachine Coding Practicelive prototype

Food delivery (Swiggy / Zomato)

An order is not a row you write once. It is a forty-minute-long object that a customer, a restaurant and a delivery partner take turns acting on — and most of this design is deciding who is allowed to do what, when. Put that in one state machine owned by Order and the round is yours; scatter it across three services as booleans and you will eventually ship an order that is delivered and cancelled.

The idea

What it is

“Design a food delivery app like Swiggy.” One sentence, and it is deliberately enormous. There is a menu, a cart, payments, a map, live tracking, ratings, coupons, a partner app, a restaurant tablet. You have ninety minutes.

So the first thing being graded is what you refuse to build. The second thing — the thing this whole lesson is about — is the one object everything else hangs off.

The whole lesson in one line

An order is not a row you write once — it is a long-lived object that three different actors take turns changing. A customer places it, a restaurant accepts and cooks it, a partner picks it up and delivers it. Your job is to make illegal moves impossible, not merely unlikely: one explicit state machine, owned by Order, where every transition names the state it comes from, the event that fires it, and who is allowed to fire it.

one order · three actors · forty minutes — every noun labelled as its class on the sofa 🙍 Customer id · name · address Cart paneer-butter-masala x1 butter-naan x2 (menuItemId, qty) — that is all no price is stored here the kitchen 🍳 Restaurant open 11:00 – 23:00 MenuItem paneer… 24000 ✓ butter-naan 6000 ✓ gulab-jamun 9000 ✗ sold out price + isAvailable live HERE on the road 🛵 DeliveryPartner id · eta · current load «interface» AssignmentStrategy 📍 nearest free 📦 batch two from one kitchen swapping it changes zero lines of the order flow Order — the forty-minute-long object all three of them take turns acting on PLACED ACCEPTED PREPARING READY PICKED_UP DELIVERED 🙍 places 🍳 accepts 🍳 starts 🍳 marks 🛵 picks up 🛵 delivers Bill 36000 + 2000 + 3500 + 1900 − 5000 = 38400 paise built at checkout from the CURRENT menu, then frozen — an order total must never move after PLACED
Read the bottom band twice. The states are the design; the actor under each pill is the part that beginners leave out; and the Bill is computed from the kitchen's current prices at checkout, then never again.

Here is why that is harder than it sounds. Three different apps are open at once — the customer's phone, the restaurant's tablet, the partner's rider app — and each one has a button that changes the same order. The customer taps Cancel at the exact moment the restaurant taps Order ready. A partner taps Picked up for an order the kitchen has not started. A retried tap on a flaky connection sends Place order twice.

None of those are exotic. They happen every hour in production. And a design that keeps three booleans on the order — isAccepted, isPickedUp, isCancelled — has no way to say no to any of them.

What is actually being graded

  1. Is there one explicit state machine, and does Order own it? Not a set of booleans, not an if ladder duplicated in three services. One enum, one transition table, one transition(event, actor) method.
  2. Does every transition name its actor? “Who may fire this?” is half of the domain. PICK_UP from READY is the partner's move and nobody else's. If your transition() takes only an event, you have modelled half the problem.
  3. Is cancellation modelled as a policy, with money attached? Free before the restaurant accepts, partial once the food is being cooked, impossible once it is on a bike. That rule is a CancellationPolicy object, not four ifs buried in a controller.
  4. Is the price recomputed at checkout and then frozen? The cart holds (menuItemId, qty). The bill is built from the current menu at placeOrder() time — and the moment the order exists, its total is immutable.
  5. Is money integer paise? Item subtotal, packaging, delivery fee, taxes, discount, total — every one a long, and the parts must add up to the total exactly, by construction.
  6. Can you swap partner assignment without touching the order flow? AssignmentStrategy with nearest-free and batched implementations. This is the Strategy question, and it is the easiest place in this problem to score.
  7. Do you say “search is out of scope” out loud? Browsing and ranking a menu is a whole other system. Naming it and stubbing it in one sentence buys you twenty minutes for the part that is actually being graded.

The drift to avoid

This problem looks like it wants a map, a matching algorithm and live GPS. It does not. Geography is a collaborator you call — partners.candidatesNear(restaurant) — and it belongs to a different interview. Spend your time on the lifecycle: who may do what, from which state, and what it costs. That is where every follow-up question in this round comes from.

Mechanics

How it works

Step 1 · Clarify — 5 minutes

The prompt is one sentence, so the first five minutes are yours to shrink it. Ask these, in this order, and write the answers on the board. The full method is in A repeatable 5-step framework.

  • Who are the actors? — customer, restaurant, delivery partner, and the system itself (timeouts, auto-cancels). Naming the system as a fourth actor early is worth a lot later; it is what makes the auto-reject follow-up trivial.
  • What is the life of an order? — say the six words out loud: placed, accepted, preparing, ready, picked up, delivered. If the interviewer nods, you have just been handed your central abstraction. Write it on the board before you write any class.
  • Who can cancel, and when? — this is the question they are waiting for. Ask it and you have shown that you know where the difficulty is.
  • One restaurant per order? — yes. Multi-restaurant carts are a real feature and a total distraction; say “one restaurant per order, so the cart is bound to a restaurant” and move on.
  • Do I need search, ranking, recommendations, maps? — no. “I will treat menu search and partner geo-lookup as collaborators I call: menu.search(q) and partners.candidatesNear(restaurant). I will not implement either.” Say it once, clearly, and never revisit it.
  • Real payments? — no. payment.capture(orderId, paise) returns success. Refunds are recorded, not executed.
  • Scale? — single process, in-memory, one order at a time in the demo. Offer persistence and sharding as a closing paragraph, not as code.
✓ IN — the ninety minutes go here Order state machine + who may fire it CancellationPolicy — allowed? refund? Bill built at checkout, then frozen Restaurant open hours · item availability AssignmentStrategy: nearest / batched OrderObserver on every transition idempotent placeOrder(requestId) money in integer paise, time passed in ✗ OUT — one sentence each, then never again menu search, ranking, recommendations maps, routing, live ETA, GPS tracking real payment gateways and settlement ratings, reviews, loyalty, coupons engine the customer / partner / restaurant UIs persistence, sharding, message queues “search is a collaborator: menu.search(q). Assume it returns MenuItems.” — complete.
Everything orange on the left is what the round is really about. Everything on the right is a system somebody else is interviewing for. Saying so out loud is not laziness — it is the first design decision you make.

Search is the trap in this problem

Half of all candidates open by modelling Cuisine, Tag, SearchIndex and a ranking score. Forty minutes later there is no Order class. Browsing is a read path over a catalogue; the interview is about the write path over a lifecycle. One sentence — menu.search(query) returns a list of MenuItem, stubbed” — and you are free.

Step 2 · Nouns to classes — 6 minutes

Read the prompt back as a story and underline the nouns. A customer browses a restaurant's menu, adds items to a cart, and places an order. The restaurant accepts it and cooks. A delivery partner picks it up and delivers it. The customer pays a bill. Every underlined word is a class, and two of them hide a decision.

the noun in the prompt → the class on the board → what it owns noun class holds “a customer” Customer id, name, address “a restaurant” Restaurant open/close hours, its menu, prep time “a dish on the menu” MenuItem pricePaise, isAvailable ← the truth about price “adds to a cart” Cart · CartLine (menuItemId, qty) — decision 1: NO price “places an order” Order state + item snapshot + frozen Bill “a delivery guy” DeliveryPartner id, eta, current load (position: not ours) “the final total” Bill 6 integer fields — decision 2: parts sum to total the two decisions hiding in that table: 1 · Cart stores no price — it is recomputed at checkout. 2 · Order stores a SNAPSHOT — it never changes again.
Six of these rows are bookkeeping. Rows four and five are the design: the cart deliberately knows nothing about money, and the order deliberately knows only what money was at checkout time. More on finding classes this way in Identifying entities, attributes & behaviors.

Step 3 · The booleans trap, and the enum that kills it

Almost every first draft tracks the order's life with flags. It feels natural, because each flag answers a question somebody asked for: “has the restaurant accepted?”isAccepted. “has the rider picked it up?”isPickedUp. “was it cancelled?”isCancelled.

Three booleans is eight combinations. Only five of them mean anything. The other three are states your program can reach, print, save to a database, and show to a user — and no line of code anywhere says they are impossible.

✗ three booleans on the Order isAccepted isPickedUp isCancelled what it is supposed to mean false false false ✓ waiting for the kitchen true false false ✓ accepted, cooking (or ready?) true true false ✓ on the way (or delivered?) false false true ✓ cancelled before acceptance true false true ✓ cancelled while cooking false true false picked up, never accepted false true true on a bike, and also cancelled true true true delivered AND cancelled 3 of 8 combinations are nonsense — and nothing in the code forbids them. PREPARING and READY are not even expressible. Add two more flags: 32 combinations, 8 legal. ✓ one enum on the Order enum OrderState { PLACED, ACCEPTED, PREPARING, READY, PICKED_UP, DELIVERED, CANCELLED, REJECTED } 8 values. All 8 mean something. Nothing else is representable. green = terminal, nothing follows them The sentence to say: “I want illegal states to be unrepresentable, not merely unlikely.”
The struck-through rows are not hypothetical. isDelivered && isCancelled is a real production ticket, and it is always found by an accountant, not by a test. Background: State and State diagrams.

Booleans do not just allow bad states — they hide good ones

Look at row two of the table: isAccepted=true, isPickedUp=false is both “cooking” and “ready and waiting on the counter”. Those are different things to a restaurant, to a partner, and to the customer's ETA. Flags collapse states you need. Every time you find yourself writing if (isAccepted && !isPickedUp && preparedAt != null), that expression is a state, and it wants a name.

Step 4 · One state machine, and it belongs to Order

The enum is only half of it. The other half is the transition table: a list of (from, event, actor) → to rows, and a single method that consults it. Nothing else in the system is allowed to assign to order.state.

every edge names the event AND the actor allowed to fire it PLACED ACCEPTED PREPARING READY PICKED_UP DELIVERED ACCEPT (🍳 restaurant) START_PREP (🍳 restaurant) MARK_READY (🍳 restaurant) PICK_UP (🛵 partner) DELIVER (🛵 partner) REJECTED REJECT (🍳 restaurant) REJECT (⚙️ auto after 90s) CANCELLED CANCEL (🙍 customer) — legal from those four states only ✗ PICKED_UP has no CANCEL edge the food is on a bike; this is a support ticket, not a state transition who may fire what 🙍 CUSTOMER — place, cancel 🍳 RESTAURANT — accept, reject, start prep, mark ready 🛵 PARTNER — pick up, deliver ⚙️ SYSTEM — timeouts only ▣ double border = terminal 8 states · 11 edges · 4 actors — the whole domain fits on one board, and it is the only place state changes
Count the edges: eleven. That is the entire rulebook. Anything not on this picture is refused by one throw in one method — which is why the refusal message can be precise, and why a new rule is one new row.

The table gives you something a pile of ifs never does: when a move is refused, you can say why in two distinguishable ways. Either there is no edge for that event from that state at all (a missing transition), or the edge exists but belongs to somebody else (a wrong actor). Those are different bugs in the calling app, and telling them apart is what makes the error message useful.

the whole rulebook, and the one method that enforces it
/** (from, event, who) -> to.  Eleven rows. Nothing else is legal. */
private static final List<Transition> TABLE = List.of(
    new Transition(PLACED,    ACCEPT,     RESTAURANT, ACCEPTED),
    new Transition(PLACED,    REJECT,     RESTAURANT, REJECTED),
    new Transition(PLACED,    REJECT,     SYSTEM,     REJECTED),   // auto-reject timeout
    new Transition(PLACED,    CANCEL,     CUSTOMER,   CANCELLED),
    new Transition(ACCEPTED,  START_PREP, RESTAURANT, PREPARING),
    new Transition(ACCEPTED,  CANCEL,     CUSTOMER,   CANCELLED),
    new Transition(PREPARING, MARK_READY, RESTAURANT, READY),
    new Transition(PREPARING, CANCEL,     CUSTOMER,   CANCELLED),
    new Transition(READY,     PICK_UP,    PARTNER,    PICKED_UP),
    new Transition(READY,     CANCEL,     CUSTOMER,   CANCELLED),
    new Transition(PICKED_UP, DELIVER,    PARTNER,    DELIVERED));

/**
 * ONE place decides legality, and it distinguishes the two failures:
 *   - no row for (from, event)            -> "missing transition"
 *   - a row exists but for another actor  -> "wrong actor"
 */
static OrderState next(OrderState from, OrderEvent event, Actor who) {
    Actor owner = null;
    for (Transition t : TABLE) {
        if (t.from() != from || t.event() != event) continue;
        owner = t.actor();
        if (t.actor() == who) return t.to();
    }
    if (owner == null)
        throw new IllegalTransition(event + " is not a legal event from " + from);
    throw new IllegalTransition(event + " from " + from + " is " + owner + "'s move, not " + who + "'s");
}

The line that scores this section

“Nothing outside Order.transition() assigns to the state field. The restaurant service, the partner service and the customer service all call the same method with their own Actor, and the order decides.” Say it, then make the field private and never expose a setter. That single sentence is the difference between a state machine and a diagram of one.

And now the concurrency paragraph, which in this problem is short and honest. Two actors can fire at the same instant — the customer taps Cancel while the restaurant taps Ready. The fix is one lock per order (or a compare-and-set on the state field), because the guarded region is a table lookup and one field write. There is no hot shared resource here the way there is in a seat-booking or ride-matching problem; an order is touched by three people over forty minutes. Take the cheap lock, say why it is cheap, and move on. Depth if they push: Locks, Mutex, Semaphore and Atomic operations & CAS.

the same order, forty simulated minutes, four lanes — the turns are the design 0 min 10 20 30 40 🙍 customer 🍳 restaurant 🛵 partner ⚙️ system placeOrder freeze bill · notify kitchen ACCEPT START_PREP assign partner (strategy) MARK_READY PICK_UP DELIVER on the road — 18 minutes after PICK_UP no cancel edge exists cancel window: refund 100% → 50% → 25% → ✗
The lanes are why booleans fail. Three different processes, on three different devices, each pushing the same object forward — and the only thing standing between them and nonsense is a table that says who is allowed to do what next.

Step 5 · Cancellation is the hard question, and it is a money question

Every interviewer asks about cancellation, because it is where a lifecycle stops being a diagram and starts being a refund. Two questions, and they must be answered by two different objects.

  1. Is cancelling legal from here, and by whom? — the state machine answers this. There is a CANCEL edge from PLACED, ACCEPTED, PREPARING and READY, all owned by the customer. There is no CANCEL edge from PICKED_UP at all, so the answer is not a policy decision, it is a missing row.
  2. What does it cost? — the CancellationPolicy answers this, and nothing else in the system knows the numbers. Free before the kitchen accepts. Half back once they are cooking, because the food is already made and somebody paid for it. A quarter back once it is boxed and waiting. Nothing back after the wheels move.
cancellation by state and actor — on an order whose frozen total is 38400 paise (₹384.00) state 🙍 CUSTOMER 🍳 RESTAURANT ⚙️ SYSTEM refund to the customer PLACED CANCEL ✓ REJECT ✓ REJECT after 90s 100% → 38400 ACCEPTED CANCEL ✓ 100% → 38400 PREPARING CANCEL ✓ 50% → 19200 (kitchen keeps 19200) READY CANCEL ✓ 25% → 9600 (kitchen keeps 28800) PICKED_UP no edge no edge no edge refused — support ticket, not a transition DELIVERED no edge no edge no edge terminal — nothing follows it refund = (total × bps + 5000) / 10000 in integer paise, and kept = total − refund so refund + kept == total, exactly, for every state — the same discipline as the bill, one line of code
Two objects, two questions. The grey dashes and the red “no edge” cells come from the transition table; the percentage column comes from the policy. Change the business rule to “full refund up to two minutes after acceptance” and only the policy file moves.

Why the policy is a separate object, not four ifs

Refund rules change constantly, per city, per restaurant tier, per promotion, and they are the thing a product manager edits on a Friday. A CancellationPolicy interface with one method — refundBpsFor(state) — means a new rule is a new class, Order is untouched, and you can unit-test the money without constructing an order at all. This is Strategy again, wearing a different hat, and Single Responsibility (SRP) doing the work.

The nuance interviewers love: rejection is not cancellation

A customer cancels. A restaurant rejects — “we are out of paneer”, “the kitchen is slammed”. They land in different terminal states because the accounting, the notification text, and the restaurant's own metrics all differ. Two states, not one with a reason string. If you only have CANCELLED, you cannot answer “what fraction of orders does this restaurant refuse?” without parsing free text.

Step 6 · Never store the price on the cart line

Somebody adds a paneer butter masala to their cart at 9pm and gets distracted. At 10pm the restaurant raises the price. At 10:05 they open the app and hit checkout. What do they pay?

If the cart stored the price, they pay yesterday's number, and the restaurant is quietly out of pocket on every stale cart in the city. If the price had gone down instead, the customer is overcharged and writes a review about it. Both are the same bug: a cart is a wish, not a contract.

✗ the cart stores the price 21:00 CartLine{ paneer, qty 1, price 24000 } 22:00 menu: paneer 24000 → 27000 22:05 checkout charges 24000 — the stale copy the restaurant is out 3000 paise, silently, on every cart that was open when prices moved and if the price had gone DOWN, the customer is overcharged — same bug, angrier ticket the price now lives in two places and nothing keeps them equal ✓ the cart stores (menuItemId, qty) 21:00 CartLine{ paneer, qty 1 } 22:00 menu: paneer 24000 → 27000 22:05 checkout reads the CURRENT menu → 27000 Bill rebuilt: 39000 + 2000 + 3500 + 2050 − 5000 total = 41550 paise 📌 FROZEN onto the order 23:00 menu: paneer 27000 → 30000 order.bill.total is still 41550 — it cannot move the Order carries an OrderItem SNAPSHOT: name + unitPaise + qty, copied at checkout Cart = live, recomputed on every view. Order = frozen, computed once at PLACED and never again.
Two objects that look similar and have opposite rules. Getting this backwards is the most common silent mistake in this problem, because a demo with one price change never shows it.

The freeze matters as much as the recompute. Once the order exists, its total is part of a payment, a receipt, a refund calculation and a restaurant payout. If a menu edit could reach backwards and change it, every one of those becomes unreproducible. So Order holds List<OrderItem> — a snapshot of (name, unitPaise, qty) — and a Bill, both immutable. See Immutability & value objects.

every field is a long of paise — there is no double anywhere in this system Bill «value object, immutable» itemSubtotal 36000 packaging + 2000 deliveryFee + 3500 taxes (5% of 38000) + 1900 discount (SAVE50) − 5000 total 38400 ₹384.00 — frozen at PLACED, forever the two rounding rules 1 · the total is DEFINED as the sum total = subtotal + pack + fee + tax − discount never compute it separately and round at the end — then the printed breakdown would not add up, and a customer WILL add up the printed breakdown 2 · give the leftover paisa away, do not drop it the ₹50 coupon shown per line, 3 units: 1667 + 1667 + 1666 = 5000 ✓ base = 5000 / 3 = 1666, remainder = 2, hand the two extra paise to the first two lines — deterministic Assert it in the constructor: if (subtotal + pack + fee + tax − discount != total) throw. A Bill that does not add up should be impossible to construct, not merely unlikely to be printed.
The compact constructor is where this lives — a Bill whose parts do not sum to its total cannot exist as an object. Same idea as the zero-sum assertion in Splitwise, one scope smaller.

The class diagram

Cart - restaurantId : String - lines : List<CartLine> no price Restaurant - menu : Map<String, MenuItem> - openMin, closeMin : int + isOpenAt(minuteOfDay) MenuItem - pricePaise : long ← the truth - isAvailable : boolean mutable — the kitchen edits it any time DeliveryPartner - etaMinutes : int - activeOrders : List<String> PartnerDirectory «stub» + candidatesNear(restaurant) geo lives in another interview Order «aggregate» - state : OrderState private! - items : List<OrderItem> - bill : Bill frozen - placedAtMin : int - assignment : Assignment + transition(event, actor, atMin) + cancellationQuote() + state() — getter, no setter OrderItem «snapshot» name · unitPaise · qty copied at checkout, never edited again Bill «value object» itemSubtotal · packaging deliveryFee · taxes · discount total : long ctor asserts the parts sum to total what is NOT here ✗ isAccepted / isPickedUp flags ✗ price on the CartLine OrderStateMachine + next(from, event, actor) : State the 11-row table — the only rulebook «interface» CancellationPolicy + refundBpsFor(state) : long −1 means “not allowed from here” «interface» AssignmentStrategy + pick(order, candidates) : Partner the order flow never names an impl «interface» OrderObserver + onTransition(order, from, to, …) fired once per state change NearestFree BatchedPickup PushNotifier SmsNotifier AnalyticsSink — a third one costs 0 edits every box on this column is a file you can add without ever opening Order.java
One box has a thick orange border, and that is the point: Order is the aggregate. Everything on the right is a seam — swap the implementation, Order never changes. Notation: Class diagrams; the aggregate idea: Domain modeling.

placeOrder(), end to end

This is the method they will read most carefully, because everything interesting happens in it exactly once: the validations that must run at place time, the bill built from live prices, and the freeze.

🙍 Customer DeliveryService Restaurant BillCalculator Order placeOrder(cart, requestId, atMin) seen(requestId)? → return the SAME order a double-tap must not create a second order isOpenAt(atMin) true — else REJECT “restaurant is closed” for each line: item exists AND isAvailable “Gulab Jamun is sold out” → refuse the whole order now, not at PICK_UP time build(cart, restaurant, feeRules) reads the CURRENT menu: 24000, 6000×2 36000+2000+3500+1900−5000 = 38400 ctor asserts the parts sum to total new Order(PLACED, itemSnapshot, 📌 bill) observers ← (null → PLACED)
Three gates before an order exists, and they are all at place time: is the kitchen open, is every item still available, and is this a retry. Checking availability at browse time only is the classic miss — the item sold out while the customer was choosing a payment method. Notation: Sequence diagrams.

Idempotency: the follow-up hiding in the first box

The Place order button is tapped on a train. The request times out, the app retries, and now the customer has two identical orders and two charges. The fix is one line of design: the client sends a requestId it generated, and placeOrder keeps Map<requestId, Order>. A repeat returns the same order object, not a new one. Say this unprompted — it costs you eight seconds and it is the single most common “what about…?” in this round.

Partner assignment — a strategy, deliberately shallow

The moment an order is accepted, somebody has to be sent to fetch it. This is where candidates burn twenty minutes inventing a geo-index. Do not. Finding candidates is a collaborator; choosing among them is your design.

  • partners.candidatesNear(restaurant) returns a handful of nearby partners. Say “this is backed by a spatial index — a grid or a geohash — and I am treating it as given”, and you have said everything the round needs about geography.
  • AssignmentStrategy.pick(order, candidates) is the seam. NearestFreeStrategy takes the free partner with the smallest ETA. BatchedPickupStrategy prefers a partner who is already going to the same restaurant and has room for one more, and only falls back to nearest-free if nobody qualifies.
  • Batching is a genuinely interesting rule to explain: two orders from one kitchen on one trip halves the cost per delivery and adds a few minutes to the second customer's ETA. That trade-off is a business decision, which is exactly why it belongs behind an interface that a config flag can swap.
  • The assignment is its own small object — Assignment { partnerId, assignedAtMin } — hanging off the order. When a partner drops out, you replace the assignment; the order's state does not change. Re-assignment is legal while the order is READY or PICKED_UP, and that rule lives with the assignment, not in the order enum.

The sentence that proves the seam is real

“Switching from nearest-free to batched changes zero lines of the order flow — service.setAssignmentStrategy(new BatchedPickupStrategy()) and that is the whole diff.” If you cannot say that truthfully about your code, the interface is decoration. The prototype has a chip for exactly this, and it prints order-flow code changed: 0 lines.

Restaurant availability — validate at PLACE time, not at browse time

A menu is not static. A kitchen closes at 11pm. Paneer runs out at 9:40. A customer who opened the app at 9:20 is looking at a page that is already wrong, and no amount of client-side checking fixes that — the check has to happen on the write, at the moment the order is created.

  • restaurant.isOpenAt(minuteOfDay) — pass the time in as a parameter. Never read a clock inside the domain logic; you cannot write a test for “orders at 11:01pm are refused” if the method asks the operating system what time it is. This is the same rule as billing in Parking Lot.
  • menuItem.isAvailable — checked per line, and the failure message names the dish. “Gulab Jamun is sold out” is a usable error; “invalid order” is a support ticket.
  • Browse-time checks are a nicety, not a guarantee. Greying out a sold-out dish in the UI is good product work and worth zero marks for correctness. The placeOrder gate is what makes it true.
  • What if the item sells out between ACCEPTED and PREPARING? Then the restaurant rejects, or calls the customer and the order is cancelled with a full refund. Notice that both answers are already edges on your diagram — you do not need a new mechanism, which is the sign the state machine is carrying its weight.

Notifications — an observer on every transition

Six state changes, and each one is a push notification, an SMS to some users, an update to the restaurant tablet, an analytics event, and a row in the customer's order-tracking screen. If any of that appears inside transition(), the order now depends on a notification service, an SMS vendor and a metrics client — and every new channel edits the most important method in the system.

So transition() ends with one loop over List<OrderObserver>. Adding WhatsApp is a new class and one register() call. Order is untouched. That is Observer, and this problem is one of the cleanest places to demonstrate it — the prototype shows the counter ticking and prints Order class edited: 0 lines when you add a channel.

One honest caveat to say out loud

In-process observers run inside the transition. A slow SMS vendor now slows down “mark ready”, and a throwing observer can roll back a state change that should have succeeded. The real system publishes an event to a queue and lets subscribers run elsewhere. Saying “in production this becomes a published event, not a synchronous callback — see Pub/Sub & Event-driven takes five seconds and shows you know why the toy version is a toy.

The follow-ups they always ask

  • “What if the restaurant never accepts?” — the SYSTEM actor exists for this. service.tick(nowMin) walks orders still in PLACED and fires REJECT by SYSTEM once they are older than 90 seconds, with a full refund. One extra row in the transition table, one loop. Because you named the system as an actor in minute three, this answer is free.
  • “What if the partner cancels mid-trip?” — the order stays where it is and the assignment is replaced. Re-assign from READY or PICKED_UP; from PICKED_UP the new partner has to collect the food from the old one, which is an operations problem, not a modelling one. The key line: “partner churn does not move the order's state, so it cannot corrupt the lifecycle.”
  • “Scheduled orders — deliver at 8pm.” — a scheduler that calls the ordinary placeOrder at the right minute. No new state, no new transition. Prices are picked up at that moment, which is the correct behaviour and falls out of the design for free.
  • “Ratings?” — a Rating attached to a terminal order, allowed only from DELIVERED. Modelled as a separate object so Order does not grow a nullable field it ignores for forty minutes of its life.
  • “Refunds — how do they actually happen?” — the order records a refund intent with an amount and a reason; a payment adapter executes it and calls back. The callback must be idempotent, because gateways retry. Recording is your job; executing is theirs.
  • “Two people press buttons at the same time.” — one lock per order, or a compare-and-set on the state field. The critical section is a table lookup and a field write. Do not lock the service. Detail in Locks, Mutex, Semaphore.
  • “Order history and ‘why did this happen?’” — keep an append-only List<TransitionRecord> on the order: from, to, event, actor, minute. It costs nothing, it is the tracking screen, and it answers every dispute. A state machine that keeps its own log is worth a lot more than one that does not.
  • “Multi-restaurant cart?” — the honest answer: one order per restaurant, grouped under a parent, each with its own independent state machine and its own partner. Do not try to make one state machine describe two kitchens; you will end up back at booleans.
what the next feature actually costs feature what you touch verdict a WhatsApp / SMS channel 1 class : OrderObserver — Order untouched free a rain surcharge / small-order fee 1 fee rule inside BillCalculator free batched pickups (2 orders, 1 trip) 1 class : AssignmentStrategy free scheduled orders (“deliver at 8pm”) a timer that calls the existing placeOrder free a new state (ARRIVED_AT_RESTAURANT) 1 enum value + 2 rows in the table cheap a multi-restaurant cart one order splits into N, each with a lifecycle expensive
Row five is the row that proves the design. Adding a state — the hardest kind of change in most codebases — is one enum value and two table rows, because there is exactly one place that knows the rules.

The 90 minutes

a 90-minute budget that actually fits 5m 8m 7m 30m 18m 12m 10m clarify — the six states out loud, who cancels when, and “search is out of scope” entities — Customer, Restaurant, MenuItem, Cart, Order, DeliveryPartner, Bill the transition table on the whiteboard + the class diagram’s four seams code: OrderState enum → the 11-row table → transition(event, actor) → Order Bill built from the current menu + frozen, then CancellationPolicy + refunds AssignmentStrategy (2 impls) + OrderObserver — then main(): the happy path, an illegal move, a cancel keep the last 10 minutes to RUN it and take follow-ups. An unrun program scores like an unwritten one.
The orange block is the round. If you reach minute 50 without a working transition(), abandon fees, abandon batching, abandon observers — a running state machine with a hard-coded bill beats a beautiful class diagram with no main().

How this round is lost

  • Booleans instead of a state enum. Everything downstream — cancellation rules, refunds, notifications, the tracking screen — turns into nested conditions that nobody can verify, and isDelivered && isCancelled becomes reachable.
  • A setState() that anyone can call. The enum was there, the rules were not. If three services can assign to the field, you have a diagram, not a machine.
  • Transitions with no actor. transition(PICK_UP) lets the customer's app mark its own order picked up. Half the domain went missing and it looks fine in the demo.
  • Cancellation handled with four ifs in a controller. It works on the day, and then a product manager changes the refund rule and it works differently in three places.
  • A price on the cart line. Silent, invisible in any demo, and wrong for every user whose cart outlived a menu edit.
  • A total that can change after PLACED. Receipts, refunds and payouts all stop reconciling, and no test catches it because the number is plausible.
  • double anywhere near money. ₹384.00 becomes 383.99999999999994 in a refund calculation, and refunds are the one number customers check.
  • Building search, ranking or a map. The most common way to run out of time on this problem, and it costs you the part that was actually being graded.
  • No main(). Forty minutes of beautiful interfaces and nothing you can run. Print the happy path, print one refused transition, print one refund.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

One live order, walked through its life by three actors. Press 🙍 Place order, then 🍳 Accept and 🍳 Start preparing — the state pill strip lights up as it goes. Now press 🛵 Pick up while the state is still PREPARING: the button shakes red and the call line prints order.transition(PICK_UP, PARTNER) → ✗ illegal from PREPARING. Walk on to PICKED_UP and press 🙍 Deliver in the Customer panel to see the other kind of refusal — right event, wrong actor. Then press 🚫 Try to cancel at each stage and watch the refund fall from 100% to 50% to 25% to refused, and use 💸 Menu price changed followed by 🧾 Checkout to see the bill rebuild from the current menu — and then freeze.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Walk the order through its life

Press 🙍 Place order, then 🍳 Accept, 🍳 Start preparing, 🍳 Mark ready, 🛵 Pick up, 🛵 Deliver — in that order. The pill strip lights each state as you arrive and dims the ones behind you, the elapsed counter climbs, and the 🔔 notifications counter ticks once per transition. Six presses, three different people, one object. Notice that no button ever sets a state — every one of them calls the same order.transition(event, actor) shown in the call line.

try 02

Two different kinds of “no”

Reset, then place and accept, then press 🍳 Start preparing so the state is PREPARING. Now press 🛵 Pick up. The button shakes red and the explain says “PICK_UP is not a legal event from PREPARING” — a missing transition. Now press 🍳 Mark ready and 🛵 Pick up so the state is PICKED_UP, then press 🙍 Deliver in the Customer panel. Same refusal, different reason: “DELIVER from PICKED_UP is PARTNER's move, not CUSTOMER's” — a wrong actor. One table produced both messages, and telling them apart is what makes the error useful to whoever is calling you.

try 03

Cancel at every stage and read the money

Reset, press 🙍 Place order, then press 🚫 Try to cancel straight away: refund 100% of ₹384.00. Reset, place, 🍳 Accept, then cancel: still 100%. Reset again, get to PREPARING, and cancel: 50% — the refund readout shows ₹192.00 back and ₹192.00 kept, because the kitchen already spent the food. From READY it is 25%. Get to PICKED_UP and press it again: refused, because there is no CANCEL edge from there at all. Watch which of the two objects produced each answer — the state machine said legal, the policy said how much.

try 04

Change a price and watch the freeze

Reset. Press 💸 Menu price changed — the paneer goes from 24000 to 27000 and the cart (live) total moves. Press 🧾 Checkout and the bill is rebuilt from the current menu: the panel shows the old total struck out and the new one beside it. Now press 🙍 Place order: a 📌 Frozen badge appears on the order total. Press 💸 Menu price changed twice more and watch the cart total keep moving while the order total does not budge. That is the whole “cart is a wish, order is a contract” idea in four clicks.

try 05

Prove the two seams are real

Press the 📦 Batch two orders chip. The assignment is recomputed, the partner changes, and the explain prints order-flow code changed: 0 lines — the flow calls strategy.pick(...) and has never heard of either implementation. Press 📍 Nearest free to swap back. Then press + Add SMS channel: the channel row grows, the 🔔 counter keeps counting for both channels on the next transition, and the explain prints Order class edited: 0 lines. Finally press ↺ Reset and rebuild the whole thing blank-file, in this order: the OrderState enum → the 11-row transition table → transition(event, actor, atMin)Bill with the parts-sum assertion → CancellationPolicy → observers. If you can type that from memory in thirty minutes, this round is a formality.

In practice

When to use it — and what trips people up

The shape you just learned

Take the food away and what is left is a long-lived entity with an explicit lifecycle that several different parties push forward. That shape is everywhere, and it always fails the same way — someone models it with flags, and six months later two flags are true that should never be true together.

  • A loan application — submitted, under review, approved, disbursed, rejected. The applicant, the underwriter and an automated risk engine each own different transitions, and “approved and rejected” must be unrepresentable.
  • An insurance claim — filed, assessed, approved, paid, contested. Money attaches to the transitions exactly the way refunds do here, and every edge needs an actor.
  • A support ticket — open, triaged, in progress, waiting on customer, resolved. The customer may reopen; the agent may resolve. Same two-question split: is it legal, and who may do it.
  • A shipment — booked, collected, in transit, at hub, out for delivery, delivered, returned. Literally the same diagram with more nodes, and the same “no cancel once it is moving” rule.
  • A content publishing workflow — draft, in review, scheduled, published, retracted. Authors, editors and a scheduler are three actors, and “published and still in draft” is the bug you are preventing.
  • A CI/CD pipeline run — queued, running, succeeded, failed, cancelled. A human may cancel a queued run; nobody may cancel a finished one. Familiar?

The 20-second version to say out loud

“The order owns one OrderState enum and one transition table of (from, event, actor) → to. transition() is the only thing that writes the state field, and it refuses illegal moves with a message that distinguishes a missing transition from a wrong actor. Cancellation legality comes from the table; the refund comes from a CancellationPolicy, in integer paise. The cart stores (itemId, qty) only — the bill is built from the current menu at checkout and then frozen onto the order. Assignment and notifications sit behind interfaces, so adding a channel or a batching rule is a new file and zero edits.”

Where this design stops working

  • When the actors live in separate services. An in-process synchronized transition() becomes a distributed decision. You end up with the state machine owned by one service and everyone else sending commands to it — or, if you split it, a saga with compensating actions, and the “make illegal states unrepresentable” guarantee weakens to “detect and compensate”.
  • When parts of the lifecycle run in parallel. Payment authorisation, fraud checks and kitchen acceptance can all be in flight at once. A single linear enum starts to lie, and you need either orthogonal regions (a statechart) or a separate small machine per concern — which is exactly what Assignment already is here.
  • When the table gets big. Thirty states and a hundred edges stop fitting in anyone's head; that is when you move to a hierarchical statechart with nested states and shared transitions, or generate the machine from a declarative spec.
  • When the truth lives outside your process. A payment gateway, not you, decides whether a refund succeeded. The order can record intent and reconcile on a callback, but it cannot own that state — and the callback will be delivered more than once.
  • When it has to survive a crash. In memory this is trivially consistent. With a database, the state change, the transition log row and the observer side effects must commit together, or you get an order that is DELIVERED with no notification sent and no history row explaining it. That is where the transactional outbox pattern shows up.

If you only remember one thing

Write the transition table before you write any class. Eleven rows of (from, event, actor) → to on the whiteboard takes four minutes, answers cancellation, refunds, timeouts, partner churn and notifications before they are asked, and turns the rest of the round into typing.

What it gives you

  • One enum plus one transition table makes illegal states unrepresentable rather than merely unlikely — the isDelivered-and-cancelled class of bug cannot be constructed at all.
  • Naming the actor on every edge captures half the domain for free, and it turns a vague “that call failed” into either “missing transition” or “wrong actor”, which is immediately actionable for the caller.
  • Splitting legality (the table) from cost (the CancellationPolicy) means a product manager can change refund percentages without anyone touching the lifecycle, and the money can be unit-tested without constructing an order.
  • Recomputing the bill at checkout and then freezing it gives both correctness while browsing and reproducibility afterwards — receipts, refunds and restaurant payouts all reconcile forever.
  • Assignment, notification and refund rules all sit behind interfaces, so a new channel, a new fee or a batching experiment is a new file and zero edits to the order flow.

Common mistakes

  • A central transition table is a single place every new rule must pass through, which is the point — but it also means feature teams contend on one file, and a careless row can silently open an edge nobody reviewed.
  • Modelling every actor and event explicitly is more ceremony than a small system needs; for a two-state “done / not done” workflow this is over-engineering and a boolean is genuinely correct.
  • Synchronous in-process observers put notification latency and failures inside the transition, so a slow SMS vendor can slow down “mark ready” and a throwing observer can poison a legitimate state change.
  • A single linear enum cannot express concurrent sub-flows; the moment payment, fraud and kitchen acceptance overlap you need a second machine or a statechart, and retrofitting that is real work.
  • The frozen bill is correct and inflexible: a genuine pricing mistake now needs an explicit adjustment or reversal record rather than an edit, which is more machinery than simply changing a number.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

/* ==================================================================== enums */
enum OrderState {
    PLACED, ACCEPTED, PREPARING, READY, PICKED_UP, DELIVERED, CANCELLED, REJECTED;
    boolean terminal() { return this == DELIVERED || this == CANCELLED || this == REJECTED; }
}

enum OrderEvent { ACCEPT, REJECT, START_PREP, MARK_READY, PICK_UP, DELIVER, CANCEL }

enum Actor { CUSTOMER, RESTAURANT, PARTNER, SYSTEM }

/* ==================================================================== money */
final class Money {
    static String fmt(long paise) {
        long a = Math.abs(paise);
        return (paise < 0 ? "-" : "") + "Rs." + (a / 100) + "." + String.format("%02d", a % 100);
    }
    /** Basis points of an amount, half-up, integers only. 10000 bps = 100%. */
    static long bps(long paise, long basisPoints) {
        return (paise * basisPoints + 5000) / 10000;
    }
    /** Split a total across n lines so the parts sum to the total EXACTLY. */
    static long[] spread(long total, int n) {
        long[] out = new long[n];
        long base = total / n, rem = total % n;
        for (int i = 0; i < n; i++) out[i] = base + (i < rem ? 1 : 0);
        return out;
    }
}

/* ============================================================= the rulebook */
class IllegalTransition extends RuntimeException {
    IllegalTransition(String msg) { super(msg); }
}

record Transition(OrderState from, OrderEvent event, Actor actor, OrderState to) {}

final class OrderStateMachine {
    // short aliases so the table below reads like the whiteboard
    private static final OrderState PLACED = OrderState.PLACED, ACCEPTED = OrderState.ACCEPTED,
        PREPARING = OrderState.PREPARING, READY = OrderState.READY, PICKED_UP = OrderState.PICKED_UP,
        DELIVERED = OrderState.DELIVERED, CANCELLED = OrderState.CANCELLED, REJECTED = OrderState.REJECTED;
    private static final OrderEvent ACCEPT = OrderEvent.ACCEPT, REJECT = OrderEvent.REJECT,
        START_PREP = OrderEvent.START_PREP, MARK_READY = OrderEvent.MARK_READY,
        PICK_UP = OrderEvent.PICK_UP, DELIVER = OrderEvent.DELIVER, CANCEL = OrderEvent.CANCEL;
    private static final Actor CUSTOMER = Actor.CUSTOMER, RESTAURANT = Actor.RESTAURANT,
        PARTNER = Actor.PARTNER, SYSTEM = Actor.SYSTEM;

    /** (from, event, who) -> to.  Eleven rows. Nothing else is legal, ever. */
    static final List<Transition> TABLE = List.of(
        new Transition(PLACED,    ACCEPT,     RESTAURANT, ACCEPTED),
        new Transition(PLACED,    REJECT,     RESTAURANT, REJECTED),
        new Transition(PLACED,    REJECT,     SYSTEM,     REJECTED),   // auto-reject timeout
        new Transition(PLACED,    CANCEL,     CUSTOMER,   CANCELLED),
        new Transition(ACCEPTED,  START_PREP, RESTAURANT, PREPARING),
        new Transition(ACCEPTED,  CANCEL,     CUSTOMER,   CANCELLED),
        new Transition(PREPARING, MARK_READY, RESTAURANT, READY),
        new Transition(PREPARING, CANCEL,     CUSTOMER,   CANCELLED),
        new Transition(READY,     PICK_UP,    PARTNER,    PICKED_UP),
        new Transition(READY,     CANCEL,     CUSTOMER,   CANCELLED),
        new Transition(PICKED_UP, DELIVER,    PARTNER,    DELIVERED));

    /**
     * ONE place decides legality, and it tells the two failures apart:
     *   no row for (from, event)           -> missing transition
     *   a row exists but for another actor -> wrong actor
     */
    static OrderState next(OrderState from, OrderEvent event, Actor who) {
        Actor owner = null;
        for (Transition t : TABLE) {
            if (t.from() != from || t.event() != event) continue;
            owner = t.actor();
            if (t.actor() == who) return t.to();
        }
        if (owner == null)
            throw new IllegalTransition(event + " is not a legal event from " + from
                + "   (missing transition)");
        throw new IllegalTransition(event + " from " + from + " is " + owner + "'s move, not "
            + who + "'s   (wrong actor)");
    }

    static boolean can(OrderState from, OrderEvent event, Actor who) {
        for (Transition t : TABLE)
            if (t.from() == from && t.event() == event && t.actor() == who) return true;
        return false;
    }
}

/* ================================================================ catalogue */
final class MenuItem {
    final String id, name;
    long pricePaise;             // the kitchen may change this at any moment
    boolean available = true;
    MenuItem(String id, String name, long pricePaise) {
        this.id = id; this.name = name; this.pricePaise = pricePaise;
    }
}

final class Restaurant {
    final String id, name;
    final Map<String, MenuItem> menu = new LinkedHashMap<>();
    final int openMin, closeMin;                       // minutes since midnight
    Restaurant(String id, String name, int openMin, int closeMin) {
        this.id = id; this.name = name; this.openMin = openMin; this.closeMin = closeMin;
    }
    Restaurant add(MenuItem m) { menu.put(m.id, m); return this; }
    /** Time comes IN. Nothing in this class ever asks the OS what time it is. */
    boolean isOpenAt(int minuteOfDay) { return minuteOfDay >= openMin && minuteOfDay < closeMin; }
}

/** No price here. That is the whole point of this class. */
record CartLine(String menuItemId, int qty) {}

final class Cart {
    final String restaurantId;
    final List<CartLine> lines = new ArrayList<>();
    Cart(String restaurantId) { this.restaurantId = restaurantId; }
    Cart add(String menuItemId, int qty) { lines.add(new CartLine(menuItemId, qty)); return this; }
}

/** The frozen snapshot: what this dish cost AT CHECKOUT. Never edited again. */
record OrderItem(String name, long unitPaise, int qty) {
    long linePaise() { return unitPaise * qty; }
}

/* ===================================================================== bill */
record Bill(long itemSubtotal, long packaging, long deliveryFee,
            long taxes, long discount, long total) {
    Bill {
        if (itemSubtotal + packaging + deliveryFee + taxes - discount != total)
            throw new IllegalArgumentException("bill parts do not sum to the total");
        if (total < 0) throw new IllegalArgumentException("total cannot be negative");
    }
    String pretty() {
        return Money.fmt(itemSubtotal) + " + pack " + Money.fmt(packaging)
             + " + delivery " + Money.fmt(deliveryFee) + " + tax " + Money.fmt(taxes)
             + " - off " + Money.fmt(discount) + "  =  " + Money.fmt(total);
    }
}

final class BillCalculator {
    static final long PACKAGING = 2000, DELIVERY = 3500, TAX_BPS = 500;

    /** Prices are read from the CURRENT menu, every single time this runs. */
    static Bill build(Cart cart, Restaurant r, long discountPaise, List<OrderItem> snapshotOut) {
        long subtotal = 0;
        for (CartLine line : cart.lines) {
            MenuItem m = r.menu.get(line.menuItemId());
            if (m == null) throw new IllegalArgumentException("no such item: " + line.menuItemId());
            if (!m.available) throw new IllegalArgumentException(m.name + " is sold out");
            OrderItem snap = new OrderItem(m.name, m.pricePaise, line.qty());
            snapshotOut.add(snap);
            subtotal += snap.linePaise();
        }
        long discount = Math.min(discountPaise, subtotal);          // never below zero
        long taxes = Money.bps(subtotal + PACKAGING, TAX_BPS);
        long total = subtotal + PACKAGING + DELIVERY + taxes - discount;
        return new Bill(subtotal, PACKAGING, DELIVERY, taxes, discount, total);
    }
}

/* ============================================================= cancellation */
interface CancellationPolicy {
    /** Basis points refunded, or -1 when cancelling is not allowed from this state. */
    long refundBpsFor(OrderState state);
}

final class StandardCancellationPolicy implements CancellationPolicy {
    public long refundBpsFor(OrderState s) {
        switch (s) {
            case PLACED:
            case ACCEPTED:  return 10000;   // nothing has been cooked yet
            case PREPARING: return 5000;    // the food is already on the pan
            case READY:     return 2500;    // cooked and boxed
            default:        return -1;      // on a bike, or already finished
        }
    }
}

record RefundQuote(boolean allowed, long refundPaise, long keptPaise, String reason) {}

/* ================================================================ observers */
interface OrderObserver {
    void onTransition(Order o, OrderState from, OrderState to, OrderEvent e, Actor who);
}

final class ChannelNotifier implements OrderObserver {
    final String channel;
    int sent = 0;
    ChannelNotifier(String channel) { this.channel = channel; }
    public void onTransition(Order o, OrderState from, OrderState to, OrderEvent e, Actor who) {
        sent++;
        System.out.println("        [" + channel + "] " + o.id + ": " + from + " -> " + to);
    }
}

/* ================================================== partners and assignment */
final class DeliveryPartner {
    final String id, name;
    final int etaMinutes;
    final List<String> activeOrders = new ArrayList<>();
    DeliveryPartner(String id, String name, int etaMinutes) {
        this.id = id; this.name = name; this.etaMinutes = etaMinutes;
    }
    boolean free() { return activeOrders.isEmpty(); }
}

record Assignment(String partnerId, int assignedAtMin) {}

interface AssignmentStrategy {
    String name();
    DeliveryPartner pick(Order order, List<DeliveryPartner> candidates, Map<String, Order> allOrders);
}

final class NearestFreeStrategy implements AssignmentStrategy {
    public String name() { return "nearest-free"; }
    public DeliveryPartner pick(Order order, List<DeliveryPartner> cands, Map<String, Order> all) {
        DeliveryPartner best = null;
        for (DeliveryPartner p : cands)
            if (p.free() && (best == null || p.etaMinutes < best.etaMinutes)) best = p;
        return best;
    }
}

final class BatchedPickupStrategy implements AssignmentStrategy {
    private static final int MAX_PER_TRIP = 2;
    public String name() { return "batched"; }
    public DeliveryPartner pick(Order order, List<DeliveryPartner> cands, Map<String, Order> all) {
        // 1. is somebody already going to THIS kitchen with room for one more?
        for (DeliveryPartner p : cands) {
            if (p.activeOrders.isEmpty() || p.activeOrders.size() >= MAX_PER_TRIP) continue;
            for (String oid : p.activeOrders) {
                Order other = all.get(oid);
                if (other != null && !other.state().terminal()
                        && other.restaurant.id.equals(order.restaurant.id)) return p;
            }
        }
        return new NearestFreeStrategy().pick(order, cands, all);   // 2. fall back
    }
}

/** Geography belongs to another interview. This is the seam where it plugs in. */
final class PartnerDirectory {
    final List<DeliveryPartner> all = new ArrayList<>();
    List<DeliveryPartner> candidatesNear(Restaurant r) { return all; }     // stub
}

/** Frees a partner as soon as an order reaches a terminal state. An observer. */
final class PartnerReleaser implements OrderObserver {
    private final PartnerDirectory dir;
    PartnerReleaser(PartnerDirectory dir) { this.dir = dir; }
    public void onTransition(Order o, OrderState from, OrderState to, OrderEvent e, Actor who) {
        if (!to.terminal() || o.assignment == null) return;
        for (DeliveryPartner p : dir.all)
            if (p.id.equals(o.assignment.partnerId())) p.activeOrders.remove(o.id);
    }
}

/* ==================================================================== order */
record TransitionRecord(OrderState from, OrderState to, OrderEvent event, Actor actor, int atMin) {}

final class Order {
    final String id, customerId;
    final Restaurant restaurant;
    final List<OrderItem> items;      // immutable snapshot of what was ordered
    final Bill bill;                  // frozen at PLACED, forever
    final int placedAtMin;

    private OrderState state = OrderState.PLACED;
    private final CancellationPolicy policy;
    private final List<OrderObserver> observers;
    private final List<TransitionRecord> log = new ArrayList<>();
    long refundPaise = 0;
    Assignment assignment;

    Order(String id, String customerId, Restaurant r, List<OrderItem> items, Bill bill,
          int placedAtMin, CancellationPolicy policy, List<OrderObserver> observers) {
        this.id = id; this.customerId = customerId; this.restaurant = r;
        this.items = List.copyOf(items); this.bill = bill; this.placedAtMin = placedAtMin;
        this.policy = policy; this.observers = observers;
        fanOut(null, OrderState.PLACED, null, Actor.CUSTOMER, placedAtMin);
    }

    OrderState state() { return state; }             // a getter. there is no setter.
    List<TransitionRecord> history() { return List.copyOf(log); }

    /**
     * The ONLY way the state ever changes. Synchronized because three different
     * apps push this object; the guarded region is a table lookup and one write.
     */
    synchronized OrderState transition(OrderEvent event, Actor who, int atMin) {
        OrderState from = state;
        if (from.terminal())
            throw new IllegalTransition(from + " is terminal - nothing follows it");
        OrderState to = OrderStateMachine.next(from, event, who);   // throws with a reason
        if (event == OrderEvent.CANCEL) {
            RefundQuote q = cancellationQuote();                    // the POLICY decides money
            if (!q.allowed()) throw new IllegalTransition(q.reason());
            refundPaise = q.refundPaise();
        }
        state = to;
        fanOut(from, to, event, who, atMin);
        return to;
    }

    /** Legality came from the table; this method only answers "how much?". */
    RefundQuote cancellationQuote() {
        long rate = policy.refundBpsFor(state);
        if (rate < 0) return new RefundQuote(false, 0, 0, "cancelling is not allowed from " + state);
        long refund = Money.bps(bill.total(), rate);
        return new RefundQuote(true, refund, bill.total() - refund,
            "refund " + (rate / 100) + "% of " + Money.fmt(bill.total()));
    }

    private void fanOut(OrderState from, OrderState to, OrderEvent e, Actor who, int atMin) {
        log.add(new TransitionRecord(from, to, e, who, atMin));
        for (OrderObserver o : observers) o.onTransition(this, from, to, e, who);
    }
}

/* ================================================================== service */
final class FoodDeliveryService {
    static final int AUTO_REJECT_AFTER_MIN = 2;

    private final Map<String, Order> orders = new LinkedHashMap<>();
    private final Map<String, Order> byRequestId = new HashMap<>();     // idempotency
    private final List<OrderObserver> observers = new ArrayList<>();
    private final CancellationPolicy policy;
    private final PartnerDirectory partners;
    private AssignmentStrategy strategy;
    private int seq = 0;

    FoodDeliveryService(CancellationPolicy policy, PartnerDirectory partners, AssignmentStrategy s) {
        this.policy = policy; this.partners = partners; this.strategy = s;
        observers.add(new PartnerReleaser(partners));
    }

    void register(OrderObserver o) { observers.add(o); }                 // +1 channel: 1 line
    void setAssignmentStrategy(AssignmentStrategy s) { strategy = s; }   // +1 rule:    1 line
    String strategyName() { return strategy.name(); }

    Order placeOrder(String customerId, Cart cart, Restaurant r, long discountPaise,
                     String requestId, int atMin) {
        Order seen = byRequestId.get(requestId);
        if (seen != null) return seen;                  // a retried tap is not a new order
        if (!r.isOpenAt(atMin))
            throw new IllegalArgumentException(r.name + " is closed at minute " + atMin);

        List<OrderItem> snapshot = new ArrayList<>();
        Bill bill = BillCalculator.build(cart, r, discountPaise, snapshot);   // CURRENT prices
        Order o = new Order("ORD-" + (++seq), customerId, r, snapshot, bill,
                            atMin, policy, observers);
        orders.put(o.id, o);
        byRequestId.put(requestId, o);
        return o;
    }

    /** The flow never names an implementation — that is what makes the seam real. */
    DeliveryPartner assign(Order o, int atMin) {
        DeliveryPartner p = strategy.pick(o, partners.candidatesNear(o.restaurant), orders);
        if (p == null) return null;
        p.activeOrders.add(o.id);
        o.assignment = new Assignment(p.id, atMin);
        return p;
    }

    /** The SYSTEM actor. Time comes in as a parameter; nothing here reads a clock. */
    void tick(int nowMin) {
        for (Order o : orders.values())
            if (o.state() == OrderState.PLACED && nowMin - o.placedAtMin >= AUTO_REJECT_AFTER_MIN)
                o.transition(OrderEvent.REJECT, Actor.SYSTEM, nowMin);
    }
}

/* ===================================================================== demo */
public class Main {
    static void fire(Order o, OrderEvent e, Actor who, int atMin) {
        try {
            OrderState to = o.transition(e, who, atMin);
            System.out.println("  ok   " + who + " fires " + e + "  ->  " + to);
        } catch (IllegalTransition ex) {
            System.out.println("  X    " + who + " fires " + e + "  ->  REFUSED: " + ex.getMessage());
        }
    }

    public static void main(String[] args) {
        Restaurant tandoor = new Restaurant("R1", "Tandoor House", 11 * 60, 23 * 60)
            .add(new MenuItem("paneer", "Paneer Butter Masala", 24000))
            .add(new MenuItem("naan",   "Butter Naan",           6000))
            .add(new MenuItem("jamun",  "Gulab Jamun",           9000));

        PartnerDirectory dir = new PartnerDirectory();
        dir.all.add(new DeliveryPartner("P1", "Asha",   4));
        dir.all.add(new DeliveryPartner("P2", "Vikram", 7));

        FoodDeliveryService app = new FoodDeliveryService(
            new StandardCancellationPolicy(), dir, new NearestFreeStrategy());
        ChannelNotifier push = new ChannelNotifier("push");
        app.register(push);

        Cart cart = new Cart("R1").add("paneer", 1).add("naan", 2);

        System.out.println("== 1. place the order at 20:00 (minute 1200) ==================");
        Order o = app.placeOrder("C1", cart, tandoor, 5000, "req-abc", 1200);
        System.out.println("  " + o.id + "  " + o.bill.pretty());

        System.out.println("== 2. the same tap arrives twice =============================");
        Order again = app.placeOrder("C1", cart, tandoor, 5000, "req-abc", 1200);
        System.out.println("  same object? " + (again == o) + "   (idempotency key req-abc)");

        System.out.println("== 3. the menu price moves AFTER the order exists ============");
        tandoor.menu.get("paneer").pricePaise = 27000;
        System.out.println("  menu paneer = 27000, order total still " + Money.fmt(o.bill.total())
            + "  (frozen)");

        System.out.println("== 4. illegal moves are refused, with a reason ===============");
        fire(o, OrderEvent.PICK_UP, Actor.PARTNER,  1201);      // missing transition
        fire(o, OrderEvent.ACCEPT,  Actor.CUSTOMER, 1201);      // wrong actor

        System.out.println("== 5. the happy path ========================================");
        fire(o, OrderEvent.ACCEPT, Actor.RESTAURANT, 1202);
        DeliveryPartner p = app.assign(o, 1203);
        System.out.println("  assigned " + p.name + " via " + app.strategyName()
            + " (eta " + p.etaMinutes + "m)");
        fire(o, OrderEvent.START_PREP, Actor.RESTAURANT, 1203);

        System.out.println("== 6. what would cancelling cost right now? ==================");
        RefundQuote q = o.cancellationQuote();
        System.out.println("  " + q.reason() + "  ->  back " + Money.fmt(q.refundPaise())
            + ", kitchen keeps " + Money.fmt(q.keptPaise())
            + "   (sums to " + Money.fmt(q.refundPaise() + q.keptPaise()) + ")");

        fire(o, OrderEvent.MARK_READY, Actor.RESTAURANT, 1218);
        fire(o, OrderEvent.PICK_UP,    Actor.PARTNER,    1220);

        System.out.println("== 7. cancelling once it is on a bike ========================");
        fire(o, OrderEvent.CANCEL,  Actor.CUSTOMER, 1221);
        fire(o, OrderEvent.DELIVER, Actor.PARTNER,  1238);
        System.out.println("  final " + o.state() + ", notifications sent " + push.sent
            + ", history rows " + o.history().size());

        System.out.println("== 8. a SECOND order — same cart, new prices ================");
        Order o2 = app.placeOrder("C2", cart, tandoor, 5000, "req-def", 1240);
        System.out.println("  " + o2.id + "  " + o2.bill.pretty());
        fire(o2, OrderEvent.ACCEPT,     Actor.RESTAURANT, 1241);
        fire(o2, OrderEvent.START_PREP, Actor.RESTAURANT, 1242);
        fire(o2, OrderEvent.CANCEL,     Actor.CUSTOMER,   1245);
        System.out.println("  refund due " + Money.fmt(o2.refundPaise) + " of "
            + Money.fmt(o2.bill.total()));

        System.out.println("== 9. the kitchen never answers ==============================");
        Order o3 = app.placeOrder("C3", cart, tandoor, 0, "req-ghi", 1300);
        app.tick(1301);
        System.out.println("  after 1 minute:  " + o3.state());
        app.tick(1303);
        System.out.println("  after 3 minutes: " + o3.state() + "  (SYSTEM fired REJECT)");

        System.out.println("== 10. swap the assignment strategy ==========================");
        app.setAssignmentStrategy(new BatchedPickupStrategy());
        Order a = app.placeOrder("C4", cart, tandoor, 0, "req-j", 1310);
        Order b = app.placeOrder("C5", cart, tandoor, 0, "req-k", 1311);
        fire(a, OrderEvent.ACCEPT, Actor.RESTAURANT, 1312);
        fire(b, OrderEvent.ACCEPT, Actor.RESTAURANT, 1312);
        System.out.println("  " + a.id + " -> " + app.assign(a, 1312).name);
        System.out.println("  " + b.id + " -> " + app.assign(b, 1312).name
            + "   (same rider, one trip)");
        System.out.println("  order-flow code changed: 0 lines");
    }
}

/* --- expected output (notification lines trimmed) ---------------------------
== 1. place the order at 20:00 (minute 1200) ==================
  ORD-1  Rs.360.00 + pack Rs.20.00 + delivery Rs.35.00 + tax Rs.19.00 - off Rs.50.00  =  Rs.384.00
== 2. the same tap arrives twice =============================
  same object? true   (idempotency key req-abc)
== 3. the menu price moves AFTER the order exists ============
  menu paneer = 27000, order total still Rs.384.00  (frozen)
== 4. illegal moves are refused, with a reason ===============
  X    PARTNER fires PICK_UP  ->  REFUSED: PICK_UP is not a legal event from PLACED   (missing transition)
  X    CUSTOMER fires ACCEPT  ->  REFUSED: ACCEPT from PLACED is RESTAURANT's move, not CUSTOMER's   (wrong actor)
== 5. the happy path ========================================
  ok   RESTAURANT fires ACCEPT  ->  ACCEPTED
  assigned Asha via nearest-free (eta 4m)
  ok   RESTAURANT fires START_PREP  ->  PREPARING
== 6. what would cancelling cost right now? ==================
  refund 50% of Rs.384.00  ->  back Rs.192.00, kitchen keeps Rs.192.00   (sums to Rs.384.00)
  ok   RESTAURANT fires MARK_READY  ->  READY
  ok   PARTNER fires PICK_UP  ->  PICKED_UP
== 7. cancelling once it is on a bike ========================
  X    CUSTOMER fires CANCEL  ->  REFUSED: CANCEL is not a legal event from PICKED_UP   (missing transition)
  ok   PARTNER fires DELIVER  ->  DELIVERED
  final DELIVERED, notifications sent 6, history rows 6
== 8. a SECOND order - same cart, new prices ================
  ORD-2  Rs.390.00 + pack Rs.20.00 + delivery Rs.35.00 + tax Rs.20.50 - off Rs.50.00  =  Rs.415.50
  refund due Rs.207.75 of Rs.415.50
== 9. the kitchen never answers ==============================
  after 1 minute:  PLACED
  after 3 minutes: REJECTED  (SYSTEM fired REJECT)
== 10. swap the assignment strategy ==========================
  ORD-4 -> Asha
  ORD-5 -> Asha   (same rider, one trip)
  order-flow code changed: 0 lines
--------------------------------------------------------------------------- */

References & further reading

8 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

You track an order with three booleans: isAccepted, isPickedUp, isCancelled. What is the concrete failure?

question 02 / 08

Why does transition() take an Actor and not just an OrderEvent?

question 03 / 08

The order is in PREPARING. A partner app sends PICK_UP, and a customer app sends DELIVER. Both are refused — what should the two messages say?

question 04 / 08

Where do “can this order be cancelled?” and “how much do we refund?” belong?

question 05 / 08

An item sits in a cart for an hour while the restaurant raises its price. What does a correct design do at checkout?

question 06 / 08

After the order is PLACED, the restaurant edits the dish price again. What happens to the order total?

question 07 / 08

A customer on a flaky connection taps “Place order”, the request times out, and the app retries. What prevents two orders and two charges?

question 08 / 08

A delivery partner cancels after picking the food up. What changes in your model?

0/8 answered