Skip to main content
Contents13 / 15 ▾
13
Hard · 3 of 5

Food Delivery: Multi-Actor Order Lifecycle

An order walks one strict PLACED → ACCEPTED → PREPARING → READY → PICKED_UP → DELIVERED lifecycle that three independent actors — customer, restaurant, and delivery partner — all watch and act on, while a pluggable Strategy picks and atomically books the delivery partner the moment the food is ready.

13.1Problem Statement

Design the order-and-delivery core of a food-delivery platform in the shape of Swiggy or Zomato: customers browse restaurants, build a cart, and place an order; the restaurant accepts or rejects it and cooks; a delivery partner is assigned, picks the food up, and delivers it. The interesting part is not any single actor's flow — it's that three actors act independently on the same order, and the system has to keep all three views consistent while one shared pool of delivery partners gets fought over underneath.

One Order walks a guarded state machine from PLACED to DELIVERED (or off a side exit to REJECTED/CANCELLED); a pluggable Strategy books the nearest free delivery partner the instant the order goes READY, and an Observer fans that change out to the customer, restaurant, and partner without any of them polling.

Functional requirements:

  • Customers browse restaurants by location and cuisine, view a menu, build a cart, and place an order.
  • Restaurants accept or reject an incoming order, and mark the food ready.
  • Delivery partners get assigned to a ready order, then pick up and deliver it.
  • Order lifecycle: PLACED → ACCEPTED → PREPARING → READY → PICKED_UP → DELIVERED, with REJECTED and CANCELLED as early exits.
  • A suitable delivery partner near the restaurant is assigned automatically once the order is accepted / ready.
  • All relevant actors are notified on every state change; a bill is computed from items, tax, and a delivery fee.

Non-functional constraints — read these as the actual grading rubric:

  • The order is modeled as a State machine; illegal transitions must be rejected, not silently applied.
  • Delivery-partner assignment is a pluggable Strategy — nearest-partner today, batching multiple orders onto one partner tomorrow — swappable without touching the orchestrator.
  • An Observer mechanism notifies customer, restaurant, and partner subscribers; the order never hardcodes who's listening.
  • Three actors act independently on shared state, so partner assignment must be atomic — two orders racing for the same courier must never both win.

The expected public interface, given up front so the design effort goes into behavior, not signatures:

enum class OrderStatus { PLACED, ACCEPTED, PREPARING, READY, PICKED_UP, DELIVERED, REJECTED, CANCELLED };

class PartnerAssignmentStrategy {     // Strategy
public:
    virtual DeliveryPartner* assign(const Order&, const std::vector<DeliveryPartner>&) = 0;
    virtual ~PartnerAssignmentStrategy() = default;
};

class OrderService {
public:
    Order placeOrder(const std::string& customerId, const std::string& restaurantId, const Cart& cart);
    void  restaurantRespond(const std::string& orderId, bool accept);
    void  markReady(const std::string& orderId);          // triggers/locks-in partner assignment
    void  markPickedUp(const std::string& orderId);
    void  markDelivered(const std::string& orderId);
};

class SearchService {
public:
    std::vector<Restaurant> search(Location loc, const std::string& cuisine);
};

What the interviewer is really testing:

  • A correct order State machine with guarded transitions, exercised by three different actors calling in.
  • Atomic partner assignment — no delivery partner is ever double-booked, even under concurrent calls.
  • A clean pluggable assignment Strategy and an Observer that decouples the order from whoever is listening.
  • Whether Cart, MenuItem, Restaurant, and Order are modeled cleanly, with billing kept separate from the order itself.

Follow-up questions to expect once the base design stands:

  • Order batching — one partner carries several nearby orders at once.
  • ETA estimation — combining restaurant prep time with partner travel time.
  • Coupons and offers behind a pluggable pricing/discount engine.
  • Live tracking and reassignment if a partner cancels mid-delivery.

13.2How to Approach

Mining the Constraints First

Before drawing a single class, mine the problem statement for what it names outright, because it names three patterns and one hidden requirement. "Order modeled as a State machine" means the deliverable is a guarded transition table, not scattered if checks on a status field. "Delivery-partner assignment is a pluggable Strategy" means matching logic sits behind an interface, swappable without touching the orchestrator. "Observer for customer/restaurant/partner notifications" means the order publishes changes and never knows who's subscribed. The fourth constraint is easy to miss because it isn't a pattern name: "assignment must be atomic" is telling you that the select and the reserve of a delivery partner have to be one indivisible step.

