androidinterview.com

Low Level Design (LLD) Interview Questions

Design a Food Ordering and Delivery Service

Tier: EssentialDifficulty: HardAsked of: Mid, SeniorAsked at: Zomato, Swiggy, DoorDash, Uber Eats, Amazon

Design an order that moves from a customer's checkout through restaurant preparation to delivery.

The problem

Ana orders two meals from one restaurant. The restaurant accepts and prepares them. A courier collects the order and marks it delivered. A delivery update before collection must be rejected.

Start with one restaurant per order. Validate menu availability at checkout and calculate the total using the prices at that moment. Cancellation is allowed only before preparation. Payment, live stock reservation and courier scheduling are follow-ups.

How to explain the design

“I validate the selected items and quantities when creating the order, then keep its total fixed. The order has a status. Each update must be a valid next step, so it cannot jump straight from placed to delivered. A courier must be assigned before pickup.”

MenuItem contains the checkout price and availability. OrderItem keeps the purchased quantity and price. FoodOrder owns those items, the total, courier and status. A small enum and one transition check are enough for this version.

Walk through an order

  1. Validate the cart. Reject an empty cart, an unavailable item or a nonpositive quantity.
  2. Calculate the total and create the order as PLACED.
  3. Move to PREPARING, then READY.
  4. Assign a courier and move to PICKED_UP.
  5. Move to DELIVERED. Reject further changes.

Interview implementation

The application sends updates on one thread. Authentication decides who may call these operations. The order itself checks their sequence.

Java

FoodOrder.java

package interview.food;

import java.util.Map;
import java.util.List;
import java.util.ArrayList;

public class FoodOrder {
    public enum Status { PLACED, PREPARING, READY, PICKED_UP, DELIVERED, CANCELLED }
    public record MenuItem(String id, int price, boolean available) {}
    public record OrderItem(String id, int price, int quantity) {}
    private final List<OrderItem> items;
    private final long total;
    private Status status = Status.PLACED;
    private String courier;

    public FoodOrder(Map<String, MenuItem> menu, Map<String, Integer> quantities) {
        if (quantities.isEmpty()) throw new IllegalArgumentException("Empty order");
        long amount = 0;
        var snapshot = new ArrayList<OrderItem>();
        for (var line : quantities.entrySet()) {
            MenuItem item = menu.get(line.getKey());
            if (item == null || !item.available() || item.price() <= 0 || line.getValue() <= 0) {
                throw new IllegalArgumentException("Item unavailable");
            }
            amount += (long) item.price() * line.getValue();
            snapshot.add(new OrderItem(item.id(), item.price(), line.getValue()));
        }
        total = amount;
        items = List.copyOf(snapshot);
    }

    public long total() { return total; }
    public List<OrderItem> items() { return items; }
    public Status status() { return status; }

    public void assignCourier(String id) {
        if (courier != null || id == null ||
                (status != Status.PLACED && status != Status.PREPARING && status != Status.READY)) {
            throw new IllegalStateException("Cannot assign courier");
        }
        courier = id;
    }

    public void advance(Status next) {
        boolean allowed = switch (status) {
            case PLACED -> next == Status.PREPARING || next == Status.CANCELLED;
            case PREPARING -> next == Status.READY;
            case READY -> next == Status.PICKED_UP && courier != null;
            case PICKED_UP -> next == Status.DELIVERED;
            default -> false;
        };
        if (!allowed) throw new IllegalStateException("Invalid order transition");
        status = next;
    }
}
package interview.food;

import java.util.Map;
import java.util.List;
import java.util.ArrayList;

public class FoodOrder {
    public enum Status { PLACED, PREPARING, READY, PICKED_UP, DELIVERED, CANCELLED }
    public record MenuItem(String id, int price, boolean available) {}
    public record OrderItem(String id, int price, int quantity) {}
    private final List<OrderItem> items;
    private final long total;
    private Status status = Status.PLACED;
    private String courier;

    public FoodOrder(Map<String, MenuItem> menu, Map<String, Integer> quantities) {
        if (quantities.isEmpty()) throw new IllegalArgumentException("Empty order");
        long amount = 0;
        var snapshot = new ArrayList<OrderItem>();
        for (var line : quantities.entrySet()) {
            MenuItem item = menu.get(line.getKey());
            if (item == null || !item.available() || item.price() <= 0 || line.getValue() <= 0) {
                throw new IllegalArgumentException("Item unavailable");
            }
            amount += (long) item.price() * line.getValue();
            snapshot.add(new OrderItem(item.id(), item.price(), line.getValue()));
        }
        total = amount;
        items = List.copyOf(snapshot);
    }

    public long total() { return total; }
    public List<OrderItem> items() { return items; }
    public Status status() { return status; }

    public void assignCourier(String id) {
        if (courier != null || id == null ||
                (status != Status.PLACED && status != Status.PREPARING && status != Status.READY)) {
            throw new IllegalStateException("Cannot assign courier");
        }
        courier = id;
    }

    public void advance(Status next) {
        boolean allowed = switch (status) {
            case PLACED -> next == Status.PREPARING || next == Status.CANCELLED;
            case PREPARING -> next == Status.READY;
            case READY -> next == Status.PICKED_UP && courier != null;
            case PICKED_UP -> next == Status.DELIVERED;
            default -> false;
        };
        if (!allowed) throw new IllegalStateException("Invalid order transition");
        status = next;
    }
}

Kotlin

FoodOrder.kt

package interview.food

enum class Status { PLACED, PREPARING, READY, PICKED_UP, DELIVERED, CANCELLED }
data class MenuItem(val id: String, val price: Int, val available: Boolean)
data class OrderItem(val id: String, val price: Int, val quantity: Int)

class FoodOrder(menu: Map<String, MenuItem>, quantities: Map<String, Int>) {
    val items = quantities.map { (id, quantity) ->
        val item = menu[id] ?: error("Unknown item")
        require(quantity > 0 && item.price > 0 && item.available) { "Item unavailable" }
        OrderItem(id, item.price, quantity)
    }
    val total = items.sumOf { it.price.toLong() * it.quantity }
    var status = Status.PLACED
        private set
    var courier: String? = null
        private set

    init { require(items.isNotEmpty()) }

    fun assignCourier(id: String) {
        check(status == Status.PLACED || status == Status.PREPARING || status == Status.READY)
        check(courier == null)
        courier = id
    }

    fun advance(next: Status) {
        val allowed = when (status) {
            Status.PLACED -> next == Status.PREPARING || next == Status.CANCELLED
            Status.PREPARING -> next == Status.READY
            Status.READY -> next == Status.PICKED_UP && courier != null
            Status.PICKED_UP -> next == Status.DELIVERED
            else -> false
        }
        check(allowed) { "Invalid order transition" }
        status = next
    }
}
package interview.food

enum class Status { PLACED, PREPARING, READY, PICKED_UP, DELIVERED, CANCELLED }
data class MenuItem(val id: String, val price: Int, val available: Boolean)
data class OrderItem(val id: String, val price: Int, val quantity: Int)

class FoodOrder(menu: Map<String, MenuItem>, quantities: Map<String, Int>) {
    val items = quantities.map { (id, quantity) ->
        val item = menu[id] ?: error("Unknown item")
        require(quantity > 0 && item.price > 0 && item.available) { "Item unavailable" }
        OrderItem(id, item.price, quantity)
    }
    val total = items.sumOf { it.price.toLong() * it.quantity }
    var status = Status.PLACED
        private set
    var courier: String? = null
        private set

    init { require(items.isNotEmpty()) }

    fun assignCourier(id: String) {
        check(status == Status.PLACED || status == Status.PREPARING || status == Status.READY)
        check(courier == null)
        courier = id
    }

