Design a Restaurant Management System: The Complete Guide
Building a production-grade restaurant platform - tables, orders, kitchen, inventory, payments, analytics, and multi-branch operations at scale
Table of Contents
- Introduction - The Restaurant Management Landscape
- Functional and Non-Functional Requirements
- Capacity Estimation and Back-of-Envelope
- Data Model and Schema Design
- High-Level Architecture
- API Design - RESTful Endpoints and Contracts
- Table Management and Reservations
- Menu Management and Dynamic Pricing
- Order Taking - Point of Sale System
- Kitchen Display System and Order Routing
- Inventory Management and Supply Chain
- Payment Processing and Billing
- Staff Scheduling and Labor Management
- Customer Loyalty Program
- Online Ordering Integration
- Delivery Partner Integration
- Multi-Branch Support and Franchise Operations
- Reporting and Analytics Engine
- Real-Time Dashboard
- Security and Compliance
- Cost Estimation and Infrastructure Sizing
- Testing Strategy
- Interview Q and A
1. Introduction - The Restaurant Management Landscape
The restaurant industry generates over $900 billion in annual revenue in the United States alone, with thousands of new restaurants opening every month and thousands more closing. The single most impactful factor separating thriving establishments from failing ones is operational efficiency, and that efficiency is increasingly driven by software. A modern restaurant management system (RMS) is not a single application but a constellation of interconnected services handling table reservations, menu management, point-of-sale order processing, kitchen display routing, inventory tracking, payment reconciliation, staff scheduling, loyalty programs, online ordering, delivery partner coordination, multi-branch reporting, and real-time analytics dashboards.
Unlike typical enterprise CRUD applications, a restaurant management system has unique constraints. Orders must flow from server handhelds to kitchen printers or display screens within two seconds. Payment processing must never lose a transaction. Table state must be consistently maintained across dozens of concurrent devices. Menu item availability must reflect real-time inventory. Tip calculations must comply with jurisdiction-specific tax laws. Multi-branch chains require centralized reporting while preserving location-specific menu customization and pricing. The system must handle peak dinner rush traffic, typically between 6 PM and 9 PM, when order throughput spikes to ten times the baseline.
In this comprehensive guide, we walk through designing a production-grade restaurant management system from first principles. We begin with requirements gathering and capacity estimation, progress through data modeling and API design, and then deep-dive into each major subsystem. Every design decision is backed by concrete C# code examples, architectural diagrams rendered in Mermaid, and comparison tables highlighting trade-offs. By the end, you will have a blueprint for building an RMS that can serve a single location or scale to a multinational franchise with thousands of branches.
2. Functional and Non-Functional Requirements
Functional Requirements
- Table Management: View real-time table status (available, reserved, occupied, cleaning), manage floor plans, handle waitlists, and support online reservations with time-slot logic.
- Menu Management: CRUD operations for menu categories, items, modifiers, pricing, images, allergens, and availability. Support time-based menus (breakfast, lunch, dinner), holiday specials, and promotional pricing.
- Order Taking (POS): Take dine-in, takeout, and delivery orders. Support split bills, item-level modifications, course firing, and order amendments before kitchen acceptance.
- Kitchen Display System: Route orders to appropriate kitchen stations (grill, salad, dessert, bar), display cooking timers, support bump-bar workflows, and track order preparation time.
- Inventory Management: Track ingredient levels in real-time, handle recipe-to-ingredient mapping, auto-decrement stock on order placement, generate purchase orders, and manage supplier relationships.
- Payment Processing: Support credit/debit cards, cash, digital wallets, split payments, tip adjustment, refund processing, and end-of-day reconciliation.
- Staff Management: Schedule shifts, clock-in/clock-out, track labor costs, manage role-based access control, and generate payroll reports.
- Customer Loyalty: Points accumulation, tier-based rewards, personalized promotions, and customer profile management.
- Online Ordering: Branded web portal and mobile app ordering for pickup and delivery, integrated with kitchen workflow.
- Delivery Partner Integration: Connect with Uber Eats, DoorDash, Grubhub via unified API, manage delivery status tracking, and handle commission calculations.
- Multi-Branch Support: Centralized management for chains, location-specific menus and pricing, inter-branch inventory transfers, and consolidated reporting.
- Reporting and Analytics: Real-time dashboards, daily/weekly/monthly sales reports, food cost analysis, customer behavior analytics, and demand forecasting.
Non-Functional Requirements
| Requirement | Target | Justification |
|---|---|---|
| Availability | 99.99% | Downtime during dinner rush costs thousands per minute in lost revenue |
| Order Latency (P99) | < 2 seconds | Servers must see confirmation within two seconds of placing order |
| Kitchen Display Update Latency | < 1 second | Kitchen staff must see new orders immediately for timely preparation |
| Concurrent Users per Branch | 50-100 devices | Peak staffing with multiple servers, kitchen stations, and management terminals |
| Data Consistency | Strong consistency for payments | Financial transactions must never be lost or duplicated |
| Offline Support | 4+ hours offline operation | Network outages must not block order taking or payment processing |
| Multi-Tenant Isolation | Per-branch data isolation | Each branch sees only its own data unless authorized for cross-branch views |
| PCI DSS Compliance | Level 1 SAQ-A | All payment card data must be handled in compliance with industry standards |
3. Capacity Estimation and Back-of-Envelope
Traffic Volume Assumptions
Consider a restaurant management platform serving 10,000 restaurant branches. Each branch processes an average of 200 orders per day during peak hours and 500 orders per day total across dine-in, takeout, and delivery channels. This yields approximately 5 million orders per day across the entire platform, or roughly 60 orders per second at peak.
| Metric | Per Branch | Platform Total (10K branches) |
|---|---|---|
| Orders per day | 500 | 5,000,000 |
| Peak orders per second | 0.06 | 60 |
| Menu items per branch | 200 | 2,000,000 |
| Transactions per day | 500 | 5,000,000 |
| Active tables per branch | 30 | 300,000 |
| Staff clock events per day | 100 | 1,000,000 |
| Inventory check events per day | 5,000 | 50,000,000 |
| Customer loyalty lookups per day | 200 | 2,000,000 |
Storage Estimation
Each order record with items, modifiers, pricing, and payment details averages approximately 2 KB. Five million orders per day yield 10 GB of daily order data, or 3.6 TB per year. Menu data, customer profiles, inventory logs, and analytics data add approximately 30% overhead, bringing total annual storage to roughly 4.7 TB. With replication factor of three for high availability, we need approximately 14 TB of raw storage annually for the primary database tier. The hot working set for any single branch is small - its own menu (under 500 KB), active orders (under 5 MB), and current table state (under 1 MB) - making Redis caching highly effective.
Bandwidth Estimation
Each order placement involves an API call of roughly 5 KB payload. At 60 orders per second peak, the order service alone consumes 300 KB/s of inbound bandwidth. Kitchen display updates, table state changes, and real-time inventory checks collectively add another 200 KB/s per branch, bringing per-branch bandwidth to approximately 2 MB/s at peak. For 10,000 branches, total platform bandwidth is approximately 20 MB/s inbound, well within the capacity of standard cloud infrastructure.
4. Data Model and Schema Design
The data model must support multi-branch isolation, complex relationships between restaurants, menus, orders, and customers, and temporal data for analytics. We use a relational database (PostgreSQL) as the primary data store for transactional data, with Elasticsearch for menu search and ClickHouse for analytics. Redis provides caching for frequently accessed data like table states and menu availability.
public class Restaurant
{
public Guid Id { get; set; }
public string Name { get; set; }
public string BranchCode { get; set; }
public Guid FranchiseId { get; set; }
public string Address { get; set; }
public string City { get; set; }
public string Country { get; set; }
public string TimeZone { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public string Phone { get; set; }
public string Email { get; set; }
public int MaxCapacity { get; set; }
public int TotalTables { get; set; }
public OperatingHours OperatingHours { get; set; }
public RestaurantStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
public class MenuItem
{
public Guid Id { get; set; }
public Guid RestaurantId { get; set; }
public Guid? CategoryId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal BasePrice { get; set; }
public decimal? PromotionalPrice { get; set; }
public string Currency { get; set; }
public string ImageUrl { get; set; }
public List<string> Allergens { get; set; }
public List<string> DietaryTags { get; set; }
public int PreparationTimeMinutes { get; set; }
public int KitchenStationId { get; set; }
public bool IsAvailable { get; set; }
public bool IsFeatured { get; set; }
public int DisplayOrder { get; set; }
public List<ModifierGroup> ModifierGroups { get; set; }
public List<RecipeIngredient> RecipeIngredients { get; set; }
public MenuAvailabilitySchedule AvailabilitySchedule { get; set; }
}
public class TableReservation
{
public Guid Id { get; set; }
public Guid RestaurantId { get; set; }
public int TableNumber { get; set; }
public Guid? CustomerId { get; set; }
public string GuestName { get; set; }
public string GuestPhone { get; set; }
public int PartySize { get; set; }
public DateTime ReservationTime { get; set; }
public int DurationMinutes { get; set; }
public ReservationStatus Status { get; set; }
public string SpecialRequests { get; set; }
public Guid? CreatedByStaffId { get; set; }
public string Source { get; set; }
public DateTime CreatedAt { get; set; }
}
public class Order
{
public Guid Id { get; set; }
public Guid RestaurantId { get; set; }
public int? TableNumber { get; set; }
public Guid? ServerId { get; set; }
public OrderType Type { get; set; }
public OrderStatus Status { get; set; }
public Guid? CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
public decimal SubTotal { get; set; }
public decimal TaxAmount { get; set; }
public decimal TipAmount { get; set; }
public decimal TotalAmount { get; set; }
public string Currency { get; set; }
public List<Payment> Payments { get; set; }
public DateTime PlacedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public int EstimatedReadyMinutes { get; set; }
public string DeliveryPartnerOrderId { get; set; }
public string DeliveryPartnerName { get; set; }
}
public class OrderItem
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public Guid MenuItemId { get; set; }
public string ItemName { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
public List<ItemModifier> Modifiers { get; set; }
public string SpecialInstructions { get; set; }
public OrderItemStatus Status { get; set; }
public int KitchenStationId { get; set; }
public DateTime? SentToKitchenAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public class InventoryItem
{
public Guid Id { get; set; }
public Guid RestaurantId { get; set; }
public string Name { get; set; }
public string SKU { get; set; }
public string Category { get; set; }
public decimal CurrentQuantity { get; set; }
public string UnitOfMeasure { get; set; }
public decimal MinimumThreshold { get; set; }
public decimal MaximumCapacity { get; set; }
public decimal CostPerUnit { get; set; }
public Guid? SupplierId { get; set; }
public DateTime? LastRestockedAt { get; set; }
public DateTime? ExpirationDate { get; set; }
}
public class Staff
{
public Guid Id { get; set; }
public Guid RestaurantId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Phone { get; set; }
public StaffRole Role { get; set; }
public decimal HourlyRate { get; set; }
public bool IsActive { get; set; }
public List<Shift> ScheduledShifts { get; set; }
public StaffPermissions Permissions { get; set; }
}
public class Payment
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public PaymentMethod Method { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public PaymentStatus Status { get; set; }
public string ProcessorTransactionId { get; set; }
public string CardLastFour { get; set; }
public decimal TipAmount { get; set; }
public DateTime ProcessedAt { get; set; }
public DateTime? RefundedAt { get; set; }
public decimal? RefundAmount { get; set; }
}
public class Customer
{
public Guid Id { get; set; }
public string Email { get; set; }
public string Phone { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public int LoyaltyPoints { get; set; }
public string LoyaltyTier { get; set; }
public decimal TotalSpent { get; set; }
public int TotalOrders { get; set; }
public DateTime? LastVisitAt { get; set; }
public List<string> FavoriteItems { get; set; }
public List<string> DietaryPreferences { get; set; }
public List<Address> SavedAddresses { get; set; }
public DateTime CreatedAt { get; set; }
}
public enum OrderType { DineIn, Takeout, Delivery }
public enum OrderStatus { Pending, Confirmed, Preparing, Ready, Served, Completed, Cancelled }
public enum OrderItemStatus { Pending, SentToKitchen, Preparing, Ready, Served, Cancelled }
public enum ReservationStatus { Pending, Confirmed, Seated, Completed, NoShow, Cancelled }
public enum PaymentMethod { CreditCard, DebitCard, Cash, DigitalWallet, GiftCard, LoyaltyPoints }
public enum PaymentStatus { Pending, Authorized, Captured, Failed, Refunded, PartiallyRefunded }
public enum StaffRole { Manager, Host, Server, Bartender, Chef, LineCook, Dishwasher, DeliveryDriver }
public enum RestaurantStatus { Open, Closed, TemporarilyClosed }
Entity Relationship Overview
5. High-Level Architecture
The restaurant management system follows a microservices architecture with event-driven communication between services. Each major domain - orders, tables, menu, inventory, payments, staff, loyalty, analytics - is an independently deployable service with its own database. An API Gateway handles authentication, rate limiting, and request routing. A message broker (Apache Kafka) facilitates asynchronous event propagation between services. Redis provides distributed caching and real-time pub/sub for kitchen display updates and table state synchronization.
Key Architecture Decisions
- Event Sourcing for Orders: Every order state transition is recorded as an immutable event, enabling complete audit trails and reliable kitchen display synchronization.
- CQRS for Analytics: Write-heavy order and inventory services use PostgreSQL for transactions, while read-heavy analytics queries use ClickHouse for columnar storage and fast aggregations.
- Offline-First POS: POS terminals maintain a local SQLite database that syncs with the cloud when connectivity is restored, ensuring orders are never lost during network outages.
- Redis Pub/Sub for Real-Time: Table state changes, kitchen order updates, and inventory alerts are broadcast via Redis channels for sub-second propagation to all connected clients.
6. API Design - RESTful Endpoints and Contracts
All API endpoints follow REST conventions with consistent resource naming, HTTP status codes, and JSON request/response bodies. Authentication uses JWT tokens with branch-scoped claims. Rate limiting is enforced per-branch with higher limits during peak hours.
// Order Service API Endpoints
[ApiController]
[Route("api/v1/restaurants/{restaurantId}/orders")]
[Authorize]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
[HttpPost]
[ProducesResponseType(typeof(OrderResponse), StatusCodes.Status201Created)]
[ServiceFilter(typeof(BranchAuthorizationFilter))]
public async Task<ActionResult<OrderResponse>> CreateOrder(
Guid restaurantId,
[FromBody] CreateOrderRequest request)
{
var order = await _orderService.CreateOrderAsync(restaurantId, request);
return CreatedAtAction(nameof(GetOrder),
new { restaurantId, orderId = order.Id }, order);
}
[HttpGet("{orderId}")]
public async Task<ActionResult<OrderResponse>> GetOrder(
Guid restaurantId, Guid orderId)
{
var order = await _orderService.GetOrderAsync(restaurantId, orderId);
return Ok(order);
}
[HttpPatch("{orderId}/status")]
public async Task<ActionResult> UpdateOrderStatus(
Guid restaurantId, Guid orderId,
[FromBody] UpdateOrderStatusRequest request)
{
await _orderService.UpdateStatusAsync(restaurantId, orderId, request);
return NoContent();
}
[HttpGet]
public async Task<ActionResult<PagedResult<OrderResponse>>> ListOrders(
Guid restaurantId,
[FromQuery] OrderFilter filter,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 25)
{
var result = await _orderService.ListOrdersAsync(
restaurantId, filter, page, pageSize);
return Ok(result);
}
}
// Table Management API Endpoints
[ApiController]
[Route("api/v1/restaurants/{restaurantId}/tables")]
[Authorize]
public class TablesController : ControllerBase
{
[HttpGet("status")]
public async Task<ActionResult<List<TableStatusResponse>>> GetTableStatuses(
Guid restaurantId)
{
var statuses = await _tableService.GetTableStatusesAsync(restaurantId);
return Ok(statuses);
}
[HttpPatch("{tableNumber}/assign")]
public async Task<ActionResult> AssignTable(
Guid restaurantId, int tableNumber,
[FromBody] AssignTableRequest request)
{
await _tableService.AssignTableAsync(restaurantId, tableNumber, request);
return NoContent();
}
}
// Menu API Endpoints
[ApiController]
[Route("api/v1/restaurants/{restaurantId}/menu")]
public class MenuController : ControllerBase
{
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult<MenuResponse>> GetMenu(Guid restaurantId)
{
var menu = await _menuService.GetMenuAsync(restaurantId);
return Ok(menu);
}
[HttpPut("{itemId}/availability")]
[Authorize(Roles = "Manager,Chef")]
public async Task<ActionResult> UpdateAvailability(
Guid restaurantId, Guid itemId,
[FromBody] UpdateAvailabilityRequest request)
{
await _menuService.UpdateAvailabilityAsync(
restaurantId, itemId, request.IsAvailable);
return NoContent();
}
}
// Inventory API Endpoints
[ApiController]
[Route("api/v1/restaurants/{restaurantId}/inventory")]
[Authorize(Roles = "Manager")]
public class InventoryController : ControllerBase
{
[HttpGet("low-stock")]
public async Task<ActionResult<List<InventoryItemResponse>>> GetLowStockItems(
Guid restaurantId)
{
var items = await _inventoryService.GetLowStockItemsAsync(restaurantId);
return Ok(items);
}
[HttpPost("{itemId}/restock")]
public async Task<ActionResult> RestockItem(
Guid restaurantId, Guid itemId,
[FromBody] RestockRequest request)
{
await _inventoryService.RestockAsync(restaurantId, itemId, request);
return NoContent();
}
}
API Contract Summary
| Endpoint | Method | Description | Auth Required |
|---|---|---|---|
| /api/v1/restaurants/{id}/orders | POST | Create new order | Yes (Server/POS) |
| /api/v1/restaurants/{id}/orders/{oid} | GET | Get order details | Yes |
| /api/v1/restaurants/{id}/orders/{oid}/status | PATCH | Update order status | Yes |
| /api/v1/restaurants/{id}/tables/status | GET | Get all table statuses | Yes |
| /api/v1/restaurants/{id}/tables/{num}/assign | PATCH | Assign party to table | Yes (Host) |
| /api/v1/restaurants/{id}/menu | GET | Get current menu | No |
| /api/v1/restaurants/{id}/menu/{item}/availability | PUT | Toggle item availability | Yes (Manager/Chef) |
| /api/v1/restaurants/{id}/inventory/low-stock | GET | Get low stock alerts | Yes (Manager) |
| /api/v1/restaurants/{id}/reservations | POST | Create reservation | Yes (Host/API) |
| /api/v1/restaurants/{id}/payments/{oid}/process | POST | Process payment | Yes |
7. Table Management and Reservations
Table management is the heart of dine-in restaurant operations. A modern restaurant floor plan consists of tables with different capacities, zones (indoor, outdoor, patio, private dining), and configurations. The system must track real-time table status - available, reserved, occupied, cleaning - and handle complex reservation logic including time-slot availability, party size matching, buffer time between parties, and no-show tracking.
public class TableManagementService
{
private readonly ITableRepository _tableRepo;
private readonly IReservationRepository _reservationRepo;
private readonly ICacheService _cache;
private readonly IPublisher _eventPublisher;
public async Task<TableStatusResponse> GetTableStatusesAsync(Guid restaurantId)
{
var cacheKey = $"table_status:{restaurantId}";
var cached = await _cache.GetAsync<TableStatusResponse>(cacheKey);
if (cached != null) return cached;
var tables = await _tableRepo.GetByRestaurantAsync(restaurantId);
var activeReservations = await _reservationRepo
.GetActiveReservationsAsync(restaurantId, DateTime.UtcNow);
var activeOrders = await _tableRepo
.GetOccupiedTablesAsync(restaurantId);
var statuses = tables.Select(t => new TableStatusResponse
{
TableNumber = t.TableNumber,
Capacity = t.Capacity,
Zone = t.Zone,
Status = DetermineStatus(t, activeReservations, activeOrders),
CurrentPartySize = activeOrders
.FirstOrDefault(o => o.TableNumber == t.TableNumber)?.PartySize,
OccupiedSince = activeOrders
.FirstOrDefault(o => o.TableNumber == t.TableNumber)?.PlacedAt,
NextReservation = activeReservations
.Where(r => r.TableNumber == t.TableNumber && r.ReservationTime > DateTime.UtcNow)
.OrderBy(r => r.ReservationTime)
.FirstOrDefault()?.ReservationTime,
EstimatedAvailableTime = EstimateAvailableTime(
t, activeOrders, activeReservations)
}).ToList();
await _cache.SetAsync(cacheKey, statuses, TimeSpan.FromSeconds(10));
return new TableStatusResponse { Tables = statuses };
}
public async Task<List<TimeSlot>> GetAvailableTimeSlotsAsync(
Guid restaurantId, DateTime date, int partySize)
{
var restaurant = await _restaurantRepo.GetByIdAsync(restaurantId);
var tables = await _tableRepo.GetByRestaurantAsync(restaurantId);
var suitableTables = tables
.Where(t => t.Capacity >= partySize && t.Capacity <= partySize + 2)
.ToList();
var existingReservations = await _reservationRepo
.GetReservationsForDateAsync(restaurantId, date);
var timeSlots = new List<TimeSlot>();
var openingTime = restaurant.GetOpeningTime(date);
var closingTime = restaurant.GetClosingTime(date);
var slotDuration = TimeSpan.FromMinutes(30);
for (var time = openingTime; time < closingTime; time = time.Add(slotDuration))
{
bool anyTableAvailable = suitableTables.Any(t =>
IsTableAvailableForSlot(t, time, partySize, existingReservations));
timeSlots.Add(new TimeSlot
{
Time = time,
IsAvailable = anyTableAvailable,
AvailableTableCount = suitableTables.Count(t =>
IsTableAvailableForSlot(t, time, partySize, existingReservations))
});
}
return timeSlots;
}
public async Task<TableReservation> CreateReservationAsync(
Guid restaurantId, CreateReservationRequest request)
{
var availableTables = await FindAvailableTablesAsync(
restaurantId, request.ReservationTime, request.PartySize);
if (!availableTables.Any())
throw new NoTableAvailableException(
$"No tables available for {request.PartySize} guests at {request.ReservationTime}");
var selectedTable = availableTables
.OrderBy(t => t.Capacity)
.First();
var reservation = new TableReservation
{
Id = Guid.NewGuid(),
RestaurantId = restaurantId,
TableNumber = selectedTable.TableNumber,
GuestName = request.GuestName,
GuestPhone = request.GuestPhone,
CustomerId = request.CustomerId,
PartySize = request.PartySize,
ReservationTime = request.ReservationTime,
DurationMinutes = EstimateDuration(request.PartySize),
Status = ReservationStatus.Confirmed,
SpecialRequests = request.SpecialRequests,
Source = request.Source,
CreatedAt = DateTime.UtcNow
};
await _reservationRepo.CreateAsync(reservation);
await _cache.RemoveAsync($"table_status:{restaurantId}");
await _eventPublisher.PublishAsync(new ReservationCreatedEvent
{
Reservation = reservation,
RestaurantId = restaurantId
});
return reservation;
}
private bool IsTableAvailableForSlot(
RestaurantTable table, DateTime time, int partySize,
List<TableReservation> existingReservations)
{
var slotEnd = time.AddMinutes(90);
var bufferStart = time.AddMinutes(-15);
var bufferEnd = slotEnd.AddMinutes(15);
return !existingReservations.Any(r =>
r.TableNumber == table.TableNumber &&
r.Status != ReservationStatus.Cancelled &&
r.ReservationTime < bufferEnd &&
r.ReservationTime.AddMinutes(r.DurationMinutes) > bufferStart);
}
private TableStatus DetermineStatus(
RestaurantTable table,
List<TableReservation> reservations,
List<Order> activeOrders)
{
if (activeOrders.Any(o => o.TableNumber == table.TableNumber))
return TableStatus.Occupied;
var now = DateTime.UtcNow;
var upcomingReservation = reservations
.FirstOrDefault(r =>
r.TableNumber == table.TableNumber &&
r.ReservationTime > now &&
r.ReservationTime <= now.AddMinutes(15) &&
r.Status != ReservationStatus.Cancelled);
if (upcomingReservation != null)
return TableStatus.Reserved;
return TableStatus.Available;
}
private int EstimateDuration(int partySize)
{
return partySize switch
{
<= 2 => 60,
<= 4 => 75,
<= 6 => 90,
_ => 120
};
}
}
Table Status Lifecycle
9. Order Taking - Point of Sale System
The Point of Sale system is the most critical real-time component of the restaurant management system. Servers use handheld devices or fixed terminals to take orders, send them to the kitchen, manage table tabs, and process payments. The POS must work offline when network connectivity drops - a scenario that occurs surprisingly often in restaurant environments where wireless signals compete with kitchen interference and thick walls. The POS must also support split bills, item transfers between tables, course firing (sending courses to the kitchen in sequence), and void operations that are logged for audit purposes.
public class PosOrderService
{
private readonly IOrderRepository _orderRepo;
private readonly IKitchenNotificationService _kitchenNotifier;
private readonly IInventoryService _inventoryService;
private readonly ITableService _tableService;
private readonly ILoyaltyService _loyaltyService;
private readonly IOfflineSyncService _offlineSync;
private readonly IPublisher _eventPublisher;
public async Task<OrderConfirmation> PlaceOrderAsync(
Guid restaurantId, PosOrderRequest request)
{
var order = new Order
{
Id = Guid.NewGuid(),
RestaurantId = restaurantId,
TableNumber = request.TableNumber,
ServerId = request.ServerId,
Type = request.OrderType,
Status = OrderStatus.Pending,
CustomerId = request.CustomerId,
Items = new List<OrderItem>(),
PlacedAt = DateTime.UtcNow,
Currency = "USD"
};
foreach (var itemRequest in request.Items)
{
var menuItem = await _menuRepo
.GetItemAsync(restaurantId, itemRequest.MenuItemId);
if (!menuItem.IsAvailable)
throw new MenuItemUnavailableException(menuItem.Name);
var orderItem = new OrderItem
{
Id = Guid.NewGuid(),
OrderId = order.Id,
MenuItemId = menuItem.Id,
ItemName = menuItem.Name,
Quantity = itemRequest.Quantity,
UnitPrice = await CalculateItemPriceAsync(menuItem, itemRequest.Modifiers),
Modifiers = itemRequest.Modifiers,
SpecialInstructions = itemRequest.SpecialInstructions,
Status = OrderItemStatus.Pending,
KitchenStationId = menuItem.KitchenStationId
};
orderItem.TotalPrice = orderItem.UnitPrice * orderItem.Quantity;
order.Items.Add(orderItem);
}
order.SubTotal = order.Items.Sum(i => i.TotalPrice);
order.TaxAmount = await CalculateTaxAsync(restaurantId, order.SubTotal);
order.TotalAmount = order.SubTotal + order.TaxAmount;
await _orderRepo.CreateAsync(order);
// Deduct inventory in real-time
await _inventoryService.DeductForOrderAsync(restaurantId, order.Items);
// Send to kitchen display system
await _kitchenNotifier.SendOrderToKitchenAsync(order);
// Update table status
if (request.TableNumber.HasValue)
{
await _tableService.MarkOccupiedAsync(
restaurantId, request.TableNumber.Value, request.PartySize);
}
// Award loyalty points
if (request.CustomerId.HasValue)
{
await _loyaltyService.AwardPointsAsync(
request.CustomerId.Value, order.TotalAmount);
}
// Publish event for analytics
await _eventPublisher.PublishAsync(new OrderPlacedEvent
{
Order = order,
RestaurantId = restaurantId
});
return new OrderConfirmation
{
OrderId = order.Id,
EstimatedReadyMinutes = CalculateEstimatedReadyMinutes(order),
TotalAmount = order.TotalAmount,
PlacedAt = order.PlacedAt
};
}
public async Task SplitBillAsync(
Guid restaurantId, Guid orderId, SplitBillRequest request)
{
var order = await _orderRepo.GetByIdAsync(restaurantId, orderId);
switch (request.SplitMethod)
{
case SplitMethod.Equal:
await SplitEquallyAsync(order, request.NumberOfSplits);
break;
case SplitMethod.ByItem:
await SplitByItemAsync(order, request.ItemAssignments);
break;
case SplitMethod.ByAmount:
await SplitByAmountAsync(order, request.Amounts);
break;
}
}
public async Task FireCourseAsync(
Guid restaurantId, Guid orderId, int courseNumber)
{
var order = await _orderRepo.GetByIdAsync(restaurantId, orderId);
var courseItems = order.Items
.Where(i => i.CourseNumber == courseNumber)
.ToList();
foreach (var item in courseItems)
{
item.Status = OrderItemStatus.SentToKitchen;
item.SentToKitchenAt = DateTime.UtcNow;
}
await _orderRepo.UpdateAsync(order);
await _kitchenNotifier.SendCourseToKitchenAsync(order, courseItems);
}
public async Task<bool> ProcessOfflineOrderAsync(OfflineOrder offlineOrder)
{
var cachedMenu = await _offlineSync.GetCachedMenuAsync(
offlineOrder.RestaurantId);
var validation = ValidateOrderAgainstCachedMenu(offlineOrder, cachedMenu);
if (!validation.IsValid) return false;
await _offlineSync.StorePendingOrderAsync(offlineOrder);
_offlineSync.QueueForSync(new SyncTask
{
Type = SyncTaskType.OrderPlacement,
Payload = JsonSerializer.Serialize(offlineOrder),
RetryCount = 0,
CreatedAt = DateTime.UtcNow
});
return true;
}
}
Order Lifecycle State Machine
10. Kitchen Display System and Order Routing
The Kitchen Display System replaces traditional paper ticket printers with digital screens positioned at each kitchen station. Orders are automatically routed to the appropriate station based on the items ordered - grill station receives steak and burger orders, salad station receives appetizer and salad orders, dessert station receives sweet items, and the bar receives beverage orders. Each station displays orders in priority sequence based on when they were placed, with color-coded timers indicating preparation urgency. The KDS must support bump-bar workflows where cooks mark items as complete, and the system must notify servers when all items for a table are ready.
public class KitchenDisplayService
{
private readonly IKitchenStationRepository _stationRepo;
private readonly IOrderRepository _orderRepo;
private readonly IRedisService _redis;
private readonly ISignalRHub _hub;
private readonly ITimerService _timerService;
public async Task SendOrderToKitchenAsync(Order order)
{
var stationGroups = order.Items
.GroupBy(i => i.KitchenStationId)
.ToList();
foreach (var stationGroup in stationGroups)
{
var station = await _stationRepo.GetByIdAsync(stationGroup.Key);
var kitchenOrder = new KitchenOrder
{
OrderId = order.Id,
RestaurantId = order.RestaurantId,
TableNumber = order.TableNumber,
ServerName = order.Server?.FullName,
Items = stationGroup.Select(i => new KitchenOrderItem
{
OrderItemId = i.Id,
Name = i.ItemName,
Quantity = i.Quantity,
Modifiers = i.Modifiers?.Select(m => m.DisplayText).ToList(),
SpecialInstructions = i.SpecialInstructions,
Status = "NEW",
ReceivedAt = DateTime.UtcNow,
TimerSeconds = i.EstimatedPrepTimeSeconds
}).ToList(),
Priority = DeterminePriority(order),
SentAt = DateTime.UtcNow
};
var stationKey = $"kitchen:station:{stationGroup.Key}:orders";
await _redis.ListRightPushAsync(stationKey,
JsonSerializer.Serialize(kitchenOrder));
await _hub.SendToStationAsync(station.RestaurantId,
station.Id, "NewOrder", kitchenOrder);
}
foreach (var item in order.Items)
{
await _timerService.StartTimerAsync(
$"prep_timer:{item.Id}",
item.EstimatedPrepTimeSeconds,
onElapsed: () => NotifyOverdueItem(order.Id, item));
}
}
public async Task MarkItemCompleteAsync(
Guid restaurantId, Guid stationId, Guid orderItemId)
{
var order = await _orderRepo.GetByOrderItemIdAsync(orderItemId);
var item = order.Items.First(i => i.Id == orderItemId);
item.Status = OrderItemStatus.Ready;
item.CompletedAt = DateTime.UtcNow;
await _orderRepo.UpdateAsync(order);
var stationKey = $"kitchen:station:{stationId}:orders";
await _redis.RemoveFromListAsync(stationKey, orderItemId.ToString());
await _timerService.StopTimerAsync($"prep_timer:{orderItemId}");
var allComplete = order.Items
.All(i => i.Status == OrderItemStatus.Ready ||
i.Status == OrderItemStatus.Served);
if (allComplete)
{
order.Status = OrderStatus.Ready;
await _orderRepo.UpdateAsync(order);
await _hub.NotifyServerAsync(order.ServerId, "OrderReady",
new { OrderId = order.Id, TableNumber = order.TableNumber });
}
await _hub.BroadcastToRestaurantAsync(restaurantId,
"KitchenUpdate", new
{
StationId = stationId,
CompletedItems = await GetStationCompletedCountAsync(stationId),
PendingItems = await GetStationPendingCountAsync(stationId),
AveragePrepTime = await GetStationAvgPrepTimeAsync(stationId)
});
}
public async Task<KitchenDashboard> GetDashboardAsync(
Guid restaurantId, Guid stationId)
{
var pendingOrders = await _redis
.ListRangeAsync($"kitchen:station:{stationId}:orders");
var overdueItems = await _timerService
.GetOverdueTimersAsync("prep_timer:*");
return new KitchenDashboard
{
RestaurantId = restaurantId,
StationId = stationId,
PendingOrders = pendingOrders
.Select(o => JsonSerializer.Deserialize<KitchenOrder>(o))
.ToList(),
OverdueCount = overdueItems.Count,
AveragePrepTimeMinutes = await CalculateAvgPrepTimeAsync(stationId),
OrdersCompletedToday = await GetCompletedCountAsync(
restaurantId, stationId, DateTime.Today),
PeakWaitMinutes = await GetPeakWaitAsync(
restaurantId, stationId, DateTime.Today)
};
}
private OrderPriority DeterminePriority(Order order)
{
if (order.Type == OrderType.Delivery) return OrderPriority.High;
if (order.PartySize >= 8) return OrderPriority.High;
if (order.Items.Count >= 6) return OrderPriority.Medium;
return OrderPriority.Normal;
}
}
Kitchen Station Layout
| Station | Items Handled | Display Layout | Max Concurrent |
|---|---|---|---|
| Grill | Steaks, burgers, grilled proteins | Grid with timer overlay | 12 orders |
| Sauté | Pasta, stir-fry, sautéed dishes | Sequential queue | 8 orders |
| Salad / Cold | Salads, cold appetizers, desserts | Card view with flags | 10 orders |
| Fry | Fried items, sides, appetizers | Priority queue | 10 orders |
| Dessert | Sweets, pastries, ice cream | Timer-focused layout | 6 orders |
| Bar | Cocktails, wines, non-alcoholic | Linear queue | 15 orders |
11. Inventory Management and Supply Chain
Inventory management in restaurants is fundamentally different from retail or manufacturing. Perishable goods have expiration dates. Recipes map menu items to ingredient quantities, creating a many-to-many relationship that must be maintained in real-time. When a server places a burger order, the system must decrement ground beef, buns, lettuce, tomatoes, and condiment quantities. Waste tracking accounts for kitchen prep losses and spoilage. The system must generate purchase orders when stock falls below thresholds and manage supplier relationships with lead time calculations.
public class InventoryService
{
private readonly IInventoryRepository _inventoryRepo;
private readonly IRecipeRepository _recipeRepo;
private readonly IPurchaseOrderRepository _poRepo;
private readonly ICacheService _cache;
private readonly IPublisher _eventPublisher;
public async Task DeductForOrderAsync(
Guid restaurantId, List<OrderItem> orderItems)
{
var deductions = new List<InventoryDeduction>();
foreach (var orderItem in orderItems)
{
var recipe = await _recipeRepo
.GetIngredientsAsync(restaurantId, orderItem.MenuItemId);
foreach (var ingredient in recipe)
{
var quantityNeeded = ingredient.QuantityPerUnit * orderItem.Quantity;
deductions.Add(new InventoryDeduction
{
InventoryItemId = ingredient.InventoryItemId,
Quantity = quantityNeeded,
Reference = $"Order {orderItem.OrderId}",
Reason = DeductionReason.OrderPlaced
});
}
}
await _inventoryRepo.DeductBatchAsync(restaurantId, deductions);
await CheckAndTriggerAlertsAsync(restaurantId, deductions);
await _cache.RemoveAsync($"inventory:{restaurantId}");
}
public async Task CheckAndTriggerAlertsAsync(
Guid restaurantId, List<InventoryDeduction> deductions)
{
var affectedItemIds = deductions.Select(d => d.InventoryItemId).Distinct();
var items = await _inventoryRepo.GetByIdsAsync(restaurantId, affectedItemIds);
foreach (var item in items)
{
if (item.CurrentQuantity <= item.MinimumThreshold)
{
await _eventPublisher.PublishAsync(new LowStockAlertEvent
{
RestaurantId = restaurantId,
ItemId = item.Id,
ItemName = item.Name,
CurrentQuantity = item.CurrentQuantity,
Threshold = item.MinimumThreshold,
UnitOfMeasure = item.UnitOfMeasure
});
if (item.AutoReorderEnabled)
await GeneratePurchaseOrderAsync(restaurantId, item);
}
if (item.CurrentQuantity <= 0)
{
await DisableDependentMenuItemsAsync(restaurantId, item.Id);
await _eventPublisher.PublishAsync(new OutOfStockEvent
{
RestaurantId = restaurantId,
ItemId = item.Id,
ItemName = item.Name
});
}
}
}
public async Task GeneratePurchaseOrderAsync(
Guid restaurantId, InventoryItem item)
{
var supplier = await _supplierRepo.GetByIdAsync(item.SupplierId);
var quantityToOrder = item.MaximumCapacity - item.CurrentQuantity;
var po = new PurchaseOrder
{
Id = Guid.NewGuid(),
RestaurantId = restaurantId,
SupplierId = item.SupplierId,
SupplierName = supplier.Name,
Items = new List<PurchaseOrderItem>
{
new PurchaseOrderItem
{
InventoryItemId = item.Id,
ItemName = item.Name,
Quantity = quantityToOrder,
UnitPrice = item.CostPerUnit,
TotalPrice = quantityToOrder * item.CostPerUnit
}
},
Status = POStatus.Draft,
EstimatedDeliveryDate = DateTime.UtcNow.AddDays(supplier.LeadTimeDays),
TotalAmount = quantityToOrder * item.CostPerUnit,
CreatedAt = DateTime.UtcNow
};
await _poRepo.CreateAsync(po);
}
public async Task<FoodCostReport> GenerateFoodCostReportAsync(
Guid restaurantId, DateTime startDate, DateTime endDate)
{
var totalRevenue = await _orderRepo
.GetRevenueAsync(restaurantId, startDate, endDate);
var totalIngredientCost = await _inventoryRepo
.GetTotalCostAsync(restaurantId, startDate, endDate);
var wasteCost = await _inventoryRepo
.GetWasteCostAsync(restaurantId, startDate, endDate);
var menuItems = await _menuRepo.GetItemsAsync(restaurantId);
var itemCosts = new List<MenuItemCost>();
foreach (var item in menuItems)
{
var recipe = await _recipeRepo
.GetIngredientsAsync(restaurantId, item.Id);
var ingredientCost = recipe.Sum(r =>
r.QuantityPerUnit * r.InventoryItem.CostPerUnit);
var salesData = await _orderRepo
.GetItemSalesAsync(restaurantId, item.Id, startDate, endDate);
itemCosts.Add(new MenuItemCost
{
ItemName = item.Name,
MenuPrice = item.BasePrice,
IngredientCost = ingredientCost,
FoodCostPercentage = (ingredientCost / item.BasePrice) * 100,
TotalSold = salesData.Quantity,
TotalRevenue = salesData.Revenue,
TotalCost = salesData.Quantity * ingredientCost
});
}
return new FoodCostReport
{
RestaurantId = restaurantId,
Period = $"{startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}",
TotalRevenue = totalRevenue,
TotalIngredientCost = totalIngredientCost,
FoodCostPercentage = (totalIngredientCost / totalRevenue) * 100,
WasteCost = wasteCost,
WastePercentage = (wasteCost / totalIngredientCost) * 100,
MenuItemCosts = itemCosts.OrderByDescending(c => c.FoodCostPercentage).ToList(),
TargetFoodCostPercentage = 28.0m
};
}
}
Inventory Flow Overview
12. Payment Processing and Billing
Payment processing in restaurants has unique complexities compared to standard e-commerce. Tips must be added post-authorization. Split bills require partial payment allocation. Tab management keeps cards on file for bar and restaurant tabs. Refunds may be partial - returning the cost of a specific dish while keeping the rest of the bill intact. Cash payments require change calculation. Digital wallets and gift cards add additional payment method complexity. PCI DSS compliance mandates that card data never touches the restaurant servers - tokenization through a payment processor like Stripe, Square, or Adyen is mandatory.
public class PaymentService
{
private readonly IPaymentProcessor _processor;
private readonly IPaymentRepository _paymentRepo;
private readonly IOrderRepository _orderRepo;
private readonly IPublisher _eventPublisher;
private readonly IAuditLogger _auditLogger;
public async Task<PaymentResult> ProcessPaymentAsync(
Guid restaurantId, Guid orderId, ProcessPaymentRequest request)
{
var order = await _orderRepo.GetByIdAsync(restaurantId, orderId);
if (order.Status == OrderStatus.Completed)
throw new OrderAlreadyPaidException(orderId);
var paymentAmount = request.Amount ?? order.TotalAmount;
var existingPayments = await _paymentRepo
.GetPaymentsForOrderAsync(orderId);
var alreadyPaid = existingPayments
.Where(p => p.Status != PaymentStatus.Failed &&
p.Status != PaymentStatus.Refunded)
.Sum(p => p.Amount);
if (alreadyPaid + paymentAmount > order.TotalAmount + 0.01m)
throw new OverpaymentException();
var payment = new Payment
{
Id = Guid.NewGuid(),
OrderId = orderId,
Method = request.PaymentMethod,
Amount = paymentAmount,
Currency = order.Currency,
Status = PaymentStatus.Pending,
TipAmount = request.TipAmount ?? 0,
ProcessedAt = DateTime.UtcNow
};
try
{
switch (request.PaymentMethod)
{
case PaymentMethod.CreditCard:
case PaymentMethod.DebitCard:
var cardResult = await ProcessCardPaymentAsync(
payment, request.CardToken, request.PaymentMethod);
payment.ProcessorTransactionId = cardResult.TransactionId;
payment.CardLastFour = cardResult.LastFourDigits;
payment.Status = cardResult.Success
? PaymentStatus.Captured
: PaymentStatus.Failed;
break;
case PaymentMethod.Cash:
payment.Status = PaymentStatus.Captured;
payment.ProcessorTransactionId = $"CASH-{Guid.NewGuid():N}";
break;
case PaymentMethod.DigitalWallet:
var walletResult = await ProcessWalletPaymentAsync(
payment, request.WalletToken);
payment.ProcessorTransactionId = walletResult.TransactionId;
payment.Status = walletResult.Success
? PaymentStatus.Captured
: PaymentStatus.Failed;
break;
case PaymentMethod.GiftCard:
var giftResult = await ProcessGiftCardPaymentAsync(
payment, request.GiftCardNumber);
payment.ProcessorTransactionId = giftResult.TransactionId;
payment.Status = giftResult.Success
? PaymentStatus.Captured
: PaymentStatus.Failed;
break;
case PaymentMethod.LoyaltyPoints:
var pointsResult = await ProcessLoyaltyPaymentAsync(
payment, request.CustomerId, request.PointsRedeemed);
payment.Status = pointsResult.Success
? PaymentStatus.Captured
: PaymentStatus.Failed;
break;
}
await _paymentRepo.CreateAsync(payment);
if (payment.Status == PaymentStatus.Failed)
{
await _auditLogger.LogAsync(new AuditEntry
{
Action = "PaymentFailed",
OrderId = orderId,
PaymentMethod = request.PaymentMethod,
Amount = paymentAmount,
Timestamp = DateTime.UtcNow
});
return new PaymentResult
{
Success = false,
PaymentId = payment.Id,
Message = "Payment processing failed"
};
}
var totalPaid = alreadyPaid + paymentAmount;
if (totalPaid >= order.TotalAmount - 0.01m)
{
order.Status = OrderStatus.Completed;
order.CompletedAt = DateTime.UtcNow;
order.TipAmount = existingPayments.Sum(p => p.TipAmount) + payment.TipAmount;
order.TotalAmount += order.TipAmount;
await _orderRepo.UpdateAsync(order);
}
await _eventPublisher.PublishAsync(new PaymentProcessedEvent
{
PaymentId = payment.Id,
OrderId = orderId,
RestaurantId = restaurantId,
Amount = paymentAmount,
Method = request.PaymentMethod
});
return new PaymentResult
{
Success = true,
PaymentId = payment.Id,
RemainingBalance = order.TotalAmount - totalPaid
};
}
catch (PaymentProcessorException ex)
{
await _auditLogger.LogAsync(new AuditEntry
{
Action = "PaymentProcessorError",
OrderId = orderId,
ErrorMessage = ex.Message,
Timestamp = DateTime.UtcNow
});
throw new PaymentProcessingException(ex.Message, ex);
}
}
public async Task<RefundResult> ProcessRefundAsync(
Guid restaurantId, Guid paymentId, RefundRequest request)
{
var payment = await _paymentRepo.GetByIdAsync(paymentId);
if (payment.Status != PaymentStatus.Captured)
throw new InvalidRefundException("Only captured payments can be refunded");
var refundAmount = request.Amount ?? payment.Amount;
if (refundAmount > payment.Amount)
throw new InvalidRefundException("Refund amount exceeds payment amount");
var refund = await _processor.RefundAsync(
payment.ProcessorTransactionId, refundAmount);
payment.Status = refundAmount == payment.Amount
? PaymentStatus.Refunded
: PaymentStatus.PartiallyRefunded;
payment.RefundedAt = DateTime.UtcNow;
payment.RefundAmount = refundAmount;
await _paymentRepo.UpdateAsync(payment);
await _eventPublisher.PublishAsync(new RefundProcessedEvent
{
PaymentId = paymentId,
RefundAmount = refundAmount,
RestaurantId = restaurantId
});
return new RefundResult
{
Success = true,
RefundAmount = refundAmount,
RefundTransactionId = refund.TransactionId
};
}
public async Task<DailyReconciliation> ReconcileDayAsync(
Guid restaurantId, DateTime date)
{
var payments = await _paymentRepo
.GetPaymentsForDateAsync(restaurantId, date);
var reconciliation = new DailyReconciliation
{
RestaurantId = restaurantId,
Date = date,
TotalSales = payments
.Where(p => p.Status == PaymentStatus.Captured)
.Sum(p => p.Amount),
TotalTips = payments
.Where(p => p.Status == PaymentStatus.Captured)
.Sum(p => p.TipAmount),
TotalRefunds = payments
.Where(p => p.Status == PaymentStatus.Refunded ||
p.Status == PaymentStatus.PartiallyRefunded)
.Sum(p => p.RefundAmount),
PaymentsByMethod = payments
.GroupBy(p => p.Method)
.ToDictionary(g => g.Key.ToString(), g => g.Sum(p => p.Amount)),
TransactionCount = payments.Count(p =>
p.Status == PaymentStatus.Captured),
AverageTicketSize = payments
.Where(p => p.Status == PaymentStatus.Captured)
.Average(p => p.Amount)
};
reconciliation.NetSales = reconciliation.TotalSales - reconciliation.TotalRefunds;
await _reconciliationRepo.SaveAsync(reconciliation);
return reconciliation;
}
}
Payment Flow
13. Staff Scheduling and Labor Management
Staff scheduling in restaurants must account for varying shift patterns across different roles - servers work during service hours, kitchen staff arrive earlier for prep, bartenders stay late, and dishwashers overlap with both shifts. Labor cost management requires tracking actual hours worked against forecasted revenue to maintain target labor cost percentages. The system must handle shift swaps, overtime alerts, break compliance, and multi-role assignments where a staff member might host during lunch and serve during dinner.
public class StaffSchedulingService
{
private readonly IStaffRepository _staffRepo;
private readonly IShiftRepository _shiftRepo;
private readonly ITimeClockRepository _timeClockRepo;
private readonly IPublisher _eventPublisher;
public async Task<WeeklySchedule> GenerateOptimalScheduleAsync(
Guid restaurantId, DateTime weekStart)
{
var forecastedDemand = await _demandForecast
.GetForecastAsync(restaurantId, weekStart, weekStart.AddDays(7));
var staffPool = await _staffRepo.GetActiveStaffAsync(restaurantId);
var existingShifts = await _shiftRepo
.GetShiftsForWeekAsync(restaurantId, weekStart);
var schedule = new WeeklySchedule
{
RestaurantId = restaurantId,
WeekStart = weekStart,
Shifts = new List<Shift>()
};
foreach (var day in Enumerable.Range(0, 7).Select(i => weekStart.AddDays(i)))
{
var hourlyDemand = forecastedDemand.GetHourlyBreakdown(day);
foreach (var hour in hourlyDemand)
{
var requiredStaff = CalculateRequiredStaff(
hour.StaffType, hour.ExpectedCovers, hour.Hour);
var availableStaff = staffPool
.Where(s => s.Role == hour.StaffType &&
s.Availability.IsAvailable(day, hour.Hour))
.ToList();
var currentLaborCost = schedule.Shifts
.Where(s => s.Date == day)
.Sum(s => s.ExpectedHours * s.Staff.HourlyRate);
var forecastedRevenue = forecastedDemand
.GetRevenueForHour(day, hour.Hour);
var laborCostPercentage = currentLaborCost / forecastedRevenue;
if (laborCostPercentage > 0.35m)
{
await _eventPublisher.PublishAsync(new LaborCostAlertEvent
{
RestaurantId = restaurantId,
Date = day,
ProjectedCostPercentage = laborCostPercentage
});
}
foreach (var staff in availableStaff.Take(requiredStaff))
{
var existingShift = schedule.Shifts
.FirstOrDefault(s =>
s.StaffId == staff.Id && s.Date == day);
if (existingShift != null)
{
existingShift.EndTime = existingShift.EndTime.AddHours(2);
}
else
{
schedule.Shifts.Add(new Shift
{
Id = Guid.NewGuid(),
StaffId = staff.Id,
RestaurantId = restaurantId,
Date = day,
StartTime = new TimeOnly(hour.Hour, 0),
EndTime = new TimeOnly(hour.Hour + 8, 0),
Role = staff.Role,
Status = ShiftStatus.Scheduled
});
}
}
}
}
await _shiftRepo.SaveBatchAsync(schedule.Shifts);
return schedule;
}
public async Task<TimeClockEntry> ClockInAsync(
Guid restaurantId, Guid staffId, ClockInRequest request)
{
var staff = await _staffRepo.GetByIdAsync(restaurantId, staffId);
var existingEntry = await _timeClockRepo
.GetActiveEntryAsync(staffId);
if (existingEntry != null)
throw new AlreadyClockedInException(staffId);
var entry = new TimeClockEntry
{
Id = Guid.NewGuid(),
StaffId = staffId,
RestaurantId = restaurantId,
ClockInTime = DateTime.UtcNow,
Role = staff.Role,
Method = request.Method,
Location = request.Location,
Status = TimeClockStatus.Active
};
await _timeClockRepo.CreateAsync(entry);
return entry;
}
public async Task<LaborReport> GenerateLaborReportAsync(
Guid restaurantId, DateTime startDate, DateTime endDate)
{
var entries = await _timeClockRepo
.GetEntriesAsync(restaurantId, startDate, endDate);
var revenue = await _orderRepo
.GetRevenueAsync(restaurantId, startDate, endDate);
var totalHoursWorked = entries
.Where(e => e.Status == TimeClockStatus.Completed)
.Sum(e => (e.ClockOutTime.Value - e.ClockInTime).TotalHours);
var totalLaborCost = entries
.Where(e => e.Status == TimeClockStatus.Completed)
.Sum(e =>
{
var hours = (e.ClockOutTime.Value - e.ClockInTime).TotalHours;
var overtimeHours = Math.Max(0, hours - 40);
var regularHours = hours - overtimeHours;
return (regularHours * e.Staff.HourlyRate) +
(overtimeHours * e.Staff.HourlyRate * 1.5);
});
return new LaborReport
{
RestaurantId = restaurantId,
Period = $"{startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}",
TotalHoursWorked = totalHoursWorked,
TotalLaborCost = totalLaborCost,
LaborCostPercentage = (decimal)(totalLaborCost / (double)revenue) * 100,
AverageHoursPerEmployee = totalHoursWorked / entries
.Select(e => e.StaffId).Distinct().Count(),
OvertimeHours = entries.Sum(e =>
{
var hours = (e.ClockOutTime.Value - e.ClockInTime).TotalHours;
return Math.Max(0, hours - 40);
}),
NoShows = await _shiftRepo.GetNoShowCountAsync(
restaurantId, startDate, endDate)
};
}
}
Labor Cost Benchmarks
| Metric | Target Range | Alert Threshold |
|---|---|---|
| Labor Cost % (Full Service) | 28-35% | > 38% |
| Labor Cost % (Fast Casual) | 25-32% | > 35% |
| Labor Cost % (Fine Dining) | 32-40% | > 43% |
| Revenue per Labor Hour | $45-$80 | < $35 |
| Overtime Rate | < 5% | > 8% |
| No-Show Rate | < 3% | > 5% |
14. Customer Loyalty Program
A well-designed loyalty program drives repeat visits and increases average check size. The system must track customer visits, spending patterns, and preferences to deliver personalized rewards. Points accrual rates may vary by menu item category to encourage trial of high-margin items. Tier-based programs reward top customers with exclusive benefits. Birthday and anniversary promotions create emotional connections. The loyalty engine must integrate seamlessly with the POS so servers can look up customer profiles, apply rewards, and track preferences in real-time.
public class LoyaltyService
{
private readonly ICustomerRepository _customerRepo;
private readonly ILoyaltyTransactionRepository _transactionRepo;
private readonly ICacheService _cache;
private readonly IPublisher _eventPublisher;
public async Task<LoyaltySummary> GetCustomerLoyaltyAsync(Guid customerId)
{
var cacheKey = $"loyalty:{customerId}";
var cached = await _cache.GetAsync<LoyaltySummary>(cacheKey);
if (cached != null) return cached;
var customer = await _customerRepo.GetByIdAsync(customerId);
var transactions = await _transactionRepo
.GetRecentTransactionsAsync(customerId, 100);
var summary = new LoyaltySummary
{
CustomerId = customerId,
CustomerName = $"{customer.FirstName} {customer.LastName}",
CurrentPoints = customer.LoyaltyPoints,
Tier = DetermineTier(customer.TotalSpent),
PointsToNextTier = CalculatePointsToNextTier(customer),
TotalLifetimePoints = transactions
.Where(t => t.Type == LoyaltyTransactionType.Earned)
.Sum(t => t.Points),
RecentTransactions = transactions.Take(10).ToList(),
BirthdayReward = await GetBirthdayRewardAsync(customer),
AnniversaryReward = await GetAnniversaryRewardAsync(customer),
PersonalizedOffers = await GeneratePersonalizedOffersAsync(customer)
};
await _cache.SetAsync(cacheKey, summary, TimeSpan.FromMinutes(15));
return summary;
}
public async Task AwardPointsAsync(Guid customerId, decimal orderAmount)
{
var customer = await _customerRepo.GetByIdAsync(customerId);
var tier = DetermineTier(customer.TotalSpent);
var pointsMultiplier = GetTierMultiplier(tier);
var points = (int)(orderAmount * 10 * pointsMultiplier);
customer.LoyaltyPoints += points;
customer.TotalSpent += orderAmount;
customer.TotalOrders += 1;
customer.LastVisitAt = DateTime.UtcNow;
var newTier = DetermineTier(customer.TotalSpent);
if (newTier != tier)
{
customer.LoyaltyTier = newTier;
await _eventPublisher.PublishAsync(new TierUpgradeEvent
{
CustomerId = customerId,
PreviousTier = tier,
NewTier = newTier
});
}
await _customerRepo.UpdateAsync(customer);
await _transactionRepo.CreateAsync(new LoyaltyTransaction
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Type = LoyaltyTransactionType.Earned,
Points = points,
Description = $"Order points ({tier} tier x{pointsMultiplier})",
CreatedAt = DateTime.UtcNow
});
await _cache.RemoveAsync($"loyalty:{customerId}");
}
public async Task<RedeemResult> RedeemPointsAsync(
Guid customerId, RedeemRequest request)
{
var customer = await _customerRepo.GetByIdAsync(customerId);
if (customer.LoyaltyPoints < request.PointsToRedeem)
throw new InsufficientPointsException(
customer.LoyaltyPoints, request.PointsToRedeem);
var redemptionValue = CalculateRedemptionValue(request.PointsToRedeem);
customer.LoyaltyPoints -= request.PointsToRedeem;
await _customerRepo.UpdateAsync(customer);
await _transactionRepo.CreateAsync(new LoyaltyTransaction
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Type = LoyaltyTransactionType.Redeemed,
Points = -request.PointsToRedeem,
Description = $"Redeemed for ${redemptionValue:F2}",
Metadata = JsonSerializer.Serialize(new { OrderId = request.OrderId }),
CreatedAt = DateTime.UtcNow
});
await _cache.RemoveAsync($"loyalty:{customerId}");
return new RedeemResult
{
PointsRedeemed = request.PointsToRedeem,
DiscountAmount = redemptionValue,
RemainingPoints = customer.LoyaltyPoints
};
}
private LoyaltyTier DetermineTier(decimal totalSpent)
{
return totalSpent switch
{
>= 5000 => LoyaltyTier.Platinum,
>= 2000 => LoyaltyTier.Gold,
>= 500 => LoyaltyTier.Silver,
_ => LoyaltyTier.Bronze
};
}
private decimal GetTierMultiplier(LoyaltyTier tier)
{
return tier switch
{
LoyaltyTier.Platinum => 2.0m,
LoyaltyTier.Gold => 1.5m,
LoyaltyTier.Silver => 1.25m,
_ => 1.0m
};
}
private async Task<List<PersonalizedOffer>> GeneratePersonalizedOffersAsync(
Customer customer)
{
var offers = new List<PersonalizedOffer>();
if (customer.FavoriteItems?.Any() == true)
{
offers.Add(new PersonalizedOffer
{
Type = OfferType.FreeItem,
Description = $"Free {customer.FavoriteItems.First()} on your next visit",
ExpiresAt = DateTime.UtcNow.AddDays(30),
MinimumOrderAmount = 20.00m
});
}
if (DateTime.UtcNow.Month == customer.CreatedAt.Month)
{
offers.Add(new PersonalizedOffer
{
Type = OfferType.DoublePoints,
Description = "Double points on your birthday month!",
ExpiresAt = new DateTime(
DateTime.UtcNow.Year, DateTime.UtcNow.Month,
DateTime.DaysInMonth(DateTime.UtcNow.Year, DateTime.UtcNow.Month)),
MinimumOrderAmount = 0
});
}
return offers;
}
}
public enum LoyaltyTier { Bronze, Silver, Gold, Platinum }
public enum LoyaltyTransactionType { Earned, Redeemed, Bonus, Expired, Adjusted }
public enum OfferType { FreeItem, Discount, DoublePoints, FreeDelivery, BirthdaySpecial }
Loyalty Tier Benefits
| Tier | Spend Required | Points Multiplier | Benefits |
|---|---|---|---|
| Bronze | $0 - $499 | 1.0x | 1 point per $1 spent, birthday reward |
| Silver | $500 - $1,999 | 1.25x | Priority seating, free dessert monthly |
| Gold | $2,000 - $4,999 | 1.5x | Free appetizer monthly, exclusive events |
| Platinum | $5,000+ | 2.0x | VIP table, chef table access, personalized menu |
15. Online Ordering Integration
Online ordering has become essential for restaurant revenue, with some establishments deriving over 40% of sales through digital channels. The system must provide a branded web portal and mobile app experience that reflects each restaurant identity while sharing a common backend. Online orders must flow seamlessly into the kitchen workflow alongside dine-in orders, with the KDS clearly distinguishing order types and providing accurate preparation time estimates. The system must handle pickup time scheduling, delivery zone configuration, and surge pricing during high-demand periods.
public class OnlineOrderingService
{
private readonly IOrderRepository _orderRepo;
private readonly IRestaurantRepository _restaurantRepo;
private readonly IKitchenNotificationService _kitchenNotifier;
private readonly IDeliveryZoneService _deliveryZoneService;
private readonly IPricingService _pricingService;
private readonly IPublisher _eventPublisher;
public async Task<OnlineOrderConfirmation> PlaceOnlineOrderAsync(
PlaceOnlineOrderRequest request)
{
var restaurant = await _restaurantRepo.GetByIdAsync(request.RestaurantId);
if (restaurant.Status != RestaurantStatus.Open)
throw new RestaurantClosedException();
if (request.OrderType == OrderType.Delivery)
{
var isInZone = await _deliveryZoneService
.IsWithinDeliveryZoneAsync(request.RestaurantId, request.DeliveryAddress);
if (!isInZone)
throw new OutsideDeliveryZoneException();
}
var estimatedPrepTime = await CalculatePrepTimeAsync(
request.RestaurantId, request.Items);
var surgeMultiplier = await _pricingService
.GetSurgeMultiplierAsync(request.RestaurantId);
var originalTotal = request.Items.Sum(i => i.UnitPrice * i.Quantity);
var surgeTotal = originalTotal * surgeMultiplier;
var order = new Order
{
Id = Guid.NewGuid(),
RestaurantId = request.RestaurantId,
Type = request.OrderType,
Status = OrderStatus.Pending,
CustomerId = request.CustomerId,
Items = request.Items.Select(i => new OrderItem
{
Id = Guid.NewGuid(),
MenuItemId = i.MenuItemId,
ItemName = i.ItemName,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
TotalPrice = i.UnitPrice * i.Quantity,
Modifiers = i.Modifiers,
SpecialInstructions = i.SpecialInstructions
}).ToList(),
SubTotal = surgeTotal,
DeliveryAddress = request.DeliveryAddress,
DeliveryInstructions = request.DeliveryInstructions,
PickupTime = request.PickupTime,
PlacedAt = DateTime.UtcNow,
EstimatedReadyMinutes = estimatedPrepTime,
Source = "Online"
};
order.TaxAmount = await CalculateTaxAsync(order.RestaurantId, order.SubTotal);
order.TotalAmount = order.SubTotal + order.TaxAmount;
await _orderRepo.CreateAsync(order);
await _kitchenNotifier.SendOrderToKitchenAsync(order);
return new OnlineOrderConfirmation
{
OrderId = order.Id,
EstimatedReadyTime = order.PlacedAt.AddMinutes(estimatedPrepTime),
TotalAmount = order.TotalAmount,
SurgeApplied = surgeMultiplier > 1.0m,
SurgeMultiplier = surgeMultiplier
};
}
public async Task<DeliveryEstimate> GetDeliveryEstimateAsync(
Guid restaurantId, DeliveryEstimateRequest request)
{
var distance = await _deliveryZoneService
.CalculateDistanceAsync(restaurantId, request.DeliveryAddress);
var kitchenLoad = await _kitchenNotifier
.GetCurrentLoadAsync(restaurantId);
var activeDrivers = await GetActiveDriversAsync(restaurantId);
var baseTime = 25;
var distanceAdder = (int)(distance.Miles * 3);
var loadAdder = kitchenLoad.LoadPercentage / 10;
var driverAvailability = activeDrivers > 0 ? 0 : 15;
return new DeliveryEstimate
{
EstimatedMinutes = baseTime + distanceAdder + loadAdder + driverAvailability,
DeliveryFee = CalculateDeliveryFee(distance.Miles, kitchenLoad),
MinimumOrder = await GetMinimumOrderAmountAsync(restaurantId),
IsDeliverable = distance.Miles <= await GetMaxDeliveryRadiusAsync(restaurantId),
SurgeActive = kitchenLoad.LoadPercentage > 80
};
}
private decimal CalculateDeliveryFee(double distanceMiles, KitchenLoad load)
{
var baseFee = 3.99m;
var perMileFee = (decimal)(distanceMiles * 0.50);
var surgeFee = load.LoadPercentage > 80 ? 2.00m : 0;
return Math.Round(baseFee + perMileFee + surgeFee, 2);
}
}
16. Delivery Partner Integration
Integrating with third-party delivery platforms like Uber Eats, DoorDash, and Grubhub requires a unified adapter layer that normalizes different API contracts into a common internal representation. Each delivery partner has its own order format, status webhooks, commission structures, and delivery logistics. The system must maintain mapping tables between internal menu item IDs and partner-specific item IDs, handle real-time status synchronization, manage commission deductions, and process partner-specific promotions and fees.
public class DeliveryPartnerService
{
private readonly Dictionary<string, IDeliveryPartnerAdapter> _adapters;
private readonly IDeliveryPartnerRepository _partnerRepo;
private readonly IOrderRepository _orderRepo;
private readonly IPublisher _eventPublisher;
public DeliveryPartnerService(
UberEatsAdapter uberEats,
DoorDashAdapter doorDash,
GrubhubAdapter grubhub)
{
_adapters = new Dictionary<string, IDeliveryPartnerAdapter>
{
{ "uber_eats", uberEats },
{ "doordash", doorDash },
{ "grubhub", grubhub }
};
}
public async Task<PartnerOrderResult> SyncPartnerOrderAsync(
string partnerName, PartnerWebhookPayload payload)
{
if (!_adapters.TryGetValue(partnerName, out var adapter))
throw new UnknownPartnerException(partnerName);
var normalizedOrder = await adapter.NormalizeOrderAsync(payload);
var itemMapping = await _partnerRepo
.GetItemMappingAsync(normalizedOrder.RestaurantId, partnerName);
foreach (var item in normalizedOrder.Items)
{
if (!itemMapping.TryGetValue(item.PartnerItemId, out var internalId))
throw new MenuItemMappingException(item.PartnerItemId);
item.MenuItemId = internalId;
}
var order = new Order
{
Id = Guid.NewGuid(),
RestaurantId = normalizedOrder.RestaurantId,
Type = OrderType.Delivery,
Status = OrderStatus.Pending,
Items = normalizedOrder.Items,
DeliveryPartnerOrderId = normalizedOrder.PartnerOrderId,
DeliveryPartnerName = partnerName,
SubTotal = normalizedOrder.SubTotal,
DeliveryAddress = normalizedOrder.DeliveryAddress,
PlacedAt = DateTime.UtcNow,
Source = partnerName
};
order.TaxAmount = await CalculateTaxAsync(order.RestaurantId, order.SubTotal);
order.TotalAmount = order.SubTotal + order.TaxAmount;
await _orderRepo.CreateAsync(order);
await _eventPublisher.PublishAsync(new PartnerOrderReceivedEvent
{
Order = order,
PartnerName = partnerName
});
return new PartnerOrderResult
{
Success = true,
InternalOrderId = order.Id,
PartnerOrderId = normalizedOrder.PartnerOrderId
};
}
public async Task<OrderStatusResult> UpdatePartnerOrderStatusAsync(
string partnerName, string partnerOrderId, string status)
{
var adapter = _adapters[partnerName];
var order = await _orderRepo
.GetByPartnerOrderIdAsync(partnerOrderId);
var internalStatus = adapter.MapStatus(status);
order.Status = internalStatus;
await _orderRepo.UpdateAsync(order);
await adapter.UpdateExternalStatusAsync(partnerOrderId, internalStatus);
return new OrderStatusResult
{
OrderId = order.Id,
InternalStatus = internalStatus,
PartnerStatus = status
};
}
public async Task<CommissionReport> GetCommissionReportAsync(
Guid restaurantId, DateTime startDate, DateTime endDate)
{
var partnerOrders = await _orderRepo
.GetPartnerOrdersAsync(restaurantId, startDate, endDate);
var partnerGroups = partnerOrders.GroupBy(o => o.DeliveryPartnerName);
return new CommissionReport
{
RestaurantId = restaurantId,
Period = $"{startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}",
Partners = partnerGroups.Select(g => new PartnerCommissionDetail
{
PartnerName = g.Key,
TotalOrders = g.Count(),
GrossSales = g.Sum(o => o.TotalAmount),
CommissionRate = GetCommissionRate(g.Key),
CommissionAmount = g.Sum(o => o.TotalAmount) *
GetCommissionRate(g.Key),
NetPayout = g.Sum(o => o.TotalAmount) * (1 - GetCommissionRate(g.Key))
}).ToList()
};
}
private decimal GetCommissionRate(string partnerName)
{
return partnerName.ToLower() switch
{
"uber_eats" => 0.30m,
"doordash" => 0.25m,
"grubhub" => 0.27m,
_ => 0.25m
};
}
}
public interface IDeliveryPartnerAdapter
{
Task<NormalizedOrder> NormalizeOrderAsync(PartnerWebhookPayload payload);
OrderStatus MapStatus(string partnerStatus);
Task UpdateExternalStatusAsync(string partnerOrderId, OrderStatus status);
}
Delivery Partner Comparison
| Feature | Uber Eats | DoorDash | Grubhub |
|---|---|---|---|
| Commission Rate | 30% | 25% | 27% |
| API Rate Limit | 100 req/s | 50 req/s | 75 req/s |
| Webhook Reliability | 99.5% | 99.2% | 98.8% |
| Average Delivery Time | 35 min | 32 min | 38 min |
| Menu Sync Method | Push API | Bulk CSV | REST API |
| Payment Settlement | Weekly | Daily | Weekly |
17. Multi-Branch Support and Franchise Operations
Restaurant chains require centralized management while preserving location-specific customization. A franchise with 500 branches needs a headquarters dashboard that shows real-time performance across all locations, while each branch manager sees only their own data. Menus may vary by location based on local ingredient availability and customer preferences. Pricing can differ by region. Inter-branch inventory transfers must be tracked. The data model must support strict multi-tenant isolation with role-based access spanning branch, regional, and corporate levels.
public class MultiBranchService
{
private readonly IRestaurantRepository _restaurantRepo;
private readonly IMenuRepository _menuRepo;
private readonly IReportingService _reportingService;
private readonly ICacheService _cache;
public async Task<FranchiseDashboard> GetFranchiseDashboardAsync(
Guid franchiseId, DateTime date)
{
var branches = await _restaurantRepo.GetByFranchiseAsync(franchiseId);
var branchMetrics = new List<BranchDailyMetrics>();
foreach (var branch in branches)
{
var metrics = await _reportingService
.GetDailyMetricsAsync(branch.Id, date);
branchMetrics.Add(new BranchDailyMetrics
{
BranchId = branch.Id,
BranchName = branch.Name,
City = branch.City,
TotalRevenue = metrics.TotalRevenue,
TotalOrders = metrics.TotalOrders,
AverageOrderValue = metrics.AverageOrderValue,
TableUtilization = metrics.TableUtilization,
FoodCostPercentage = metrics.FoodCostPercentage,
LaborCostPercentage = metrics.LaborCostPercentage,
CustomerSatisfaction = metrics.CustomerSatisfaction,
OnlineOrderPercentage = metrics.OnlineOrderPercentage,
TopSellingItems = metrics.TopSellingItems
});
}
return new FranchiseDashboard
{
FranchiseId = franchiseId,
Date = date,
TotalRevenue = branchMetrics.Sum(b => b.TotalRevenue),
TotalOrders = branchMetrics.Sum(b => b.TotalOrders),
AverageRevenuePerBranch = branchMetrics.Average(b => b.TotalRevenue),
AverageOrderValue = branchMetrics.Average(b => b.AverageOrderValue),
BranchCount = branches.Count,
TopPerformingBranch = branchMetrics
.OrderByDescending(b => b.TotalRevenue).First(),
BottomPerformingBranch = branchMetrics
.OrderBy(b => b.TotalRevenue).First(),
BranchMetrics = branchMetrics
.OrderByDescending(b => b.TotalRevenue).ToList(),
RegionalBreakdown = branchMetrics
.GroupBy(b => b.City)
.Select(g => new RegionalMetrics
{
Region = g.Key,
BranchCount = g.Count(),
TotalRevenue = g.Sum(b => b.TotalRevenue),
AverageRevenue = g.Average(b => b.TotalRevenue)
}).ToList()
};
}
public async Task<CrossBranchInventory> GetCrossBranchInventoryAsync(
Guid franchiseId, string ingredientName)
{
var branches = await _restaurantRepo.GetByFranchiseAsync(franchiseId);
var stockLevels = new List<BranchStockLevel>();
foreach (var branch in branches)
{
var stock = await _inventoryRepo
.GetStockByNameAsync(branch.Id, ingredientName);
if (stock != null)
{
stockLevels.Add(new BranchStockLevel
{
BranchId = branch.Id,
BranchName = branch.Name,
CurrentStock = stock.CurrentQuantity,
MinimumRequired = stock.MinimumThreshold,
MaximumCapacity = stock.MaximumCapacity,
Status = stock.CurrentQuantity < stock.MinimumThreshold
? StockStatus.Low
: stock.CurrentQuantity > stock.MaximumCapacity * 0.9
? StockStatus.Overstocked
: StockStatus.Normal
});
}
}
var surplusBranches = stockLevels
.Where(s => s.Status == StockStatus.Normal || s.Status == StockStatus.Overstocked)
.OrderByDescending(s => s.CurrentStock - s.MinimumRequired);
var deficitBranches = stockLevels
.Where(s => s.Status == StockStatus.Low)
.OrderBy(s => s.CurrentStock);
return new CrossBranchInventory
{
IngredientName = ingredientName,
BranchStockLevels = stockLevels,
SuggestedTransfers = GenerateTransferSuggestions(
surplusBranches, deficitBranches),
TotalSurplus = surplusBranches.Sum(s => s.CurrentStock - s.MinimumRequired),
TotalDeficit = deficitBranches.Sum(s => s.MinimumRequired - s.CurrentStock)
};
}
public async Task PublishMenuUpdateAsync(
Guid franchiseId, MenuUpdateRequest request)
{
var branches = await _restaurantRepo.GetByFranchiseAsync(franchiseId);
foreach (var branch in branches)
{
if (branch.Status == RestaurantStatus.Open)
{
await _menuRepo.UpdateMenuItemAsync(
branch.Id, request.MenuItemId, request.Updates);
}
}
foreach (var branch in branches)
await _cache.RemoveAsync($"menu:{branch.Id}");
}
}
Multi-Branch Access Control
| Role | Scope | Can Access | Cannot Access |
|---|---|---|---|
| Branch Manager | Single branch | All branch data, local reports | Other branches, franchise-level settings |
| Regional Director | Region (10-50 branches) | Regional aggregate, branch comparison | Other regions, individual item-level data |
| Franchise Owner | All franchise branches | Full franchise data, all reports | Other franchises |
| Corporate Admin | Global | All franchises, system settings | None |
18. Reporting and Analytics Engine
The analytics engine transforms raw transactional data into actionable business intelligence. Restaurant owners need to understand which menu items are most profitable, which dayparts generate the most revenue, which servers have the highest average check sizes, and how customer loyalty drives repeat visits. The analytics pipeline uses Apache Kafka for real-time event streaming, ClickHouse for columnar analytical storage, and a scheduled aggregation service for pre-computed daily, weekly, and monthly reports.
public class AnalyticsService
{
private readonly IClickHouseRepository _clickhouse;
private readonly IReportRepository _reportRepo;
private readonly IOrderRepository _orderRepo;
public async Task<SalesReport> GenerateSalesReportAsync(
Guid restaurantId, ReportPeriod period)
{
var query = $@"
SELECT
toDate(order_placed_at) as order_date,
count(DISTINCT order_id) as total_orders,
sum(total_amount) as total_revenue,
avg(total_amount) as avg_order_value,
countIf(order_type = 'DineIn') as dine_in_orders,
countIf(order_type = 'Takeout') as takeout_orders,
countIf(order_type = 'Delivery') as delivery_orders,
sumIf(total_amount, order_type = 'DineIn') as dine_in_revenue,
sumIf(total_amount, order_type = 'Takeout') as takeout_revenue,
sumIf(total_amount, order_type = 'Delivery') as delivery_revenue,
quantile(0.50)(total_amount) as median_order_value,
quantile(0.95)(total_amount) as p95_order_value,
max(total_amount) as max_order_value
FROM orders
WHERE restaurant_id = '{restaurantId}'
AND order_placed_at >= '{period.StartDate:yyyy-MM-dd}'
AND order_placed_at < '{period.EndDate.AddDays(1):yyyy-MM-dd}'
GROUP BY order_date
ORDER BY order_date
";
var dailyData = await _clickhouse.QueryAsync<DailySalesData>(query);
var topItemsQuery = $@"
SELECT
oi.item_name,
count(*) as times_ordered,
sum(oi.quantity) as total_quantity,
sum(oi.total_price) as total_revenue,
avg(oi.unit_price) as avg_price
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.restaurant_id = '{restaurantId}'
AND o.order_placed_at >= '{period.StartDate:yyyy-MM-dd}'
AND o.order_placed_at < '{period.EndDate.AddDays(1):yyyy-MM-dd}'
GROUP BY oi.item_name
ORDER BY total_revenue DESC
LIMIT 20
";
var topItems = await _clickhouse
.QueryAsync<TopSellingItem>(topItemsQuery);
return new SalesReport
{
RestaurantId = restaurantId,
Period = period,
DailyData = dailyData,
TotalRevenue = dailyData.Sum(d => d.TotalRevenue),
TotalOrders = dailyData.Sum(d => d.TotalOrders),
AverageOrderValue = dailyData.Average(d => d.AvgOrderValue),
TopSellingItems = topItems,
RevenueGrowth = CalculateGrowthRate(dailyData),
PeakDay = dailyData.OrderByDescending(d => d.TotalRevenue).First(),
SlowestDay = dailyData.OrderBy(d => d.TotalRevenue).First()
};
}
public async Task<MenuAnalytics> GetMenuAnalyticsAsync(
Guid restaurantId, DateTime startDate, DateTime endDate)
{
var query = $@"
SELECT
mi.name as item_name,
mc.name as category_name,
count(DISTINCT oi.order_id) as order_count,
sum(oi.quantity) as quantity_sold,
sum(oi.total_price) as total_revenue,
avg(oi.unit_price) as avg_selling_price,
(SELECT sum(ri.quantity_per_unit * inv.cost_per_unit)
FROM recipe_ingredients ri
JOIN inventory_items inv ON inv.id = ri.inventory_item_id
WHERE ri.menu_item_id = mi.id) as ingredient_cost
FROM order_items oi
JOIN menu_items mi ON mi.id = oi.menu_item_id
JOIN menu_categories mc ON mc.id = mi.category_id
JOIN orders o ON o.id = oi.order_id
WHERE o.restaurant_id = '{restaurantId}'
AND o.order_placed_at >= '{startDate:yyyy-MM-dd}'
AND o.order_placed_at < '{endDate.AddDays(1):yyyy-MM-dd}'
GROUP BY mi.name, mc.name, mi.id
ORDER BY total_revenue DESC
";
var items = await _clickhouse.QueryAsync<MenuItemAnalytics>(query);
return new MenuAnalytics
{
RestaurantId = restaurantId,
Period = new ReportPeriod { StartDate = startDate, EndDate = endDate },
ItemAnalytics = items.Select(i => new ExtendedMenuItemAnalytics
{
ItemName = i.ItemName,
CategoryName = i.CategoryName,
QuantitySold = i.QuantitySold,
TotalRevenue = i.TotalRevenue,
IngredientCost = i.IngredientCost,
ProfitMargin = ((i.TotalRevenue - i.IngredientCost) / i.TotalRevenue) * 100,
FoodCostPercentage = (i.IngredientCost / i.TotalRevenue) * 100
}).ToList(),
CategoryBreakdown = items.GroupBy(i => i.CategoryName)
.Select(g => new CategoryAnalytics
{
CategoryName = g.Key,
TotalRevenue = g.Sum(i => i.TotalRevenue),
TotalItemsSold = g.Sum(i => i.QuantitySold),
AverageProfitMargin = g.Average(i =>
((i.TotalRevenue - i.IngredientCost) / i.TotalRevenue) * 100)
}).ToList()
};
}
public async Task<CustomerInsights> GetCustomerInsightsAsync(
Guid restaurantId, DateTime startDate, DateTime endDate)
{
var customerData = await _clickhouse.QueryAsync<CustomerVisitData>($@"
SELECT
c.id as customer_id,
c.first_name || ' ' || c.last_name as customer_name,
c.email,
c.loyalty_tier,
count(DISTINCT o.id) as visit_count,
sum(o.total_amount) as total_spent,
avg(o.total_amount) as avg_order_value,
min(o.order_placed_at) as first_visit,
max(o.order_placed_at) as last_visit,
dateDiff('day', max(o.order_placed_at), today()) as days_since_last_visit
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.restaurant_id = '{restaurantId}'
AND o.order_placed_at >= '{startDate:yyyy-MM-dd}'
AND o.order_placed_at < '{endDate.AddDays(1):yyyy-MM-dd}'
GROUP BY c.id, c.first_name, c.last_name, c.email, c.loyalty_tier
ORDER BY total_spent DESC
");
var totalCustomers = customerData.Count;
var newCustomers = customerData.Count(c => c.FirstVisit >= startDate);
return new CustomerInsights
{
RestaurantId = restaurantId,
TotalActiveCustomers = totalCustomers,
NewCustomersInPeriod = newCustomers,
ReturningCustomers = totalCustomers - newCustomers,
AverageCustomerLifetimeValue = customerData.Average(c => c.TotalSpent),
TopCustomersBySpend = customerData.Take(20).ToList(),
CustomersAtRisk = customerData
.Where(c => c.DaysSinceLastVisit > 60).ToList(),
TierDistribution = customerData
.GroupBy(c => c.LoyaltyTier)
.ToDictionary(g => g.Key, g => g.Count())
};
}
}
Key Analytics Metrics
| Metric | Calculation | Target |
|---|---|---|
| Average Order Value | Total Revenue / Total Orders | $25-$45 (varies by segment) |
| Food Cost Percentage | Ingredient Cost / Revenue * 100 | 25-35% |
| Labor Cost Percentage | Labor Cost / Revenue * 100 | 28-35% |
| Table Turnover Rate | Parties Served / Available Tables | 2-3x per meal period |
| Customer Retention Rate | Returning / Total Customers * 100 | > 40% |
| Online Order Mix | Online Orders / Total Orders * 100 | 25-40% |
| Order Accuracy Rate | Correct Orders / Total Orders * 100 | > 98% |
19. Real-Time Dashboard
The real-time dashboard provides restaurant managers with a live view of operations. It shows current table occupancy, active orders in the kitchen pipeline, hourly revenue accumulation, server performance, and inventory alerts. The dashboard must update in real-time using WebSocket connections (via SignalR in ASP.NET Core) to push updates from the backend without requiring manual page refreshes. The data shown is aggregated from Redis for real-time metrics and ClickHouse for historical comparison.
[Authorize]
public class DashboardHub : Hub
{
private readonly IRedisService _redis;
private readonly IAnalyticsService _analytics;
private readonly IKitchenNotificationService _kitchenNotifier;
public async Task JoinBranchRoom(Guid restaurantId)
{
await Groups.AddToGroupAsync(
Context.ConnectionId, $"branch:{restaurantId}");
var initialState = await GetDashboardStateAsync(restaurantId);
await Clients.Caller.SendAsync("InitialState", initialState);
}
public async Task<DashboardState> GetDashboardStateAsync(Guid restaurantId)
{
return new DashboardState
{
Timestamp = DateTime.UtcNow,
TableStatus = await GetTableStatusSummaryAsync(restaurantId),
KitchenStatus = await GetKitchenSummaryAsync(restaurantId),
RevenueToday = await GetTodayRevenueAsync(restaurantId),
OrdersToday = await GetTodayOrderCountAsync(restaurantId),
AverageOrderValue = await GetTodayAOVAsync(restaurantId),
StaffOnDuty = await GetStaffOnDutyAsync(restaurantId),
InventoryAlerts = await GetActiveAlertsAsync(restaurantId),
HourlyRevenueChart = await GetHourlyRevenueAsync(restaurantId),
ComparisonToYesterday = await CompareToYesterdayAsync(restaurantId)
};
}
private async Task<TableStatusSummary> GetTableStatusSummaryAsync(
Guid restaurantId)
{
var tableStates = await _redis.HashGetAllAsync(
$"table_status:{restaurantId}");
var statuses = tableStates
.Select(h => JsonSerializer.Deserialize<TableInfo>(h.Value))
.GroupBy(t => t.Status)
.ToDictionary(g => g.Key, g => g.Count());
return new TableStatusSummary
{
TotalTables = tableStates.Length,
Available = statuses.GetValueOrDefault("Available", 0),
Occupied = statuses.GetValueOrDefault("Occupied", 0),
Reserved = statuses.GetValueOrDefault("Reserved", 0),
Cleaning = statuses.GetValueOrDefault("Cleaning", 0),
UtilizationRate = tableStates.Length > 0
? (double)statuses.GetValueOrDefault("Occupied", 0) /
tableStates.Length * 100
: 0
};
}
private async Task<KitchenSummary> GetKitchenSummaryAsync(
Guid restaurantId)
{
var stations = await _kitchenNotifier
.GetStationStatusesAsync(restaurantId);
return new KitchenSummary
{
TotalPendingOrders = stations.Sum(s => s.PendingOrderCount),
TotalPreparingItems = stations.Sum(s => s.PreparingItemCount),
TotalReadyItems = stations.Sum(s => s.ReadyItemCount),
AveragePrepTime = stations.Average(s => s.AveragePrepTimeMinutes),
OverdueItems = stations.Sum(s => s.OverdueCount),
StationDetails = stations
};
}
}
public class RealTimeDashboardService
{
private readonly DashboardHub _hub;
private readonly Timer _updateTimer;
public RealTimeDashboardService(DashboardHub hub)
{
_hub = hub;
_updateTimer = new Timer(
async _ => await BroadcastUpdatesAsync(),
null,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(5));
}
private async Task BroadcastUpdatesAsync()
{
var activeRestaurants = await GetActiveRestaurantIdsAsync();
foreach (var restaurantId in activeRestaurants)
{
var state = await _hub.GetDashboardStateAsync(restaurantId);
await _hub.Clients.Group($"branch:{restaurantId}")
.SendAsync("DashboardUpdate", state);
}
}
}
Dashboard Widgets
20. Security and Compliance
Restaurant management systems handle sensitive data across multiple compliance domains. Payment processing requires PCI DSS compliance. Customer personal data (names, emails, phone numbers, addresses) falls under GDPR and CCPA regulations. Employee data including Social Security numbers and bank accounts for payroll must be encrypted at rest. The system must implement role-based access control (RBAC) with granular permissions, audit logging for all financial transactions, and secure API authentication using JWT tokens with short expiration and refresh token rotation.
public class SecurityService
{
private readonly IJwtTokenService _tokenService;
private readonly IAuditLogger _auditLogger;
private readonly IEncryptionService _encryption;
public async Task<AuthResult> AuthenticateAsync(LoginRequest request)
{
var staff = await _staffRepo.GetByEmailAsync(request.Email);
if (staff == null || !VerifyPassword(request.Password, staff.PasswordHash))
{
await _auditLogger.LogAsync(new AuditEntry
{
Action = "LoginFailed",
Email = request.Email,
Timestamp = DateTime.UtcNow,
IpAddress = request.IpAddress
});
return new AuthResult { Success = false };
}
var permissions = await GetPermissionsAsync(staff.Role, staff.RestaurantId);
var accessToken = _tokenService.GenerateAccessToken(new TokenPayload
{
StaffId = staff.Id,
RestaurantId = staff.RestaurantId,
Role = staff.Role,
Permissions = permissions,
Expiration = DateTime.UtcNow.AddMinutes(15)
});
var refreshToken = await _tokenService.GenerateRefreshTokenAsync(staff.Id);
await _auditLogger.LogAsync(new AuditEntry
{
Action = "LoginSuccess",
StaffId = staff.Id,
RestaurantId = staff.RestaurantId,
Timestamp = DateTime.UtcNow,
IpAddress = request.IpAddress
});
return new AuthResult
{
Success = true,
AccessToken = accessToken,
RefreshToken = refreshToken.Token,
ExpiresIn = 900
};
}
public async Task<bool> AuthorizeAsync(
Guid staffId, string resource, string action)
{
var staff = await _staffRepo.GetByIdAsync(staffId);
var permissions = await GetPermissionsAsync(staff.Role, staff.RestaurantId);
return permissions.Any(p =>
p.Resource == resource &&
p.Actions.Contains(action));
}
private async Task<List<Permission>> GetPermissionsAsync(
StaffRole role, Guid restaurantId)
{
return role switch
{
StaffRole.Manager => new List<Permission>
{
new("orders", new[] { "create", "read", "update", "delete", "void" }),
new("menu", new[] { "create", "read", "update", "delete" }),
new("inventory", new[] { "create", "read", "update", "restock" }),
new("payments", new[] { "process", "refund", "reconcile" }),
new("staff", new[] { "create", "read", "update", "schedule" }),
new("reports", new[] { "read", "export" }),
new("settings", new[] { "read", "update" })
},
StaffRole.Server => new List<Permission>
{
new("orders", new[] { "create", "read", "update" }),
new("menu", new[] { "read" }),
new("payments", new[] { "process" }),
new("tables", new[] { "read", "assign" })
},
StaffRole.Chef => new List<Permission>
{
new("orders", new[] { "read", "update_status" }),
new("menu", new[] { "read", "update_availability" }),
new("inventory", new[] { "read" })
},
_ => new List<Permission>()
};
}
}
Security Controls Matrix
| Control | Implementation | Frequency |
|---|---|---|
| Authentication | JWT with 15-min expiry + refresh tokens | Every request |
| Authorization | RBAC with role + resource + action matrix | Every request |
| Data Encryption | AES-256 at rest, TLS 1.3 in transit | Always |
| Payment Data | Tokenized via processor (never stored locally) | Every transaction |
| Audit Logging | Immutable append-only log for all financial events | Every event |
| Rate Limiting | Per-branch, per-endpoint with sliding window | Every request |
| Input Validation | FluentValidation + parameterized queries | Every request |
| Vulnerability Scanning | Automated dependency and container scanning | Daily |
21. Cost Estimation and Infrastructure Sizing
Running a restaurant management platform for 10,000 branches requires significant infrastructure investment. The cost model must account for compute resources, database hosting, message broker licensing, CDN for static assets, monitoring and observability tools, and third-party service fees. Below we provide a detailed breakdown for a cloud-native deployment on AWS.
| Component | Specification | Monthly Cost (USD) |
|---|---|---|
| Application Servers (EKS) | 20x m5.xlarge (4 vCPU, 16GB RAM) | $5,500 |
| PostgreSQL (RDS) | Multi-AZ, db.r5.2xlarge, 2TB storage | $3,200 |
| Redis Cluster (ElastiCache) | 6-node, r5.xlarge | $2,800 |
| ClickHouse (EC2) | 4x r5.2xlarge, 4TB NVMe | $3,500 |
| Apache Kafka (MSK) | 6 brokers, kafka.m5.2xlarge | $3,000 |
| Elasticsearch (OpenSearch) | 6-node, r5.xlarge.search | $2,400 |
| S3 Storage | 5TB, Standard + Glacier | $250 |
| CloudFront CDN | 10TB monthly transfer | $850 |
| Monitoring (Datadog) | 20 hosts, logs, APM | $2,000 |
| PagerDuty | Team plan, 10 users | $300 |
| Domain and DNS (Route53) | Hosted zone + queries | $50 |
| SSL Certificates | ACM managed | $0 |
| Total Infrastructure | $23,850 |
Additional operational costs include DevOps engineer salaries, on-call rotation staffing, disaster recovery testing, and compliance auditing. For a SaaS platform selling to 10,000 branches at an average of $200 per branch per month, the platform generates approximately $2 million in monthly recurring revenue, with infrastructure costs representing approximately 12% of revenue - a healthy margin for the restaurant technology vertical.
22. Testing Strategy
A comprehensive testing strategy for a restaurant management system must cover unit tests for business logic, integration tests for database and external service interactions, end-to-end tests for critical user flows like order placement and payment processing, performance tests simulating peak dinner rush traffic, and chaos engineering tests that verify the system behavior during network partitions and service failures.
public class OrderServiceTests
{
private readonly Mock<IOrderRepository> _orderRepo;
private readonly Mock<IKitchenNotificationService> _kitchenNotifier;
private readonly Mock<IInventoryService> _inventoryService;
private readonly Mock<ILoyaltyService> _loyaltyService;
private readonly PosOrderService _sut;
public OrderServiceTests()
{
_orderRepo = new Mock<IOrderRepository>();
_kitchenNotifier = new Mock<IKitchenNotificationService>();
_inventoryService = new Mock<IInventoryService>();
_loyaltyService = new Mock<ILoyaltyService>();
_sut = new PosOrderService(
_orderRepo.Object, _kitchenNotifier.Object,
_inventoryService.Object, null, _loyaltyService.Object,
null, null);
}
[Fact]
public async Task PlaceOrder_ValidDineInOrder_CreatesOrderAndNotifiesKitchen()
{
// Arrange
var restaurantId = Guid.NewGuid();
var menuItem = new MenuItem
{
Id = Guid.NewGuid(),
Name = "Classic Burger",
BasePrice = 12.99m,
IsAvailable = true
};
var request = new PosOrderRequest
{
TableNumber = 5,
PartySize = 2,
ServerId = Guid.NewGuid(),
OrderType = OrderType.DineIn,
Items = new List<OrderItemRequest>
{
new OrderItemRequest
{
MenuItemId = menuItem.Id,
Quantity = 2
}
}
};
_menuRepo.Setup(r => r.GetItemAsync(restaurantId, It.IsAny<Guid>()))
.ReturnsAsync(menuItem);
// Act
var result = await _sut.PlaceOrderAsync(restaurantId, request);
// Assert
Assert.NotNull(result);
Assert.NotEqual(Guid.Empty, result.OrderId);
Assert.Equal(25.98m, result.TotalAmount, 2);
_orderRepo.Verify(r => r.CreateAsync(It.IsAny<Order>()), Times.Once);
_kitchenNotifier.Verify(n => n.SendOrderToKitchenAsync(It.IsAny<Order>()), Times.Once);
_inventoryService.Verify(i => i.DeductForOrderAsync(restaurantId, It.IsAny<List<OrderItem>>()), Times.Once);
}
[Fact]
public async Task PlaceOrder_UnavailableMenuItem_ThrowsException()
{
// Arrange
var restaurantId = Guid.NewGuid();
var menuItem = new MenuItem
{
Id = Guid.NewGuid(),
Name = "Daily Special",
IsAvailable = false
};
_menuRepo.Setup(r => r.GetItemAsync(restaurantId, It.IsAny<Guid>()))
.ReturnsAsync(menuItem);
var request = new PosOrderRequest
{
TableNumber = 5,
OrderType = OrderType.DineIn,
Items = new List<OrderItemRequest>
{
new OrderItemRequest
{
MenuItemId = menuItem.Id,
Quantity = 1
}
}
};
// Act and Assert
await Assert.ThrowsAsync<MenuItemUnavailableException>(
() => _sut.PlaceOrderAsync(restaurantId, request));
}
[Fact]
public async Task SplitBill_EqualSplit_CreatesCorrectPayments()
{
// Arrange
var restaurantId = Guid.NewGuid();
var orderId = Guid.NewGuid();
var order = new Order
{
Id = orderId,
TotalAmount = 60.00m,
Items = new List<OrderItem>
{
new OrderItem { TotalPrice = 20.00m },
new OrderItem { TotalPrice = 20.00m },
new OrderItem { TotalPrice = 20.00m }
}
};
_orderRepo.Setup(r => r.GetByIdAsync(restaurantId, orderId))
.ReturnsAsync(order);
// Act
await _sut.SplitBillAsync(restaurantId, orderId, new SplitBillRequest
{
SplitMethod = SplitMethod.Equal,
NumberOfSplits = 3
});
// Assert
_orderRepo.Verify(r => r.UpdateAsync(It.Is<Order>(o =>
o.SplitPayments.Count == 3 &&
o.SplitPayments.All(p => p.Amount == 20.00m))),
Times.Once);
}
}
public class PaymentServiceTests
{
[Fact]
public async Task ProcessPayment_CardPayment_ProcessesSuccessfully()
{
// Arrange
var paymentService = CreatePaymentService();
var order = CreateOrderWithTotal(45.50m);
var request = new ProcessPaymentRequest
{
PaymentMethod = PaymentMethod.CreditCard,
CardToken = "tok_visa_1234",
TipAmount = 8.00m
};
// Act
var result = await paymentService.ProcessPaymentAsync(
order.RestaurantId, order.Id, request);
// Assert
Assert.True(result.Success);
Assert.Equal(PaymentStatus.Captured,
result.Payment.Status);
Assert.Equal(45.50m, result.Payment.Amount);
Assert.Equal(8.00m, result.Payment.TipAmount);
}
[Fact]
public async Task ProcessPayment_Overpayment_ThrowsException()
{
// Arrange
var paymentService = CreatePaymentService();
var order = CreateOrderWithTotal(45.50m);
var request = new ProcessPaymentRequest
{
PaymentMethod = PaymentMethod.CreditCard,
CardToken = "tok_visa_1234",
Amount = 100.00m
};
// Act and Assert
await Assert.ThrowsAsync<OverpaymentException>(
() => paymentService.ProcessPaymentAsync(
order.RestaurantId, order.Id, request));
}
}
Testing Coverage Targets
| Test Type | Coverage Target | Key Focus Areas |
|---|---|---|
| Unit Tests | 90% line coverage | Order logic, pricing, inventory deduction, loyalty calculations |
| Integration Tests | All API endpoints | Database operations, Redis caching, Kafka event publishing |
| E2E Tests | Critical user flows | Order-to-payment, reservation lifecycle, delivery sync |
| Performance Tests | All critical paths | Peak dinner rush (100x baseline), concurrent POS operations |
| Chaos Tests | Failure scenarios | Network partitions, database failover, Redis eviction |
| Security Tests | OWASP Top 10 | SQL injection, XSS, broken authentication, privilege escalation |
23. Interview Q and A
Q1: How would you handle network outages at a restaurant so orders are not lost?
Answer: Implement an offline-first architecture on POS terminals using a local SQLite database. When a network outage occurs, the POS continues to accept orders and store them locally with unique UUIDs. A background sync service monitors connectivity and automatically pushes pending orders to the server when connectivity is restored. The server uses idempotency keys (the UUID) to prevent duplicate order creation. For payment processing, the system should queue card-present transactions using the payment processor offline mode or defer to a store-and-forward mechanism. The local cache should hold the current menu, table states, and staff information updated periodically so the POS can validate orders against available items even without server access.
Q2: How do you prevent double-booking a table when two hosts try to reserve simultaneously?
Answer: Use optimistic concurrency control on the table resource. Each table record has a version number. When creating a reservation, the system reads the current version, checks availability, and then issues an UPDATE with a WHERE clause matching the version. If another host reserved the same table in between, the version will not match, the update affects zero rows, and the system retries with an alternative table. For higher consistency guarantees, use a distributed lock (Redis-based Redlock) keyed to the table number and time slot during the reservation creation window. The lock should have a TTL of 5 seconds to prevent deadlocks.
Q3: How would you design the kitchen display system to handle 50 concurrent orders across 6 stations?
Answer: Each kitchen station runs an independent display client connected to the backend via a dedicated SignalR group. When an order is placed, the order service groups items by kitchen station ID and publishes individual station-specific messages. Redis lists maintain per-station order queues, allowing fast push/pop operations without database round-trips. The display clients poll at 5-second intervals as a fallback but primarily rely on SignalR real-time push. Each station queue stores serialized KitchenOrder objects with embedded timers. A separate timer service tracks preparation time per item and triggers alerts when items exceed their estimated preparation time. The bump-bar input maps to an HTTP POST to the mark-complete endpoint, which updates the order item status, removes it from the Redis queue, and checks if all items for the parent order are complete before notifying the server.
Q4: How do you handle the inventory deduction race condition when the same menu item is ordered on two POS terminals simultaneously?
Answer: Use a database-level atomic decrement operation. Instead of reading the current quantity, subtracting in application code, and writing back (which creates a TOCTOU race), issue a single UPDATE statement: UPDATE inventory_items SET current_quantity = current_quantity - :required WHERE current_quantity >= :required AND id = :id. The WHERE clause ensures the deduction only succeeds if sufficient stock exists. If the update affects zero rows, the item is out of stock and an exception is thrown. For high-throughput scenarios, use a Redis-based semaphore or Lua script for atomic check-and-deduct operations, with periodic reconciliation against the PostgreSQL source of truth. The Kafka event-log provides an audit trail of all deductions for debugging discrepancies.
Q5: How would you design the system to support multi-branch franchises with different menus and pricing?
Answer: Use a multi-tenant architecture where each branch is a tenant with a unique restaurant_id. The menu_items table has a composite key of (restaurant_id, item_id), allowing each branch to have its own menu items, pricing, availability, and modifiers. A franchise-level template menu exists that branches can inherit from and customize. The API Gateway extracts the restaurant_id from the JWT token claims and passes it to all downstream services, ensuring data isolation. For analytics and franchise-level reporting, a separate analytics service queries a denormalized ClickHouse dataset that aggregates across branches. Menu updates from headquarters are published as Kafka events, and each branch service listens for updates relevant to its franchise_id, applying changes only to items that have not been locally customized.
Q6: Explain how you would handle tip distribution and end-of-day reconciliation.
Answer: Tips are captured at the payment level, not the order level, because a single order may have multiple payments (split bill). Each Payment record stores a TipAmount field. During end-of-day reconciliation, the system aggregates all tips per server (identified by the order ServerId), groups them by payment method (credit card tips go through the processor, cash tips are manually entered by the server), and generates a tip report. Credit card tips are settled through the payment processor batch settlement file. Cash tips are reconciled against the cash drawer count. The reconciliation report shows total sales, total tips, total refunds, net sales, payment method breakdown, and per-server tip totals. Discrepancies above a configurable threshold (e.g., $5) trigger an alert to the restaurant manager.
Q7: How do you ensure the system can handle peak dinner rush traffic without degrading performance?
Answer: Peak dinner rush (6-9 PM) sees 10x normal traffic. The system handles this through several strategies. First, Redis caching eliminates database reads for hot paths like menu data, table states, and customer loyalty lookups. Second, the order service uses asynchronous processing: the POS receives an immediate acknowledgment after the order is persisted to PostgreSQL, while kitchen notification, inventory deduction, and loyalty point accrual happen asynchronously via Kafka events. Third, database connection pooling prevents connection exhaustion. Fourth, the POS terminals use local caching with a 60-second TTL for menu data. Fifth, horizontal pod autoscaling on Kubernetes scales the order service from 3 to 20 replicas based on CPU utilization exceeding 70%. Sixth, read replicas handle analytics queries without impacting the write-primary. Load testing confirms the system handles 200 orders per second with P99 latency under 500ms.
Q8: How would you design the online ordering system to handle surge pricing during high-demand periods?
Answer: The pricing service calculates a surge multiplier based on three factors: current kitchen load (pending orders as a percentage of capacity), time since last price adjustment, and historical demand patterns for the current daypart. The multiplier ranges from 1.0x (no surge) to 1.5x (maximum surge) and is recalculated every 60 seconds. When a customer views the menu online, the surge multiplier is applied transparently with clear messaging. The surge state is stored in Redis with a short TTL to ensure real-time accuracy. The surge threshold is configurable per restaurant and can be disabled entirely. During surge, the system also increases the estimated delivery time displayed to customers and may pause new order intake if kitchen capacity exceeds 95% utilization.
Q9: How do you handle delivery partner integrations when their APIs have different rate limits and reliability characteristics?
Answer: Each delivery partner is wrapped in an adapter that implements a common IDeliveryPartnerAdapter interface. The adapter handles authentication, rate limiting, retry logic, and webhook verification specific to each partner. For rate limiting, each adapter maintains a token bucket or sliding window counter. If a partner API returns a 429 Too Many Requests response, the adapter queues the request and retries after the recommended backoff period. For webhook reliability gaps (e.g., Grubhub at 98.8%), the system implements a polling fallback that queries the partner API for order status every 30 seconds if no webhook has been received within 2 minutes of an expected status change. Circuit breaker patterns prevent cascading failures: if a partner API fails 5 consecutive requests, the circuit opens for 30 seconds, and orders from that partner are queued for manual processing by restaurant staff.
Q10: What metrics would you monitor in production to ensure system health?
Answer: Key metrics span four categories. Availability metrics: API uptime (target 99.99%), service health check status, database replication lag, and Redis memory utilization. Performance metrics: API response times (P50, P95, P99) per endpoint, order placement latency, kitchen display update latency, and database query execution time. Business metrics: orders per minute, average order value, table utilization percentage, online order conversion rate, and payment failure rate. Infrastructure metrics: CPU and memory utilization per service, Kafka consumer lag, disk I/O on database servers, and network throughput. Custom alerts trigger on anomalies like: payment failure rate exceeding 1%, kitchen display latency exceeding 2 seconds, inventory deduction failures, or any 5xx error rate exceeding 0.1% over a 5-minute window. All metrics are collected via Prometheus, visualized in Grafana, and alerted via PagerDuty.
Q11: How would you implement table status tracking across multiple devices and screen sizes?
Answer: Table state is stored in Redis as a hash map keyed by restaurant_id, with each field being a table number and the value being a JSON-serialized TableInfo object containing status, party size, occupied-since timestamp, and next reservation time. All devices subscribe to a Redis pub/sub channel for their restaurant. When a table state changes (host assigns a party, server seats them, kitchen marks order complete), the service updates the Redis hash and publishes the change event. All connected devices receive the event and update their local state. For the host tablet, a floor plan visualization renders tables as colored rectangles (green=available, yellow=reserved, red=occupied, blue=cleaning). The host screen auto-refreshes every 10 seconds via polling as a fallback. For mobile devices used by servers, a compact table list view shows relevant tables with swipe gestures for quick status updates.
Q12: Design a system for automatic purchase order generation based on consumption patterns.
Answer: The inventory service runs a nightly batch job that analyzes consumption patterns over the past 30 days. For each inventory item, it calculates: average daily consumption, standard deviation, day-of-week patterns (Friday dinner requires 2x more burger patties), and seasonal trends (ice cream consumption peaks in summer). The reorder point is set at: (average_daily_consumption * supplier_lead_time_days) + safety_stock. Safety stock is calculated as: z_score * std_dev * sqrt(lead_time). When an item crosses its reorder point, the system auto-generates a draft purchase order with the optimal order quantity (calculated using the Economic Order Quantity formula to balance ordering costs against holding costs). The PO is routed to the restaurant manager for approval via the management dashboard, with email and push notifications for low-stock and out-of-stock critical alerts. Approved POs are sent to suppliers via EDI or email integration.