When a prompt names three patterns by name, don't invent a fourth. Map each one to the requirement it solves and the architecture is half-written before you've typed a class. The one requirement without a pattern name attached — "atomic" — is usually the one candidates forget, precisely because it doesn't come with a label to hang a diagram on.

Nouns Into Classes

Circle the nouns — customer, restaurant, menu, item, cart, order, delivery partner, location, bill, status — and split them along the seam the constraints already drew: dumb entities that hold state and tiny invariants, versus the one orchestrator that coordinates all three actors.

ClassOwnsWhy it exists
Orderid, actors, status, partnerthe State machine instance
DeliveryPartnerid, location, available flagthe bookable resource — the hinge of atomicity
PartnerAssignmentStrategythe matching algorithmStrategy: how a partner gets picked
OrderObserveronStatusChange(...)Observer: the subscriber role
Bill / BillCalculatorsubtotal, tax, delivery feepricing kept out of Order on purpose
OrderServiceorders, partner pool, strategy, observersthe orchestrator — drives the lifecycle, books partners, publishes

When actors act independently on shared state, that separation is the whole game: coordination belongs to exactly one class, and it never leaks into the entities it coordinates.

The Interface Is the Edge List

The given interface already commits to a decision worth stating out loud: each public verb on OrderService is exactly one transition. restaurantRespond(accept=true) is PLACED → ACCEPTED (then straight on to PREPARING); markReady is PREPARING → READY and the moment a partner gets locked in. List the edges of the state diagram first, and the method names write themselves.

One refinement is worth making before writing assign's body: the interface sketch takes the partner list by const std::vector<DeliveryPartner>&, but booking a partner means mutating its available flag as part of the same call — so the real signature drops the const. That single word is where "atomic" actually gets implemented: find-and-book becomes one call instead of a caller-visible read followed by a separate write.

The Key Insight: a Guarded Transition Table

The naive answer scatters if (order.status == X) order.status = Y; across every method of the service. It works for the happy path, then the ninth method forgets a guard and an order goes DELIVERED without ever being ACCEPTED. The fix is to encode legality as data, in one place, behind a single gate every status change must pass through:

PLACED ──accept──► ACCEPTED ──(auto)──► PREPARING ──ready──► READY
   │                                                            │
 reject                                                      assign + pickup
   ▼                                                            ▼
REJECTED                                                    PICKED_UP ──deliver──► DELIVERED

(CANCELLED reachable from PLACED / ACCEPTED / PREPARING, before pickup)

Encode that graph as a lookup from each status to its legal next statuses, and check every move against it inside one method, conventionally called transitionTo. Terminal states — DELIVERED, REJECTED, CANCELLED — simply have no row in the table, so a delivered order cannot physically move again. Adding a future state is one new row, not an audit of every method that touches status.

Whenever something has a lifecycle, make legality a lookup table checked by a single gate method. Centralize the guard once; never write status = X anywhere else in the codebase.

Atomic Assignment: Select and Reserve as One Step

The default strategy is ordinary: scan available partners, pick the nearest to the restaurant. The part that actually earns the word "atomic" is that booking happens inside the same function that finds the winner:

for (auto& p : partners) {
    if (!p.available) continue;
    double d = p.loc.distanceTo(restaurant_->loc);
    if (d < bestDist) { bestDist = d; best = &p; }
}
if (best) best->available = false;   // book it inside the same critical section

available is the resource lock — flipping it to false in the same pass that found the winner closes the window where a second order could see the same partner as free. That still isn't thread-safe by itself: two threads can both finish the scan before either flips the flag. The actual fix is where assign gets called from — inside OrderService, wrapped in the same mutex that guards the state transition. The lock is what makes "scan, then flip" indivisible from any other thread's point of view; the flag is just what it's protecting.

The bug this guards against is invisible in a single-threaded demo and shows up only under load: two customers' orders both call markReady around the same instant, both scan the partner pool, both see the same nearest courier as free, and both book them. Ask yourself "can someone slip between the read and the write?" for any shared resource you select-then-reserve — if yes, the read and the write belong inside one lock, not two separate ones.

Observer Ties the Three Actors Together

The order publishes; it does not know who's listening. Every transition ends by iterating a list of OrderObserver* and calling onStatusChange — a customer-facing notifier, a partner-facing notifier, and (for free) any future analytics or SMS subscriber, without ever touching OrderService again to add one.

Orchestration Rhythm

Every method on OrderService follows the same three beats: validate the transition through the guarded gate, mutate side effects (book a partner, free a partner), then publish to observers — all inside one mutex, so the three independently-acting actors can hammer the service concurrently without a partner ever getting double-booked or an order ever skipping a state. Fix that rhythm once and every method becomes a two-line variation on it.

