Skip to content

37 — LLD: Restaurant Food Ordering and Rating

Understanding the Problem

A Zomato/Swiggy-style discovery and ordering system. Users search restaurants with composable filters (cuisine, price, rating, distance, veg-only), place an order for items in a single restaurant, and rate after delivery. The hardest design decision is how to compose filters cleanly — the Specification pattern beats nested if/else every time.

What is the Food Ordering System?

A searchable catalog of restaurants, each with a menu. Users place single-restaurant orders and rate after delivery. Ratings aggregate into an average displayed alongside each restaurant.


Requirements

Clarifying Questions

You: Real-time search or pre-indexed?

Interviewer: In-memory, live. A few hundred restaurants, thousands of active users.

Scale allows in-process search; no need for Elasticsearch yet. Call it out as a future extension.

You: Can an order contain items from multiple restaurants?

Interviewer: Single restaurant per order.

So Order.restaurantId is a single field, not a list. Simplifies pricing and rating attribution.

You: Rating per order or per item?

Interviewer: Per order. Optionally per item too.

Core path is order-level. Item-level ratings can be layered on.

Final Requirements

  1. search(SearchRequest) -> List<Restaurant> with composable filters.
  2. placeOrder(userId, restaurantId, items) -> Order.
  3. rate(orderId, stars, comment) — updates the restaurant's average. Rating is valid only after delivery.
  4. Thread-safe rating aggregation under concurrent writes.

Out of scope: Payment flow, delivery tracking, driver dispatch, cart persistence.

Deferred tradeoff: Filter evaluation is O(N) across all restaurants. Fine for hundreds; for thousands we would add inverted indexes per filter field.


Core Entities and Relationships

A Restaurant is the aggregate that owns its menu and rating. Filters are small behavioral objects — each one answers "does this restaurant match?" Composing them (AND/OR) is just function composition.

EntityResponsibility
Restaurantid, name, cuisine, priceTier, location, menu, rating stats.
MenuItemid, name, price, isVeg, tags.
RatingStatscount, sum — enables O(1) average. Thread-safe.
Orderid, userId, restaurantId, line items, total, state.
SearchFilter(Restaurant) -> boolean. Composable.
RestaurantIndexIn-memory index, all + optional byCuisine bucket.
OrderServiceplaceOrder, lifecycle.
RatingServiceaccept rating, update stats, notify listeners.

Why is RatingStats its own class? Because the invariant "average = sum / count" and thread-safe aggregation deserve isolation. Using LongAdder inside keeps writes contention-free.

Why are filters first-class objects, not flags on a request? Because composition (and, or) and reuse are cleaner when each filter is an object with a single test() method.

Design is iterative. If search latency becomes the bottleneck, we will add a geohash index and per-field inverted lists behind the same filter interface.


Class Design

RatingStats (thread-safe aggregator)

  • count: LongAdder.
  • sum: LongAdder.
  • add(stars) — validates 1-5, increments both.
  • average() -> doublesum / count, 0 if no ratings.

Restaurant

  • id, cuisine, priceTier, location, menu (Map by itemId), rating.

SearchFilter (Specification pattern)

  • test(Restaurant) -> boolean.
  • and(other), or(other) — default methods for composition.

Concrete filters: CuisineFilter, MinRatingFilter, PriceFilter, DistanceFilter, VegOnlyFilter.

Order

  • id, userId, restaurantId, line items, total, state (PLACED/PREPARING/OUT_FOR_DELIVERY/DELIVERED/CANCELLED), createdAt.

OrderService & RatingService

  • OrderService.placeOrder(userId, restaurantId, items) -> Order.
  • RatingService.rate(orderId, stars, comment) — updates the restaurant's stats.
java
public interface SearchFilter {
    boolean test(Restaurant r);
    default SearchFilter and(SearchFilter o) { return r -> test(r) && o.test(r); }
    default SearchFilter or(SearchFilter o) { return r -> test(r) || o.test(r); }
}

public class CuisineFilter implements SearchFilter {
    private final Set<String> cuisines;
    public CuisineFilter(Set<String> c) { this.cuisines = c; }
    public boolean test(Restaurant r) { return cuisines.contains(r.cuisine()); }
}

public class MinRatingFilter implements SearchFilter {
    private final double min;
    public MinRatingFilter(double m) { this.min = m; }
    public boolean test(Restaurant r) { return r.avgRating() >= min; }
}