    fun advance(next: Status) {
        val allowed = when (status) {
            Status.PLACED -> next == Status.PREPARING || next == Status.CANCELLED
            Status.PREPARING -> next == Status.READY
            Status.READY -> next == Status.PICKED_UP && courier != null
            Status.PICKED_UP -> next == Status.DELIVERED
            else -> false
        }
        check(allowed) { "Invalid order transition" }
        status = next
    }
}

Follow-up questions

The menu price changes?

“An existing order keeps the price agreed at checkout.” Copy the item price into the order instead of reading the menu again when calculating its total. New orders can use the new price. This also keeps a receipt stable if the restaurant removes the item later.

The interview implementation already copies prices. This example changes the menu after placing an order, while the total stays 200.

Kotlin

val menu = mutableMapOf("meal" to MenuItem("meal", 100, true))
val order = FoodOrder(menu, mapOf("meal" to 2))
menu["meal"] = MenuItem("meal", 150, true)
println(order.total) // 200

Java

var menu = new HashMap<String, FoodOrder.MenuItem>();
menu.put("meal", new FoodOrder.MenuItem("meal", 100, true));
var order = new FoodOrder(menu, Map.of("meal", 2));
menu.put("meal", new FoodOrder.MenuItem("meal", 150, true));
System.out.println(order.total()); // 200

A limited number of meals?

“I would check and reserve stock when accepting the order, as one operation.” Otherwise two orders could both see the last meal and buy it. For a cart with several items, reserve all quantities together or reject the whole cart. If cancellation releases reserved stock, record that release so a repeated cancellation cannot add the stock back twice.

Duplicate updates from a server?

“I would attach an event ID to each update and remember which ones were already applied.” A repeated ID returns the earlier result without repeating side effects. A version number can also reject an update based on an old order state. Check the ID or version and change the order in the same transaction, then still enforce valid status transitions.

What should I test?

“An empty cart, unknown item or nonpositive quantity should fail before an order is accepted.” Changing menu prices should not change an existing total. Cancellation is allowed while placed and should prevent later preparation. Pickup without a courier and a jump from placed straight to delivered should fail without changing the status.

For the screen and API design, see the checkout screen walkthrough.

Extended implementation and optional features

This reference explores a larger scope. Use it after you can explain and write the interview version. Its extra types and features are not required for the scope above.

Java

com.androidinterview.foodordering.delivery.AssignmentStrategy.java

package com.androidinterview.foodordering.delivery;

import java.util.List;

import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;

// Ranking only. It orders the couriers and stops there, because claiming one
// has to be atomic and a policy author should not have to get that right again
// in every new strategy.
public interface AssignmentStrategy {
    List<DeliveryAgent> rank(Order order, List<DeliveryAgent> candidates);
}
package com.androidinterview.foodordering.delivery;

import java.util.List;

import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;

public interface AssignmentStrategy {
    List<DeliveryAgent> rank(Order order, List<DeliveryAgent> candidates);
}

com.androidinterview.foodordering.delivery.NearestAgentStrategy.java

package com.androidinterview.foodordering.delivery;

import java.util.Comparator;
import java.util.List;

import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;

// Nearest to the restaurant, because the courier has to collect before they can
// deliver. A least busy or zone based policy is a different class and one line
// of wiring.
public final class NearestAgentStrategy implements AssignmentStrategy {

    @Override
    public List<DeliveryAgent> rank(Order order, List<DeliveryAgent> candidates) {
        var kitchen = order.restaurant().location();
        return candidates.stream()
                .sorted(Comparator.comparingDouble(agent -> agent.location().distanceKmTo(kitchen)))
                .toList();
    }
}
package com.androidinterview.foodordering.delivery;

import java.util.Comparator;
import java.util.List;

import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;

public final class NearestAgentStrategy implements AssignmentStrategy {

    @Override
    public List<DeliveryAgent> rank(Order order, List<DeliveryAgent> candidates) {
        var kitchen = order.restaurant().location();
        return candidates.stream()
                .sorted(Comparator.comparingDouble(agent -> agent.location().distanceKmTo(kitchen)))
                .toList();
    }
}

com.androidinterview.foodordering.model.Cart.java

package com.androidinterview.foodordering.model;

import java.util.ArrayList;
import java.util.List;

// One restaurant per cart, which is the usual rule and worth confirming out
// loud. The cart records the price it saw when an item went in and holds the
// live restaurant rather than a copy of its menu, so checkout compares the two
// and can tell the customer what changed rather than silently charging more.
public final class Cart {

    public record Line(String itemId, String name, Money priceWhenAdded, int quantity) {}

    private final Customer customer;
    private final Restaurant restaurant;
    private final List<Line> lines = new ArrayList<>();

    public Cart(Customer customer, Restaurant restaurant) {
        this.customer = customer;
        this.restaurant = restaurant;
    }

    public Customer customer() { return customer; }
    public Restaurant restaurant() { return restaurant; }
    public List<Line> lines() { return List.copyOf(lines); }

    public void add(MenuItem item, int quantity) {
        lines.add(new Line(item.id(), item.name(), item.price(), quantity));
    }

    public void remove(String itemId) {
        lines.removeIf(line -> line.itemId().equals(itemId));
    }

    // Validate the whole cart at checkout, never at add time. An item can sell
    // out or change price while the customer is still choosing, and the honest
    // answer is a list of what changed rather than one unhelpful failure.
    public List<String> problemsAtCheckout() {
        List<String> problems = new ArrayList<>();
        if (!restaurant.open()) {
            problems.add("the restaurant is closed");
        }
        for (Line line : lines) {
            MenuItem live = restaurant.itemById(line.itemId());
            if (live == null || !live.available()) {
                problems.add(line.name() + " is no longer available");
            } else if (!live.price().equals(line.priceWhenAdded())) {
                problems.add(line.name() + " has changed price");
            }
        }
        return problems;
    }
}
package com.androidinterview.foodordering.model;

import java.util.ArrayList;
import java.util.List;

public final class Cart {

    public record Line(String itemId, String name, Money priceWhenAdded, int quantity) {}

    private final Customer customer;
    private final Restaurant restaurant;
    private final List<Line> lines = new ArrayList<>();

    public Cart(Customer customer, Restaurant restaurant) {
        this.customer = customer;
        this.restaurant = restaurant;
    }

    public Customer customer() { return customer; }
    public Restaurant restaurant() { return restaurant; }
    public List<Line> lines() { return List.copyOf(lines); }

    public void add(MenuItem item, int quantity) {
        lines.add(new Line(item.id(), item.name(), item.price(), quantity));
    }

    public void remove(String itemId) {
        lines.removeIf(line -> line.itemId().equals(itemId));
    }

    public List<String> problemsAtCheckout() {
        List<String> problems = new ArrayList<>();
        if (!restaurant.open()) {
            problems.add("the restaurant is closed");
        }
        for (Line line : lines) {
            MenuItem live = restaurant.itemById(line.itemId());
            if (live == null || !live.available()) {
                problems.add(line.name() + " is no longer available");
            } else if (!live.price().equals(line.priceWhenAdded())) {
                problems.add(line.name() + " has changed price");
            }
        }
        return problems;
    }
}

com.androidinterview.foodordering.model.Customer.java

package com.androidinterview.foodordering.model;

public record Customer(String id, String name, Location address) {
}
package com.androidinterview.foodordering.model;

public record Customer(String id, String name, Location address) {
}

com.androidinterview.foodordering.model.DeliveryAgent.java

package com.androidinterview.foodordering.model;

// The same class as a ride hailing driver, wearing an apron. Two orders can
// reach the same courier at the same instant, so the check and the set have to
// be one operation. It is the identical bug in a different costume, and saying
// that out loud is worth a mark.
public final class DeliveryAgent {

    private final String id;
    private final String name;
    private Location location;
    private String currentOrderId;
    private boolean online;