The follow-ups all land on seams the design already has, which is worth saying out loud in an interview: batching is just a new PartnerAssignmentStrategy that books an already-busy partner instead of a fresh one; ETA estimation is a new field published through the existing Observer channel; coupons promote BillCalculator::compute to its own pricing Strategy; and a partner cancelling mid-delivery is one new transition edge that frees the old partner and re-runs assignment. None of them force a rewrite of OrderService — that's the payoff of putting matching, notification, and pricing behind interfaces from the start.

13.3Whole Solution

The reference solution below merges every incremental step above into one self-contained C++17 program: the leaf value types, the guarded Order state machine, the NearestPartnerStrategy with its atomic find-and-book, billing kept separate, the OrderObserver hierarchy, and the OrderService orchestrator that ties all three patterns together behind one mutex. main() walks the full happy path — place, accept, ready (which books a partner), pick up, deliver — and then deliberately attempts an illegal transition to prove the guard actually rejects it.

#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <map>
#include <memory>
#include <mutex>
#include <cmath>
#include <limits>
#include <algorithm>
#include <stdexcept>

// ---------- Order lifecycle: PLACED -> ... -> DELIVERED (+ REJECTED / CANCELLED) ----------
enum class OrderStatus { PLACED, ACCEPTED, PREPARING, READY, PICKED_UP, DELIVERED, REJECTED, CANCELLED };

// ---------- Leaves: dependency-free value types ----------
struct Location {
    double x = 0, y = 0;
    double distanceTo(const Location& o) const {
        double dx = x - o.x, dy = y - o.y;
        return std::sqrt(dx * dx + dy * dy);   // good enough; swap for haversine in prod
    }
};

struct MenuItem {
    std::string id;
    std::string name;
    int price = 0;   // integer minor-units (paise/cents) -- never float money
};

// Thin basket tied to one restaurant: ids + quantities, never duplicated prices.
class Cart {
public:
    std::string restaurantId;
    void add(const std::string& itemId, int qty) { lines_[itemId] += qty; }
    const std::unordered_map<std::string, int>& lines() const { return lines_; }
private:
    std::unordered_map<std::string, int> lines_;
};

class Restaurant {
public:
    std::string id;
    std::string cuisine;
    Location    loc;

    void addItem(const MenuItem& m) { menu_[m.id] = m; }
    const MenuItem& item(const std::string& id) const { return menu_.at(id); }  // throws if missing
private:
    std::unordered_map<std::string, MenuItem> menu_;
};

// Bookable resource -- the single `available` flag is the hinge of atomic assignment.
struct DeliveryPartner {
    std::string id;
    Location    loc;
    bool        available = true;   // false once booked
};

class Order;   // forward declaration -- Strategy needs it before Order is fully defined

// ---------- Strategy: pluggable partner assignment ----------
class PartnerAssignmentStrategy {
public:
    // Non-const ref to the partner pool: assigning must MUTATE (flip `available`)
    // as one indivisible find-and-book step -- that's what "atomic" buys us.
    virtual DeliveryPartner* assign(const Order& order,
                                    std::vector<DeliveryPartner>& partners) = 0;
    virtual ~PartnerAssignmentStrategy() = default;
};

// ---------- The order: a guarded State machine, not a struct with a mutable status ----------
class Order {
public:
    Order(std::string id, std::string customerId, std::string restaurantId)
        : id_(std::move(id)), customerId_(std::move(customerId)),
          restaurantId_(std::move(restaurantId)), status_(OrderStatus::PLACED) {}

    OrderStatus status() const { return status_; }
    const std::string& id() const { return id_; }
    const std::string& customerId() const { return customerId_; }
    const std::string& restaurantId() const { return restaurantId_; }

    DeliveryPartner* partner() const { return partner_; }
    void setPartner(DeliveryPartner* p) { partner_ = p; }

    // The ONE gate every status change goes through.
    void transitionTo(OrderStatus next) {
        if (!isLegal(status_, next))
            throw std::logic_error("illegal transition: "
                + std::to_string(int(status_)) + " -> " + std::to_string(int(next)));
        status_ = next;
    }

private:
    static bool isLegal(OrderStatus from, OrderStatus to) {
        static const std::map<OrderStatus, std::vector<OrderStatus>> table = {
            {OrderStatus::PLACED,    {OrderStatus::ACCEPTED, OrderStatus::REJECTED, OrderStatus::CANCELLED}},
            {OrderStatus::ACCEPTED,  {OrderStatus::PREPARING, OrderStatus::CANCELLED}},
            {OrderStatus::PREPARING, {OrderStatus::READY, OrderStatus::CANCELLED}},
            {OrderStatus::READY,     {OrderStatus::PICKED_UP}},
            {OrderStatus::PICKED_UP, {OrderStatus::DELIVERED}},
            // DELIVERED / REJECTED / CANCELLED are terminal -> no outgoing edges
        };
        auto it = table.find(from);
        if (it == table.end()) return false;   // terminal state
        return std::find(it->second.begin(), it->second.end(), to) != it->second.end();
    }