public class Restaurant {
    private final String id;
    private final String cuisine;
    private final RatingStats rating = new RatingStats();
    public double avgRating() { return rating.average(); }
    public void addRating(int stars) { rating.add(stars); }
    // ... other fields
}

public final class RatingStats {
    private final LongAdder count = new LongAdder();
    private final LongAdder sum = new LongAdder();
    public void add(int stars) {
        if (stars < 1 || stars > 5) throw new IllegalArgumentException();
        count.increment();
        sum.add(stars);
    }
    public double average() {
        long c = count.sum();
        return c == 0 ? 0.0 : (double) sum.sum() / c;
    }
}

public class SearchService {
    private final Collection<Restaurant> all;
    public SearchService(Collection<Restaurant> all) { this.all = all; }
    public List<Restaurant> search(SearchFilter f, Comparator<Restaurant> rank, int limit) {
        return all.stream().filter(f::test).sorted(rank).limit(limit).toList();
    }
}
cpp
struct Restaurant {
    std::string id, cuisine;
    std::atomic<long> ratingCount{0}, ratingSum{0};
    double avgRating() const { long c = ratingCount.load(); return c == 0 ? 0.0 : (double)ratingSum.load() / c; }
    void addRating(int stars) {
        if (stars < 1 || stars > 5) return;
        ratingCount.fetch_add(1);
        ratingSum.fetch_add(stars);
    }
};

using Filter = std::function<bool(const Restaurant&)>;

inline Filter AND(Filter a, Filter b) { return [=](const Restaurant& r){ return a(r) && b(r); }; }
inline Filter OR(Filter a, Filter b)  { return [=](const Restaurant& r){ return a(r) || b(r); }; }
typescript
export interface SearchFilter { (r: Restaurant): boolean; }
export const and = (a: SearchFilter, b: SearchFilter): SearchFilter => (r) => a(r) && b(r);
export const or  = (a: SearchFilter, b: SearchFilter): SearchFilter => (r) => a(r) || b(r);

export const cuisineFilter = (cuisines: Set<string>): SearchFilter =>
  (r) => cuisines.has(r.cuisine);
export const minRatingFilter = (min: number): SearchFilter =>
  (r) => r.avgRating() >= min;

Design principles at play:

  • Specification / Composite Filter: Each filter is a self-contained unit. and/or compose them.
  • Strategy: Ranking (Comparator<Restaurant>) is swappable — by rating, distance, price.
  • Observer: Rating listeners for cache invalidation or trending-restaurant aggregation.
  • Separation of Concerns: RatingStats isolated from Restaurant business fields so its concurrency story does not leak.

Implementation

Core Logic: placeOrder

  1. Validate the restaurant exists and the cart is non-empty.
  2. For each cart item, resolve the menu item (throw if missing), compute line total.
  3. Build the Order with state PLACED.
  4. Store it.

Core Logic: rate

  1. Look up the order. Must be in state DELIVERED.
  2. Validate stars 1-5.
  3. Update restaurant.rating via addRating.
  4. Optionally update per-item ratings.
  5. Notify listeners.

Implementations

java
public Order placeOrder(String userId, String restaurantId, List<CartItem> items) {
    Restaurant r = restaurantById.get(restaurantId);
    if (r == null) throw new NoSuchElementException();
    if (items.isEmpty()) throw new IllegalArgumentException("empty cart");
    BigDecimal total = BigDecimal.ZERO;
    List<OrderItem> lineItems = new ArrayList<>();
    for (CartItem c : items) {
        MenuItem m = r.findItem(c.itemId());
        if (m == null) throw new NoSuchElementException(c.itemId());
        BigDecimal line = m.price().multiply(BigDecimal.valueOf(c.qty()));
        total = total.add(line);
        lineItems.add(new OrderItem(m.id(), m.name(), m.price(), c.qty()));
    }
    Order o = new Order(UUID.randomUUID().toString(), userId, restaurantId,
                       lineItems, total, OrderState.PLACED, Instant.now());
    orders.put(o.id(), o);
    return o;
}