    public DeliveryAgent(String id, String name, Location location) {
        this.id = id;
        this.name = name;
        this.location = location;
    }

    public String id() { return id; }
    public String name() { return name; }
    public synchronized Location location() { return location; }
    public synchronized boolean isFree() { return online && currentOrderId == null; }

    public synchronized void goOnline(Location at) {
        this.online = true;
        this.location = at;
    }

    public synchronized boolean tryAssign(String orderId) {
        if (!online || currentOrderId != null) {
            return false;
        }
        currentOrderId = orderId;
        return true;
    }

    // Owner checked, so a retry or a stale timer cannot free a courier who has
    // already picked up somebody else's order.
    public synchronized boolean releaseIfOn(String orderId) {
        if (!orderId.equals(currentOrderId)) {
            return false;
        }
        currentOrderId = null;
        return true;
    }
}
package com.androidinterview.foodordering.model;

public final class DeliveryAgent {

    private final String id;
    private final String name;
    private Location location;
    private String currentOrderId;
    private boolean online;

    public DeliveryAgent(String id, String name, Location location) {
        this.id = id;
        this.name = name;
        this.location = location;
    }

    public String id() { return id; }
    public String name() { return name; }
    public synchronized Location location() { return location; }
    public synchronized boolean isFree() { return online && currentOrderId == null; }

    public synchronized void goOnline(Location at) {
        this.online = true;
        this.location = at;
    }

    public synchronized boolean tryAssign(String orderId) {
        if (!online || currentOrderId != null) {
            return false;
        }
        currentOrderId = orderId;
        return true;
    }

    public synchronized boolean releaseIfOn(String orderId) {
        if (!orderId.equals(currentOrderId)) {
            return false;
        }
        currentOrderId = null;
        return true;
    }
}

com.androidinterview.foodordering.model.Location.java

package com.androidinterview.foodordering.model;

// Distance sits on the value object, so nothing else has to know how it is
// measured. Straight line is fine for ranking couriers inside one city, which
// is why this is simpler than the haversine in the ride hailing answer. Real
// routing is a service call and is not this round.
public record Location(double latitude, double longitude) {

    public double distanceKmTo(Location other) {
        double dLat = other.latitude - latitude;
        double dLng = other.longitude - longitude;
        return Math.sqrt(dLat * dLat + dLng * dLng) * 111.0;
    }
}
package com.androidinterview.foodordering.model;

public record Location(double latitude, double longitude) {

    public double distanceKmTo(Location other) {
        double dLat = other.latitude - latitude;
        double dLng = other.longitude - longitude;
        return Math.sqrt(dLat * dLat + dLng * dLng) * 111.0;
    }
}

com.androidinterview.foodordering.model.MenuItem.java

package com.androidinterview.foodordering.model;

// Availability is on the item because a kitchen turns dishes off mid service.
// That single flag is what makes checkout validation necessary.
public record MenuItem(String id, String name, Money price, boolean available) {
}
package com.androidinterview.foodordering.model;

public record MenuItem(String id, String name, Money price, boolean available) {
}

com.androidinterview.foodordering.model.Money.java

package com.androidinterview.foodordering.model;

public record Money(String currency, long amount) {

    public Money plus(Money other) {
        return new Money(currency, amount + other.amount);
    }

    public Money times(int quantity) {
        return new Money(currency, amount * quantity);
    }
}
package com.androidinterview.foodordering.model;

public record Money(String currency, long amount) {

    public Money plus(Money other) {
        return new Money(currency, amount + other.amount);
    }

    public Money times(int quantity) {
        return new Money(currency, amount * quantity);
    }
}

com.androidinterview.foodordering.model.Order.java

package com.androidinterview.foodordering.model;

import java.util.List;

// One order. The status is the only thing that changes, and it changes through
// one guarded call.
public final class Order {

    private final String id;
    private final Customer customer;
    private final Restaurant restaurant;
    private final List<OrderItem> items;
    private final Money total;
    private OrderStatus status = OrderStatus.PLACED;
    private DeliveryAgent agent;

    public Order(String id, Customer customer, Restaurant restaurant,
                 List<OrderItem> items, Money total) {
        this.id = id;
        this.customer = customer;
        this.restaurant = restaurant;
        this.items = List.copyOf(items);
        this.total = total;
    }

    public String id() { return id; }
    public Customer customer() { return customer; }
    public Restaurant restaurant() { return restaurant; }
    public List<OrderItem> items() { return items; }
    public Money total() { return total; }
    public synchronized OrderStatus status() { return status; }
    public synchronized DeliveryAgent agent() { return agent; }

    // A compare and set, not a plain setter. The restaurant marking an order
    // ready and the customer cancelling it can arrive in the same millisecond.
    // Both name the status they believe the order is in, so exactly one wins
    // and the loser gets false instead of overwriting.
    public synchronized boolean compareAndSetStatus(OrderStatus expected, OrderStatus next) {
        if (status != expected || !status.canMoveTo(next)) {
            return false;
        }
        status = next;
        return true;
    }

    // First courier to arrive keeps the job. Overwriting here would orphan the
    // courier who was already claimed, leaving them busy with no order.
    public synchronized boolean attachAgent(DeliveryAgent assigned) {
        if (this.agent != null) {
            return false;
        }
        this.agent = assigned;
        return true;
    }
}
package com.androidinterview.foodordering.model;

import java.util.List;

public final class Order {

    private final String id;
    private final Customer customer;
    private final Restaurant restaurant;
    private final List<OrderItem> items;
    private final Money total;
    private OrderStatus status = OrderStatus.PLACED;
    private DeliveryAgent agent;

    public Order(String id, Customer customer, Restaurant restaurant,
                 List<OrderItem> items, Money total) {
        this.id = id;
        this.customer = customer;
        this.restaurant = restaurant;
        this.items = List.copyOf(items);
        this.total = total;
    }

    public String id() { return id; }
    public Customer customer() { return customer; }
    public Restaurant restaurant() { return restaurant; }
    public List<OrderItem> items() { return items; }
    public Money total() { return total; }
    public synchronized OrderStatus status() { return status; }
    public synchronized DeliveryAgent agent() { return agent; }

    public synchronized boolean compareAndSetStatus(OrderStatus expected, OrderStatus next) {
        if (status != expected || !status.canMoveTo(next)) {
            return false;
        }
        status = next;
        return true;
    }

    public synchronized boolean attachAgent(DeliveryAgent assigned) {
        if (this.agent != null) {
            return false;
        }
        this.agent = assigned;
        return true;
    }
}

com.androidinterview.foodordering.model.OrderItem.java

package com.androidinterview.foodordering.model;

// A snapshot, not a reference to the live menu. The price the customer agreed
// to is fixed at order time, so a menu edit tomorrow cannot rewrite yesterday's
// receipt.
public record OrderItem(String itemId, String name, Money unitPrice, int quantity) {

    public Money lineTotal() {
        return unitPrice.times(quantity);
    }
}
package com.androidinterview.foodordering.model;

public record OrderItem(String itemId, String name, Money unitPrice, int quantity) {

    public Money lineTotal() {
        return unitPrice.times(quantity);
    }
}

com.androidinterview.foodordering.model.OrderStatus.java

package com.androidinterview.foodordering.model;

// The spine of this problem. Every legal move is written down once, so the
// illegal ones are visible at a glance and no caller has to remember them.
//
// Cancellation is allowed while the kitchen has not finished. Once the food is
// ready somebody has already paid for ingredients, so a cancellation after that
// is a refund policy question rather than a status change.
public enum OrderStatus {
    PLACED,
    CONFIRMED,
    PREPARING,
    READY_FOR_PICKUP,
    OUT_FOR_DELIVERY,
    DELIVERED,
    CANCELLED;