    std::string id_, customerId_, restaurantId_;
    OrderStatus status_;
    DeliveryPartner* partner_ = nullptr;   // non-owning; OrderService owns the pool
};

// Default concrete Strategy: nearest available partner to the restaurant.
class NearestPartnerStrategy : public PartnerAssignmentStrategy {
public:
    explicit NearestPartnerStrategy(const Restaurant* r) : restaurant_(r) {}

    DeliveryPartner* assign(const Order&,
                            std::vector<DeliveryPartner>& partners) override {
        DeliveryPartner* best = nullptr;
        double bestDist = std::numeric_limits<double>::max();
        for (auto& p : partners) {
            if (!p.available) continue;
            double d = p.loc.distanceTo(restaurant_->loc);
            if (d < bestDist) { bestDist = d; best = &p; }
        }
        if (best) best->available = false;   // BOOK it inside the critical section
        return best;                         // nullptr => nobody free
    }
private:
    const Restaurant* restaurant_;
};

// ---------- Billing, kept out of Order on purpose ----------
struct Bill {
    int subtotal    = 0;
    int tax         = 0;
    int deliveryFee = 0;
    int total() const { return subtotal + tax + deliveryFee; }
};

class BillCalculator {
public:
    // Price the cart against the restaurant's live menu.
    static Bill compute(const Cart& cart, const Restaurant& r) {
        Bill b;
        for (auto& [itemId, qty] : cart.lines())
            b.subtotal += r.item(itemId).price * qty;
        b.tax         = b.subtotal * 5 / 100;   // 5% -- a real engine would be pluggable
        b.deliveryFee = 3000;                    // flat 30.00
        return b;
    }
};

// ---------- Observer: order publishes, subscribers react ----------
class OrderObserver {
public:
    virtual void onStatusChange(const Order& order, OrderStatus newStatus) = 0;
    virtual ~OrderObserver() = default;
};

// Example concrete observers -- one per actor.
class CustomerNotifier : public OrderObserver {
public:
    void onStatusChange(const Order& o, OrderStatus s) override {
        std::cout << "[customer " << o.customerId() << "] order " << o.id()
                  << " is now " << int(s) << "\n";
    }
};

class PartnerNotifier : public OrderObserver {
public:
    void onStatusChange(const Order& o, OrderStatus s) override {
        if (s == OrderStatus::READY && o.partner())
            std::cout << "[partner " << o.partner()->id << "] pickup ready for "
                      << o.id() << "\n";
    }
};

// ---------- Orchestrator: validate (guarded transition) -> side effects -> publish ----------
class OrderService {
public:
    OrderService(std::vector<DeliveryPartner> partners,
                 std::unique_ptr<PartnerAssignmentStrategy> strategy)
        : partners_(std::move(partners)), strategy_(std::move(strategy)) {}

    void addObserver(OrderObserver* o) { observers_.push_back(o); }

    Order& placeOrder(const std::string& customerId,
                      const std::string& restaurantId, const Cart&) {
        std::lock_guard<std::mutex> lock(mtx_);
        std::string id = "ORD-" + std::to_string(++counter_);
        auto [it, inserted] = orders_.emplace(id, Order(id, customerId, restaurantId));
        publish(it->second, OrderStatus::PLACED);   // already PLACED at construction
        return it->second;
    }

    void restaurantRespond(const std::string& orderId, bool accept) {
        std::lock_guard<std::mutex> lock(mtx_);
        Order& o = find(orderId);
        if (accept) {
            o.transitionTo(OrderStatus::ACCEPTED);   publish(o, OrderStatus::ACCEPTED);
            o.transitionTo(OrderStatus::PREPARING);  publish(o, OrderStatus::PREPARING);
        } else {
            o.transitionTo(OrderStatus::REJECTED);   publish(o, OrderStatus::REJECTED);
        }
    }

    void markReady(const std::string& orderId) {
        std::lock_guard<std::mutex> lock(mtx_);   // assignment runs inside this lock => atomic
        Order& o = find(orderId);
        o.transitionTo(OrderStatus::READY);

        DeliveryPartner* p = strategy_->assign(o, partners_);   // find + book, atomic
        if (!p) throw std::runtime_error("no delivery partner available");
        o.setPartner(p);
        publish(o, OrderStatus::READY);
    }

