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.
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
- Is there one explicit state machine, and does
Orderown it? Not a set of booleans, not anifladder duplicated in three services. One enum, one transition table, onetransition(event, actor)method. - Does every transition name its actor? “Who may fire this?” is half of the domain.
PICK_UPfromREADYis the partner's move and nobody else's. If yourtransition()takes only an event, you have modelled half the problem. - 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
CancellationPolicyobject, not fourifs buried in a controller. - Is the price recomputed at checkout and then frozen? The cart holds
(menuItemId, qty). The bill is built from the current menu atplaceOrder()time — and the moment the order exists, its total is immutable. - 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. - Can you swap partner assignment without touching the order flow?
AssignmentStrategywith nearest-free and batched implementations. This is the Strategy question, and it is the easiest place in this problem to score. - 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)andpartners.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.
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.
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.
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.
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.
/** (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.
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.
- Is cancelling legal from here, and by whom? — the state machine answers this. There is a
CANCELedge fromPLACED,ACCEPTED,PREPARINGandREADY, all owned by the customer. There is noCANCELedge fromPICKED_UPat all, so the answer is not a policy decision, it is a missing row. - What does it cost? — the
CancellationPolicyanswers 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.
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 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.
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
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.
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.NearestFreeStrategytakes the free partner with the smallest ETA.BatchedPickupStrategyprefers 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 isREADYorPICKED_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
placeOrdergate 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
SYSTEMactor exists for this.service.tick(nowMin)walks orders still inPLACEDand firesREJECTbySYSTEMonce 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
READYorPICKED_UP; fromPICKED_UPthe 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
placeOrderat 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
Ratingattached to a terminal order, allowed only fromDELIVERED. Modelled as a separate object soOrderdoes 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.
The 90 minutes
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 && isCancelledbecomes 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. doubleanywhere 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.
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.
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.
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.
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.
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
Assignmentalready 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
DELIVEREDwith 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- Articlegithub.com
awesome-low-level-design — machine coding problems
The canonical list of LLD interview problems with entity breakdowns. Read the food-delivery and ride-sharing write-ups back to back to feel how different their cores are.
- Docsrefactoring.guru
Refactoring Guru — State pattern
The class-per-state variant of what this lesson does with a table. Worth knowing both: a table is compact and easy to print, classes-per-state are better when each state carries a lot of behaviour.
- Articlefsharpforfunandprofit.com
Designing with types — making illegal states unrepresentable
Scott Wlaschin's essay, and the clearest statement of the idea behind the booleans-versus-enum figure. Written in F#, but the argument is language-agnostic.
- Docsdocs.stripe.com
Stripe API — idempotent requests
How a real payments API handles the retried “place order” tap: a client-generated key, stored results, and a defined window. This is the answer to the idempotency follow-up, in production form.
- Articlemartinfowler.com
Martin Fowler — Money (Patterns of Enterprise Application Architecture)
Why money is a value type over an integer number of minor units. Two pages, and it is the reason every amount in this lesson is a long of paise.
- Specw3.org
W3C — State Chart XML (SCXML)
What state machines look like when they grow up: hierarchical states, parallel regions, guards and history. Skim the introduction to see where a flat eleven-row table stops being enough.
- Book
Domain-Driven Design — Eric Evans
The chapters on Aggregates and Value Objects. Order is the textbook aggregate root that owns its own invariants, and Bill is the textbook value object.
- Talkyoutube.com
Christopher Okhravi — design patterns on video
His State and Strategy episodes are the clearest walkthroughs of the two patterns this problem leans on, and they are worth watching before you draw the class diagram from memory.
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