    // A courier is worth claiming from the moment the restaurant accepts until
    // the food is handed over. Claiming one for a delivered or cancelled order
    // strands a courier in a busy state with nothing to carry.
    public boolean acceptsCourier() {
        return this == CONFIRMED || this == PREPARING || this == READY_FOR_PICKUP;
    }

    public boolean canMoveTo(OrderStatus next) {
        return switch (this) {
            case PLACED -> next == CONFIRMED || next == CANCELLED;
            case CONFIRMED -> next == PREPARING || next == CANCELLED;
            case PREPARING -> next == READY_FOR_PICKUP || next == CANCELLED;
            case READY_FOR_PICKUP -> next == OUT_FOR_DELIVERY;
            case OUT_FOR_DELIVERY -> next == DELIVERED;
            case DELIVERED, CANCELLED -> false;
        };
    }
}
package com.androidinterview.foodordering.model;

public enum OrderStatus {
    PLACED,
    CONFIRMED,
    PREPARING,
    READY_FOR_PICKUP,
    OUT_FOR_DELIVERY,
    DELIVERED,
    CANCELLED;

    public boolean acceptsCourier() {
        return this == CONFIRMED || this == PREPARING || this == READY_FOR_PICKUP;
    }

    public boolean canMoveTo(OrderStatus next) {
        return switch (this) {
            case PLACED -> next == CONFIRMED || next == CANCELLED;
            case CONFIRMED -> next == PREPARING || next == CANCELLED;
            case PREPARING -> next == READY_FOR_PICKUP || next == CANCELLED;
            case READY_FOR_PICKUP -> next == OUT_FOR_DELIVERY;
            case OUT_FOR_DELIVERY -> next == DELIVERED;
            case DELIVERED, CANCELLED -> false;
        };
    }
}

com.androidinterview.foodordering.model.Restaurant.java

package com.androidinterview.foodordering.model;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

// The restaurant owns its menu, and the menu is live. A cart that copied the
// menu when it was opened could never notice a dish being turned off, which is
// the exact case checkout exists to catch, so the kitchen edits this object and
// every open cart reads the edit on its next lookup.
public final class Restaurant {

    private final String id;
    private final String name;
    private final Location location;
    private final Map<String, MenuItem> menu = new ConcurrentHashMap<>();
    private volatile boolean open;

    public Restaurant(String id, String name, Location location, boolean open, List<MenuItem> menu) {
        this.id = id;
        this.name = name;
        this.location = location;
        this.open = open;
        for (MenuItem item : menu) {
            this.menu.put(item.id(), item);
        }
    }

    public String id() { return id; }
    public String name() { return name; }
    public Location location() { return location; }
    public boolean open() { return open; }
    public List<MenuItem> menu() { return List.copyOf(menu.values()); }

    public MenuItem itemById(String itemId) {
        return menu.get(itemId);
    }

    public void setOpen(boolean nowOpen) {
        this.open = nowOpen;
    }

    // The kitchen turning a dish off or repricing it, mid service, while carts
    // are open. One write, and the next checkout sees it.
    public void updateItem(MenuItem item) {
        menu.put(item.id(), item);
    }
}
package com.androidinterview.foodordering.model;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public final class Restaurant {

    private final String id;
    private final String name;
    private final Location location;
    private final Map<String, MenuItem> menu = new ConcurrentHashMap<>();
    private volatile boolean open;

    public Restaurant(String id, String name, Location location, boolean open, List<MenuItem> menu) {
        this.id = id;
        this.name = name;
        this.location = location;
        this.open = open;
        for (MenuItem item : menu) {
            this.menu.put(item.id(), item);
        }
    }

    public String id() { return id; }
    public String name() { return name; }
    public Location location() { return location; }
    public boolean open() { return open; }
    public List<MenuItem> menu() { return List.copyOf(menu.values()); }

    public MenuItem itemById(String itemId) {
        return menu.get(itemId);
    }

    public void setOpen(boolean nowOpen) {
        this.open = nowOpen;
    }

    public void updateItem(MenuItem item) {
        menu.put(item.id(), item);
    }
}

com.androidinterview.foodordering.notify.CourierApp.java

package com.androidinterview.foodordering.notify;

import java.util.ArrayList;
import java.util.List;

import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

// The courier side of the same event, and the reason the fan out is three
// listeners rather than one. This one filters on the assigned courier, so a
// rider only ever sees their own jobs.
public final class CourierApp implements OrderObserver {

    private final String agentId;
    private final List<String> jobs = new ArrayList<>();

    public CourierApp(String agentId) {
        this.agentId = agentId;
    }

    @Override
    public void onStatusChanged(Order order, OrderStatus from) {
        DeliveryAgent assigned = order.agent();
        if (assigned == null || !assigned.id().equals(agentId)) {
            return;
        }
        switch (order.status()) {
            case READY_FOR_PICKUP -> jobs.add(order.id());
            case DELIVERED, CANCELLED -> jobs.remove(order.id());
            default -> { }
        }
    }

    public List<String> jobs() {
        return List.copyOf(jobs);
    }
}
package com.androidinterview.foodordering.notify;

import java.util.ArrayList;
import java.util.List;

import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

public final class CourierApp implements OrderObserver {

    private final String agentId;
    private final List<String> jobs = new ArrayList<>();

    public CourierApp(String agentId) {
        this.agentId = agentId;
    }

    @Override
    public void onStatusChanged(Order order, OrderStatus from) {
        DeliveryAgent assigned = order.agent();
        if (assigned == null || !assigned.id().equals(agentId)) {
            return;
        }
        switch (order.status()) {
            case READY_FOR_PICKUP -> jobs.add(order.id());
            case DELIVERED, CANCELLED -> jobs.remove(order.id());
            default -> { }
        }
    }

    public List<String> jobs() {
        return List.copyOf(jobs);
    }
}

com.androidinterview.foodordering.notify.CustomerTracker.java

package com.androidinterview.foodordering.notify;

import java.util.ArrayList;
import java.util.List;

import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

// What the tracking screen reads. It keeps the trail rather than sending a
// push, because a domain object that talks to a push service is a domain object
// you cannot test.
public final class CustomerTracker implements OrderObserver {

    private final List<String> trail = new ArrayList<>();

    @Override
    public void onStatusChanged(Order order, OrderStatus from) {
        if (from == null) {
            trail.add(order.id() + " placed");
            return;
        }
        trail.add(order.id() + " moved from " + from + " to " + order.status());
    }

    public List<String> trail() {
        return List.copyOf(trail);
    }
}
package com.androidinterview.foodordering.notify;

import java.util.ArrayList;
import java.util.List;

import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

public final class CustomerTracker implements OrderObserver {

    private final List<String> trail = new ArrayList<>();

    @Override
    public void onStatusChanged(Order order, OrderStatus from) {
        if (from == null) {
            trail.add(order.id() + " placed");
            return;
        }
        trail.add(order.id() + " moved from " + from + " to " + order.status());
    }

    public List<String> trail() {
        return List.copyOf(trail);
    }
}

com.androidinterview.foodordering.notify.KitchenBoard.java

package com.androidinterview.foodordering.notify;

import java.util.ArrayList;
import java.util.List;

import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

// The restaurant side of the same event. It only cares about work arriving and
// work leaving, which is the argument for a fan out rather than one notifier
// with a switch inside it.
public final class KitchenBoard implements OrderObserver {

    private final List<String> queue = new ArrayList<>();

    @Override
    public void onStatusChanged(Order order, OrderStatus from) {
        switch (order.status()) {
            case CONFIRMED -> queue.add(order.id());
            case OUT_FOR_DELIVERY, CANCELLED -> queue.remove(order.id());
            default -> { }
        }
    }

    public List<String> queue() {
        return List.copyOf(queue);
    }
}
package com.androidinterview.foodordering.notify;

import java.util.ArrayList;
import java.util.List;

import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

public final class KitchenBoard implements OrderObserver {

