Capstone: Design a Complete Online Food Ordering Platform
A full LLD walkthrough of a Swiggy/UberEats-style food ordering platform — class diagram, order state machine, pricing, delivery assignment, and 8+ design patterns applied in one cohesive system.
Capstone: Design a Complete Online Food Ordering Platform
Every earlier guide in this roadmap isolated one idea — one principle, one pattern, one refactor. Production systems don't hand you problems in isolation. A single "place an order" button click has to survive concurrent coupon redemptions, a delivery partner's phone dying mid-route, a payment gateway timing out with an unknown outcome, and three different UIs that all need to hear about a status change within two seconds of each other.
This capstone designs the whole thing: a Swiggy/UberEats-style food ordering platform, at the class level, end to end. It's deliberately the longest guide in this roadmap — the goal is a design dense enough to defend in a 60-minute LLD interview, and complete enough to be a portfolio artifact on its own. Every design decision below is justified against a concrete failure mode, not asserted because "that's the pattern for this."
1. Requirements
Functional requirements
- Customers browse restaurants by location, view menus grouped into categories, and see items with variants (e.g. Half/Full), add-ons (e.g. extra cheese), and live availability.
- Customers build a cart, customize each line item (variant, add-ons, quantity), apply a coupon, and check out with a chosen payment method.
- Orders move through a well-defined lifecycle from placement to completion, with cancellation possible only while it's still safe to cancel.
- Restaurant owners manage their own menu (CRUD on categories/items/variants/add-ons), toggle item and restaurant availability, and accept or reject incoming orders.
- Delivery partners go online/offline, receive assignment offers, accept or reject them, and update status through pickup and delivery.
- The platform assigns a delivery partner automatically — no manual dispatch.
- Both the customer and the restaurant dashboard and the delivery partner's app see order status changes in near-real-time.
- After completion, customers rate and review both the restaurant and the delivery partner independently.
- Payments must be processed exactly once per checkout attempt, even if the client retries the request (flaky network, app killed mid-request, double-tap on "Pay").
- Admins onboard/suspend restaurants and create platform-wide coupons.
Non-functional requirements & scale assumptions
This is a class-design (LLD) document, not a capacity plan — but every non-functional constraint below directly justifies a design decision later (the State pattern, the idempotency key, compare-and-set on delivery assignment), so the assumptions are made explicit up front rather than left implicit.
| Dimension | Assumption | Why it matters to the design |
|---|---|---|
| Active restaurants | ~200,000 across all served cities | Catalog reads must scale independently of order writes |
| Peak concurrent order placement | ~50,000 orders/minute platform-wide (dinner rush) | Delivery assignment and payment paths must not serialize on a single lock |
| Delivery partners online at peak | ~500,000 | Assignment must be a fast, local decision, not a global optimization on every order |
| Catalog read:write ratio | ~1000:1 | Menu/restaurant data should be cache-friendly and rarely invalidated |
| Order placement ack latency (p99) | < 300ms | Rules out synchronous cross-service 2PC; favors an orchestrated saga with local fast-fail |
| Payment consistency | At-least-once delivery from client, exactly-once effect | Drives the idempotency-key design in Section 10 |
| Status propagation latency | < 2s from state change to all observer channels | Fine in-process at moderate scale; revisited in Section 12 |
| Availability | 99.9%+ on the checkout path | Every external call in the saga needs a defined compensating action |
This design assumes a single geographic region's data lives together (an order, its restaurant, and its delivery partner are always in the same city). Cross-region concerns — a customer traveling between cities mid-session — are out of scope, same as most real platforms treat it.
2. Actors & Use Cases
| Actor | Key use cases |
|---|---|
| Customer | Browse restaurants and menus · build and customize a cart · apply a coupon · check out and pay · track order status live · rate and review restaurant and delivery partner |
| Restaurant Owner | Manage menu (categories, items, variants, add-ons) · toggle item/restaurant availability · accept or reject incoming orders · update preparation status |
| Delivery Partner | Go online/offline · accept or reject an assignment offer · update live location · mark order picked up and delivered |
| Admin | Onboard and suspend restaurants · create and manage platform-wide coupons · handle the refund/dispute queue · moderate reviews |
3. Domain Model / Class Diagram
Thirty-plus entities in a single diagram is unreadable, so the model is split into three diagrams along the same seams the system would actually be split into services: Catalog & Cart, Order & Payment, and Delivery, Tracking & Rating. Read them together — later sections reference classes across all three.
3.1 Catalog & Cart
User is an abstract base — Customer, RestaurantOwner, DeliveryPartner, and Admin all inherit identity fields but have no behavioral overlap, which is why this is inheritance (a true is-a, sharing only static attributes) rather than a fat shared interface. Notice Restaurant *-- MenuCategory is composition — a category has no meaning outside its restaurant — while MenuItem o-- AddOn is aggregation, since the same add-on ("Extra cheese") can be shared across many menu items.
3.2 Order & Payment
Order doesn't hold a status enum — it holds an OrderState reference, the object at the center of Section 5. PriceBreakdown is the frozen output of the pricing pipeline in Section 6; once an order is placed, its breakdown never recomputes even if the coupon or tax rates change later.
3.3 Delivery, Tracking & Rating
Order o-- OrderStatusObserver is aggregation, not composition: observers are wired in at runtime by whatever process constructs the order (the notification service), and the order has no idea how many there are or what they do — see Section 9.
The aggregation vs. composition calls above aren't cosmetic — they're the same judgment call covered in Coupling & Cohesion. Getting MenuCategory *-- MenuItem (composition, dies with the restaurant) versus MenuItem o-- AddOn (aggregation, outlives any one item) backwards would let deleting a category silently orphan shared add-ons across the whole menu.
4. Order State Machine
The lifecycle is Cart → Placed → Confirmed → Preparing → PickedUp → Delivered → Completed, with a Cancelled branch that closes off once a delivery partner has physically picked up the food — cancelling after that point means food and a trip are already committed, so it becomes a refund/dispute flow instead of a state transition.
| From | To | Trigger | Actor |
|---|---|---|---|
| Cart | Placed | checkout() — cart converted to an immutable order | Customer |
| Placed | Confirmed | Restaurant accepts the order | Restaurant Owner |
| Placed | Cancelled | Manual cancel, or restaurant doesn't respond within the accept SLA | Customer / System |
| Confirmed | Preparing | Kitchen marks preparation started | Restaurant Owner |
| Confirmed | Cancelled | Restaurant rejects (item unavailable, kitchen overloaded) | Restaurant Owner |
| Preparing | PickedUp | Delivery partner scans/confirms pickup at the restaurant | Delivery Partner |
| Preparing | Cancelled | Exceptional cancellation, requires admin approval and a refund flow | Admin |
| PickedUp | Delivered | Delivery partner marks delivery complete (geofence or manual confirm) | Delivery Partner |
| Delivered | Completed | Auto-transition after the rating window, or an explicit customer confirm | System / Customer |
Once PickedUp is reached, cancel() simply doesn't exist as a transition on that state (see Section 5) — not "cancel exists but is blocked by an if-check." That's the entire value of modeling this as a State pattern instead of an enum: illegal transitions aren't a runtime check you might forget to add, they're a method that was never implemented on that class.
5. Core Class Design (Java)
5.1 The OrderState hierarchy
Each concrete state implements only the transitions legal from that state. Everything else falls through to a shared "illegal transition" default, so a missing override is impossible to overlook — it's the absence of a method, not a missed case in a switch.
// Every legal transition returns the NEXT state; illegal ones throw.
interface OrderState {
OrderState confirm(Order order);
OrderState startPreparing(Order order);
OrderState markPickedUp(Order order);
OrderState markDelivered(Order order);
OrderState complete(Order order);
OrderState cancel(Order order);
String name();
}
abstract class BaseOrderState implements OrderState {
public OrderState confirm(Order order) { return illegal("confirm"); }
public OrderState startPreparing(Order order) { return illegal("startPreparing"); }
public OrderState markPickedUp(Order order) { return illegal("markPickedUp"); }
public OrderState markDelivered(Order order) { return illegal("markDelivered"); }
public OrderState complete(Order order) { return illegal("complete"); }
public OrderState cancel(Order order) { return illegal("cancel"); }
private OrderState illegal(String action) {
throw new IllegalStateException("Cannot " + action + " an order in state " + name());
}
}class PlacedState extends BaseOrderState {
public String name() { return "PLACED"; }
@Override public OrderState confirm(Order order) {
order.notifyObservers("CONFIRMED");
return new ConfirmedState();
}
@Override public OrderState cancel(Order order) {
order.notifyObservers("CANCELLED");
return new CancelledState();
}
}
class ConfirmedState extends BaseOrderState {
public String name() { return "CONFIRMED"; }
@Override public OrderState startPreparing(Order order) {
order.notifyObservers("PREPARING");
return new PreparingState();
}
@Override public OrderState cancel(Order order) {
order.notifyObservers("CANCELLED");
return new CancelledState();
}
}
class PreparingState extends BaseOrderState {
public String name() { return "PREPARING"; }
@Override public OrderState markPickedUp(Order order) {
order.notifyObservers("PICKED_UP");
return new PickedUpState();
}
@Override public OrderState cancel(Order order) {
order.notifyObservers("CANCELLED"); // admin-approved exceptional path
return new CancelledState();
}
}
class PickedUpState extends BaseOrderState {
public String name() { return "PICKED_UP"; }
@Override public OrderState markDelivered(Order order) {
order.notifyObservers("DELIVERED");
return new DeliveredState();
}
// No cancel() override: food is out for delivery, cancellation is no
// longer a state transition — it becomes a post-delivery refund case.
}
class DeliveredState extends BaseOrderState {
public String name() { return "DELIVERED"; }
@Override public OrderState complete(Order order) {
order.notifyObservers("COMPLETED");
return new CompletedState();
}
}
class CompletedState extends BaseOrderState {
public String name() { return "COMPLETED"; } // terminal — every transition is illegal
}
class CancelledState extends BaseOrderState {
public String name() { return "CANCELLED"; } // terminal — every transition is illegal
}class Order {
private final String id;
private final String idempotencyKey;
private final List<OrderStatusObserver> observers = new ArrayList<>();
private OrderState state;
Order(String id, String idempotencyKey) {
this.id = id;
this.idempotencyKey = idempotencyKey;
this.state = new PlacedState(); // an Order is only ever constructed post-checkout
}
void addObserver(OrderStatusObserver observer) { observers.add(observer); }
void notifyObservers(String newStatus) {
for (OrderStatusObserver observer : observers) {
observer.onStatusChanged(this, newStatus);
}
}
void confirm() { state = state.confirm(this); }
void startPreparing() { state = state.startPreparing(this); }
void markPickedUp() { state = state.markPickedUp(this); }
void markDelivered() { state = state.markDelivered(this); }
void complete() { state = state.complete(this); }
void cancel() { state = state.cancel(this); }
String currentStatus() { return state.name(); }
String getId() { return id; }
}Compare this to enum OrderStatus { PLACED, CONFIRMED, ... } plus a switch in every service method that transitions an order. The enum version compiles fine with a missing case, and the legality check lives wherever a developer remembered to put an if. The State pattern makes "what can happen from PICKED_UP" a property of exactly one class (PickedUpState) instead of a fact scattered across every call site — the same win the Strategy pattern gets for Open/Closed in the SOLID guide, applied to a lifecycle instead of a calculation.
5.2 Cart, CartItem, and item customization
A Cart belongs to exactly one restaurant at a time in this design (multi-restaurant carts are an extension — see Section 12). Customization — variant, add-ons, quantity — lives on CartItem, not on MenuItem, since the same menu item is customized differently per cart line.
class Cart {
private final String id;
private final Customer owner;
private final Restaurant restaurant;
private final List<CartItem> items = new ArrayList<>();
Cart(String id, Customer owner, Restaurant restaurant) {
this.id = id;
this.owner = owner;
this.restaurant = restaurant;
}
void addItem(MenuItem item, ItemVariant variant, int quantity, List<AddOn> addOns) {
if (!item.isAvailable()) {
throw new IllegalStateException(item.getName() + " is currently unavailable");
}
items.add(new CartItem(item, variant, quantity, addOns));
}
void removeItem(String cartItemId) {
items.removeIf(ci -> ci.getId().equals(cartItemId));
}
double subtotal() {
return items.stream().mapToDouble(CartItem::lineTotal).sum();
}
List<CartItem> getItems() { return Collections.unmodifiableList(items); }
Order checkout(Address deliveryAddress, PricingCalculator pricing, String idempotencyKey) {
if (items.isEmpty()) {
throw new IllegalStateException("Cannot checkout an empty cart");
}
// Re-validate availability at checkout time, not just at add-to-cart time —
// an item can go out of stock while it's sitting in the cart.
for (CartItem ci : items) {
if (!ci.getMenuItem().isAvailable()) {
throw new IllegalStateException(ci.getMenuItem().getName() + " is no longer available");
}
}
Order order = new Order(UUID.randomUUID().toString(), idempotencyKey);
// ... map CartItems -> OrderItems, attach restaurant/address/priceBreakdown
return order;
}
}
class CartItem {
private final String id = UUID.randomUUID().toString();
private final MenuItem menuItem;
private final ItemVariant variant; // nullable — not every item has variants
private int quantity;
private final List<AddOn> addOns;
CartItem(MenuItem menuItem, ItemVariant variant, int quantity, List<AddOn> addOns) {
this.menuItem = menuItem;
this.variant = variant;
this.quantity = quantity;
this.addOns = new ArrayList<>(addOns);
}
double lineTotal() {
double unitPrice = menuItem.getBasePrice() + (variant != null ? variant.getPriceDelta() : 0);
double addOnTotal = addOns.stream().mapToDouble(AddOn::getPrice).sum();
return (unitPrice + addOnTotal) * quantity;
}
String getId() { return id; }
MenuItem getMenuItem() { return menuItem; }
}5.3 MenuItem and ItemVariant
class MenuItem {
private final String id;
private final String name;
private final double basePrice;
private boolean available;
private final List<ItemVariant> variants;
private final List<AddOn> availableAddOns;
MenuItem(String id, String name, double basePrice, List<ItemVariant> variants, List<AddOn> availableAddOns) {
this.id = id;
this.name = name;
this.basePrice = basePrice;
this.available = true;
this.variants = variants;
this.availableAddOns = availableAddOns;
}
boolean isAvailable() { return available; }
void setAvailable(boolean available) { this.available = available; } // restaurant owner toggles this
double getBasePrice() { return basePrice; }
String getName() { return name; }
}
class ItemVariant {
private final String id;
private final String label; // "Half", "Full", "Large"
private final double priceDelta; // added on top of MenuItem.basePrice
ItemVariant(String id, String label, double priceDelta) {
this.id = id;
this.label = label;
this.priceDelta = priceDelta;
}
double getPriceDelta() { return priceDelta; }
}
class AddOn {
private final String id;
private final String name; // "Extra cheese", "Extra spicy"
private final double price;
AddOn(String id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
double getPrice() { return price; }
}6. Pricing Strategy
Total price is itemsSubtotal + tax + deliveryFee - discount, but each term varies independently: tax rate depends on locality, delivery fee depends on distance (and later, surge), and discount depends on whichever coupon (if any) is applied. Strategy pattern picks which rule computes each term; Decorator pattern stacks those terms onto a running total without a combinatorial explosion of subclasses for every combination of "has tax + has delivery fee + has discount."
class PriceContext {
final double itemsSubtotal;
final double distanceKm;
final String city;
PriceContext(double itemsSubtotal, double distanceKm, String city) {
this.itemsSubtotal = itemsSubtotal;
this.distanceKm = distanceKm;
this.city = city;
}
}
interface TaxStrategy {
double computeTax(PriceContext context, double taxableAmount);
}
class GstTaxStrategy implements TaxStrategy {
private final double ratePercent; // GST slab varies by restaurant category/locality
GstTaxStrategy(double ratePercent) { this.ratePercent = ratePercent; }
public double computeTax(PriceContext context, double taxableAmount) {
return taxableAmount * ratePercent / 100.0;
}
}
interface DeliveryFeeStrategy {
double computeFee(PriceContext context);
}
class DistanceBasedDeliveryFee implements DeliveryFeeStrategy {
private static final double BASE_FEE = 25.0;
private static final double PER_KM = 6.0;
public double computeFee(PriceContext context) {
return BASE_FEE + context.distanceKm * PER_KM;
}
}// Decorator chain: each layer wraps the previous total and adds exactly one term.
interface PricingComponent {
double amount(PriceContext context);
String label();
}
class ItemsSubtotalComponent implements PricingComponent {
public double amount(PriceContext context) { return context.itemsSubtotal; }
public String label() { return "Items subtotal"; }
}
abstract class PricingDecorator implements PricingComponent {
protected final PricingComponent wrapped;
PricingDecorator(PricingComponent wrapped) { this.wrapped = wrapped; }
}
class TaxDecorator extends PricingDecorator {
private final TaxStrategy taxStrategy;
TaxDecorator(PricingComponent wrapped, TaxStrategy taxStrategy) {
super(wrapped);
this.taxStrategy = taxStrategy;
}
public double amount(PriceContext context) {
double base = wrapped.amount(context);
return base + taxStrategy.computeTax(context, base);
}
public String label() { return wrapped.label() + " + tax"; }
}
class DeliveryFeeDecorator extends PricingDecorator {
private final DeliveryFeeStrategy feeStrategy;
DeliveryFeeDecorator(PricingComponent wrapped, DeliveryFeeStrategy feeStrategy) {
super(wrapped);
this.feeStrategy = feeStrategy;
}
public double amount(PriceContext context) {
return wrapped.amount(context) + feeStrategy.computeFee(context);
}
public String label() { return wrapped.label() + " + delivery fee"; }
}
class DiscountDecorator extends PricingDecorator {
private final Coupon coupon;
DiscountDecorator(PricingComponent wrapped, Coupon coupon) {
super(wrapped);
this.coupon = coupon;
}
public double amount(PriceContext context) {
double runningTotal = wrapped.amount(context);
double discount = coupon.discountFor(context.itemsSubtotal);
return Math.max(runningTotal - discount, 0);
}
public String label() { return wrapped.label() + " - coupon(" + coupon.getCode() + ")"; }
}class PricingCalculator {
double calculateTotal(PriceContext context, TaxStrategy tax, DeliveryFeeStrategy fee, Coupon coupon) {
PricingComponent chain = new ItemsSubtotalComponent();
chain = new TaxDecorator(chain, tax);
chain = new DeliveryFeeDecorator(chain, fee);
if (coupon != null) {
chain = new DiscountDecorator(chain, coupon);
}
return chain.amount(context);
}
}Worked example
Cart subtotal ₹450.00, 3.2 km delivery, 5% GST, coupon FLAT50:
| Step | Component | Running total |
|---|---|---|
| 1 | Items subtotal | ₹450.00 |
| 2 | + GST (5% of ₹450.00) | ₹450.00 + ₹22.50 = ₹472.50 |
| 3 | + Delivery fee (₹25 base + 3.2 km × ₹6) | ₹472.50 + ₹44.20 = ₹516.70 |
| 4 | − Coupon FLAT50 | ₹516.70 − ₹50.00 = ₹466.70 |
| Total payable | ₹466.70 |
This decorator chain is the same Open/Closed win covered in the SOLID Principles guide: adding "convenience fee" or "packaging charge" later means writing one new PricingDecorator subclass and inserting it into the chain — zero existing decorators are touched or re-tested.
7. Coupon & Discount System
A coupon is valid only if it passes every rule: not expired, order meets the minimum, the customer is eligible, and the customer hasn't exceeded their per-user usage limit. Chain of Responsibility keeps each rule as an independent, testable unit — adding a new rule (e.g. "first order only") means writing one class and inserting it into the chain, not editing a monolithic isValid() method.
abstract class CouponValidator {
private CouponValidator next;
CouponValidator linkWith(CouponValidator next) {
this.next = next;
return next;
}
final void validate(Coupon coupon, Customer customer, double orderSubtotal) {
check(coupon, customer, orderSubtotal);
if (next != null) {
next.validate(coupon, customer, orderSubtotal);
}
}
protected abstract void check(Coupon coupon, Customer customer, double orderSubtotal);
}
class ExpiryValidator extends CouponValidator {
protected void check(Coupon coupon, Customer customer, double orderSubtotal) {
if (LocalDate.now().isAfter(coupon.getExpiryDate())) {
throw new CouponInvalidException(coupon.getCode() + " has expired");
}
}
}
class MinimumOrderValidator extends CouponValidator {
protected void check(Coupon coupon, Customer customer, double orderSubtotal) {
if (orderSubtotal < coupon.getMinimumOrderValue()) {
throw new CouponInvalidException(
"Order must be at least " + coupon.getMinimumOrderValue() + " to use " + coupon.getCode());
}
}
}
class UserEligibilityValidator extends CouponValidator {
protected void check(Coupon coupon, Customer customer, double orderSubtotal) {
if (coupon.isUserSpecific() && !coupon.getEligibleUserIds().contains(customer.getId())) {
throw new CouponInvalidException(coupon.getCode() + " is not valid for this account");
}
}
}
class UsageLimitValidator extends CouponValidator {
private final CouponUsageRepository usageRepository;
UsageLimitValidator(CouponUsageRepository usageRepository) {
this.usageRepository = usageRepository;
}
protected void check(Coupon coupon, Customer customer, double orderSubtotal) {
int timesUsed = usageRepository.countUsage(coupon.getCode(), customer.getId());
if (timesUsed >= coupon.getPerUserLimit()) {
throw new CouponInvalidException(coupon.getCode() + " usage limit reached for this account");
}
}
}CouponValidator chain = new ExpiryValidator();
chain.linkWith(new MinimumOrderValidator())
.linkWith(new UserEligibilityValidator())
.linkWith(new UsageLimitValidator(usageRepository));
// Throws CouponInvalidException on the first failing rule; the exception
// message is exactly what the client should surface to the customer.
chain.validate(coupon, customer, cart.subtotal());Coupon abuse via timing, not logic: a coupon can pass validation the moment it's applied to the cart, then the cart sits open for ten minutes while the coupon expires, the customer's usage limit gets consumed by a race on another device, or the order subtotal drops below the minimum because an item went out of stock. Re-run the full validator chain immediately before payment capture — not only when the coupon was first applied — and make usage-count increment atomic (UPDATE ... WHERE times_used < limit) so two concurrent checkouts can't both slip under the same limit.
8. Delivery Partner Assignment
class DeliveryPartner {
private final String id;
private GeoPoint currentLocation;
private DeliveryPartnerStatus status; // AVAILABLE, ON_DELIVERY, OFFLINE
private int activeDeliveries;
private static final int MAX_CONCURRENT_DELIVERIES = 2; // batched-pickup capacity
boolean hasCapacity() { return activeDeliveries < MAX_CONCURRENT_DELIVERIES; }
GeoPoint getCurrentLocation() { return currentLocation; }
String getId() { return id; }
}
interface DeliveryAssignmentStrategy {
Optional<DeliveryPartner> assign(Order order, Restaurant restaurant);
}
class NearestAvailablePartnerStrategy implements DeliveryAssignmentStrategy {
private static final double SEARCH_RADIUS_KM = 5.0;
private final DeliveryPartnerRepository partnerRepository;
NearestAvailablePartnerStrategy(DeliveryPartnerRepository partnerRepository) {
this.partnerRepository = partnerRepository;
}
public Optional<DeliveryPartner> assign(Order order, Restaurant restaurant) {
GeoPoint pickupPoint = restaurant.getAddress().toGeoPoint();
return partnerRepository.findAvailableNear(pickupPoint, SEARCH_RADIUS_KM).stream()
.filter(DeliveryPartner::hasCapacity)
.min(Comparator.comparingDouble(p -> distanceKm(p.getCurrentLocation(), pickupPoint)));
}
private double distanceKm(GeoPoint a, GeoPoint b) {
// Haversine distance — omitted for brevity
return 0.0;
}
}Picking a candidate and claiming them are two different steps — the gap between them is exactly where two concurrent orders can pick the same nearest partner:
class DeliveryAssignmentService {
private final DeliveryPartnerRepository partnerRepository;
DeliveryAssignment assign(Order order, DeliveryPartner candidate) {
// Atomic compare-and-set: only succeeds if the partner is still AVAILABLE.
boolean claimed = partnerRepository.compareAndSetStatus(
candidate.getId(), DeliveryPartnerStatus.AVAILABLE, DeliveryPartnerStatus.ON_DELIVERY);
if (!claimed) {
throw new PartnerAlreadyAssignedException(candidate.getId());
// caller catches this and retries assignment with the next-nearest candidate
}
return new DeliveryAssignment(order, candidate, Instant.now());
}
}Race condition: "find nearest available partner, then assign them" as two separate steps (a SELECT followed by an UPDATE) lets two orders placed milliseconds apart both read the same partner as available and both attempt to assign them. The compare-and-set above closes that window — the UPDATE only commits if the partner's status is still exactly what was read, so the loser gets a clean PartnerAlreadyAssignedException and retries against the next-nearest candidate instead of silently double-booking.
Greedy nearest-match vs. batched assignment
NearestAvailablePartnerStrategy is a greedy, per-order decision: O(partners in radius) work, sub-second, and simple to reason about. It's also myopic — assigning the single nearest partner to order A can leave a much worse match for order B that arrives two seconds later, when a short batching window would have let both orders be assigned jointly for a lower total distance.
| Approach | Latency | Solution quality | Complexity |
|---|---|---|---|
| Greedy nearest-available | Sub-second, decided per order | Locally optimal, globally suboptimal | Low — a repository query and a CAS |
| Batched assignment (collect orders in a short window, solve as a min-cost bipartite matching) | Adds the batch window (e.g. 5-15s) | Better total distance and partner utilization | High — needs a matching solver and a windowing mechanism |
Real platforms use greedy assignment for the common case and fall back to batching only in dense zones during peak hours, where the matching-quality gain outweighs the added latency. This capstone's design uses the greedy strategy as the default DeliveryAssignmentStrategy implementation specifically because the interface is the seam — swapping in a BatchedMatchingStrategy later touches no caller.
9. Real-Time Order Tracking
Every legal state transition in Section 5 already calls order.notifyObservers(newStatus). The Order doesn't know or care who's listening — it just walks its observer list. Three concrete observers plug into the same list to serve three completely different channels.
interface OrderStatusObserver {
void onStatusChanged(Order order, String newStatus);
}
class CustomerAppNotifier implements OrderStatusObserver {
private final PushNotificationGateway pushGateway;
CustomerAppNotifier(PushNotificationGateway pushGateway) { this.pushGateway = pushGateway; }
public void onStatusChanged(Order order, String newStatus) {
pushGateway.send(order.getCustomerId(), "Your order is now " + newStatus);
}
}
class RestaurantDashboardNotifier implements OrderStatusObserver {
private final WebSocketHub dashboardHub;
RestaurantDashboardNotifier(WebSocketHub dashboardHub) { this.dashboardHub = dashboardHub; }
public void onStatusChanged(Order order, String newStatus) {
dashboardHub.broadcast(order.getRestaurantId(), new OrderStatusEvent(order.getId(), newStatus));
}
}
class DeliveryPartnerAppNotifier implements OrderStatusObserver {
private final PushNotificationGateway pushGateway;
DeliveryPartnerAppNotifier(PushNotificationGateway pushGateway) { this.pushGateway = pushGateway; }
public void onStatusChanged(Order order, String newStatus) {
// Pre-alert the partner while food is still being prepared, so they
// can be en route before it's ready — not just notified at pickup.
if (newStatus.equals("CONFIRMED") || newStatus.equals("PREPARING")) {
pushGateway.send(order.getAssignedPartnerId(), "Order " + order.getId() + " is " + newStatus);
}
}
}Order order = new Order(orderId, idempotencyKey);
order.addObserver(new CustomerAppNotifier(pushGateway));
order.addObserver(new RestaurantDashboardNotifier(dashboardHub));
order.addObserver(new DeliveryPartnerAppNotifier(pushGateway));
order.confirm(); // ConfirmedState fires notifyObservers("CONFIRMED") — all three channels updateIn-process Order.observers works cleanly for a single order-service instance behind one WebSocket edge. It stops working the moment order-service is horizontally scaled: the customer's WebSocket connection might be held by instance A while the state transition happens on instance B, and instance B's in-memory observer list never reaches it. Section 12 covers the replacement — publishing an OrderStatusChanged event to a broker instead of calling observers directly.
10. Idempotent Payment Processing
A client can retry a checkout request for reasons that have nothing to do with whether the first attempt succeeded: a timeout on the response, an app crash before the response is read, a double-tap on "Pay Now." The idempotency key — a UUID the client generates once per checkout attempt and sends on every retry of that same attempt — is what turns "at-least-once request delivery" into "exactly-once charge."
class PaymentService {
private final IdempotencyKeyStore keyStore;
private final PaymentGateway gateway;
PaymentResult charge(String idempotencyKey, Order order, PaymentMethod method) {
Optional<PaymentResult> existing = keyStore.lookup(idempotencyKey);
if (existing.isPresent()) {
return existing.get(); // safe replay: same result, gateway never re-charged
}
PaymentResult result;
try {
result = gateway.charge(order.getId(), order.getTotalPayable(), method);
} catch (GatewayTimeoutException e) {
// Outcome is UNKNOWN — the charge may have gone through at the gateway
// even though this call timed out. Do NOT store a result here, and do
// NOT let the caller blindly retry; force a status reconciliation instead.
throw new PaymentUncertainException(idempotencyKey, e);
}
keyStore.store(idempotencyKey, result); // atomic put-if-absent (unique constraint)
return result;
}
}Idempotency-key collisions are a design bug, not bad luck. The key must be generated client-side, once, per checkout attempt — never derived server-side from order contents (two identical carts would collide) and never regenerated on each HTTP retry (that defeats the whole mechanism). Storage must be an atomic put-if-absent — typically a unique constraint on the key column — so that if two requests with the same key race in, the second one blocks or fails fast instead of both proceeding to charge the gateway independently.
Order placement as a saga
Placing an order touches three independent services with no shared database transaction: reserve inventory, charge payment, get restaurant confirmation. An orchestrated saga runs each step and, on any failure, rolls back the completed steps in reverse via compensating actions — there's no rollback log to replay, just explicit undo code per step.
class OrderPlacementSaga {
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final RestaurantService restaurantService;
void execute(Order order, PaymentMethod method, String idempotencyKey) {
List<Runnable> compensations = new ArrayList<>();
try {
inventoryService.reserve(order);
compensations.add(() -> inventoryService.release(order));
PaymentResult payment = paymentService.charge(idempotencyKey, order, method);
compensations.add(() -> paymentService.refund(payment.getTransactionId()));
restaurantService.sendForConfirmation(order);
compensations.add(() -> restaurantService.cancelConfirmationRequest(order));
order.confirm(); // flips state ONLY once every step has succeeded
} catch (SagaStepException e) {
Collections.reverse(compensations);
for (Runnable compensate : compensations) {
compensate.run(); // best-effort — see the callout below
}
order.cancel();
throw e;
}
}
}Happy path:
Compensation path (restaurant rejects after payment succeeded):
Compensations can fail too. If refund() throws because the payment gateway is down, the for (compensate : compensations) loop above must not silently swallow that and move on — a failed compensation means the customer was charged for a cancelled order. Production sagas push a failed-compensation event to a dead-letter queue for async reconciliation (retry with backoff, then page a human) rather than treating compensation as a step that's assumed to always succeed. Never model a saga as "guaranteed eventually consistent" without an explicit answer to "what happens when the undo also fails."
11. Design Patterns Applied
| Pattern | Where used | Why |
|---|---|---|
| State | OrderState hierarchy (PlacedState, ConfirmedState, …) | Illegal lifecycle transitions become missing methods, not runtime checks scattered across services |
| Strategy | TaxStrategy, DeliveryFeeStrategy, DeliveryAssignmentStrategy | Swap tax slabs, fee models, or assignment algorithms per city/season without touching callers |
| Decorator | PricingComponent chain (subtotal → tax → delivery fee → discount) | Stack optional pricing terms in any combination without a subclass per combination |
| Chain of Responsibility | CouponValidator chain (expiry, minimum order, eligibility, usage limit) | Each validation rule is independent, testable alone, and addable without editing the others |
| Observer | OrderStatusObserver (customer app, restaurant dashboard, delivery partner app) | Decouples the state machine from every channel that reacts to a transition |
| Saga | OrderPlacementSaga (inventory → payment → restaurant confirmation) | Coordinates a multi-service transaction with explicit compensations where no shared ACID transaction exists |
| Factory Method | Building Order/OrderItem from Cart/CartItem at checkout; channel-specific Notification construction | Centralizes object-creation logic that varies by type in one place instead of scattering new calls |
| Builder | Constructing MenuItem/Order with many optional fields (variants, add-ons, delivery instructions) | Avoids a telescoping constructor for objects with several optional, independently-set fields |
| Singleton | IdempotencyKeyStore, DeliveryPartnerRepository connection pool | One shared, thread-safe instance coordinating state across every concurrent request |
Nine patterns, but notice none of them were reached for because "the interview expects patterns." Each row exists because a specific failure mode (double charge, race on assignment, orphaned refund) demanded it. That's the difference between pattern-spotting and pattern application: every pattern above is deletable only if you're also willing to reintroduce the bug it prevents.
12. Trade-offs, Scaling Notes & Extensions
At ~10x current scale
- Delivery assignment becomes its own service with a dedicated geo-index (a quad-tree or H3 hex-grid index rather than a
WHERE lat/lng BETWEENrange query), since partner-location updates arrive far more often than order placements and shouldn't contend with the order database. - Observer-based tracking becomes event-driven.
Order.notifyObservers()is replaced by publishing anOrderStatusChangedevent to a broker (Kafka);CustomerAppNotifier,RestaurantDashboardNotifier, andDeliveryPartnerAppNotifierbecome independent consumers of that topic instead of objects held in an in-process list. This is the direct fix for the cross-instance gap called out in Section 9. - The saga's three participants become genuinely separate services with their own datastores — the design already treats them that way logically, so this is a deployment change, not a redesign.
- Catalog reads move to a cache/CDN layer (Redis or an edge cache in front of
Restaurant/MenuItemreads) given the ~1000:1 read:write ratio assumed in Section 1; writes (owner edits) invalidate narrowly by restaurant ID.
At ~100x current scale
- Order and partner data is sharded by city/region — an order almost never needs to join across regions, so region becomes the shard key.
- Delivery assignment shifts from per-order greedy to windowed batched matching (see Section 8) in dense zones during peak hours, trading a few seconds of latency for meaningfully better partner utilization.
- The idempotency key store moves off a relational unique-constraint table to something built for high-throughput key lookups with TTL (Redis), since a plain table's unique-index contention becomes the bottleneck at this volume.
- Saga orchestration moves from in-process (
OrderPlacementSaga.execute()running inside one service instance) to a durable workflow engine (Temporal, AWS Step Functions), so a crashed order-service instance mid-saga doesn't strand an order in a half-compensated state with no process left to finish the rollback.
Extension ideas
- Multi-restaurant cart splitting. Let
Carthold items from several restaurants;checkout()splits it into siblingOrders — one per restaurant — sharing a singlePaymentrecord via per-order allocations, each with its ownOrderStatemachine and its ownDeliveryAssignment. - Subscription / loyalty tiers. A
LoyaltyTierplugs into the pricing decorator chain from Section 6 as another discount source (e.g., free delivery above a tier threshold), backed by aPointsLedgerrecording earn/redeem events per order. - Surge pricing. A
SurgeMultiplierStrategyimplementsDeliveryFeeStrategyand derives its multiplier from a live driver-to-demand ratio per geo-cell. The multiplier must be locked at cart-view time and carried through to checkout unchanged — a precondition worth stating explicitly in the Design by Contract sense, since silently recomputing a higher surge multiplier between cart view and payment is exactly the kind of broken invariant that produces customer disputes.
Interview Questions
- Why is
Orderstatus modeled as aStatepattern hierarchy instead of an enum with aswitch? What specifically would you lose reverting to enum+switch as the number of states grows? - Walk through what happens if two requests carrying the same idempotency key arrive concurrently. Where exactly does the design prevent a double charge, and what would break if
keyStore.store()weren't atomic? - What happens if a saga compensation step itself fails — say
refund()throws because the payment gateway is down? How should that be handled operationally, not just which exception to catch? - Why does delivery-partner assignment use compare-and-set on partner status instead of a plain read-then-assign? What specific race does it close?
- Greedy nearest-available assignment vs. batched optimal matching — what's the latency/quality trade-off, and under what load conditions would you switch strategies?
- How could a customer abuse a coupon by adding it to the cart while eligible and checking out after conditions change (expiry, usage limit, subtotal drop)? Where must re-validation happen to prevent it?
- The Observer pattern notifies customer, restaurant, and delivery-partner apps in-process on every state transition. What's the first thing that breaks as the order-service is horizontally scaled, and what replaces it?
- How would you extend the domain model in Section 3 to support a single cart with items from two different restaurants, without breaking the existing per-restaurant
Orderstate machine? - Why is pricing built as a Decorator chain (subtotal → tax → delivery fee → discount) instead of one
calculateTotal()method with all the arithmetic inline? - If a delivery partner's app crashes mid-delivery after
PickedUp, how does the system detect the stall and reassign the order without violating the state machine — an order can't go back to "unassigned" once it'sPickedUp?