    void markPickedUp(const std::string& orderId) {
        std::lock_guard<std::mutex> lock(mtx_);
        Order& o = find(orderId);
        o.transitionTo(OrderStatus::PICKED_UP);  publish(o, OrderStatus::PICKED_UP);
    }

    void markDelivered(const std::string& orderId) {
        std::lock_guard<std::mutex> lock(mtx_);
        Order& o = find(orderId);
        o.transitionTo(OrderStatus::DELIVERED);  publish(o, OrderStatus::DELIVERED);
        if (o.partner()) o.partner()->available = true;   // free the partner
    }

private:
    Order& find(const std::string& id) {
        auto it = orders_.find(id);
        if (it == orders_.end()) throw std::invalid_argument("unknown order: " + id);
        return it->second;
    }
    void publish(const Order& o, OrderStatus s) {
        for (auto* obs : observers_) obs->onStatusChange(o, s);
    }

    std::mutex mtx_;
    std::map<std::string, Order> orders_;
    std::vector<DeliveryPartner> partners_;                 // the pool we own + book
    std::unique_ptr<PartnerAssignmentStrategy> strategy_;   // injected -- swappable
    std::vector<OrderObserver*> observers_;                 // non-owning subscribers
    int counter_ = 0;
};

// ---------- Driver: walk the happy path, then hit an illegal transition ----------
int main() {
    Restaurant r; r.id = "R1"; r.cuisine = "indian"; r.loc = {0, 0};
    r.addItem({"M1", "Paneer Tikka", 25000});

    std::vector<DeliveryPartner> partners = {
        {"P1", {10, 10}, true},
        {"P2", {1, 1},   true},   // closest to R1 -> should be booked
    };

    OrderService svc(std::move(partners),
                     std::make_unique<NearestPartnerStrategy>(&r));
    CustomerNotifier cust; PartnerNotifier part;
    svc.addObserver(&cust); svc.addObserver(&part);

    Cart cart; cart.restaurantId = "R1"; cart.add("M1", 2);
    Bill bill = BillCalculator::compute(cart, r);
    std::cout << "Bill total: " << bill.total() << "\n";   // 2*25000 + tax + fee

    Order& o = svc.placeOrder("C1", "R1", cart);           // PLACED
    svc.restaurantRespond(o.id(), true);                   // ACCEPTED -> PREPARING
    svc.markReady(o.id());                                 // READY + books P2
    svc.markPickedUp(o.id());                              // PICKED_UP
    svc.markDelivered(o.id());                             // DELIVERED, frees P2

    // Illegal transition now throws -- DELIVERED is terminal.
    try { svc.markPickedUp(o.id()); }
    catch (const std::exception& e) { std::cout << "Rejected: " << e.what() << "\n"; }
    return 0;
}
The interface sketch has assign take the partner list by const reference. Why does the real implementation drop the const?

Because assignment isn't just a lookup — it's a lookup and a reservation, and those two have to happen as one step for "atomic" to mean anything. If assign only read the pool and returned a pointer, the caller would need a second, separate call to flip available, and that gap between "found" and "booked" is exactly where two orders could pick the same courier. Dropping const lets the winning partner get flagged unavailable inside the same function that found it, so from the outside the whole operation looks like one indivisible step — which then only needs one mutex around it to be genuinely thread-safe.

How would you add order batching — one partner carrying several orders — without touching OrderService?

Write a new BatchingStrategy that implements PartnerAssignmentStrategy. Instead of only scanning for a fully free partner, it also considers partners already carrying an order whose route passes near the new restaurant, and appends to their batch — which means available stops being a bare bool and becomes a capacity counter inside that strategy's own bookkeeping. OrderService never changes: it still just calls strategy_->assign(order, partners_) under its lock. Swapping NearestPartnerStrategy for BatchingStrategy at construction time is the entire migration, which is exactly the open/closed payoff of putting matching behind an interface in the first place.

A delivery partner cancels mid-delivery, after PICKED_UP. What changes in the design?

Add one new edge: a partnerCancel(orderId) method that frees the current partner (available = true), leaves the order's status where it is rather than rewinding it, and re-runs strategy_->assign under the same lock to book a replacement — then publishes the reassignment through the existing Observer channel so the customer sees "new partner assigned" rather than silence. It's one more guarded transition and a reuse of the exact same select-and-reserve path markReady already uses; nothing about Order, PartnerAssignmentStrategy, or the Observer hierarchy needs to change shape.