    private final List<String> queue = new ArrayList<>();

    @Override
    public void onStatusChanged(Order order, OrderStatus from) {
        switch (order.status()) {
            case CONFIRMED -> queue.add(order.id());
            case OUT_FOR_DELIVERY, CANCELLED -> queue.remove(order.id());
            default -> { }
        }
    }

    public List<String> queue() {
        return List.copyOf(queue);
    }
}

com.androidinterview.foodordering.notify.OrderObserver.java

package com.androidinterview.foodordering.notify;

import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

// One status change, three audiences that care about different parts of it.
// The order service publishes and knows none of them.
//
// A brand new order came from nowhere, so from is null for the creation event.
// Reporting a move from placed to placed would be a lie about a transition that
// never happened.
@FunctionalInterface
public interface OrderObserver {
    void onStatusChanged(Order order, OrderStatus from);
}
package com.androidinterview.foodordering.notify;

import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderStatus;

@FunctionalInterface
public interface OrderObserver {
    void onStatusChanged(Order order, OrderStatus from);
}

com.androidinterview.foodordering.service.CheckoutResult.java

package com.androidinterview.foodordering.service;

import java.util.List;

import com.androidinterview.foodordering.model.Order;

// Checkout either produces an order or a list of reasons it could not. An empty
// Optional would throw away the reasons, which are the only part the customer
// can act on.
public sealed interface CheckoutResult {

    record Placed(Order order) implements CheckoutResult {}

    record Rejected(List<String> reasons) implements CheckoutResult {
        public Rejected {
            reasons = List.copyOf(reasons);
        }
    }
}
package com.androidinterview.foodordering.service;

import java.util.List;

import com.androidinterview.foodordering.model.Order;

public sealed interface CheckoutResult {

    record Placed(Order order) implements CheckoutResult {}

    record Rejected(List<String> reasons) implements CheckoutResult {
        public Rejected {
            reasons = List.copyOf(reasons);
        }
    }
}

com.androidinterview.foodordering.service.OrderService.java

package com.androidinterview.foodordering.service;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;

import com.androidinterview.foodordering.delivery.AssignmentStrategy;
import com.androidinterview.foodordering.model.Cart;
import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.MenuItem;
import com.androidinterview.foodordering.model.Money;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderItem;
import com.androidinterview.foodordering.model.OrderStatus;
import com.androidinterview.foodordering.notify.OrderObserver;

// The one class the three apps talk to. Every method is a guarded transition
// plus a side effect, which is what makes the state machine the spine of the
// design rather than a diagram in the slides.
public final class OrderService {

    private final AssignmentStrategy assignment;
    private final String currency;
    private final List<OrderObserver> observers = new CopyOnWriteArrayList<>();

    public OrderService(AssignmentStrategy assignment, String currency) {
        this.assignment = assignment;
        this.currency = currency;
    }

    public void subscribe(OrderObserver observer) {
        observers.add(observer);
    }

    // Checkout validates the whole cart at once against the live menu and
    // snapshots every price. Failing with a list of reasons lets the app show
    // the customer exactly what changed.
    public CheckoutResult placeOrder(Cart cart) {
        List<String> problems = cart.problemsAtCheckout();
        if (!problems.isEmpty()) {
            return new CheckoutResult.Rejected(problems);
        }
        List<OrderItem> items = new ArrayList<>();
        Money total = new Money(currency, 0);
        for (Cart.Line line : cart.lines()) {
            MenuItem live = cart.restaurant().itemById(line.itemId());
            OrderItem item = new OrderItem(live.id(), live.name(), live.price(), line.quantity());
            items.add(item);
            total = total.plus(item.lineTotal());
        }
        Order order = new Order(UUID.randomUUID().toString(),
                cart.customer(), cart.restaurant(), items, total);
        publish(order, null);
        return new CheckoutResult.Placed(order);
    }

    public boolean restaurantAccepts(Order order) {
        return move(order, OrderStatus.PLACED, OrderStatus.CONFIRMED);
    }

    public boolean startCooking(Order order) {
        return move(order, OrderStatus.CONFIRMED, OrderStatus.PREPARING);
    }

    public boolean markReady(Order order) {
        return move(order, OrderStatus.PREPARING, OrderStatus.READY_FOR_PICKUP);
    }

    // Ranking is policy and the claim is concurrency, so they stay apart. The
    // first courier whose claim succeeds is the one we get, and every loser
    // costs a single step down the list.
    //
    // The status check keeps a courier off an order that is already delivered
    // or cancelled, and the attach is a compare and set so a second call cannot
    // overwrite the first courier and strand them.
    public Optional<DeliveryAgent> assignAgent(Order order, List<DeliveryAgent> fleet) {
        if (!order.status().acceptsCourier() || order.agent() != null) {
            return Optional.empty();
        }
        for (DeliveryAgent agent : assignment.rank(order, fleet)) {
            if (agent.tryAssign(order.id())) {
                if (order.attachAgent(agent)) {
                    return Optional.of(agent);
                }
                agent.releaseIfOn(order.id());
                return Optional.empty();
            }
        }
        return Optional.empty();
    }

    public boolean pickUp(Order order) {
        return move(order, OrderStatus.READY_FOR_PICKUP, OrderStatus.OUT_FOR_DELIVERY);
    }

    public boolean deliver(Order order) {
        if (!move(order, OrderStatus.OUT_FOR_DELIVERY, OrderStatus.DELIVERED)) {
            return false;
        }
        releaseAgent(order);
        return true;
    }

    // Cancellation names the status the caller believed the order was in. If
    // the kitchen moved it first, this returns false and the app tells the
    // customer the food is already being made.
    public boolean cancel(Order order, OrderStatus expected) {
        if (!move(order, expected, OrderStatus.CANCELLED)) {
            return false;
        }
        releaseAgent(order);
        return true;
    }

    private void releaseAgent(Order order) {
        DeliveryAgent agent = order.agent();
        if (agent != null) {
            agent.releaseIfOn(order.id());
        }
    }

    private boolean move(Order order, OrderStatus expected, OrderStatus next) {
        if (!order.compareAndSetStatus(expected, next)) {
            return false;
        }
        publish(order, expected);
        return true;
    }

    private void publish(Order order, OrderStatus from) {
        for (OrderObserver observer : observers) {
            observer.onStatusChanged(order, from);
        }
    }
}
package com.androidinterview.foodordering.service;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;

import com.androidinterview.foodordering.delivery.AssignmentStrategy;
import com.androidinterview.foodordering.model.Cart;
import com.androidinterview.foodordering.model.DeliveryAgent;
import com.androidinterview.foodordering.model.MenuItem;
import com.androidinterview.foodordering.model.Money;
import com.androidinterview.foodordering.model.Order;
import com.androidinterview.foodordering.model.OrderItem;
import com.androidinterview.foodordering.model.OrderStatus;
import com.androidinterview.foodordering.notify.OrderObserver;

public final class OrderService {

    private final AssignmentStrategy assignment;
    private final String currency;
    private final List<OrderObserver> observers = new CopyOnWriteArrayList<>();

    public OrderService(AssignmentStrategy assignment, String currency) {
        this.assignment = assignment;
        this.currency = currency;
    }

    public void subscribe(OrderObserver observer) {
        observers.add(observer);
    }

    public CheckoutResult placeOrder(Cart cart) {
        List<String> problems = cart.problemsAtCheckout();
        if (!problems.isEmpty()) {
            return new CheckoutResult.Rejected(problems);
        }
        List<OrderItem> items = new ArrayList<>();
        Money total = new Money(currency, 0);
        for (Cart.Line line : cart.lines()) {
            MenuItem live = cart.restaurant().itemById(line.itemId());
            OrderItem item = new OrderItem(live.id(), live.name(), live.price(), line.quantity());
            items.add(item);
            total = total.plus(item.lineTotal());
        }
        Order order = new Order(UUID.randomUUID().toString(),
                cart.customer(), cart.restaurant(), items, total);
        publish(order, null);
        return new CheckoutResult.Placed(order);
    }

