Design Food Ordering System
A production-grade food ordering system for restaurants and cafeterias needs to handle real-time menu updates, concurrent orders, kitchen coordination, payment processing, and inventory management while maintaining high availability and low latency.
Step 1: Requirements Clarification
Functional Requirements
Menu Management:
- Create, update, and delete menu items with categories, prices, and descriptions
- Support for item variants (sizes, modifications, add-ons)
- Real-time availability updates based on inventory
- Time-based menu availability (breakfast, lunch, dinner)
- Dietary information and allergen tags
- High-quality image storage and serving
Order Placement:
- Browse menu with search and filtering
- Add items to cart with customizations
- Apply discounts and promotional codes
- Support multiple payment methods (card, mobile wallet, cash)
- Order scheduling (immediate or future)
- Special instructions and preferences
Kitchen Display System (KDS):
- Real-time order reception with audio/visual alerts
- Order routing to appropriate kitchen stations
- Order priority and timing management
- Status updates (received, preparing, ready)
- Order modification and cancellation handling
- Performance metrics per station
Payment Processing:
- Secure payment gateway integration
- Support for credit/debit cards, mobile wallets
- Split payments and tips
- Refund processing
- Payment status tracking
- PCI DSS compliance
Order Tracking:
- Real-time order status updates for customers
- Estimated preparation time
- Notification system (SMS, push, email)
- Order history and receipts
- Reordering functionality
Inventory Management:
- Real-time stock tracking
- Automatic menu item deactivation when out of stock
- Low stock alerts and reorder triggers
- Ingredient-level tracking for menu items
- Waste tracking and reporting
- Supplier management
Non-Functional Requirements
Scale:
- Support 10,000+ daily orders per location
- Handle 500+ concurrent users during peak hours
- Store 10+ million historical orders
- Support 1,000+ menu items across multiple locations
Performance:
- Menu loading: < 500ms
- Order placement: < 2s end-to-end
- Real-time KDS updates: < 1s latency
- Payment processing: < 3s
- 99.9% uptime during business hours
Consistency:
- Strong consistency for payment transactions
- Eventual consistency acceptable for menu updates
- Atomic order state transitions
- Inventory accuracy within 5-second window
Security:
- PCI DSS compliance for payment data
- Encrypted data at rest and in transit
- Role-based access control (customer, staff, kitchen, admin)
- Audit logging for all transactions
Step 2: High-Level Design
System Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Client Layer │
│ (Mobile App, Web App, Kiosk, Kitchen Display Tablets) │
└──────────────────────────┬──────────────────────────────────────┘
│
┌──────▼──────┐
│ API Gateway │
│ (Kong/Nginx)│
└──────┬──────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌───────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐
│ Menu Service │ │Order Service│ │Payment Service │
│ (Java/Go) │ │ (Java/Go) │ │ (Java/Go) │
└───────┬────────┘ └──────┬──────┘ └────────┬────────┘
│ │ │
┌───────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐
│ Kitchen Service│ │Inventory Svc│ │Notification Svc │
│ (Java/Go) │ │ (Java/Go) │ │ (Java/Go) │
└───────┬────────┘ └──────┬──────┘ └────────┬────────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌───────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐
│ PostgreSQL │ │ Redis │ │ Kafka │
│ (Primary DB) │ │ (Cache) │ │ (Event Bus) │
└────────────────┘ └─────────────┘ └─────────────────┘
│
┌───────▼────────┐ ┌──────────────┐ ┌─────────────────┐
│ PostgreSQL │ │ S3 │ │ WebSocket │
│ (Read Replica)│ │ (Images) │ │ Gateway │
└────────────────┘ └──────────────┘ └─────────────────┘
Core Services
1. Menu Service
- CRUD operations for menu items and categories
- Menu versioning and scheduling
- Image upload and management
- Search and filtering with Elasticsearch
- Cache management for frequently accessed items
- Integration with inventory for real-time availability
2. Order Service
- Order creation, validation, and placement
- Cart management with Redis-backed sessions
- Order state machine management
- Order history and analytics
- Discount and promotion application
- Order modification and cancellation logic
3. Kitchen Service
- Order routing to kitchen stations
- Real-time order queue management
- Station workload balancing
- Order timing and priority algorithms
- Performance tracking and SLA monitoring
- WebSocket connections for live updates
4. Payment Service
- Payment gateway integration (Stripe, Square)
- Transaction processing and validation
- Refund and chargeback handling
- Payment method tokenization
- Receipt generation
- Financial reconciliation
5. Inventory Service
- Real-time stock tracking
- Ingredient-to-menu-item mapping
- Automatic reorder point calculations
- Supplier integration
- Waste and loss tracking
- Predictive analytics for demand forecasting
6. Notification Service
- Multi-channel notifications (SMS, email, push)
- Template management
- Delivery tracking and retry logic
- User preference management
- Event-driven triggers from Kafka
Data Storage Strategy
PostgreSQL (Primary Database):
- Menu items, categories, and metadata
- Orders with full transaction history
- Customer profiles and preferences
- Inventory records
- Payment transactions (encrypted)
- Strong ACID guarantees for critical operations
Redis (Caching & Session):
- Menu cache with TTL-based invalidation
- Active cart sessions
- Real-time inventory counters
- Order status cache
- Rate limiting and throttling
- Leaderboard for popular items
Elasticsearch:
- Menu search with fuzzy matching
- Advanced filtering (dietary, price, category)
- Analytics and reporting queries
- Full-text search on descriptions
S3 (Object Storage):
- Menu item images
- Receipt PDFs
- Analytics reports
- Backup storage
Kafka (Event Streaming):
- Order events (created, updated, completed)
- Inventory events (depleted, restocked)
- Payment events (authorized, captured, refunded)
- Kitchen events (order received, prepared, ready)
- Notification triggers
Step 3: Deep Dives
3.1 Digital Menu with Real-Time Availability
Architecture:
Menu data is stored in PostgreSQL with aggressive caching in Redis. Inventory service maintains real-time stock counters in Redis using atomic DECR operations.
Database Schema:
-- PostgreSQL Schema
CREATE TABLE menu_categories (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
display_order INT,
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE menu_items (
id UUID PRIMARY KEY,
category_id UUID REFERENCES menu_categories(id),
name VARCHAR(255) NOT NULL,
description TEXT,
base_price DECIMAL(10,2) NOT NULL,
image_url VARCHAR(500),
preparation_time_minutes INT DEFAULT 15,
dietary_tags TEXT[], -- ['vegetarian', 'gluten-free', etc.]
allergen_tags TEXT[],
active BOOLEAN DEFAULT true,
available_from TIME,
available_until TIME,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE item_variants (
id UUID PRIMARY KEY,
item_id UUID REFERENCES menu_items(id),
name VARCHAR(100), -- 'Small', 'Large', etc.
price_modifier DECIMAL(10,2),
variant_type VARCHAR(50) -- 'SIZE', 'SPICE_LEVEL', etc.
);
CREATE TABLE item_modifiers (
id UUID PRIMARY KEY,
item_id UUID REFERENCES menu_items(id),
name VARCHAR(100), -- 'Extra Cheese', 'No Onions', etc.
price DECIMAL(10,2) DEFAULT 0,
modifier_type VARCHAR(50) -- 'ADD_ON', 'REMOVAL', etc.
);
CREATE TABLE inventory_items (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
unit VARCHAR(50), -- 'kg', 'liters', 'pieces'
current_quantity DECIMAL(10,2),
minimum_quantity DECIMAL(10,2),
reorder_quantity DECIMAL(10,2),
last_restocked_at TIMESTAMP
);
CREATE TABLE menu_item_ingredients (
id UUID PRIMARY KEY,
menu_item_id UUID REFERENCES menu_items(id),
inventory_item_id UUID REFERENCES inventory_items(id),
quantity_required DECIMAL(10,2),
UNIQUE(menu_item_id, inventory_item_id)
);
CREATE INDEX idx_menu_items_category ON menu_items(category_id);
CREATE INDEX idx_menu_items_active ON menu_items(active);
CREATE INDEX idx_inventory_minimum ON inventory_items(current_quantity, minimum_quantity);
Real-Time Availability Logic:
# Menu Service - Availability Check
class MenuAvailabilityService:
def get_available_menu(self, restaurant_id: str) -> List[MenuItem]:
# Try Redis cache first
cache_key = f"menu:restaurant:{restaurant_id}:available"
cached_menu = redis.get(cache_key)
if cached_menu:
return json.loads(cached_menu)
# Fetch from database
menu_items = db.query("""
SELECT mi.*,
COALESCE(
BOOL_AND(ii.current_quantity >= mii.quantity_required),
true
) as in_stock
FROM menu_items mi
LEFT JOIN menu_item_ingredients mii ON mi.id = mii.menu_item_id
LEFT JOIN inventory_items ii ON mii.inventory_item_id = ii.id
WHERE mi.active = true
AND (mi.available_from IS NULL OR CURRENT_TIME >= mi.available_from)
AND (mi.available_until IS NULL OR CURRENT_TIME <= mi.available_until)
GROUP BY mi.id
""")
# Enrich with real-time inventory status from Redis
available_items = []
for item in menu_items:
stock_key = f"inventory:item:{item.id}:available"
is_available = redis.get(stock_key)
if is_available != "0": # Not explicitly marked unavailable
item.available = item.in_stock
available_items.append(item)
# Cache for 60 seconds
redis.setex(cache_key, 60, json.dumps(available_items))
return available_items
def mark_item_unavailable(self, item_id: str, reason: str):
# Atomic operation to mark unavailable
redis.set(f"inventory:item:{item_id}:available", "0", ex=3600)
redis.set(f"inventory:item:{item_id}:reason", reason, ex=3600)
# Invalidate menu cache
restaurant_id = self.get_restaurant_id(item_id)
redis.delete(f"menu:restaurant:{restaurant_id}:available")
# Publish event for WebSocket broadcast
kafka.produce("menu.availability.changed", {
"item_id": item_id,
"available": False,
"reason": reason,
"timestamp": datetime.utcnow().isoformat()
})
WebSocket Updates:
Clients subscribe to menu availability changes via WebSocket. When inventory depletes or items are manually marked unavailable, updates are pushed in real-time.
// Client-side WebSocket subscription
const ws = new WebSocket('wss://api.foodorder.com/menu/updates');
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
if (update.type === 'ITEM_UNAVAILABLE') {
// Grey out item in UI, prevent ordering
disableMenuItem(update.item_id);
showNotification(`${update.item_name} is currently unavailable`);
}
};
3.2 Order Workflow and State Machine
Order State Diagram:
CART_ACTIVE → PAYMENT_PENDING → PAYMENT_AUTHORIZED → ORDER_PLACED
↓
ORDER_CONFIRMED
↓
┌───────────────────────────────────────┤
↓ ↓
PREPARING (Kitchen) CANCELLED
↓
READY_FOR_PICKUP
↓
COMPLETED
Database Schema:
CREATE TYPE order_status AS ENUM (
'CART_ACTIVE',
'PAYMENT_PENDING',
'PAYMENT_AUTHORIZED',
'ORDER_PLACED',
'ORDER_CONFIRMED',
'PREPARING',
'READY_FOR_PICKUP',
'COMPLETED',
'CANCELLED',
'REFUNDED'
);
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
restaurant_id UUID NOT NULL,
status order_status DEFAULT 'CART_ACTIVE',
subtotal DECIMAL(10,2) NOT NULL,
tax DECIMAL(10,2) NOT NULL,
discount DECIMAL(10,2) DEFAULT 0,
total DECIMAL(10,2) NOT NULL,
payment_method VARCHAR(50),
payment_transaction_id VARCHAR(255),
special_instructions TEXT,
estimated_ready_time TIMESTAMP,
placed_at TIMESTAMP,
confirmed_at TIMESTAMP,
ready_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID REFERENCES orders(id),
menu_item_id UUID REFERENCES menu_items(id),
quantity INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
variant_selections JSONB, -- {'size': 'Large', 'spice': 'Medium'}
modifiers JSONB, -- [{'name': 'Extra Cheese', 'price': 2.00}]
special_instructions TEXT,
kitchen_station VARCHAR(50), -- 'GRILL', 'FRY', 'SALAD', etc.
item_status VARCHAR(50) -- 'PENDING', 'PREPARING', 'READY'
);
CREATE TABLE order_status_history (
id UUID PRIMARY KEY,
order_id UUID REFERENCES orders(id),
from_status order_status,
to_status order_status,
changed_by UUID, -- user_id or 'SYSTEM'
reason TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_restaurant_status ON orders(restaurant_id, status);
CREATE INDEX idx_orders_placed_at ON orders(placed_at);
CREATE INDEX idx_order_items_order ON order_items(order_id);
Order State Machine Implementation:
# Order Service - State Machine
from enum import Enum
from typing import Optional
class OrderStatus(Enum):
CART_ACTIVE = "CART_ACTIVE"
PAYMENT_PENDING = "PAYMENT_PENDING"
PAYMENT_AUTHORIZED = "PAYMENT_AUTHORIZED"
ORDER_PLACED = "ORDER_PLACED"
ORDER_CONFIRMED = "ORDER_CONFIRMED"
PREPARING = "PREPARING"
READY_FOR_PICKUP = "READY_FOR_PICKUP"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class OrderStateMachine:
VALID_TRANSITIONS = {
OrderStatus.CART_ACTIVE: [OrderStatus.PAYMENT_PENDING],
OrderStatus.PAYMENT_PENDING: [OrderStatus.PAYMENT_AUTHORIZED, OrderStatus.CART_ACTIVE],
OrderStatus.PAYMENT_AUTHORIZED: [OrderStatus.ORDER_PLACED],
OrderStatus.ORDER_PLACED: [OrderStatus.ORDER_CONFIRMED, OrderStatus.CANCELLED],
OrderStatus.ORDER_CONFIRMED: [OrderStatus.PREPARING, OrderStatus.CANCELLED],
OrderStatus.PREPARING: [OrderStatus.READY_FOR_PICKUP, OrderStatus.CANCELLED],
OrderStatus.READY_FOR_PICKUP: [OrderStatus.COMPLETED],
OrderStatus.COMPLETED: [],
OrderStatus.CANCELLED: []
}
def __init__(self, db_session, kafka_producer):
self.db = db_session
self.kafka = kafka_producer
def transition(self, order_id: str, to_status: OrderStatus,
user_id: Optional[str] = None, reason: Optional[str] = None):
# Acquire row-level lock
order = self.db.query(Order).filter_by(id=order_id).with_for_update().first()
if not order:
raise OrderNotFoundException(f"Order {order_id} not found")
current_status = OrderStatus(order.status)
# Validate transition
if to_status not in self.VALID_TRANSITIONS[current_status]:
raise InvalidStateTransitionException(
f"Cannot transition from {current_status.value} to {to_status.value}"
)
# Pre-transition hooks
self._execute_pre_transition_hooks(order, current_status, to_status)
# Update order status
old_status = order.status
order.status = to_status.value
order.updated_at = datetime.utcnow()
# Set timestamp fields
if to_status == OrderStatus.ORDER_PLACED:
order.placed_at = datetime.utcnow()
order.estimated_ready_time = self._calculate_ready_time(order)
elif to_status == OrderStatus.ORDER_CONFIRMED:
order.confirmed_at = datetime.utcnow()
elif to_status == OrderStatus.READY_FOR_PICKUP:
order.ready_at = datetime.utcnow()
elif to_status == OrderStatus.COMPLETED:
order.completed_at = datetime.utcnow()
# Record state transition history
history = OrderStatusHistory(
order_id=order_id,
from_status=old_status,
to_status=to_status.value,
changed_by=user_id or 'SYSTEM',
reason=reason
)
self.db.add(history)
# Commit transaction
self.db.commit()
# Post-transition hooks (outside transaction)
self._execute_post_transition_hooks(order, current_status, to_status)
# Publish event to Kafka
self.kafka.produce("order.status.changed", {
"order_id": order_id,
"customer_id": order.customer_id,
"from_status": old_status,
"to_status": to_status.value,
"timestamp": datetime.utcnow().isoformat()
})
return order
def _execute_pre_transition_hooks(self, order, from_status, to_status):
if to_status == OrderStatus.PREPARING:
# Reserve inventory
self._reserve_inventory_for_order(order)
def _execute_post_transition_hooks(self, order, from_status, to_status):
if to_status == OrderStatus.ORDER_PLACED:
# Send to kitchen display system
self._send_to_kitchen(order)
# Send confirmation notification
self._send_order_confirmation(order)
elif to_status == OrderStatus.READY_FOR_PICKUP:
# Notify customer
self._notify_order_ready(order)
elif to_status == OrderStatus.COMPLETED:
# Deduct from inventory
self._deduct_inventory_for_order(order)
# Update analytics
self._update_analytics(order)
3.3 Kitchen Display System (KDS) with Order Routing
KDS Architecture:
The Kitchen Display System receives orders via WebSocket and routes them to appropriate stations (grill, fryer, salad, drinks) based on menu item configuration.
Order Routing Logic:
# Kitchen Service - Order Routing
class KitchenOrderRouter:
STATION_PRIORITIES = {
'GRILL': 1,
'FRY': 2,
'SALAD': 3,
'DRINKS': 4,
'DESSERT': 5
}
def route_order(self, order: Order):
# Group order items by kitchen station
station_groups = {}
for item in order.items:
station = item.kitchen_station or self._determine_station(item.menu_item_id)
if station not in station_groups:
station_groups[station] = {
'station': station,
'items': [],
'priority': self.STATION_PRIORITIES.get(station, 99),
'estimated_time': 0
}
station_groups[station]['items'].append(item)
station_groups[station]['estimated_time'] = max(
station_groups[station]['estimated_time'],
item.preparation_time_minutes
)
# Send to each station with proper sequencing
for station, group in sorted(station_groups.items(),
key=lambda x: x[1]['priority']):
self._send_to_station(order.id, station, group)
def _send_to_station(self, order_id: str, station: str, group: dict):
# Calculate station workload
current_load = redis.get(f"kitchen:station:{station}:load") or 0
# Create station order record
station_order = {
'order_id': order_id,
'station': station,
'items': group['items'],
'estimated_time': group['estimated_time'],
'priority': self._calculate_priority(order_id, current_load),
'received_at': datetime.utcnow().isoformat()
}
# Add to station queue in Redis (sorted set by priority)
redis.zadd(
f"kitchen:station:{station}:queue",
{json.dumps(station_order): station_order['priority']}
)
# Update station load
redis.incrby(f"kitchen:station:{station}:load",
group['estimated_time'])
# Push to WebSocket for KDS tablets
self._broadcast_to_station_display(station, station_order)
def _calculate_priority(self, order_id: str, current_load: int) -> float:
order = self.get_order(order_id)
# Priority factors:
# 1. Age of order (older = higher priority)
# 2. Special flags (VIP customer, expedite request)
# 3. Order size (smaller orders get slight boost)
# 4. Current station load (dynamic adjustment)
age_minutes = (datetime.utcnow() - order.placed_at).total_seconds() / 60
base_priority = age_minutes * 10 # 10 points per minute
if order.is_expedited:
base_priority += 1000
if order.customer_tier == 'VIP':
base_priority += 500
# Boost smaller orders when station is busy
if current_load > 30: # More than 30 minutes of work queued
item_count = len(order.items)
if item_count <= 3:
base_priority += 200
return base_priority
def _broadcast_to_station_display(self, station: str, order_data: dict):
# WebSocket broadcast to all connected KDS tablets for this station
websocket_gateway.broadcast(
channel=f"kitchen.station.{station}",
event="NEW_ORDER",
data=order_data
)
# Also play audio alert
websocket_gateway.broadcast(
channel=f"kitchen.station.{station}",
event="AUDIO_ALERT",
data={"sound": "new_order_chime"}
)
KDS Display Component:
# Kitchen Service - Display Management
class KitchenDisplayManager:
def get_station_queue(self, station: str, limit: int = 20) -> List[dict]:
# Fetch from Redis sorted set (highest priority first)
queue = redis.zrevrange(
f"kitchen:station:{station}:queue",
0, limit - 1,
withscores=True
)
orders = []
current_time = datetime.utcnow()
for order_json, priority in queue:
order = json.loads(order_json)
# Calculate elapsed time
received_at = datetime.fromisoformat(order['received_at'])
elapsed_seconds = (current_time - received_at).total_seconds()
# Color coding based on elapsed time vs estimated time
estimated_seconds = order['estimated_time'] * 60
if elapsed_seconds > estimated_seconds * 1.5:
order['alert_level'] = 'CRITICAL' # Red
elif elapsed_seconds > estimated_seconds:
order['alert_level'] = 'WARNING' # Yellow
else:
order['alert_level'] = 'NORMAL' # Green
order['elapsed_time'] = int(elapsed_seconds / 60)
orders.append(order)
return orders
def mark_item_ready(self, order_id: str, item_id: str, station: str):
# Update item status
db.execute("""
UPDATE order_items
SET item_status = 'READY'
WHERE id = :item_id AND order_id = :order_id
""", {"item_id": item_id, "order_id": order_id})
# Check if all items in order are ready
all_ready = db.query("""
SELECT COUNT(*) as total,
SUM(CASE WHEN item_status = 'READY' THEN 1 ELSE 0 END) as ready
FROM order_items
WHERE order_id = :order_id
""", {"order_id": order_id}).first()
if all_ready.total == all_ready.ready:
# All items ready, transition order status
order_state_machine.transition(
order_id,
OrderStatus.READY_FOR_PICKUP,
reason="All items prepared"
)
# Remove from all station queues
self._remove_from_all_station_queues(order_id)
# Update station load
item = db.query(OrderItem).get(item_id)
redis.decrby(
f"kitchen:station:{station}:load",
item.preparation_time_minutes
)
# Broadcast update
websocket_gateway.broadcast(
channel=f"kitchen.station.{station}",
event="ITEM_COMPLETED",
data={"order_id": order_id, "item_id": item_id}
)
3.4 Order Aggregation and Batching
For high-volume periods (lunch rush), batching similar orders improves kitchen efficiency.
# Kitchen Service - Order Batching
class OrderBatchingEngine:
BATCH_WINDOW_SECONDS = 120 # 2-minute batching window
SIMILARITY_THRESHOLD = 0.7
def process_for_batching(self, order: Order):
# Check if order is eligible for batching
if not self._is_batchable(order):
self.route_immediately(order)
return
# Create order signature for similarity matching
signature = self._create_order_signature(order)
# Add to batching pool
redis.zadd(
"kitchen:batching:pool",
{json.dumps({
"order_id": order.id,
"signature": signature,
"items": [item.menu_item_id for item in order.items]
}): time.time()}
)
# Check if batch is ready
batch = self._try_form_batch(signature)
if batch:
self._route_as_batch(batch)
else:
# Schedule timeout for individual routing
redis.setex(
f"kitchen:batch:timeout:{order.id}",
self.BATCH_WINDOW_SECONDS,
"1"
)
def _create_order_signature(self, order: Order) -> str:
# Create normalized signature based on menu items
item_ids = sorted([item.menu_item_id for item in order.items])
return hashlib.md5('|'.join(item_ids).encode()).hexdigest()
def _try_form_batch(self, signature: str, min_batch_size: int = 3) -> Optional[List[str]]:
# Find orders with similar signatures
current_time = time.time()
cutoff_time = current_time - self.BATCH_WINDOW_SECONDS
# Get recent orders from pool
pool = redis.zrangebyscore(
"kitchen:batching:pool",
cutoff_time,
current_time,
withscores=False
)
similar_orders = []
for order_json in pool:
order_data = json.loads(order_json)
if self._calculate_similarity(signature, order_data['signature']) >= self.SIMILARITY_THRESHOLD:
similar_orders.append(order_data['order_id'])
if len(similar_orders) >= min_batch_size:
# Remove from pool
for order_id in similar_orders:
redis.zrem("kitchen:batching:pool", order_id)
return similar_orders
return None
def _route_as_batch(self, order_ids: List[str]):
batch_id = str(uuid.uuid4())
# Create batch record
redis.setex(
f"kitchen:batch:{batch_id}",
3600,
json.dumps({
"batch_id": batch_id,
"order_ids": order_ids,
"created_at": datetime.utcnow().isoformat()
})
)
# Route with batch context
for order_id in order_ids:
order = self.get_order(order_id)
order.batch_id = batch_id
self.kitchen_router.route_order(order)
# Notify kitchen of batch
websocket_gateway.broadcast(
channel="kitchen.batches",
event="BATCH_CREATED",
data={
"batch_id": batch_id,
"order_count": len(order_ids),
"orders": order_ids
}
)
3.5 Payment Integration
Payment Service Architecture:
# Payment Service - Gateway Integration
class PaymentProcessor:
def __init__(self, stripe_client, square_client):
self.stripe = stripe_client
self.square = square_client
def process_payment(self, order_id: str, payment_method: dict) -> PaymentResult:
order = self.get_order(order_id)
# Idempotency key to prevent duplicate charges
idempotency_key = f"{order_id}:{order.updated_at.isoformat()}"
try:
# Determine payment gateway based on method
gateway = self._select_gateway(payment_method['type'])
# Create payment intent
payment_intent = gateway.create_payment_intent(
amount=int(order.total * 100), # Convert to cents
currency='usd',
payment_method=payment_method['token'],
metadata={
'order_id': order_id,
'customer_id': order.customer_id,
'restaurant_id': order.restaurant_id
},
idempotency_key=idempotency_key
)
# Store payment transaction
transaction = PaymentTransaction(
id=str(uuid.uuid4()),
order_id=order_id,
gateway=gateway.name,
gateway_transaction_id=payment_intent.id,
amount=order.total,
currency='usd',
status='AUTHORIZED',
payment_method_type=payment_method['type'],
created_at=datetime.utcnow()
)
db.session.add(transaction)
# Update order status
order.payment_transaction_id = transaction.id
order_state_machine.transition(
order_id,
OrderStatus.PAYMENT_AUTHORIZED
)
db.session.commit()
# Capture payment (immediate capture for food orders)
self._capture_payment(transaction.id)
return PaymentResult(
success=True,
transaction_id=transaction.id,
gateway_transaction_id=payment_intent.id
)
except PaymentGatewayException as e:
# Log and handle payment failure
self._handle_payment_failure(order_id, str(e))
raise
def _capture_payment(self, transaction_id: str):
transaction = db.query(PaymentTransaction).get(transaction_id)
gateway = self._get_gateway(transaction.gateway)
try:
capture_result = gateway.capture_payment(
transaction.gateway_transaction_id
)
transaction.status = 'CAPTURED'
transaction.captured_at = datetime.utcnow()
db.session.commit()
# Transition order to placed
order_state_machine.transition(
transaction.order_id,
OrderStatus.ORDER_PLACED,
reason="Payment captured successfully"
)
except Exception as e:
transaction.status = 'CAPTURE_FAILED'
transaction.error_message = str(e)
db.session.commit()
raise
def process_refund(self, order_id: str, amount: Optional[Decimal] = None,
reason: str = None) -> RefundResult:
order = self.get_order(order_id)
transaction = self.get_payment_transaction(order.payment_transaction_id)
refund_amount = amount or transaction.amount
gateway = self._get_gateway(transaction.gateway)
try:
refund = gateway.create_refund(
payment_intent_id=transaction.gateway_transaction_id,
amount=int(refund_amount * 100),
reason=reason
)
# Record refund
refund_record = PaymentRefund(
id=str(uuid.uuid4()),
transaction_id=transaction.id,
amount=refund_amount,
reason=reason,
gateway_refund_id=refund.id,
status='COMPLETED',
created_at=datetime.utcnow()
)
db.session.add(refund_record)
# Update order status if full refund
if refund_amount == transaction.amount:
order_state_machine.transition(
order_id,
OrderStatus.REFUNDED,
reason=f"Full refund: {reason}"
)
db.session.commit()
return RefundResult(success=True, refund_id=refund_record.id)
except Exception as e:
logger.error(f"Refund failed for order {order_id}: {str(e)}")
raise
3.6 Real-Time Order Tracking for Customers
WebSocket Implementation:
# Notification Service - WebSocket Handler
class OrderTrackingWebSocketHandler:
def on_connect(self, websocket, customer_id: str):
# Subscribe to customer's active orders
active_orders = redis.smembers(f"customer:{customer_id}:active_orders")
for order_id in active_orders:
websocket.subscribe(f"order:{order_id}:updates")
# Send current status of all active orders
for order_id in active_orders:
order = self.get_order_status(order_id)
websocket.send(json.dumps({
"event": "ORDER_STATUS",
"order_id": order_id,
"status": order.status,
"estimated_ready_time": order.estimated_ready_time.isoformat(),
"items": [self._format_item(item) for item in order.items]
}))
def broadcast_order_update(self, order_id: str, update_type: str, data: dict):
# Publish to WebSocket channel
channel = f"order:{order_id}:updates"
websocket_gateway.publish(channel, {
"event": update_type,
"order_id": order_id,
"timestamp": datetime.utcnow().isoformat(),
"data": data
})
# Also send push notification
order = self.get_order(order_id)
notification_service.send_push(
user_id=order.customer_id,
title=self._get_notification_title(update_type),
body=self._get_notification_body(update_type, data),
data={"order_id": order_id, "type": update_type}
)
3.7 Inventory Management and Alerts
Inventory Service:
# Inventory Service - Stock Management
class InventoryManager:
def reserve_ingredients(self, order_id: str) -> bool:
order = self.get_order(order_id)
# Calculate total ingredient requirements
ingredient_requirements = {}
for item in order.items:
ingredients = self.get_item_ingredients(item.menu_item_id)
for ingredient in ingredients:
key = ingredient.inventory_item_id
required_qty = ingredient.quantity_required * item.quantity
ingredient_requirements[key] = ingredient_requirements.get(key, 0) + required_qty
# Atomic reservation using Lua script in Redis
lua_script = """
local ingredients = cjson.decode(ARGV[1])
local reservation_id = ARGV[2]
for item_id, quantity in pairs(ingredients) do
local key = 'inventory:' .. item_id .. ':available'
local current = tonumber(redis.call('GET', key) or 0)
if current < quantity then
return {false, item_id, current, quantity}
end
end
-- All checks passed, perform reservation
for item_id, quantity in pairs(ingredients) do
local key = 'inventory:' .. item_id .. ':available'
redis.call('DECRBY', key, quantity)
-- Track reservation
redis.call('HSET', 'inventory:reservation:' .. reservation_id, item_id, quantity)
end
return {true}
"""
result = redis.eval(
lua_script,
0,
json.dumps(ingredient_requirements),
order_id
)
if not result[0]:
raise InsufficientInventoryException(
f"Insufficient inventory for item {result[1]}: "
f"available={result[2]}, required={result[3]}"
)
# Check for low stock alerts
self._check_low_stock_alerts(ingredient_requirements.keys())
return True
def deduct_inventory(self, order_id: str):
# Called when order is completed
# Release reservation and update actual inventory
with db.begin():
reservation = redis.hgetall(f"inventory:reservation:{order_id}")
for item_id, quantity in reservation.items():
db.execute("""
UPDATE inventory_items
SET current_quantity = current_quantity - :quantity,
last_updated_at = NOW()
WHERE id = :item_id
""", {"item_id": item_id, "quantity": float(quantity)})
# Record transaction
db.execute("""
INSERT INTO inventory_transactions
(id, inventory_item_id, transaction_type, quantity,
reference_type, reference_id, created_at)
VALUES (uuid_generate_v4(), :item_id, 'DEDUCTION', :quantity,
'ORDER', :order_id, NOW())
""", {"item_id": item_id, "quantity": float(quantity),
"order_id": order_id})
# Clear reservation
redis.delete(f"inventory:reservation:{order_id}")
def _check_low_stock_alerts(self, item_ids: List[str]):
for item_id in item_ids:
item = db.query(InventoryItem).get(item_id)
current = float(redis.get(f"inventory:{item_id}:available") or 0)
if current <= item.minimum_quantity:
self._trigger_low_stock_alert(item, current)
def _trigger_low_stock_alert(self, item: InventoryItem, current_quantity: float):
# Check if alert already sent recently (prevent spam)
alert_key = f"inventory:alert:sent:{item.id}"
if redis.exists(alert_key):
return
# Create alert
alert = InventoryAlert(
id=str(uuid.uuid4()),
inventory_item_id=item.id,
alert_type='LOW_STOCK',
current_quantity=current_quantity,
minimum_quantity=item.minimum_quantity,
message=f"{item.name} is running low: {current_quantity} {item.unit} remaining",
created_at=datetime.utcnow()
)
db.session.add(alert)
db.session.commit()
# Send notification to managers
notification_service.send_to_role(
role='INVENTORY_MANAGER',
title=f"Low Stock Alert: {item.name}",
body=alert.message,
priority='HIGH'
)
# Mark alert as sent (24-hour cooldown)
redis.setex(alert_key, 86400, "1")
# Trigger auto-reorder if enabled
if item.auto_reorder_enabled:
self._create_purchase_order(item)
3.8 Analytics and Reporting
Analytics Service:
# Analytics Service - Reporting Engine
class OrderAnalyticsService:
def generate_daily_report(self, restaurant_id: str, date: datetime) -> dict:
# Aggregate metrics from completed orders
metrics = db.query("""
WITH order_metrics AS (
SELECT
COUNT(*) as total_orders,
SUM(total) as total_revenue,
AVG(total) as avg_order_value,
AVG(EXTRACT(EPOCH FROM (completed_at - placed_at))/60) as avg_completion_time,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total) as median_order_value
FROM orders
WHERE restaurant_id = :restaurant_id
AND DATE(placed_at) = :date
AND status = 'COMPLETED'
),
item_metrics AS (
SELECT
mi.name,
mi.category_id,
SUM(oi.quantity) as units_sold,
SUM(oi.quantity * oi.unit_price) as item_revenue
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
JOIN menu_items mi ON oi.menu_item_id = mi.id
WHERE o.restaurant_id = :restaurant_id
AND DATE(o.placed_at) = :date
AND o.status = 'COMPLETED'
GROUP BY mi.id, mi.name, mi.category_id
ORDER BY item_revenue DESC
LIMIT 10
),
hourly_metrics AS (
SELECT
EXTRACT(HOUR FROM placed_at) as hour,
COUNT(*) as orders,
SUM(total) as revenue
FROM orders
WHERE restaurant_id = :restaurant_id
AND DATE(placed_at) = :date
AND status = 'COMPLETED'
GROUP BY EXTRACT(HOUR FROM placed_at)
ORDER BY hour
)
SELECT
(SELECT row_to_json(order_metrics) FROM order_metrics) as summary,
(SELECT json_agg(row_to_json(item_metrics)) FROM item_metrics) as top_items,
(SELECT json_agg(row_to_json(hourly_metrics)) FROM hourly_metrics) as hourly
""", {"restaurant_id": restaurant_id, "date": date}).first()
return {
"date": date.isoformat(),
"summary": metrics.summary,
"top_items": metrics.top_items,
"hourly_distribution": metrics.hourly
}
def get_kitchen_performance_metrics(self, date: datetime) -> dict:
# Analyze kitchen efficiency
return db.query("""
SELECT
kitchen_station,
COUNT(*) as orders_processed,
AVG(EXTRACT(EPOCH FROM (
COALESCE(ready_at, NOW()) - placed_at
))/60) as avg_prep_time_minutes,
PERCENTILE_CONT(0.95) WITHIN GROUP (
ORDER BY EXTRACT(EPOCH FROM (ready_at - placed_at))/60
) as p95_prep_time,
SUM(CASE WHEN ready_at <= estimated_ready_time THEN 1 ELSE 0 END) * 100.0
/ COUNT(*) as on_time_percentage
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE DATE(o.placed_at) = :date
AND o.status IN ('COMPLETED', 'READY_FOR_PICKUP')
GROUP BY oi.kitchen_station
""", {"date": date}).all()
Step 4: Wrap-Up
Key Design Decisions
1. Database Choice:
- PostgreSQL for transactional integrity and complex queries
- Redis for real-time inventory, caching, and session management
- Trade-off: Consistency vs. performance, chosen based on use case
2. Event-Driven Architecture:
- Kafka for reliable event streaming across services
- Enables loose coupling and independent scaling
- Supports audit trails and analytics
3. WebSocket for Real-Time Updates:
- Bidirectional communication for KDS and customer tracking
- Reduces polling overhead
- Maintains connection state for active orders
4. State Machine Pattern:
- Enforces valid order state transitions
- Simplifies error handling and rollback
- Provides clear audit trail
5. Payment Security:
- Never store full card details (PCI DSS compliance)
- Use tokenization via payment gateways
- Idempotency keys prevent duplicate charges
Scalability Strategies
Horizontal Scaling:
- Stateless services deployed across multiple instances
- Load balancer distributes traffic (Round-robin, least connections)
- Database read replicas for query distribution
Caching Layers:
- Redis for hot data (menu, active orders)
- CDN for static assets (menu images)
- Application-level caching for computed results
Database Optimization:
- Partitioning orders table by date (monthly partitions)
- Indexing on frequently queried columns
- Connection pooling to manage database connections
Asynchronous Processing:
- Background workers for non-critical tasks (analytics, email)
- Queue-based processing for notifications
- Batch operations during off-peak hours
Monitoring and Observability
Key Metrics:
- Order placement latency (p50, p95, p99)
- Payment success rate
- Kitchen station SLA adherence
- Inventory accuracy rate
- WebSocket connection health
Alerting:
- Order failure rate > 1%
- Payment gateway downtime
- Database connection pool exhaustion
- High memory usage on Redis
- Kitchen order backlog > 30 minutes
Logging:
- Structured logging with correlation IDs
- Centralized log aggregation (ELK stack)
- Audit logs for all financial transactions
Future Enhancements
Advanced Features:
- ML-based demand forecasting for inventory
- Dynamic pricing during peak hours
- Customer preference learning for recommendations
- Multi-tenant support for restaurant chains
- Integration with third-party delivery services
Optimization:
- GraphQL API for flexible client queries
- Server-side rendering for faster initial loads
- Image optimization and lazy loading
- Database query optimization based on access patterns
This architecture provides a solid foundation for a production-grade food ordering system capable of handling high traffic, maintaining data consistency, and delivering excellent customer experience.
Comments