public void rate(String orderId, int stars, String comment) {
    Order o = orders.get(orderId);
    if (o == null) throw new NoSuchElementException();
    if (o.state() != OrderState.DELIVERED) throw new IllegalStateException("rate only after delivery");
    Restaurant r = restaurantById.get(o.restaurantId());
    r.addRating(stars);
    ratingListeners.forEach(l -> l.onRated(o.restaurantId(), stars));
}
cpp
Order OrderService::placeOrder(const std::string& userId, const std::string& rid,
                               const std::vector<CartItem>& items) {
    auto r = findRestaurant(rid);
    double total = 0;
    std::vector<OrderItem> line;
    for (auto& c : items) {
        auto m = r->findItem(c.itemId);
        double amt = m->price * c.qty;
        total += amt;
        line.push_back({m->id, m->name, m->price, c.qty});
    }
    Order o{newUuid(), userId, rid, line, total, OrderState::PLACED, now()};
    orders[o.id] = o;
    return o;
}
typescript
placeOrder(userId: string, rid: string, items: CartItem[]): Order {
  const r = this.restaurantById.get(rid);
  if (!r) throw new Error("no restaurant");
  if (items.length === 0) throw new Error("empty cart");
  let total = 0;
  const line: OrderItem[] = [];
  for (const c of items) {
    const m = r.menu.get(c.itemId);
    if (!m) throw new Error(`no item ${c.itemId}`);
    total += m.price * c.qty;
    line.push({ itemId: m.id, name: m.name, price: m.price, qty: c.qty });
  }
  const o: Order = {
    id: crypto.randomUUID(), userId, restaurantId: rid,
    items: line, total, state: OrderState.PLACED, createdAt: Date.now(),
  };
  this.orders.set(o.id, o);
  return o;
}

Thread Safety

RatingStats uses LongAdder — contention-free under high write rates. Reads via average() are not perfectly atomic (count and sum may be from slightly different instants), but the display skew is tiny and acceptable.

restaurantById and orders are ConcurrentHashMap. No explicit lock needed for the common paths.

If you need exact-at-snapshot averages (e.g., for billing), add a version counter or read count+sum inside a read lock. Call this out rather than silently accepting inconsistency.

Verification

Filter chain: cuisine in {Italian, Indian} AND rating >= 4.0, ranked by rating descending.

RestaurantCuisineRatingPasses?
AItalian4.5yes
BChinese4.8no (cuisine)
CIndian3.9no (rating)
DItalian4.1yes

Result after ranking: [A, D].

Rating trace: rate(orderX, 5) on a restaurant with count=4, sum=16 (avg 4.0):

  • count.increment() → 5.
  • sum.add(5) → 21.
  • average() → 21 / 5 = 4.2.

Complete Code Implementation

java
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

public enum OrderState { PLACED, PREPARING, OUT_FOR_DELIVERY, DELIVERED, CANCELLED }

public record MenuItem(String id, String name, BigDecimal price, boolean isVeg, Set<String> tags) {}
public record CartItem(String itemId, int qty) {}
public record OrderItem(String itemId, String name, BigDecimal price, int qty) {}
public record Order(String id, String userId, String restaurantId,
                    List<OrderItem> items, BigDecimal total, OrderState state, Instant createdAt) {}

public final class RatingStats {
    private final LongAdder count = new LongAdder();
    private final LongAdder sum = new LongAdder();
    public void add(int stars) {
        if (stars < 1 || stars > 5) throw new IllegalArgumentException();
        count.increment();
        sum.add(stars);
    }
    public double average() {
        long c = count.sum();
        return c == 0 ? 0.0 : (double) sum.sum() / c;
    }
}

public class Restaurant {
    public final String id, name, cuisine, location;
    public final int priceTier;
    public final Map<String, MenuItem> menu;
    private final RatingStats rating = new RatingStats();
    public Restaurant(String id, String name, String cuisine, String loc, int tier, Map<String, MenuItem> menu) {
        this.id = id; this.name = name; this.cuisine = cuisine;
        this.location = loc; this.priceTier = tier; this.menu = menu;
    }
    public String cuisine() { return cuisine; }
    public double avgRating() { return rating.average(); }
    public void addRating(int stars) { rating.add(stars); }
    public MenuItem findItem(String id) { return menu.get(id); }
}

public interface SearchFilter {
    boolean test(Restaurant r);
    default SearchFilter and(SearchFilter o) { return r -> test(r) && o.test(r); }
    default SearchFilter or(SearchFilter o) { return r -> test(r) || o.test(r); }
}