    public boolean restaurantAccepts(Order order) {
        return move(order, OrderStatus.PLACED, OrderStatus.CONFIRMED);
    }

    public boolean startCooking(Order order) {
        return move(order, OrderStatus.CONFIRMED, OrderStatus.PREPARING);
    }

    public boolean markReady(Order order) {
        return move(order, OrderStatus.PREPARING, OrderStatus.READY_FOR_PICKUP);
    }

    public Optional<DeliveryAgent> assignAgent(Order order, List<DeliveryAgent> fleet) {
        if (!order.status().acceptsCourier() || order.agent() != null) {
            return Optional.empty();
        }
        for (DeliveryAgent agent : assignment.rank(order, fleet)) {
            if (agent.tryAssign(order.id())) {
                if (order.attachAgent(agent)) {
                    return Optional.of(agent);
                }
                agent.releaseIfOn(order.id());
                return Optional.empty();
            }
        }
        return Optional.empty();
    }

    public boolean pickUp(Order order) {
        return move(order, OrderStatus.READY_FOR_PICKUP, OrderStatus.OUT_FOR_DELIVERY);
    }

    public boolean deliver(Order order) {
        if (!move(order, OrderStatus.OUT_FOR_DELIVERY, OrderStatus.DELIVERED)) {
            return false;
        }
        releaseAgent(order);
        return true;
    }

    public boolean cancel(Order order, OrderStatus expected) {
        if (!move(order, expected, OrderStatus.CANCELLED)) {
            return false;
        }
        releaseAgent(order);
        return true;
    }

    private void releaseAgent(Order order) {
        DeliveryAgent agent = order.agent();
        if (agent != null) {
            agent.releaseIfOn(order.id());
        }
    }

    private boolean move(Order order, OrderStatus expected, OrderStatus next) {
        if (!order.compareAndSetStatus(expected, next)) {
            return false;
        }
        publish(order, expected);
        return true;
    }

    private void publish(Order order, OrderStatus from) {
        for (OrderObserver observer : observers) {
            observer.onStatusChanged(order, from);
        }
    }
}

Kotlin

com.androidinterview.foodordering.delivery.Assignment.kt

package com.androidinterview.foodordering.delivery

import com.androidinterview.foodordering.model.DeliveryAgent
import com.androidinterview.foodordering.model.Order

// Ranking only. It orders the couriers and stops there, because claiming one
// has to be atomic and a policy author should not have to get that right again
// in every new strategy.
//
// A function type rather than an interface, since a policy here has no state
// and no configuration.
typealias AssignmentStrategy = (Order, List<DeliveryAgent>) -> List<DeliveryAgent>

// Nearest to the kitchen, because a courier has to collect before they can
// deliver.
val nearestToKitchen: AssignmentStrategy = { order, candidates ->
    candidates.sortedBy { it.location.distanceKmTo(order.restaurant.location) }
}

// The follow up policy an interviewer asks for. One line of wiring, no change
// anywhere else.
val closestToCustomer: AssignmentStrategy = { order, candidates ->
    candidates.sortedBy { it.location.distanceKmTo(order.customer.address) }
}
package com.androidinterview.foodordering.delivery

import com.androidinterview.foodordering.model.DeliveryAgent
import com.androidinterview.foodordering.model.Order

typealias AssignmentStrategy = (Order, List<DeliveryAgent>) -> List<DeliveryAgent>

val nearestToKitchen: AssignmentStrategy = { order, candidates ->
    candidates.sortedBy { it.location.distanceKmTo(order.restaurant.location) }
}

val closestToCustomer: AssignmentStrategy = { order, candidates ->
    candidates.sortedBy { it.location.distanceKmTo(order.customer.address) }
}

com.androidinterview.foodordering.model.Cart.kt

package com.androidinterview.foodordering.model

// One restaurant per cart, which is the usual rule and worth confirming out
// loud. The cart records the price it saw when an item went in and holds the
// live restaurant rather than a copy of its menu, so checkout compares the two
// and can say what changed rather than silently charging more.
class Cart(val customer: Customer, val restaurant: Restaurant) {

    data class Line(val itemId: String, val name: String, val priceWhenAdded: Money, val quantity: Int)

    private val _lines = mutableListOf<Line>()
    val lines: List<Line> get() = _lines.toList()

    fun add(item: MenuItem, quantity: Int) {
        _lines += Line(item.id, item.name, item.price, quantity)
    }

    fun remove(itemId: String) {
        _lines.removeAll { it.itemId == itemId }
    }

    // Validate the whole cart at checkout, never at add time. An item can sell
    // out or change price while the customer is still choosing, and the honest
    // answer is a list of what changed rather than one unhelpful failure.
    fun problemsAtCheckout(): List<String> = buildList {
        if (!restaurant.open) add("the restaurant is closed")
        for (line in lines) {
            val live = restaurant.itemById(line.itemId)
            when {
                live == null || !live.available -> add("${line.name} is no longer available")
                live.price != line.priceWhenAdded -> add("${line.name} has changed price")
            }
        }
    }
}
package com.androidinterview.foodordering.model

class Cart(val customer: Customer, val restaurant: Restaurant) {

    data class Line(val itemId: String, val name: String, val priceWhenAdded: Money, val quantity: Int)

    private val _lines = mutableListOf<Line>()
    val lines: List<Line> get() = _lines.toList()

    fun add(item: MenuItem, quantity: Int) {
        _lines += Line(item.id, item.name, item.price, quantity)
    }

    fun remove(itemId: String) {
        _lines.removeAll { it.itemId == itemId }
    }

    fun problemsAtCheckout(): List<String> = buildList {
        if (!restaurant.open) add("the restaurant is closed")
        for (line in lines) {
            val live = restaurant.itemById(line.itemId)
            when {
                live == null || !live.available -> add("${line.name} is no longer available")
                live.price != line.priceWhenAdded -> add("${line.name} has changed price")
            }
        }
    }
}

com.androidinterview.foodordering.model.DeliveryAgent.kt

package com.androidinterview.foodordering.model

import java.util.concurrent.atomic.AtomicReference

// The same class as a ride hailing driver, wearing an apron. Two orders can
// reach the same courier at the same instant, so the check and the set have to
// be one operation. It is the identical bug in a different costume, and saying
// that out loud is worth a mark.
class DeliveryAgent(val id: String, val name: String, initialLocation: Location) {

    private val currentOrderId = AtomicReference<String?>(null)

    @Volatile
    var location: Location = initialLocation
        private set

    @Volatile
    var online: Boolean = false
        private set

    val isFree: Boolean get() = online && currentOrderId.get() == null

    fun goOnline(at: Location) {
        location = at
        online = true
    }

    fun ping(at: Location) {
        location = at
    }

    // The check and the set in one instruction. Two orders reaching this
    // courier at the same moment, and exactly one of them gets true.
    fun tryAssign(orderId: String): Boolean = online && currentOrderId.compareAndSet(null, orderId)

    // Owner checked, and the compare is the owner check. A stale timer cannot
    // free a courier who is already carrying somebody else's food.
    fun releaseIfOn(orderId: String): Boolean = currentOrderId.compareAndSet(orderId, null)
}
package com.androidinterview.foodordering.model

import java.util.concurrent.atomic.AtomicReference

class DeliveryAgent(val id: String, val name: String, initialLocation: Location) {

    private val currentOrderId = AtomicReference<String?>(null)

    @Volatile
    var location: Location = initialLocation
        private set

    @Volatile
    var online: Boolean = false
        private set

    val isFree: Boolean get() = online && currentOrderId.get() == null

    fun goOnline(at: Location) {
        location = at
        online = true
    }

