16 — LLD: Food Ordering System
Understanding the Problem
Design the backend classes for a food ordering system like Swiggy or DoorDash. Users search for restaurants, browse menus, place orders, and rate their experience. Restaurants manage their menus and process incoming orders. The order progresses through a state machine from placement to delivery.
What is a Food Ordering System?
At its core, this is a search-sort-order-rate pipeline. The interesting LLD challenges are: (1) flexible search with filters, (2) pluggable sorting strategies, (3) an order state machine with valid transitions, and (4) a rating system that updates restaurant scores. Every piece uses a different design pattern.
Requirements
Clarifying Questions
You: The main flows are: user searches for restaurants, browses a menu, places an order, and rates the restaurant after delivery. Is that the scope?
Interviewer: Yes. Focus on those four flows.
You: For search — should I support filtering by cuisine, rating, distance, and price range?
Interviewer: Yes. And the filters should be composable — a user might filter by cuisine AND minimum rating.
You: For sorting — should the user be able to sort results by rating, distance, price, or delivery time?
Interviewer: Yes. And I want to see the Strategy pattern here.
You: For orders — what states does an order go through?
Interviewer: Placed, Confirmed, Preparing, Out for Delivery, Delivered, Cancelled. Not all transitions are valid — for example, you cannot go from Delivered back to Preparing.
You: Should I model the delivery agent or payment?
Interviewer: Keep it simple. Focus on the restaurant, menu, order, and user classes. Delivery and payment are out of scope.
You: For ratings — is it per-order or per-restaurant?
Interviewer: Per-order, but the restaurant maintains an aggregate rating.
Final Requirements
- Restaurant with menu (items grouped by category), location, cuisine tags.
- Search with composable filters: cuisine, minimum rating, max distance, price range.
- Sorting by: rating, distance, price, delivery time — using Strategy pattern.
- Order state machine: PLACED -> CONFIRMED -> PREPARING -> OUT_FOR_DELIVERY -> DELIVERED. Cancellation allowed from PLACED or CONFIRMED only.
- Rating system: users rate after delivery, restaurant aggregate updates.
- Order history per user.
Out of scope: Payment processing, delivery agent assignment, real-time tracking, promotions/coupons.
Core Entities and Relationships
| Entity | Responsibility |
|---|---|
User | Identity, order history, delivery address |
Restaurant | Menu, location, cuisine, aggregate rating |
MenuItem | Name, price, category, availability |
Order | Links user to restaurant + items, manages state transitions |
OrderStatus | Enum of valid states |
Rating | A single rating (1-5) with optional review text |
RestaurantFilter | Interface for search filters (like the File Filtering problem) |
SortStrategy | Interface for sorting restaurant search results |
OrderService | Orchestrates order placement, state transitions, and ratings |
SearchService | Orchestrates search, filtering, and sorting |
Why separate SearchService from OrderService? Search and ordering are independent workflows. A user can search without ordering. An order can be placed without a preceding search (e.g., reordering from history). Separate services means each has a single responsibility.
Why a state machine for orders? An order's state transitions are constrained (you cannot deliver a cancelled order). Modeling this explicitly prevents invalid states and makes the transition rules auditable.
Class Design
MenuItem
State:
itemId: stringname: stringprice: numbercategory: string(e.g., "Appetizers", "Main Course", "Drinks")isAvailable: boolean
Restaurant
State:
restaurantId: stringname: stringcuisines: string[]location: [number, number](lat, lng)menu: MenuItem[]ratings: Rating[]avgDeliveryTimeMin: number
Methods:
getAverageRating() -> numbergetAvailableItems() -> MenuItem[]addRating(rating: Rating)
OrderStatus (Enum + State Machine)
PLACED -> CONFIRMED -> PREPARING -> OUT_FOR_DELIVERY -> DELIVERED
PLACED -> CANCELLED
CONFIRMED -> CANCELLEDOrder
State:
orderId: stringuser: Userrestaurant: Restaurantitems: Array<[MenuItem, number]>(item, quantity)status: OrderStatustotal: numbercreatedAt: Daterating: Rating | null
Methods:
transitionTo(newStatus)— validates and executes state transitioncalculateTotal() -> number
Final Class Design
enum OrderStatus {
PLACED = "placed",
CONFIRMED = "confirmed",
PREPARING = "preparing",
OUT_FOR_DELIVERY = "out_for_delivery",
DELIVERED = "delivered",
CANCELLED = "cancelled",
}
// Valid transitions defined as a map
const VALID_TRANSITIONS: Map<OrderStatus, Set<OrderStatus>> = new Map([
[OrderStatus.PLACED, new Set([OrderStatus.CONFIRMED, OrderStatus.CANCELLED])],
[OrderStatus.CONFIRMED, new Set([OrderStatus.PREPARING, OrderStatus.CANCELLED])],
[OrderStatus.PREPARING, new Set([OrderStatus.OUT_FOR_DELIVERY])],
[OrderStatus.OUT_FOR_DELIVERY, new Set([OrderStatus.DELIVERED])],
[OrderStatus.DELIVERED, new Set()], // terminal state
[OrderStatus.CANCELLED, new Set()], // terminal state
]);
class Rating {
public readonly createdAt: Date;
constructor(
public readonly score: number,
public readonly review: string = "",
) {
if (score < 1 || score > 5) {
throw new Error(`Rating must be 1-5, got ${score}`);
}
this.createdAt = new Date();
}
}
class MenuItem {
constructor(
public readonly itemId: string,
public readonly name: string,
public readonly price: number,
public readonly category: string,
public isAvailable: boolean = true,
) {}
}
class User {
constructor(
public readonly userId: string,
public readonly name: string,
public readonly address: [number, number] = [0, 0], // lat, lng
) {}
}Design principles at play:
- State Pattern (simplified): Order status transitions are governed by a transition table. Invalid transitions raise errors. This is simpler than the full State pattern (where each state is a class) but sufficient for an interview.
- Strategy Pattern: Sort strategies are interchangeable. Each implements the same interface but orders results differently.
- Single Responsibility: Restaurant manages its menu and rating. Order manages its state. Services orchestrate flows.
Implementation
Restaurant
class Restaurant {
public readonly restaurantId: string;
public readonly name: string;
public readonly cuisines: string[];
public readonly location: [number, number];
public menu: MenuItem[] = [];
public ratings: Rating[] = [];
public avgDeliveryTimeMin: number;
constructor(
restaurantId: string,
name: string,
cuisines: string[],
location: [number, number],
avgDeliveryTimeMin: number = 30,
) {
this.restaurantId = restaurantId;
this.name = name;
this.cuisines = cuisines.map((c) => c.toLowerCase());
this.location = location;
this.avgDeliveryTimeMin = avgDeliveryTimeMin;
}
addMenuItem(item: MenuItem): void {
this.menu.push(item);
}
getAvailableItems(): MenuItem[] {
return this.menu.filter((item) => item.isAvailable);
}
getAverageRating(): number {
if (this.ratings.length === 0) return 0;
const sum = this.ratings.reduce((acc, r) => acc + r.score, 0);
return Math.round((sum / this.ratings.length) * 10) / 10;
}
addRating(rating: Rating): void {
this.ratings.push(rating);
}
getAveragePrice(): number {
const available = this.getAvailableItems();
if (available.length === 0) return 0;
return available.reduce((acc, item) => acc + item.price, 0) / available.length;
}
}Order with State Machine
class InvalidTransitionError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidTransitionError";
}
}
class Order {
public readonly orderId: string;
public readonly user: User;
public readonly restaurant: Restaurant;
public readonly items: Array<[MenuItem, number]>;
public status: OrderStatus = OrderStatus.PLACED;
public readonly total: number;
public readonly createdAt: Date;
public rating: Rating | null = null;
private statusHistory: Array<[OrderStatus, Date]>;
constructor(
orderId: string,
user: User,
restaurant: Restaurant,
items: Array<[MenuItem, number]>,
) {
this.orderId = orderId;
this.user = user;
this.restaurant = restaurant;
this.items = items;
this.total = items.reduce((acc, [item, qty]) => acc + item.price * qty, 0);
this.createdAt = new Date();
this.statusHistory = [[OrderStatus.PLACED, this.createdAt]];
}
transitionTo(newStatus: OrderStatus): void {
const valid = VALID_TRANSITIONS.get(this.status) ?? new Set();
if (!valid.has(newStatus)) {
throw new InvalidTransitionError(
`Cannot transition from ${this.status} to ${newStatus}. ` +
`Valid transitions: ${[...valid].join(", ")}`
);
}
this.status = newStatus;
this.statusHistory.push([newStatus, new Date()]);
}
addRating(score: number, review: string = ""): void {
if (this.status !== OrderStatus.DELIVERED) {
throw new Error("Can only rate delivered orders");
}
if (this.rating !== null) {
throw new Error("Order already rated");
}
this.rating = new Rating(score, review);
this.restaurant.addRating(this.rating);
}
get isActive(): boolean {
return (
this.status !== OrderStatus.DELIVERED &&
this.status !== OrderStatus.CANCELLED
);
}
}Search: Filters (Composable)
We reuse the same Composite pattern from the File Filtering problem — it is the same concept applied to restaurants.
interface RestaurantFilter {
matches(restaurant: Restaurant, userLocation: [number, number]): boolean;
}
class CuisineFilter implements RestaurantFilter {
private readonly cuisine: string;
constructor(cuisine: string) {
this.cuisine = cuisine.toLowerCase();
}
matches(restaurant: Restaurant, userLocation: [number, number]): boolean {
return restaurant.cuisines.includes(this.cuisine);
}
}
class MinRatingFilter implements RestaurantFilter {
constructor(private readonly minRating: number) {}
matches(restaurant: Restaurant, userLocation: [number, number]): boolean {
return restaurant.getAverageRating() >= this.minRating;
}
}
class MaxDistanceFilter implements RestaurantFilter {
constructor(private readonly maxKm: number) {}
matches(restaurant: Restaurant, userLocation: [number, number]): boolean {
const dist = MaxDistanceFilter.haversine(userLocation, restaurant.location);
return dist <= this.maxKm;
}
static haversine(loc1: [number, number], loc2: [number, number]): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const [lat1, lon1] = [toRad(loc1[0]), toRad(loc1[1])];
const [lat2, lon2] = [toRad(loc2[0]), toRad(loc2[1])];
const dlat = lat2 - lat1;
const dlon = lon2 - lon1;
const a =
Math.sin(dlat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlon / 2) ** 2;
return 6371 * 2 * Math.asin(Math.sqrt(a));
}
}
class PriceRangeFilter implements RestaurantFilter {
constructor(private readonly maxAvgPrice: number) {}
matches(restaurant: Restaurant, userLocation: [number, number]): boolean {
return restaurant.getAveragePrice() <= this.maxAvgPrice;
}
}
class AndRestaurantFilter implements RestaurantFilter {
private readonly filters: RestaurantFilter[];
constructor(...filters: RestaurantFilter[]) {
this.filters = filters;
}
matches(restaurant: Restaurant, userLocation: [number, number]): boolean {
return this.filters.every((f) => f.matches(restaurant, userLocation));
}
}Search: Sorting Strategies
interface SortStrategy {
sortKey(restaurant: Restaurant, userLocation: [number, number]): number;
}
class SortByRating implements SortStrategy {
sortKey(restaurant: Restaurant, userLocation: [number, number]): number {
return -restaurant.getAverageRating(); // negative for descending
}
}
class SortByDistance implements SortStrategy {
sortKey(restaurant: Restaurant, userLocation: [number, number]): number {
return MaxDistanceFilter.haversine(userLocation, restaurant.location);
}
}
class SortByPrice implements SortStrategy {
sortKey(restaurant: Restaurant, userLocation: [number, number]): number {
return restaurant.getAveragePrice();
}
}
class SortByDeliveryTime implements SortStrategy {
sortKey(restaurant: Restaurant, userLocation: [number, number]): number {
return restaurant.avgDeliveryTimeMin;
}
}Services
class SearchService {
constructor(private restaurants: Restaurant[]) {}
search(
userLocation: [number, number],
filter?: RestaurantFilter,
sortStrategy?: SortStrategy,
): Restaurant[] {
let results = this.restaurants;
if (filter) {
results = results.filter((r) => filter.matches(r, userLocation));
}
if (sortStrategy) {
results = [...results].sort(
(a, b) =>
sortStrategy.sortKey(a, userLocation) -
sortStrategy.sortKey(b, userLocation),
);
}
return results;
}
}
class OrderService {
public orders: Map<string, Order> = new Map();
private counter = 0;
placeOrder(
user: User,
restaurant: Restaurant,
itemQuantities: Record<string, number>,
): Order {
const available = new Map(
restaurant.getAvailableItems().map((i) => [i.itemId, i]),
);
const orderItems: Array<[MenuItem, number]> = [];
for (const [itemId, qty] of Object.entries(itemQuantities)) {
const item = available.get(itemId);
if (!item) {
throw new Error(`Item ${itemId} not available`);
}
if (qty <= 0) {
throw new Error("Quantity must be positive");
}
orderItems.push([item, qty]);
}
if (orderItems.length === 0) {
throw new Error("Order must have at least one item");
}
this.counter += 1;
const orderId = `ORD-${String(this.counter).padStart(5, "0")}`;
const order = new Order(orderId, user, restaurant, orderItems);
this.orders.set(order.orderId, order);
return order;
}
updateStatus(orderId: string, newStatus: OrderStatus): void {
const order = this.orders.get(orderId);
if (!order) throw new Error(`Order ${orderId} not found`);
order.transitionTo(newStatus);
}
rateOrder(orderId: string, score: number, review: string = ""): void {
const order = this.orders.get(orderId);
if (!order) throw new Error(`Order ${orderId} not found`);
order.addRating(score, review);
}
getUserOrders(userId: string): Order[] {
return [...this.orders.values()].filter((o) => o.user.userId === userId);
}
}Edge Cases
- Restaurant with no ratings —
getAverageRating()returns 0.0. AMinRatingFilter(4.0)will exclude it. This is intentional — new restaurants need to earn visibility. - Order with unavailable item —
placeOrdervalidates availability at order time. If an item becomes unavailable between search and order, the order is rejected. - Invalid state transition —
transitionToraisesInvalidTransitionError. You cannot cancel a delivered order or prepare a cancelled order. - Rating a non-delivered order — Rejected. You can only rate after delivery.
- Double rating — Rejected. Each order can be rated once.
- Empty search results — Valid. No restaurants match the filter criteria.
Verification
Setup: Three restaurants near the user.
- "Pizza Palace" — Italian, 4.2 rating, 2km away, $15 avg, 25 min delivery.
- "Sushi Spot" — Japanese, 4.8 rating, 5km away, $30 avg, 40 min delivery.
- "Burger Barn" — American, 3.5 rating, 1km away, $12 avg, 20 min delivery.
Step 1: Search for restaurants within 3km, sorted by rating.
const filter = new MaxDistanceFilter(3.0);
const sort = new SortByRating();
const results = searchService.search(userLocation, filter, sort);- MaxDistanceFilter(3km): Pizza Palace (2km) MATCH, Sushi Spot (5km) NO, Burger Barn (1km) MATCH.
- SortByRating: Pizza Palace (4.2) first, Burger Barn (3.5) second.
- Result: [Pizza Palace, Burger Barn].
Step 2: Place an order at Pizza Palace.
const order = orderService.placeOrder(
alice,
pizzaPalace,
{ margherita: 2, garlic_bread: 1 },
);- Order created: ORD-00001, status=PLACED, total=$35.
Step 3: Progress the order through the state machine.
orderService.updateStatus("ORD-00001", OrderStatus.CONFIRMED) // PLACED -> CONFIRMED
orderService.updateStatus("ORD-00001", OrderStatus.PREPARING) // CONFIRMED -> PREPARING
orderService.updateStatus("ORD-00001", OrderStatus.OUT_FOR_DELIVERY)
orderService.updateStatus("ORD-00001", OrderStatus.DELIVERED)- Each transition is valid. Status history: PLACED -> CONFIRMED -> PREPARING -> OUT_FOR_DELIVERY -> DELIVERED.
Step 4: Rate the order.
orderService.rateOrder("ORD-00001", 5, "Best pizza ever!");- Order.rating set. Pizza Palace's ratings list updated. Average rating recalculated.
Step 5: Try to cancel the delivered order.
orderService.updateStatus("ORD-00001", OrderStatus.CANCELLED);
// Throws: InvalidTransitionError — Cannot transition from delivered to cancelledComplete Code Implementation
// ---- Enums and Constants ----
enum OrderStatus {
PLACED = "placed",
CONFIRMED = "confirmed",
PREPARING = "preparing",
OUT_FOR_DELIVERY = "out_for_delivery",
DELIVERED = "delivered",
CANCELLED = "cancelled",
}
const VALID_TRANSITIONS: Map<OrderStatus, Set<OrderStatus>> = new Map([
[OrderStatus.PLACED, new Set([OrderStatus.CONFIRMED, OrderStatus.CANCELLED])],
[OrderStatus.CONFIRMED, new Set([OrderStatus.PREPARING, OrderStatus.CANCELLED])],
[OrderStatus.PREPARING, new Set([OrderStatus.OUT_FOR_DELIVERY])],
[OrderStatus.OUT_FOR_DELIVERY, new Set([OrderStatus.DELIVERED])],
[OrderStatus.DELIVERED, new Set()],
[OrderStatus.CANCELLED, new Set()],
]);
// ---- Data Classes ----
class Rating {
public readonly createdAt: Date;
constructor(
public readonly score: number,
public readonly review: string = "",
) {
if (score < 1 || score > 5) {
throw new Error(`Rating must be 1-5, got ${score}`);
}
this.createdAt = new Date();
}
}
class MenuItem {
constructor(
public readonly itemId: string,
public readonly name: string,
public readonly price: number,
public readonly category: string,
public isAvailable: boolean = true,
) {}
}
class User {
constructor(
public readonly userId: string,
public readonly name: string,
public readonly address: [number, number] = [0, 0],
) {}
}
// ---- Restaurant ----
class Restaurant {
public menu: MenuItem[] = [];
public ratings: Rating[] = [];
public readonly cuisines: string[];
constructor(
public readonly restaurantId: string,
public readonly name: string,
cuisines: string[],
public readonly location: [number, number],
public avgDeliveryTimeMin: number = 30,
) {
this.cuisines = cuisines.map((c) => c.toLowerCase());
}
addMenuItem(item: MenuItem): void {
this.menu.push(item);
}
getAvailableItems(): MenuItem[] {
return this.menu.filter((i) => i.isAvailable);
}
getAverageRating(): number {
if (this.ratings.length === 0) return 0;
const sum = this.ratings.reduce((acc, r) => acc + r.score, 0);
return Math.round((sum / this.ratings.length) * 10) / 10;
}
addRating(rating: Rating): void {
this.ratings.push(rating);
}
getAveragePrice(): number {
const available = this.getAvailableItems();
if (available.length === 0) return 0;
return available.reduce((acc, i) => acc + i.price, 0) / available.length;
}
toString(): string {
return `Restaurant(${this.name}, rating=${this.getAverageRating()})`;
}
}
// ---- Order ----
class InvalidTransitionError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidTransitionError";
}
}
class Order {
public status: OrderStatus = OrderStatus.PLACED;
public readonly total: number;
public readonly createdAt: Date;
public rating: Rating | null = null;
private history: Array<[OrderStatus, Date]>;
constructor(
public readonly orderId: string,
public readonly user: User,
public readonly restaurant: Restaurant,
public readonly items: Array<[MenuItem, number]>,
) {
this.total = items.reduce((acc, [item, qty]) => acc + item.price * qty, 0);
this.createdAt = new Date();
this.history = [[OrderStatus.PLACED, this.createdAt]];
}
transitionTo(newStatus: OrderStatus): void {
const valid = VALID_TRANSITIONS.get(this.status) ?? new Set();
if (!valid.has(newStatus)) {
throw new InvalidTransitionError(
`Cannot go from ${this.status} to ${newStatus}`
);
}
this.status = newStatus;
this.history.push([newStatus, new Date()]);
}
addRating(score: number, review: string = ""): void {
if (this.status !== OrderStatus.DELIVERED) {
throw new Error("Can only rate delivered orders");
}
if (this.rating !== null) {
throw new Error("Already rated");
}
this.rating = new Rating(score, review);
this.restaurant.addRating(this.rating);
}
toString(): string {
const itemsStr = this.items.map(([i, q]) => `${i.name}x${q}`).join(", ");
return `Order(${this.orderId}, ${this.status}, [${itemsStr}], $${this.total})`;
}
}
// ---- Search: Filters ----
interface RestaurantFilter {
matches(restaurant: Restaurant, userLoc: [number, number]): boolean;
}
class CuisineFilter implements RestaurantFilter {
private readonly cuisine: string;
constructor(cuisine: string) {
this.cuisine = cuisine.toLowerCase();
}
matches(restaurant: Restaurant, userLoc: [number, number]): boolean {
return restaurant.cuisines.includes(this.cuisine);
}
}
class MinRatingFilter implements RestaurantFilter {
constructor(private readonly minRating: number) {}
matches(restaurant: Restaurant, userLoc: [number, number]): boolean {
return restaurant.getAverageRating() >= this.minRating;
}
}
class MaxDistanceFilter implements RestaurantFilter {
constructor(private readonly maxKm: number) {}
matches(restaurant: Restaurant, userLoc: [number, number]): boolean {
return MaxDistanceFilter.distance(userLoc, restaurant.location) <= this.maxKm;
}
static distance(a: [number, number], b: [number, number]): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const [lat1, lon1] = [toRad(a[0]), toRad(a[1])];
const [lat2, lon2] = [toRad(b[0]), toRad(b[1])];
const dlat = lat2 - lat1;
const dlon = lon2 - lon1;
const h =
Math.sin(dlat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlon / 2) ** 2;
return 6371 * 2 * Math.asin(Math.sqrt(h));
}
}
class PriceRangeFilter implements RestaurantFilter {
constructor(private readonly maxAvgPrice: number) {}
matches(restaurant: Restaurant, userLoc: [number, number]): boolean {
return restaurant.getAveragePrice() <= this.maxAvgPrice;
}
}
class AndRestaurantFilter implements RestaurantFilter {
private readonly filters: RestaurantFilter[];
constructor(...filters: RestaurantFilter[]) {
this.filters = filters;
}
matches(restaurant: Restaurant, userLoc: [number, number]): boolean {
return this.filters.every((f) => f.matches(restaurant, userLoc));
}
}
// ---- Search: Sort Strategies ----
interface SortStrategy {
sortKey(restaurant: Restaurant, userLoc: [number, number]): number;
}
class SortByRating implements SortStrategy {
sortKey(restaurant: Restaurant, userLoc: [number, number]): number {
return -restaurant.getAverageRating();
}
}
class SortByDistance implements SortStrategy {
sortKey(restaurant: Restaurant, userLoc: [number, number]): number {
return MaxDistanceFilter.distance(userLoc, restaurant.location);
}
}
class SortByPrice implements SortStrategy {
sortKey(restaurant: Restaurant, userLoc: [number, number]): number {
return restaurant.getAveragePrice();
}
}
class SortByDeliveryTime implements SortStrategy {
sortKey(restaurant: Restaurant, userLoc: [number, number]): number {
return restaurant.avgDeliveryTimeMin;
}
}
// ---- Services ----
class SearchService {
constructor(private restaurants: Restaurant[]) {}
search(
userLocation: [number, number],
filter?: RestaurantFilter,
sortStrategy?: SortStrategy,
): Restaurant[] {
let results = this.restaurants;
if (filter) {
results = results.filter((r) => filter.matches(r, userLocation));
}
if (sortStrategy) {
results = [...results].sort(
(a, b) =>
sortStrategy.sortKey(a, userLocation) -
sortStrategy.sortKey(b, userLocation),
);
}
return results;
}
}
class OrderService {
public orders: Map<string, Order> = new Map();
private counter = 0;
placeOrder(
user: User,
restaurant: Restaurant,
itemQuantities: Record<string, number>,
): Order {
const available = new Map(
restaurant.getAvailableItems().map((i) => [i.itemId, i]),
);
const orderItems: Array<[MenuItem, number]> = [];
for (const [itemId, qty] of Object.entries(itemQuantities)) {
const item = available.get(itemId);
if (!item) throw new Error(`Item ${itemId} not available`);
if (qty <= 0) throw new Error("Quantity must be positive");
orderItems.push([item, qty]);
}
if (orderItems.length === 0) {
throw new Error("Order must have at least one item");
}
this.counter += 1;
const order = new Order(
`ORD-${String(this.counter).padStart(5, "0")}`,
user,
restaurant,
orderItems,
);
this.orders.set(order.orderId, order);
return order;
}
updateStatus(orderId: string, newStatus: OrderStatus): void {
const order = this.orders.get(orderId);
if (!order) throw new Error(`Order ${orderId} not found`);
order.transitionTo(newStatus);
}
rateOrder(orderId: string, score: number, review: string = ""): void {
const order = this.orders.get(orderId);
if (!order) throw new Error(`Order ${orderId} not found`);
order.addRating(score, review);
}
getUserOrders(userId: string): Order[] {
return [...this.orders.values()].filter((o) => o.user.userId === userId);
}
}
// ---- Demo ----
// Setup restaurants
const pizza = new Restaurant("R1", "Pizza Palace", ["Italian"], [28.6139, 77.2090], 25);
pizza.addMenuItem(new MenuItem("margherita", "Margherita", 12.0, "Pizza"));
pizza.addMenuItem(new MenuItem("garlic_bread", "Garlic Bread", 6.0, "Sides"));
pizza.ratings = [new Rating(4), new Rating(5), new Rating(4)];
const sushi = new Restaurant("R2", "Sushi Spot", ["Japanese"], [28.6200, 77.2150], 40);
sushi.addMenuItem(new MenuItem("salmon_roll", "Salmon Roll", 25.0, "Sushi"));
sushi.ratings = [new Rating(5), new Rating(5), new Rating(4)];
const burger = new Restaurant("R3", "Burger Barn", ["American"], [28.6130, 77.2080], 20);
burger.addMenuItem(new MenuItem("classic", "Classic Burger", 10.0, "Burgers"));
burger.ratings = [new Rating(3), new Rating(4), new Rating(3)];
// Search
const user = new User("U1", "Alice", [28.6140, 77.2085]);
const searchSvc = new SearchService([pizza, sushi, burger]);
const results = searchSvc.search(
user.address,
new AndRestaurantFilter(new MinRatingFilter(4.0)),
new SortByRating(),
);
console.log("Restaurants with 4.0+ rating, sorted by rating:");
for (const r of results) {
console.log(` ${r.name} — ${r.getAverageRating()}`);
}
// Place order
const orderSvc = new OrderService();
const order = orderSvc.placeOrder(user, pizza, { margherita: 2, garlic_bread: 1 });
console.log(`\nOrder placed: ${order}`);
// Progress order
for (const status of [
OrderStatus.CONFIRMED,
OrderStatus.PREPARING,
OrderStatus.OUT_FOR_DELIVERY,
OrderStatus.DELIVERED,
]) {
orderSvc.updateStatus(order.orderId, status);
}
console.log(`Order after delivery: ${order}`);
// Rate order
orderSvc.rateOrder(order.orderId, 5, "Excellent pizza!");
console.log(`Pizza Palace new rating: ${pizza.getAverageRating()}`);Extensibility
1. Delivery Agent Assignment
Add a DeliveryAgent entity and an assignment strategy:
class DeliveryAgent {
constructor(
public readonly agentId: string,
public readonly name: string,
public location: [number, number],
public isAvailable: boolean = true,
) {}
}
interface DeliveryAssigner {
assign(order: Order, agents: DeliveryAgent[]): DeliveryAgent;
}
class NearestAgentAssigner implements DeliveryAssigner {
assign(order: Order, agents: DeliveryAgent[]): DeliveryAgent {
const available = agents.filter((a) => a.isAvailable);
if (available.length === 0) {
throw new Error("No delivery agents available");
}
return available.reduce((closest, agent) => {
const closestDist = MaxDistanceFilter.distance(
closest.location,
order.restaurant.location,
);
const agentDist = MaxDistanceFilter.distance(
agent.location,
order.restaurant.location,
);
return agentDist < closestDist ? agent : closest;
});
}
}Tradeoff: Assignment strategies can be swapped (nearest, load-balanced, rating-based). The Strategy pattern makes this pluggable.
2. Promotions and Coupons
interface Coupon {
apply(total: number): number;
}
class PercentageCoupon implements Coupon {
constructor(
private readonly percent: number,
private readonly maxDiscount: number,
) {}
apply(total: number): number {
const discount = (total * this.percent) / 100;
return total - Math.min(discount, this.maxDiscount);
}
}Tradeoff: Coupons can be stacked or mutually exclusive. A rules engine might be needed for complex promo logic, but for an interview, a simple Strategy is sufficient.
3. Real-Time Order Tracking (Observer)
interface OrderObserver {
onStatusChange(order: Order, oldStatus: OrderStatus, newStatus: OrderStatus): void;
}
class CustomerNotifier implements OrderObserver {
onStatusChange(order: Order, oldStatus: OrderStatus, newStatus: OrderStatus): void {
console.log(
`Notification to ${order.user.name}: Order ${order.orderId} is now ${newStatus}`
);
}
}Add private observers: OrderObserver[] to Order and call them in transitionTo. Tradeoff: Observers run synchronously — slow observers block the transition. In production, use an event queue.
What is Expected at Each Level
| Level | Expectations |
|---|---|
| Junior | Model Restaurant, MenuItem, Order, User. Implement basic order placement and a couple of filters. May not handle state transitions rigorously. |
| Mid-level | State machine for orders with valid transition enforcement. Strategy pattern for sorting. Composable filters. Rating system that updates aggregates. Clean service layer separation. |
| Senior | Everything above plus: transition table as data (not if/else), Haversine for distance, Observer pattern for notifications, discussion of how to handle concurrent order placement (restaurant going out of stock between search and order), analysis of search performance for large restaurant datasets (geospatial indexing), fluent filter builder. |