public class CuisineFilter implements SearchFilter {
    private final Set<String> cuisines;
    public CuisineFilter(Set<String> c) { this.cuisines = c; }
    public boolean test(Restaurant r) { return cuisines.contains(r.cuisine); }
}

public class MinRatingFilter implements SearchFilter {
    private final double min;
    public MinRatingFilter(double m) { this.min = m; }
    public boolean test(Restaurant r) { return r.avgRating() >= min; }
}

public class VegOnlyFilter implements SearchFilter {
    public boolean test(Restaurant r) {
        return r.menu.values().stream().anyMatch(MenuItem::isVeg);
    }
}

public class SearchService {
    private final Collection<Restaurant> all;
    public SearchService(Collection<Restaurant> all) { this.all = all; }
    public List<Restaurant> search(SearchFilter f, Comparator<Restaurant> rank, int limit) {
        return all.stream().filter(f::test).sorted(rank).limit(limit).toList();
    }
}

public class OrderService {
    private final Map<String, Restaurant> restaurantById;
    private final Map<String, Order> orders = new ConcurrentHashMap<>();

    public OrderService(Map<String, Restaurant> rs) { this.restaurantById = rs; }

    public Order placeOrder(String userId, String rid, List<CartItem> items) {
        Restaurant r = restaurantById.get(rid);
        if (r == null) throw new NoSuchElementException();
        if (items.isEmpty()) throw new IllegalArgumentException("empty cart");
        BigDecimal total = BigDecimal.ZERO;
        List<OrderItem> line = new ArrayList<>();
        for (CartItem c : items) {
            MenuItem m = r.findItem(c.itemId());
            if (m == null) throw new NoSuchElementException(c.itemId());
            BigDecimal lineTotal = m.price().multiply(BigDecimal.valueOf(c.qty()));
            total = total.add(lineTotal);
            line.add(new OrderItem(m.id(), m.name(), m.price(), c.qty()));
        }
        Order o = new Order(UUID.randomUUID().toString(), userId, rid, line, total, OrderState.PLACED, Instant.now());
        orders.put(o.id(), o);
        return o;
    }
}

public interface RatingListener { void onRated(String restaurantId, int stars); }

public class RatingService {
    private final Map<String, Order> orders;
    private final Map<String, Restaurant> restaurantById;
    private final List<RatingListener> listeners = new CopyOnWriteArrayList<>();

    public RatingService(Map<String, Order> o, Map<String, Restaurant> r) { this.orders = o; this.restaurantById = r; }

    public void rate(String orderId, int stars) {
        Order o = orders.get(orderId);
        if (o == null) throw new NoSuchElementException();
        if (o.state() != OrderState.DELIVERED) throw new IllegalStateException("rate only after delivery");
        Restaurant r = restaurantById.get(o.restaurantId());
        r.addRating(stars);
        listeners.forEach(l -> l.onRated(o.restaurantId(), stars));
    }

    public void subscribe(RatingListener l) { listeners.add(l); }
}
cpp
#include <atomic>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>

struct MenuItem { std::string id, name; double price; bool isVeg; };

struct Restaurant {
    std::string id, cuisine;
    std::unordered_map<std::string, MenuItem> menu;
    std::atomic<long> ratingCount{0}, ratingSum{0};
    double avgRating() const {
        long c = ratingCount.load();
        return c == 0 ? 0.0 : (double) ratingSum.load() / c;
    }
    void addRating(int stars) {
        if (stars < 1 || stars > 5) return;
        ratingCount.fetch_add(1);
        ratingSum.fetch_add(stars);
    }
};

using Filter = std::function<bool(const Restaurant&)>;
inline Filter AND(Filter a, Filter b) { return [=](const Restaurant& r){ return a(r) && b(r); }; }
inline Filter OR(Filter a, Filter b)  { return [=](const Restaurant& r){ return a(r) || b(r); }; }
typescript
export enum OrderState { PLACED, PREPARING, OUT_FOR_DELIVERY, DELIVERED, CANCELLED }

export interface MenuItem { id: string; name: string; price: number; isVeg: boolean; tags: Set<string>; }
export interface CartItem { itemId: string; qty: number; }
export interface OrderItem { itemId: string; name: string; price: number; qty: number; }
export interface Order {
  id: string; userId: string; restaurantId: string;
  items: OrderItem[]; total: number; state: OrderState; createdAt: number;
}