    fun ping(at: Location) {
        location = at
    }

    fun tryAssign(orderId: String): Boolean = online && currentOrderId.compareAndSet(null, orderId)

    fun releaseIfOn(orderId: String): Boolean = currentOrderId.compareAndSet(orderId, null)
}

com.androidinterview.foodordering.model.Domain.kt

package com.androidinterview.foodordering.model

import java.util.concurrent.ConcurrentHashMap
import kotlin.math.sqrt

data class Money(val currency: String, val amount: Long) {
    operator fun plus(other: Money) = copy(amount = amount + other.amount)
    operator fun times(quantity: Int) = copy(amount = amount * quantity)
}

// Distance sits on the value object, so nothing else has to know how it is
// measured. Straight line is fine for ranking couriers inside one city, which
// is why this is simpler than the haversine in the ride hailing answer. Real
// routing is a service call and is not this round.
data class Location(val latitude: Double, val longitude: Double) {
    fun distanceKmTo(other: Location): Double {
        val dLat = other.latitude - latitude
        val dLng = other.longitude - longitude
        return sqrt(dLat * dLat + dLng * dLng) * 111.0
    }
}

// Availability is on the item because a kitchen turns dishes off mid service.
// That single flag is what makes checkout validation necessary.
data class MenuItem(val id: String, val name: String, val price: Money, val available: Boolean)

// The restaurant owns its menu, and the menu is live. A cart that copied the
// menu when it was opened could never notice a dish being turned off, which is
// the exact case checkout exists to catch, so the kitchen edits this object and
// every open cart reads the edit on its next lookup.
class Restaurant(
    val id: String,
    val name: String,
    val location: Location,
    open: Boolean,
    menu: List<MenuItem>,
) {
    private val items = ConcurrentHashMap<String, MenuItem>().apply {
        menu.forEach { put(it.id, it) }
    }

    @Volatile
    var open: Boolean = open

    val menu: List<MenuItem> get() = items.values.toList()

    fun itemById(itemId: String): MenuItem? = items[itemId]

    // The kitchen turning a dish off or repricing it, mid service, while carts
    // are open. One write, and the next checkout sees it.
    fun updateItem(item: MenuItem) {
        items[item.id] = item
    }
}

data class Customer(val id: String, val name: String, val address: Location)

// A snapshot, not a reference to the live menu. The price the customer agreed
// to is fixed at order time, so a menu edit tomorrow cannot rewrite yesterday's
// receipt.
data class OrderItem(val itemId: String, val name: String, val unitPrice: Money, val quantity: Int) {
    val lineTotal: Money get() = unitPrice * quantity
}
package com.androidinterview.foodordering.model

import java.util.concurrent.ConcurrentHashMap
import kotlin.math.sqrt

data class Money(val currency: String, val amount: Long) {
    operator fun plus(other: Money) = copy(amount = amount + other.amount)
    operator fun times(quantity: Int) = copy(amount = amount * quantity)
}

data class Location(val latitude: Double, val longitude: Double) {
    fun distanceKmTo(other: Location): Double {
        val dLat = other.latitude - latitude
        val dLng = other.longitude - longitude
        return sqrt(dLat * dLat + dLng * dLng) * 111.0
    }
}

data class MenuItem(val id: String, val name: String, val price: Money, val available: Boolean)

class Restaurant(
    val id: String,
    val name: String,
    val location: Location,
    open: Boolean,
    menu: List<MenuItem>,
) {
    private val items = ConcurrentHashMap<String, MenuItem>().apply {
        menu.forEach { put(it.id, it) }
    }

    @Volatile
    var open: Boolean = open

    val menu: List<MenuItem> get() = items.values.toList()

    fun itemById(itemId: String): MenuItem? = items[itemId]

    fun updateItem(item: MenuItem) {
        items[item.id] = item
    }
}

data class Customer(val id: String, val name: String, val address: Location)

data class OrderItem(val itemId: String, val name: String, val unitPrice: Money, val quantity: Int) {
    val lineTotal: Money get() = unitPrice * quantity
}

com.androidinterview.foodordering.model.Order.kt

package com.androidinterview.foodordering.model

import java.util.concurrent.atomic.AtomicReference

// The spine of this problem. Every legal move is written down once, so the
// illegal ones are visible at a glance and no caller has to remember them.
//
// Cancellation is allowed while the kitchen has not finished. Once the food is
// ready somebody has paid for ingredients, so cancelling after that is a refund
// policy question rather than a status change.
enum class OrderStatus {
    PLACED,
    CONFIRMED,
    PREPARING,
    READY_FOR_PICKUP,
    OUT_FOR_DELIVERY,
    DELIVERED,
    CANCELLED;

    // A courier is worth claiming from the moment the restaurant accepts until
    // the food is handed over. Claiming one for a delivered or cancelled order
    // strands a courier in a busy state with nothing to carry.
    val acceptsCourier: Boolean
        get() = this == CONFIRMED || this == PREPARING || this == READY_FOR_PICKUP

    fun canMoveTo(next: OrderStatus) = next in when (this) {
        PLACED -> setOf(CONFIRMED, CANCELLED)
        CONFIRMED -> setOf(PREPARING, CANCELLED)
        PREPARING -> setOf(READY_FOR_PICKUP, CANCELLED)
        READY_FOR_PICKUP -> setOf(OUT_FOR_DELIVERY)
        OUT_FOR_DELIVERY -> setOf(DELIVERED)
        DELIVERED, CANCELLED -> emptySet()
    }
}

class Order(
    val id: String,
    val customer: Customer,
    val restaurant: Restaurant,
    val items: List<OrderItem>,
    val total: Money,
) {
    private val state = AtomicReference(OrderStatus.PLACED)
    private val assigned = AtomicReference<DeliveryAgent?>(null)

    val status: OrderStatus get() = state.get()
    val agent: DeliveryAgent? get() = assigned.get()

    // A compare and set, not a setter. The restaurant marking an order ready
    // and the customer cancelling it can arrive in the same millisecond. Both
    // name the status they believe the order is in, so exactly one wins and the
    // loser gets false instead of overwriting.
    fun compareAndSetStatus(expected: OrderStatus, next: OrderStatus): Boolean =
        expected.canMoveTo(next) && state.compareAndSet(expected, next)

    // First courier to arrive keeps the job. Overwriting here would orphan the
    // courier who was already claimed, leaving them busy with no order.
    fun attachAgent(agent: DeliveryAgent): Boolean = assigned.compareAndSet(null, agent)
}
package com.androidinterview.foodordering.model

import java.util.concurrent.atomic.AtomicReference

enum class OrderStatus {
    PLACED,
    CONFIRMED,
    PREPARING,
    READY_FOR_PICKUP,
    OUT_FOR_DELIVERY,
    DELIVERED,
    CANCELLED;

    val acceptsCourier: Boolean
        get() = this == CONFIRMED || this == PREPARING || this == READY_FOR_PICKUP

    fun canMoveTo(next: OrderStatus) = next in when (this) {
        PLACED -> setOf(CONFIRMED, CANCELLED)
        CONFIRMED -> setOf(PREPARING, CANCELLED)
        PREPARING -> setOf(READY_FOR_PICKUP, CANCELLED)
        READY_FOR_PICKUP -> setOf(OUT_FOR_DELIVERY)
        OUT_FOR_DELIVERY -> setOf(DELIVERED)
        DELIVERED, CANCELLED -> emptySet()
    }
}

class Order(
    val id: String,
    val customer: Customer,
    val restaurant: Restaurant,
    val items: List<OrderItem>,
    val total: Money,
) {
    private val state = AtomicReference(OrderStatus.PLACED)
    private val assigned = AtomicReference<DeliveryAgent?>(null)

    val status: OrderStatus get() = state.get()
    val agent: DeliveryAgent? get() = assigned.get()

    fun compareAndSetStatus(expected: OrderStatus, next: OrderStatus): Boolean =
        expected.canMoveTo(next) && state.compareAndSet(expected, next)

    fun attachAgent(agent: DeliveryAgent): Boolean = assigned.compareAndSet(null, agent)
}

