Skip to main content

Food Delivery System

Three actors need to agree on the state of one order at once: the restaurant cooking it, the delivery partner carrying it, and the customer waiting for it. The design problem is keeping that agreement consistent without any of the three polling the other two.

Requirements

Functional

  • A Restaurant publishes a menu of MenuItems; a Customer places an Order against one restaurant's menu.
  • Once placed, an order moves through a fixed lifecycle: placed, accepted by the restaurant, being prepared, ready for pickup, out for delivery, delivered.
  • When an order is ready for pickup, the system assigns it to a DeliveryPartner using a matching strategy, not a fixed one-partner-per-restaurant rule.
  • Only valid forward transitions are allowed - an order can't jump from placed straight to delivered, and it can't move backward.

Non-functional

  • Assigning a partner should be swappable (nearest-available today, load-balanced or rated-based tomorrow) without touching the order lifecycle code.
  • An order's current status must be a single source of truth that every actor reads the same way, not three separate flags that can disagree.

Design

Order owns a status field that only moves forward through a fixed OrderStatus sequence, enforced by one transitionTo method - nothing sets status directly. Partner assignment is pulled out into a DeliveryAssignmentStrategy, handed the pool of available partners and the restaurant's location, exactly the same shape as the fee and pricing strategies elsewhere in this module.

CustomerRestaurantOrderAssignmentStrategyDeliveryPartnernew Order(restaurant, items)1transitionTo(ACCEPTED)2transitionTo(READY_FOR_PICKUP)3assign(partners, restaurant.location)4markBusy()5
  1. 1The order starts in PLACED, before the restaurant has even seen it.
  2. 2Every status change goes through the same guarded method.
  3. 3Only once it's ready does assignment even begin.
  4. 4The order delegates "who delivers this" entirely to the strategy.
  5. 5The partner updates its own availability - the order never touches that field directly.

Order never reaches into DeliveryPartner to change the partner's own status - instead each side updates only what it owns (Order.status, DeliveryPartner.available), which means a partner going offline mid-delivery is a partner-side concern, not something that corrupts the order's lifecycle.

Class diagram

«interface»DeliveryAssignmentStrategy+ assign(partners, location): DeliveryPartnerRestaurant- id: string- name: string- menu: List<MenuItem>- location: LocationMenuItem- id: string- name: string- price: doubleOrder- restaurant: Restaurant- items: List<MenuItem>- status: OrderStatus- partner: DeliveryPartner+ transitionTo(status): void+ total(): doubleNearestAvailableStrategy+ assign(partners, location): DeliveryPartnerDeliveryPartner- id: string- location: Location- available: bool+ markBusy()+ markAvailable()Customer- id: string- name: string- address: Location
implementsuses
Order enforces its own forward-only status transitions; DeliveryAssignmentStrategy picks a partner once the order is ready.

Code

import java.util.*;
 
enum OrderStatus { PLACED, ACCEPTED, PREPARING, READY_FOR_PICKUP, OUT_FOR_DELIVERY, DELIVERED }
 
class Location {
final double lat;
final double lng;
 
Location(double lat, double lng) {
this.lat = lat;
this.lng = lng;
}
 
double distanceTo(Location other) {
return Math.hypot(lat - other.lat, lng - other.lng);
}
}
 
class MenuItem {
final String id;
final String name;
final double price;
 
MenuItem(String id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
}
 
class Restaurant {
final String id;
final String name;
final Location location;
final List<MenuItem> menu;
 
Restaurant(String id, String name, Location location, List<MenuItem> menu) {
this.id = id;
this.name = name;
this.location = location;
this.menu = menu;
}
}
 
class DeliveryPartner {
final String id;
Location location;
private boolean available = true;
 
DeliveryPartner(String id, Location location) {
this.id = id;
this.location = location;
}
 
boolean isAvailable() {
return available;
}
 
void markBusy() {
available = false;
}
 
void markAvailable() {
available = true;
}
}
 
interface DeliveryAssignmentStrategy {
Optional<DeliveryPartner> assign(List<DeliveryPartner> partners, Location pickupLocation);
}
 
class NearestAvailableStrategy implements DeliveryAssignmentStrategy {
public Optional<DeliveryPartner> assign(List<DeliveryPartner> partners, Location pickupLocation) {
return partners.stream()
.filter(DeliveryPartner::isAvailable)
.min(Comparator.comparingDouble(p -> p.location.distanceTo(pickupLocation)));
}
}
 
class Order {
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED_NEXT = Map.of(
OrderStatus.PLACED, Set.of(OrderStatus.ACCEPTED),
OrderStatus.ACCEPTED, Set.of(OrderStatus.PREPARING),
OrderStatus.PREPARING, Set.of(OrderStatus.READY_FOR_PICKUP),
OrderStatus.READY_FOR_PICKUP, Set.of(OrderStatus.OUT_FOR_DELIVERY),
OrderStatus.OUT_FOR_DELIVERY, Set.of(OrderStatus.DELIVERED),
OrderStatus.DELIVERED, Set.of()
);
 
final Restaurant restaurant;
final List<MenuItem> items;
private OrderStatus status = OrderStatus.PLACED;
private DeliveryPartner partner;
 
Order(Restaurant restaurant, List<MenuItem> items) {
this.restaurant = restaurant;
this.items = items;
}
 
void transitionTo(OrderStatus next, DeliveryAssignmentStrategy strategy, List<DeliveryPartner> pool) {
if (!ALLOWED_NEXT.get(status).contains(next)) {
throw new IllegalStateException("Cannot move from " + status + " to " + next);
}
status = next;
if (status == OrderStatus.READY_FOR_PICKUP) {
partner = strategy.assign(pool, restaurant.location)
.orElseThrow(() -> new IllegalStateException("No delivery partner available"));
partner.markBusy();
}
}
 
double total() {
return items.stream().mapToDouble(i -> i.price).sum();
}
 
OrderStatus status() {
return status;
}
}

Design decisions

  • Status transitions are validated in one method, transitionTo, against an explicit allowed-next-states map. Scattering order.status = X assignments across the codebase means nothing stops a bug from setting DELIVERED on a just-placed order. Centralizing the check means every illegal jump fails the same way, in the same place, the first time it's attempted.
  • Partner assignment is a DeliveryAssignmentStrategy, evaluated only once the order hits READY_FOR_PICKUP. Assigning too early (right when the order is placed) would lock in a partner before anyone knows how long preparation takes, wasting their time waiting; tying assignment to the status transition itself keeps the two concerns in sync automatically rather than by convention.
  • DeliveryPartner.available is a field the partner's own methods flip, not something Order sets directly. Order calls partner.markBusy() rather than partner.available = false - the partner is the one thing that should decide what "busy" means for itself, especially once real systems add partners juggling more than one active delivery.
  • What's missing for a real system: the matching strategy here only looks at current availability and distance; a production system also needs partner capacity limits (some can carry two orders at once) and a reassignment path for when an assigned partner cancels mid-flight, neither of which changes the shape of Order's status machine, just what feeds into it.
0%0 of 122 pages studied