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.