com.androidinterview.foodordering.service.OrderService.kt

package com.androidinterview.foodordering.service

import com.androidinterview.foodordering.delivery.AssignmentStrategy
import com.androidinterview.foodordering.model.Cart
import com.androidinterview.foodordering.model.DeliveryAgent
import com.androidinterview.foodordering.model.Money
import com.androidinterview.foodordering.model.Order
import com.androidinterview.foodordering.model.OrderItem
import com.androidinterview.foodordering.model.OrderStatus
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList

// Checkout either produces an order or a list of reasons it could not. A
// nullable Order would throw away the reasons, which are the only part the
// customer can act on.
sealed interface CheckoutResult {
    data class Placed(val order: Order) : CheckoutResult
    data class Rejected(val reasons: List<String>) : CheckoutResult
}

// The one class the three apps talk to. Every method is a guarded transition
// plus a side effect, which is what makes the state machine the spine of the
// design rather than a diagram on a slide.
//
// Observers are function types. The customer app, the kitchen board and the
// courier app all want the same event and none of them belong in the order.
class OrderService(
    private val assignment: AssignmentStrategy,
    private val currency: String = "INR",
) {
    private val observers = CopyOnWriteArrayList<(Order, OrderStatus?) -> Unit>()

    // A brand new order came from nowhere, so from is null for the creation
    // event. Reporting a move from placed to placed would be a lie about a
    // transition that never happened.
    fun subscribe(observer: (order: Order, from: OrderStatus?) -> Unit) {
        observers += observer
    }

    // Checkout validates the whole cart at once against the live menu and
    // snapshots every price.
    fun placeOrder(cart: Cart): CheckoutResult {
        val problems = cart.problemsAtCheckout()
        if (problems.isNotEmpty()) return CheckoutResult.Rejected(problems)

        val items = cart.lines.mapNotNull { line ->
            cart.restaurant.itemById(line.itemId)?.let {
                OrderItem(it.id, it.name, it.price, line.quantity)
            }
        }
        val total = items.fold(Money(currency, 0)) { running, item -> running + item.lineTotal }
        val order = Order(UUID.randomUUID().toString(), cart.customer, cart.restaurant, items, total)
        publish(order, null)
        return CheckoutResult.Placed(order)
    }

    fun restaurantAccepts(order: Order) = move(order, OrderStatus.PLACED, OrderStatus.CONFIRMED)

    fun startCooking(order: Order) = move(order, OrderStatus.CONFIRMED, OrderStatus.PREPARING)

    fun markReady(order: Order) = move(order, OrderStatus.PREPARING, OrderStatus.READY_FOR_PICKUP)

    fun pickUp(order: Order) = move(order, OrderStatus.READY_FOR_PICKUP, OrderStatus.OUT_FOR_DELIVERY)

    // Ranking is policy and the claim is concurrency, so they stay apart. The
    // claim is the predicate of firstOrNull, so the first courier whose
    // compareAndSet wins is the one we get.
    //
    // The status check keeps a courier off an order that is already delivered
    // or cancelled, and the attach is a compare and set so a second call cannot
    // overwrite the first courier and strand them.
    fun assignAgent(order: Order, fleet: List<DeliveryAgent>): DeliveryAgent? {
        if (!order.status.acceptsCourier || order.agent != null) return null
        val claimed = assignment(order, fleet).firstOrNull { it.tryAssign(order.id) } ?: return null
        if (order.attachAgent(claimed)) return claimed
        claimed.releaseIfOn(order.id)
        return null
    }

    fun deliver(order: Order): Boolean =
        move(order, OrderStatus.OUT_FOR_DELIVERY, OrderStatus.DELIVERED)
            .also { if (it) order.agent?.releaseIfOn(order.id) }

    // Cancellation names the status the caller believed the order was in. If
    // the kitchen moved it first this returns false, and the app tells the
    // customer the food is already being made.
    fun cancel(order: Order, expected: OrderStatus): Boolean =
        move(order, expected, OrderStatus.CANCELLED)
            .also { if (it) order.agent?.releaseIfOn(order.id) }

    private fun move(order: Order, expected: OrderStatus, next: OrderStatus): Boolean {
        if (!order.compareAndSetStatus(expected, next)) return false
        publish(order, expected)
        return true
    }

    private fun publish(order: Order, from: OrderStatus?) = observers.forEach { it(order, from) }
}
package com.androidinterview.foodordering.service

import com.androidinterview.foodordering.delivery.AssignmentStrategy
import com.androidinterview.foodordering.model.Cart
import com.androidinterview.foodordering.model.DeliveryAgent
import com.androidinterview.foodordering.model.Money
import com.androidinterview.foodordering.model.Order
import com.androidinterview.foodordering.model.OrderItem
import com.androidinterview.foodordering.model.OrderStatus
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList

sealed interface CheckoutResult {
    data class Placed(val order: Order) : CheckoutResult
    data class Rejected(val reasons: List<String>) : CheckoutResult
}

class OrderService(
    private val assignment: AssignmentStrategy,
    private val currency: String = "INR",
) {
    private val observers = CopyOnWriteArrayList<(Order, OrderStatus?) -> Unit>()

    fun subscribe(observer: (order: Order, from: OrderStatus?) -> Unit) {
        observers += observer
    }

    fun placeOrder(cart: Cart): CheckoutResult {
        val problems = cart.problemsAtCheckout()
        if (problems.isNotEmpty()) return CheckoutResult.Rejected(problems)

        val items = cart.lines.mapNotNull { line ->
            cart.restaurant.itemById(line.itemId)?.let {
                OrderItem(it.id, it.name, it.price, line.quantity)
            }
        }
        val total = items.fold(Money(currency, 0)) { running, item -> running + item.lineTotal }
        val order = Order(UUID.randomUUID().toString(), cart.customer, cart.restaurant, items, total)
        publish(order, null)
        return CheckoutResult.Placed(order)
    }

    fun restaurantAccepts(order: Order) = move(order, OrderStatus.PLACED, OrderStatus.CONFIRMED)

    fun startCooking(order: Order) = move(order, OrderStatus.CONFIRMED, OrderStatus.PREPARING)

    fun markReady(order: Order) = move(order, OrderStatus.PREPARING, OrderStatus.READY_FOR_PICKUP)

    fun pickUp(order: Order) = move(order, OrderStatus.READY_FOR_PICKUP, OrderStatus.OUT_FOR_DELIVERY)

    fun assignAgent(order: Order, fleet: List<DeliveryAgent>): DeliveryAgent? {
        if (!order.status.acceptsCourier || order.agent != null) return null
        val claimed = assignment(order, fleet).firstOrNull { it.tryAssign(order.id) } ?: return null
        if (order.attachAgent(claimed)) return claimed
        claimed.releaseIfOn(order.id)
        return null
    }

    fun deliver(order: Order): Boolean =
        move(order, OrderStatus.OUT_FOR_DELIVERY, OrderStatus.DELIVERED)
            .also { if (it) order.agent?.releaseIfOn(order.id) }

    fun cancel(order: Order, expected: OrderStatus): Boolean =
        move(order, expected, OrderStatus.CANCELLED)
            .also { if (it) order.agent?.releaseIfOn(order.id) }

    private fun move(order: Order, expected: OrderStatus, next: OrderStatus): Boolean {
        if (!order.compareAndSetStatus(expected, next)) return false
        publish(order, expected)
        return true
    }

    private fun publish(order: Order, from: OrderStatus?) = observers.forEach { it(order, from) }
}

Watch