export class RatingStats {
  private count = 0;
  private sum = 0;
  add(stars: number): void {
    if (stars < 1 || stars > 5) throw new Error("bad rating");
    this.count++;
    this.sum += stars;
  }
  average(): number { return this.count === 0 ? 0 : this.sum / this.count; }
}

export class Restaurant {
  rating = new RatingStats();
  constructor(
    public id: string,
    public name: string,
    public cuisine: string,
    public location: string,
    public priceTier: number,
    public menu: Map<string, MenuItem>,
  ) {}
  avgRating(): number { return this.rating.average(); }
  addRating(stars: number): void { this.rating.add(stars); }
}

export type SearchFilter = (r: Restaurant) => boolean;
export const and = (a: SearchFilter, b: SearchFilter): SearchFilter => (r) => a(r) && b(r);
export const or  = (a: SearchFilter, b: SearchFilter): SearchFilter => (r) => a(r) || b(r);
export const cuisineFilter = (cuisines: Set<string>): SearchFilter => (r) => cuisines.has(r.cuisine);
export const minRatingFilter = (min: number): SearchFilter => (r) => r.avgRating() >= min;

export class OrderService {
  private orders = new Map<string, Order>();
  constructor(private restaurantById: Map<string, Restaurant>) {}

  placeOrder(userId: string, rid: string, items: CartItem[]): Order {
    const r = this.restaurantById.get(rid);
    if (!r) throw new Error("no restaurant");
    if (items.length === 0) throw new Error("empty cart");
    let total = 0;
    const line: OrderItem[] = [];
    for (const c of items) {
      const m = r.menu.get(c.itemId);
      if (!m) throw new Error(`no item ${c.itemId}`);
      total += m.price * c.qty;
      line.push({ itemId: m.id, name: m.name, price: m.price, qty: c.qty });
    }
    const o: Order = {
      id: crypto.randomUUID(), userId, restaurantId: rid,
      items: line, total, state: OrderState.PLACED, createdAt: Date.now(),
    };
    this.orders.set(o.id, o);
    return o;
  }

  getOrder(id: string): Order | undefined { return this.orders.get(id); }
}

export class RatingService {
  constructor(
    private orders: Map<string, Order>,
    private restaurantById: Map<string, Restaurant>,
  ) {}

  rate(orderId: string, stars: number): void {
    const o = this.orders.get(orderId);
    if (!o) throw new Error("no order");
    if (o.state !== OrderState.DELIVERED) throw new Error("rate after delivery");
    this.restaurantById.get(o.restaurantId)!.addRating(stars);
  }
}

Extensibility

1. "Search by distance from the user?"

Add a DistanceFilter(userLoc, maxKm). For hundreds of restaurants, in-memory distance computation per request is fine. For scale, pre-index by geohash so distance filters only evaluate nearby buckets.

java
public class DistanceFilter implements SearchFilter {
    private final Location userLoc;
    private final double maxKm;
    public boolean test(Restaurant r) { return haversine(userLoc, r.location()) <= maxKm; }
}

Add a RatingListener that records (timestamp, restaurantId, stars) events. A windowed aggregator computes a rolling rating delta over 24h. Trending = highest delta. Run the aggregator on a scheduled executor to avoid slowing rate.

3. "Weighted average rating that decays over time?"

Replace LongAdder with time-bucketed counters and apply an exponential weighted moving average. Recent ratings count more than old ones. Harder to parallelize — you give up the contention-free write.

Tradeoff: You trade some write throughput for a more meaningful rating signal.


What is Expected at Each Level

LevelExpectations
Junior (L4)Single Restaurant.rating as a plain double. Hard-coded if/else in search. Not thread-safe.
Mid (L5)RatingStats with atomic counters, composable filters via Specification, correct pre-conditions on rating (delivered only).
Senior (L5A)LongAdder for contention-free ratings, clean Specification/AND/OR composition, Observer on rate, explicit acknowledgment of the tiny read skew in average(). Clear extensibility plan for geo and trending.
Staff (L6)Search backed by an inverted index or Elasticsearch, rating service as an independent stream consumer with exactly-once semantics, anti-abuse filters, A/B experimentation on ranking.

Frontend interview preparation reference.