system-design61 min read

How to Design an Online Grocery Delivery Platform — A Senior+ Guide | Ayodhyya

How to Design an Online Grocery Delivery Platform

Building Instacart / BigBasket at Scale — Inventory, Routing, Picker Workflows & Real-Time Tracking

Senior+ System Design Guide 10,000+ Words 25 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why Grocery is Unique

Online grocery delivery is one of the most operationally complex consumer platforms ever built. Unlike food delivery (where a restaurant prepares a fixed menu) or e-commerce (where a warehouse ships sealed packages), grocery requires a human picker to walk through aisles, select perishable items by hand, weigh variable-weight products, handle substitutions in real time, and deliver everything within a narrow time window — all while maintaining cold chain integrity and freshness guarantees.

Companies like Instacart, BigBasket, Walmart Grocery, Amazon Fresh, and Blinkit (formerly Grofers) have each taken different architectural approaches, but they all contend with the same fundamental challenges: real-time inventory accuracy across thousands of SKUs, slot capacity management during peak hours, picker efficiency optimization, batch delivery routing, and a seamless customer experience that handles the inherent uncertainty of picking fresh produce and meat. The global online grocery market exceeded $300 billion in 2024 and continues to grow at 25%+ annually, making this one of the most important platform design problems in modern software engineering.

Key Insight: A grocery delivery platform is simultaneously a real-time inventory system, a logistics optimization engine, a two-sided marketplace, and a slot-constrained scheduling service. The unique complexity arises from the intersection of perishable goods (inventory decays), variable quantities (weight-based items), human-in-the-loop picking (substitutions), and tight delivery windows (1-hour slots). Most system design interviews will test 2-3 of these dimensions; grocery requires all of them working in concert.

Real-World Case Studies

CompanyModelScaleKey Innovation
InstacartMarketplace (pick from partner stores)1,400+ retailers, 80K+ shoppersReal-time shopper marketplace, batched order grouping
BigBasketDark store + delivery25M+ customers, 1,800 SKUs per storeDark store model for 10-minute delivery, own inventory
BlinkitQuick commerce (dark stores)10-minute delivery promiseHyper-local dark stores, demand-driven inventory placement
Walmart GroceryStore pickup + delivery4,700+ storesCurbside pickup integration, in-store picker workforce
OcadoAutomated warehouseCentralized fulfillment centersRobotic picking, grid-based storage, ML-driven orchestration

Instacart pioneered the marketplace model where gig-economy shoppers pick items from existing retail stores on behalf of customers. This dramatically reduced capital expenditure (no need to build warehouses) but introduced inventory accuracy challenges — the store's POS system may show 5 units in stock, but another shopper just took the last one. BigBasket and Blinkit solved this differently with "dark stores" — small warehouses designed exclusively for online fulfillment with inventory managed by the platform. Ocado took the most extreme approach with fully automated warehouses where robots pick items from a grid system. Each model has distinct architectural implications, and this guide primarily focuses on the marketplace model with dark store extensions, as it covers the broadest set of design challenges.

The fundamental lesson from studying these companies is that grocery delivery is not primarily a technology problem — it is an operations problem that technology must solve. The hardest parts are not building a search engine or payment processor; they are ensuring inventory accuracy when a human picks an avocado, calculating the correct price when bananas are sold by weight, routing a driver to deliver four orders in two hours while keeping frozen items frozen, and deciding whether to substitute a customer's preferred organic milk with a regular brand when it runs out. These operational realities must drive every architectural decision.

2. Functional & Non-Functional Requirements

Functional Requirements

  1. Product Discovery: Customers can browse products by category, search with fuzzy matching, filter by dietary preferences/allergens, and view real-time availability per store.
  2. Shopping Cart & Checkout: Customers build a cart across categories, see estimated totals including weight-based items, select delivery slots, apply promo codes, and pay via multiple methods.
  3. Slot-Based Scheduling: Customers choose from 1-hour delivery windows. System manages slot capacity per store per time window, handling peak-hour demand and fair queuing.
  4. Picker Workflow: Shoppers receive orders, pick items aisle-by-aisle within a store, handle substitutions in real time with customer pre-approval rules, and confirm item weights.
  5. Delivery & Tracking: Real-time GPS tracking of the delivery driver, ETA updates, proof of delivery, and contactless delivery options.
  6. Substitution Engine: When items are out of stock, suggest acceptable alternatives based on customer preferences, brand similarity, and nutrition profiles. Customer approves or rejects via chat.
  7. Payment & Tipping: Support credit/debit cards, digital wallets, tipping (pre-set or custom), and split payments. Final bill adjusted for actual weights and substitutions.
  8. Promos & Loyalty: Promo code system, loyalty points, first-order discounts, and dynamic pricing based on demand and time of day.
  9. Order Management: Order tracking, cancellation, refund flow, reorder from past orders, and scheduled reorders.
  10. Reviews & Ratings: Rate order experience, rate individual items, review shoppers, and flag quality issues.
  11. Customer Support: In-app chat support, automated refund for quality issues, and escalation workflows.
  12. Store Management: Admin panel for stores to manage inventory, set delivery zones, configure slot capacity, and view analytics.

Non-Functional Requirements

RequirementTargetWhy It Matters
Availability99.95% (4.38h downtime/year)Customers shop at all hours; downtime means lost orders
Inventory Accuracy≥98% within 2 minutesWrong availability = terrible UX and substitutions
Search Latency (p99)<200msFuzzy search must be fast for browsing
Slot Release Latency<500msWhen a slot opens, next customer in queue must see it instantly
Order Throughput10K orders/minute at peakPeak hours (Friday evening, holidays) must not degrade
GPS Update FrequencyEvery 5 secondsSmooth tracking experience without battery drain
Data ConsistencyEventual consistency for inventory; strong for paymentsInventory can lag briefly; money must be exact
SecurityPCI-DSS Level 1, GDPR compliantPayment data and personal info protection
Design Trade-off: Grocery inventory is fundamentally inconsistent. The store's POS, the platform's database, and the picker's actual shelf count will always differ slightly. Rather than fighting this with distributed transactions (which would kill performance), we design for eventual consistency with a 2-minute staleness window and handle discrepancies at pick time through the substitution flow.

3. Capacity Estimation & Cost Analysis

Scale Assumptions

MetricValueCalculation
Daily Active Users5 millionMid-size platform (regional scale)
Orders per Day500,00010% conversion rate
Peak Orders per Minute10,000Friday 6-8 PM surge
Average Items per Order2512.5M items picked daily
Product Catalog Size50,000 SKUsAcross all stores
Number of Stores2,000Partner stores + dark stores
Active Shoppers/Drivers100,000Gig workers
Average Delivery Slots per Day14 (7am-9pm, hourly)Per store
Slot Capacity per Store per Hour30 ordersPick + pack + deliver capacity

Storage Estimation

  • Product Catalog: 50K SKUs x 2KB avg = ~100MB (fits in memory on any Redis node)
  • Inventory Records: 50K SKUs x 2K stores x 100 bytes = ~10GB (distributed across store partitions)
  • Orders: 500K/day x 1KB per order header + 25 items x 200 bytes = ~3TB/year
  • Order Items (line items): 12.5M items/day x 300 bytes = ~1.4TB/year
  • GPS Tracking Logs: 100K drivers x 5s interval x 100 bytes x 14h = ~100GB/day
  • User Profiles: 5M users x 1KB = ~5GB
  • Search Index: 50K SKUs x 500 bytes (tokenized) = ~25MB inverted index

Bandwidth Estimation

  • Search Queries: 500K DAU x 20 searches/day = 10M queries/day = ~115 QPS average, ~500 QPS peak
  • Inventory Updates: 12.5M item picks/day + restocks = ~200 updates/second average
  • GPS Updates: 100K drivers x 1 update/5s = 20K updates/second
  • Order Creation: 500K/day = ~6 QPS average, ~170 QPS peak

Cost Estimation (Monthly)

ServiceSpecificationMonthly Cost
Application Servers (API)16x c6i.2xlarge (8 vCPU, 16GB)$4,500
PostgreSQL (Orders, Users)r6g.2xlarge, multi-AZ, 1TB$3,200
Redis Cluster (Inventory, Cache)6x r6g.xlarge nodes$2,800
Elasticsearch (Search)6x m6i.xlarge nodes$3,600
Kafka (Event Streaming)6x m6i.xlarge brokers$2,400
ClickHouse (GPS, Analytics)4x c6i.4xlarge$4,200
CDN (Product Images)50TB/month transfer$500
ML Inference (Forecasting)2x g5.xlarge (GPU)$1,800
Total~$23,000/month
Cost per Order: $23,000 / (500K x 30 days) = ~$0.15 per order in infrastructure cost. At an average order value of $80 with a 15% take rate ($12 revenue per order), this represents ~1.25% of revenue — well within sustainable unit economics for a grocery platform.

4. Product Catalog & Real-Time Inventory

The product catalog and inventory system is the backbone of any grocery platform. Unlike standard e-commerce where a product is either "in stock" or "out of stock," grocery inventory is dynamic, perishable, weight-based, and store-specific. A customer in downtown sees different availability than one in the suburbs, and both may see different availability at 8 AM versus 8 PM as items sell out throughout the day.

Catalog Schema

C#
public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }                  // "Organic Whole Milk 1 Gallon"
    public string Brand { get; set; }                 // "Horizon Organic"
    public string Category { get; set; }              // "Dairy & Eggs"
    public string Subcategory { get; set; }           // "Milk"
    public List<string> Images { get; set; }
    public string Unit { get; set; }                  // "each", "lb", "oz", "fl oz"
    public decimal UnitPrice { get; set; }            // Price per unit
    public bool IsWeightBased { get; set; }           // true for produce, meat
    public bool RequiresAgeVerification { get; set; } // Alcohol
    public List<string> Allergens { get; set; }      // ["Milk", "Soy"]
    public List<string> DietaryTags { get; set; }    // ["Organic", "Non-GMO", "Keto"]
    public NutritionalInfo Nutrition { get; set; }
    public string Barcode { get; set; }
    public string Description { get; set; }
    public Dictionary<Guid, StoreInventory> StoreInventories { get; set; }
}

public class StoreInventory
{
    public Guid StoreId { get; set; }
    public Guid ProductId { get; set; }
    public int QuantityOnHand { get; set; }
    public int QuantityReserved { get; set; }         // In active carts / being picked
    public int ReorderPoint { get; set; }
    public int ReorderQuantity { get; set; }
    public DateTime LastRestockedAt { get; set; }
    public DateTime LastSyncedAt { get; set; }        // POS sync timestamp
    public decimal? ActualWeight { get; set; }        // For weight-based items
    public string BatchId { get; set; }               // For traceability
    public DateTime? ExpiryDate { get; set; }
}

public class NutritionalInfo
{
    public int Calories { get; set; }
    public decimal FatGrams { get; set; }
    public decimal ProteinGrams { get; set; }
    public decimal CarbohydrateGrams { get; set; }
    public decimal SugarGrams { get; set; }
    public decimal FiberGrams { get; set; }
    public decimal SodiumMilligrams { get; set; }
    public List<string> Ingredients { get; set; }
}

Real-Time Inventory Architecture

Inventory updates flow through a multi-layer architecture designed for speed and consistency. The critical insight is that we don't need perfect real-time accuracy for browsing — we need accuracy at the moment of checkout. During browsing, a slightly stale count is acceptable. At checkout, we lock inventory and verify freshness.

graph TB POS[Store POS Systems] -->|Webhook/Polling| Sync[Inventory Sync Service] Sync -->|Kafka| EventBus[Inventory Event Bus] EventBus -->|Update| Redis[(Redis Inventory Cache)] EventBus -->|Persist| PG[(PostgreSQL Inventory DB)] EventBus -->|Update| ES[(Search Index)] Redis -->|Read| API[API Gateway] PG -->|Reconciliation| Reconcile[Reconciliation Worker] Reconcile -->|Alert| Ops[Operations Dashboard] POS -.->|Periodic Full Sync| Reconcile

Inventory Update Flow

The inventory sync service supports two modes: event-driven (webhooks from POS systems when items are sold) and polling-based (periodic reconciliation every 5 minutes). Event-driven updates provide near-real-time accuracy, but not all partner stores support webhooks. The polling fallback ensures we never drift too far from reality.

C#
public class InventoryService
{
    private readonly IDistributedCache _redis;
    private readonly IEventBus _eventBus;
    private readonly IInventoryRepository _db;

    public async Task<InventoryAvailability> CheckAvailabilityAsync(
        Guid productId, Guid storeId, int requestedQty)
    {
        var key = $"inventory:{storeId}:{productId}";
        var cached = await _redis.GetAsync<StoreInventory>(key);

        if (cached == null || IsStale(cached.LastSyncedAt, TimeSpan.FromMinutes(2)))
        {
            cached = await _db.GetInventoryAsync(productId, storeId);
            await _redis.SetAsync(key, cached, TimeSpan.FromMinutes(5));
        }

        var available = cached.QuantityOnHand - cached.QuantityReserved;
        return new InventoryAvailability
        {
            IsAvailable = available >= requestedQty,
            AvailableQuantity = available,
            IsEstimated = IsStale(cached.LastSyncedAt, TimeSpan.FromMinutes(1)),
            LastUpdated = cached.LastSyncedAt
        };
    }

    public async Task<bool> ReserveInventoryAsync(
        Guid productId, Guid storeId, int quantity, Guid orderId)
    {
        var lockKey = $"lock:inventory:{storeId}:{productId}";
        using var redLock = await _redis.AcquireLockAsync(
            lockKey, TimeSpan.FromSeconds(10));

        if (redLock == null)
            throw new ConcurrencyException("Could not acquire inventory lock");

        var inventory = await _db.GetInventoryAsync(productId, storeId);
        var available = inventory.QuantityOnHand - inventory.QuantityReserved;

        if (available < quantity) return false;

        inventory.QuantityReserved += quantity;
        await _db.UpdateInventoryAsync(inventory);
        await _redis.SetAsync(
            $"inventory:{storeId}:{productId}",
            inventory,
            TimeSpan.FromMinutes(5));

        await _eventBus.PublishAsync(new InventoryReservedEvent
        {
            ProductId = productId,
            StoreId = storeId,
            Quantity = quantity,
            OrderId = orderId,
            Timestamp = DateTime.UtcNow
        });

        return true;
    }

    private bool IsStale(DateTime lastSynced, TimeSpan threshold)
        => DateTime.UtcNow - lastSynced > threshold;
}
Distributed Lock Pattern: Inventory reservation uses Redis-based distributed locks (Redlock algorithm) to prevent overselling. The lock scope is per-product-per-store, allowing high concurrency across different products while serializing writes to the same product. The lock TTL of 10 seconds prevents deadlocks if the holder crashes.

Inventory Reconciliation

Every 5 minutes, a reconciliation worker compares the platform's inventory database against the store's POS system via bulk API or database replication. Discrepancies are logged, and alerts fire when drift exceeds 5% for any store. This is crucial for marketplace stores where the platform doesn't own the inventory. For dark stores, the platform controls inventory directly, and reconciliation is internal.

5. Store Management & Delivery Zones

Stores are the fundamental unit of fulfillment. Each store has its own product assortment, inventory, operating hours, picker workforce, and delivery zone. The platform must intelligently route customers to the nearest store with the best availability for their cart items.

Store Data Model

C#
public class Store
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public StoreType Type { get; set; }          // Partner, DarkStore, Hybrid
    public GeoLocation Location { get; set; }
    public GeoFence DeliveryZone { get; set; }   // Polygon of delivery area
    public int MaxDeliveryRadiusKm { get; set; }
    public TimeSpan OpeningHours { get; set; }
    public TimeSpan ClosingHours { get; set; }
    public int SlotCapacityPerHour { get; set; } // Max concurrent orders/hour
    public int ActivePickers { get; set; }
    public List<string> SupportedCategories { get; set; }
    public StoreStatus Status { get; set; }
}

public class GeoFence
{
    public List<GeoLocation> Vertices { get; set; }  // Polygon vertices
    public GeoLocation Centroid { get; set; }

    public bool ContainsPoint(GeoLocation point)
    {
        // Ray-casting algorithm for point-in-polygon
        bool inside = false;
        for (int i = 0, j = Vertices.Count - 1; i < Vertices.Count; j = i++)
        {
            if ((Vertices[i].Lat > point.Lat) != (Vertices[j].Lat > point.Lat) &&
                point.Lon < (Vertices[j].Lon - Vertices[i].Lon) *
                    (point.Lat - Vertices[i].Lat) /
                    (Vertices[j].Lat - Vertices[i].Lat) + Vertices[i].Lon)
            {
                inside = !inside;
            }
        }
        return inside;
    }
}

public enum StoreType
{
    Partner,     // Existing retail store (e.g., Kroger, Safeway)
    DarkStore,   // Platform-owned warehouse for online only
    Hybrid       // Retail store with dedicated online fulfillment area
}

Store Routing Algorithm

When a customer places an order, the system must determine the optimal fulfillment store. This is a multi-factor optimization considering distance, item availability, store workload, and delivery slot availability.

C#
public class StoreRoutingService
{
    private readonly IStoreRepository _stores;
    private readonly IInventoryService _inventory;

    public async Task<StoreRoutingResult> FindBestStoreAsync(
        List<CartLineItem> cart, GeoLocation customerLocation)
    {
        var candidateStores = await _stores
            .GetStoresInRadiusAsync(customerLocation, maxRadiusKm: 15);

        var scoredStores = new List<StoreScore>();

        foreach (var store in candidateStores)
        {
            if (!store.DeliveryZone.ContainsPoint(customerLocation))
                continue;

            var availability = await _inventory
                .CheckBulkAvailabilityAsync(store.Id, cart);
            var availabilityRate = availability.Count(a => a.IsAvailable)
                / (decimal)cart.Count;

            if (availabilityRate < 0.6m) continue; // Skip stores missing 40%+ items

            var distance = GeoLocation.CalculateDistance(
                customerLocation, store.Location);
            var slots = await GetAvailableSlotsAsync(store.Id, DateTime.UtcNow);
            var avgPickTime = await _stores
                .GetAveragePickTimeAsync(store.Id);

            var score = new StoreScore
            {
                Store = store,
                Distance = distance,
                AvailabilityRate = availabilityRate,
                AvailableSlots = slots.Count,
                EstimatedPickTime = avgPickTime,
                CompositeScore = CalculateCompositeScore(
                    distance, availabilityRate, slots.Count, avgPickTime)
            };

            scoredStores.Add(score);
        }

        return scoredStores
            .OrderByDescending(s => s.CompositeScore)
            .FirstOrDefault();
    }

    private decimal CalculateCompositeScore(
        decimal distance, decimal availability,
        int slots, TimeSpan pickTime)
    {
        // Weighted scoring: availability (40%), distance (30%),
        // slot availability (20%), pick time (10%)
        var distanceScore = Math.Max(0, 1 - distance / 15m) * 0.3m;
        var availabilityScore = availability * 0.4m;
        var slotScore = Math.Min(slots / 10m, 1m) * 0.2m;
        var pickTimeScore = Math.Max(0, 1 - (decimal)pickTime.TotalMinutes
            / 60m) * 0.1m;

        return distanceScore + availabilityScore + slotScore + pickTimeScore;
    }
}

Delivery Zone Management

Delivery zones are polygons defined on a map. Each zone specifies which store fulfills orders for that area. Zones can overlap, creating situations where a customer is served by two stores — in that case, the routing algorithm picks the best one. Zone boundaries are reviewed weekly based on delivery time SLAs; if a zone consistently exceeds 45-minute delivery times, it is shrunk or reassigned.

Edge Case — Zone Boundary Customers: Customers near zone boundaries can experience dramatically different delivery times depending on which store fulfills. We implement a 1km buffer zone overlap between adjacent stores, and the routing algorithm explicitly penalizes orders that would need to cross the boundary to reach items available only at the farther store.

7. Shopping Cart, Pricing & Weight-Based Items

The grocery cart is deceptively complex. Unlike standard e-commerce where each item has a fixed price, grocery carts must handle weight-based items (bananas sold at $0.79/lb), variable quantities, multi-buy discounts ("3 for $5"), loyalty pricing, dynamic pricing, and estimated totals that may change at checkout based on actual item weights and substitutions.

Cart Data Model

C#
public class ShoppingCart
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public Guid? StoreId { get; set; }
    public List<CartLineItem> Items { get; set; }
    public CartPricing Pricing { get; set; }
    public DeliverySlot? SelectedSlot { get; set; }
    public List<AppliedPromo> AppliedPromos { get; set; }
    public List<SubstitutionPreference> SubstitutionPrefs { get; set; }
    public DateTime UpdatedAt { get; set; }
    public CartStatus Status { get; set; }
}

public class CartLineItem
{
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public string ImageUrl { get; set; }
    public int Quantity { get; set; }               // For "each" items
    public decimal? RequestedWeight { get; set; }   // For "lb" items (in lbs)
    public decimal UnitPrice { get; set; }
    public decimal EstimatedPrice { get; set; }     // Unit x Quantity or Unit x Weight
    public bool IsWeightBased { get; set; }
    public string Unit { get; set; }                // "each", "lb", "oz"
    public SubstitutionPreference SubstitutionPref { get; set; }
    public ItemSpecialInstruction? SpecialInstruction { get; set; }
    public bool IsAvailable { get; set; }
    public DateTime AddedAt { get; set; }
}

public class CartPricing
{
    public decimal Subtotal { get; set; }
    public decimal ItemDiscounts { get; set; }
    public decimal DeliveryFee { get; set; }
    public decimal ServiceFee { get; set; }
    public decimal Tip { get; set; }
    public decimal Tax { get; set; }
    public decimal Total { get; set; }
    public List<PriceAdjustment> Adjustments { get; set; }
    public List<EstimatedRange> WeightBasedEstimates { get; set; }
}

public class EstimatedRange
{
    public Guid ProductId { get; set; }
    public decimal MinEstimatedPrice { get; set; }
    public decimal MaxEstimatedPrice { get; set; }
    public string Explanation { get; set; }
}

Weight-Based Item Handling

Weight-based items are one of the most complex aspects of grocery e-commerce. A customer orders 2 lbs of apples, but the picker may pick 1.8 lbs or 2.2 lbs because apples vary in size. The final price is adjusted based on actual weight. The customer sees an estimated price at cart time and a final price after picking.

C#
public class WeightBasedPricingService
{
    public PricingResult CalculatePricing(
        CartLineItem item, StoreInventory inventory)
    {
        if (!item.IsWeightBased)
        {
            return new PricingResult
            {
                EstimatedPrice = item.UnitPrice * item.Quantity,
                FinalPrice = null,
                PriceRange = null
            };
        }

        var requestedWeight = item.RequestedWeight ?? item.Quantity;
        var estimatedPrice = requestedWeight * item.UnitPrice;

        var weightVariance = GetWeightVariance(item.ProductId);
        var minWeight = requestedWeight * (1 - weightVariance);
        var maxWeight = requestedWeight * (1 + weightVariance);

        return new PricingResult
        {
            EstimatedPrice = Math.Round(estimatedPrice, 2),
            FinalPrice = null, // Set after picker confirms actual weight
            PriceRange = new EstimatedRange
            {
                MinEstimatedPrice = Math.Round(minWeight * item.UnitPrice, 2),
                MaxEstimatedPrice = Math.Round(maxWeight * item.UnitPrice, 2),
                Explanation = $"Actual price depends on picked weight. " +
                    $"Estimated ${Math.Round(minWeight * item.UnitPrice, 2)} - " +
                    $"${Math.Round(maxWeight * item.UnitPrice, 2)}"
            }
        };
    }

    private decimal GetWeightVariance(Guid productId)
    {
        return 0.10m; // Default 10% variance
    }
}

public class PriceAdjustmentService
{
    public CartPricing RecalculateFinalPrice(
        ShoppingCart cart, List<PickedItem> pickedItems)
    {
        var pricing = new CartPricing();

        foreach (var cartItem in cart.Items)
        {
            var pickedItem = pickedItems
                .FirstOrDefault(p => p.ProductId == cartItem.ProductId);

            if (pickedItem == null) continue;

            if (cartItem.IsWeightBased)
            {
                pricing.Subtotal += pickedItem.ActualWeight
                    * cartItem.UnitPrice;
            }
            else
            {
                pricing.Subtotal += pickedItem.Quantity * cartItem.UnitPrice;
            }
        }

        pricing.ItemDiscounts = ApplyMultiBuyDiscounts(cart, pickedItems);
        pricing.DeliveryFee = CalculateDeliveryFee(cart);
        pricing.ServiceFee = CalculateServiceFee(pricing.Subtotal);
        pricing.Tip = cart.Pricing.Tip;
        pricing.Tax = CalculateTax(
            pricing.Subtotal - pricing.ItemDiscounts, cart.StoreId);
        pricing.Total = pricing.Subtotal - pricing.ItemDiscounts
            + pricing.DeliveryFee + pricing.ServiceFee + pricing.Tip
            + pricing.Tax;

        return pricing;
    }
}

Multi-Buy & Combo Pricing

Promo TypeExampleCalculation
Multi-Buy"3 for $5" on yogurtIf quantity ≥ 3, price = (qty / 3) x $5 + (qty % 3) x unit_price
Buy X Get Y Free"Buy 2 Get 1 Free" on chipsEvery 3rd item free: price = ceil(qty / 3) x 2 x unit_price
Bundle Discount"Pasta + Sauce = $6" (normally $8)Apply when both items in cart
Threshold Discount"$10 off orders over $100"Apply when subtotal ≥ threshold
Category Discount"20% off all Organic"Apply multiplier to matching items

8. Slot-Based Delivery Scheduling

Delivery slot management is one of the most critical components of a grocery platform. Unlike food delivery where orders are dispatched immediately, grocery orders are scheduled into 1-hour delivery windows. The system must manage capacity per store per hour, handle waitlists during peak periods, and dynamically adjust availability based on real-time picker and driver workload. Poor slot management leads to either wasted capacity (slots go unfilled) or overselling (too many orders assigned to an hour, causing late deliveries).

Slot Capacity Planning Model

Each store operates with a finite number of pickers and drivers during any given hour. The maximum number of orders assignable to a 1-hour window is constrained by three factors: picking throughput (how fast shoppers can assemble orders), packing capacity (how many stations are available), and delivery throughput (how many drivers can complete deliveries within the window). The system pre-computes slot capacity at the start of each day and dynamically adjusts it throughout the day based on real-time conditions.

graph TB AM[Availability Service] --> SC[Slot Calculator] SC -->|Picker Count| PK[Picker Workload Model] SC -->|Driver Count| DR[Driver Capacity Model] SC -->|Store Hours| SH[Store Config] PK --> CAP[Slot Capacity Matrix] DR --> CAP SH --> CAP CAP -->|Redis| SLOT[Slot Availability Cache] SLOT -->|Read| BOOK[Booking API] BOOK -->|Reserve| RG[Reservation Guard] RG -->|Confirm| QUEUE[Order Queue]

Slot Data Model

C#
public class DeliverySlot
{
    public Guid Id { get; set; }
    public Guid StoreId { get; set; }
    public DateOnly Date { get; set; }
    public TimeOnly WindowStart { get; set; }       // e.g., 10:00
    public TimeOnly WindowEnd { get; set; }         // e.g., 11:00
    public int TotalCapacity { get; set; }          // Max orders in this window
    public int BookedCount { get; set; }            // Currently assigned orders
    public int ReservedCount { get; set; }          // In-checkout reservations
    public int WaitlistedCount { get; set; }        // Queue depth
    public SlotStatus Status { get; set; }
    public DeliveryFeeStrategy FeeStrategy { get; set; }
    public DateTime? PeakOverrideUntil { get; set; }
}

public enum SlotStatus
{
    Available,        // Capacity remaining
    Limited,          // Less than 20% capacity left
    Full,             // At capacity, waitlist active
    Overbooked,       // Emergency: exceeded capacity
    Closed            // Outside operating hours
}

public class SlotCapacityPlan
{
    public Guid StoreId { get; set; }
    public DateOnly Date { get; set; }
    public List<HourlyCapacity> HourlyPlans { get; set; }
}

public class HourlyCapacity
{
    public int Hour { get; set; }                    // 0-23
    public int BaseCapacity { get; set; }            // Default from config
    public int ActivePickers { get; set; }
    public int AvailableDrivers { get; set; }
    public int AdjustedCapacity { get; set; }        // min(base, picker*pickRate, driver*deliverRate)
    public decimal DemandMultiplier { get; set; }    // From forecasting
}

Slot Booking Engine

C#
public class SlotBookingService
{
    private readonly IDistributedCache _redis;
    private readonly ISlotRepository _slots;
    private readonly IEventBus _eventBus;

    public async Task<SlotBookingResult> BookSlotAsync(
        Guid storeId, Guid orderId, DeliverySlot requested,
        BookingPriority priority = BookingPriority.Normal)
    {
        var lockKey = $"lock:slot:{storeId}:{requested.Date}:{requested.WindowStart}";
        using var redLock = await _redis.AcquireLockAsync(
            lockKey, TimeSpan.FromSeconds(5));

        if (redLock == null)
            throw new SlotConcurrencyException("Slot booking in progress");

        var slot = await _slots.GetSlotAsync(
            storeId, requested.Date, requested.WindowStart);

        if (slot == null)
            return SlotBookingResult.Failure("Slot not found");

        if (slot.Status == SlotStatus.Closed)
            return SlotBookingResult.Failure("Store closed for this window");

        // Allow slight overbooking for high-priority orders
        var capacityThreshold = priority == BookingPriority.High
            ? slot.TotalCapacity + 2
            : slot.TotalCapacity;

        if (slot.BookedCount >= capacityThreshold)
        {
            if (priority == BookingPriority.Normal)
            {
                slot.WaitlistedCount++;
                await _slots.UpdateSlotAsync(slot);
                await _eventBus.PublishAsync(new SlotWaitlistedEvent
                {
                    OrderId = orderId,
                    StoreId = storeId,
                    SlotStart = requested.WindowStart,
                    Position = slot.WaitlistedCount
                });
                return SlotBookingResult.Waitlisted(slot.WaitlistedCount);
            }
            return SlotBookingResult.Failure("Slot at capacity");
        }

        slot.BookedCount++;
        slot.Status = CalculateSlotStatus(slot);
        await _slots.UpdateSlotAsync(slot);

        await _eventBus.PublishAsync(new SlotBookedEvent
        {
            OrderId = orderId,
            StoreId = storeId,
            WindowStart = requested.WindowStart,
            WindowEnd = requested.WindowEnd,
            Timestamp = DateTime.UtcNow
        });

        return SlotBookingResult.Success(slot);
    }

    private SlotStatus CalculateSlotStatus(DeliverySlot slot)
    {
        var utilizationRate = (decimal)slot.BookedCount / slot.TotalCapacity;
        if (utilizationRate >= 1.0m) return SlotStatus.Full;
        if (utilizationRate >= 0.8m) return SlotStatus.Limited;
        return SlotStatus.Available;
    }
}

public class SlotReplanningService
{
    // Runs every 15 minutes during peak hours
    public async Task RebalanceSlotsAsync(Guid storeId, DateOnly date)
    {
        var plan = await GetCapacityPlanAsync(storeId, date);
        var currentHour = TimeOnly.FromDateTime(DateTime.UtcNow);

        foreach (var hour in plan.HourlyPlans.Where(h =>
            h.Hour >= currentHour.Hour && h.Hour <= currentHour.Hour + 3))
        {
            var activeSlots = await _slots
                .GetSlotsForHourAsync(storeId, date, hour.Hour);

            foreach (var slot in activeSlots)
            {
                var newCapacity = hour.AdjustedCapacity;
                if (slot.BookedCount > newCapacity)
                {
                    // Trigger overflow: move excess orders to adjacent hours
                    var overflow = slot.BookedCount - newCapacity;
                    await MoveWaitlistedOrdersToAdjacentSlot(
                        storeId, date, hour.Hour, overflow);
                }
            }
        }
    }
}
Dynamic Slot Pricing: During peak hours, delivery fees increase to manage demand. A slot at 6-7 PM on Friday might cost $7.99 while a Tuesday morning slot costs $2.99. This is implemented as a multiplier applied to the base delivery fee, stored in the FeeStrategy of each DeliverySlot. The pricing algorithm considers historical demand, current booking rate, and remaining capacity to set prices in 30-minute intervals.

Waitlist and Slot Release

When a customer cancels an order, their slot is released back into the pool. The waitlist service then contacts the next customer in queue via push notification. The customer has 5 minutes to accept the slot before it moves to the next person. This prevents slot hoarding where customers book multiple slots and release them late. All slot changes flow through an event log for audit purposes.

Slot Availability Table (Example)

Time WindowCapacityBookedAvailableStatusDelivery Fee
7:00 - 8:00 AM301218Available$2.99
8:00 - 9:00 AM30255Limited$2.99
9:00 - 10:00 AM30300Full$3.99
12:00 - 1:00 PM35332Limited$5.99
5:00 - 6:00 PM40400Full$7.99
6:00 - 7:00 PM4042-2Overbooked$7.99
8:00 - 9:00 PM251015Available$4.99

9. Substitution Logic for Out-of-Stock Items

Substitution is the single most complex and customer-sensitive aspect of grocery delivery. When a picker cannot find an item on the shelf, they must decide what to substitute — or whether to skip the item entirely. The substitution engine must balance customer preferences, brand loyalty, nutritional similarity, price equivalence, and dietary restrictions while operating in real time under picking time pressure. A poor substitution (e.g., substituting organic milk with a non-dairy alternative) can lose a customer permanently.

Substitution Decision Tree

flowchart TD A[Item Not Found on Shelf] --> B{Customer Pre-Set Preference} B -->|Allow Substitutions| C[Query Substitution Engine] B -->|No Substitutions| D[Remove Item & Refund] C --> E[Generate Ranked Alternatives] E --> F{Alternative Found?} F -->|No| G[Contact Customer via Chat] F -->|Yes| H[Score Alternatives] H --> I[Check Dietary/Allergen Match] I --> J{Passes Safety Check?} J -->|No| K[Exclude Alternative] J -->|Yes| L[Compare Price Range] L --> M{Price Within Tolerance?} M -->|No| N[Flag as Premium/Budget] M -->|Yes| O[Present to Customer] N --> O O --> P{Customer Approves?} P -->|Yes| Q[Pick Substitute] P -->|No| R[Remove Item] G --> S{Customer Responds?} S -->|Selects Alternative| Q S -->|No Response in 5min| T[Use Auto-Substitution Rule]

Substitution Scoring Engine

C#
public class SubstitutionScorer
{
    private readonly IProductRepository _products;

    public List<ScoredAlternative> RankAlternatives(
        CartLineItem originalItem, List<Product> candidates,
        CustomerSubstitutionProfile profile)
    {
        return candidates
            .Select(c => new ScoredAlternative
            {
                Product = c,
                Score = CalculateScore(originalItem, c, profile)
            })
            .OrderByDescending(a => a.Score.Total)
            .ToList();
    }

    private SubstitutionScore CalculateScore(
        CartLineItem original, Product candidate,
        CustomerSubstitutionProfile profile)
    {
        var score = new SubstitutionScore();

        // Brand match (30% weight)
        if (string.Equals(original.ProductName.Split(' ')[0],
            candidate.Brand, StringComparison.OrdinalIgnoreCase))
            score.BrandMatch = 1.0m;
        else if (profile.PreferredBrands.Contains(candidate.Brand))
            score.BrandMatch = 0.7m;
        else
            score.BrandMatch = 0.3m;

        // Price proximity (25% weight)
        var priceRatio = candidate.UnitPrice / original.UnitPrice;
        score.PriceProximity = priceRatio switch
        {
            >= 0.9m and <= 1.1m => 1.0m,      // Within 10%
            >= 0.7m and <= 1.3m => 0.7m,      // Within 30%
            >= 0.5m and <= 1.5m => 0.4m,      // Within 50%
            _ => 0.1m
        };

        // Category/Subcategory match (20% weight)
        score.CategoryMatch = candidate.Subcategory ==
            original.Subcategory ? 1.0m : 0.4m;

        // Dietary compliance (15% weight)
        score.DietaryMatch = CheckDietaryMatch(
            original, candidate, profile);

        // Organic/quality tier match (10% weight)
        score.QualityMatch = CheckQualityTier(original, candidate);

        return score;
    }

    private decimal CheckDietaryMatch(
        CartLineItem original, Product candidate,
        CustomerSubstitutionProfile profile)
    {
        var score = 1.0m;

        // Must not contain customer allergens
        if (candidate.Allergens.Any(a =>
            profile.ExcludedAllergens.Contains(a)))
            return 0.0m; // Hard filter

        // Check organic preference
        if (original.ProductName.Contains("Organic") &&
            !candidate.DietaryTags.Contains("Organic"))
            score *= 0.5m;

        return score;
    }
}

public class CustomerSubstitutionProfile
{
    public Guid CustomerId { get; set; }
    public SubstitutionDefault DefaultPreference { get; set; }
    public List<string> ExcludedAllergens { get; set; }
    public List<string> PreferredBrands { get; set; }
    public decimal MaxPriceTolerance { get; set; }      // e.g., 0.20 = 20% over
    public bool AllowDifferentBrand { get; set; }
    public bool AllowDifferentSize { get; set; }
    public bool AllowGenericInsteadOfBrand { get; set; }
    public List<SubstitutionHistoryEntry> PastDecisions { get; set; }
}

public enum SubstitutionDefault
{
    AutoApprove,        // Pick best match without asking
    AskEveryTime,       // Always ask customer
    NoSubstitutions,    // Remove item instead of substituting
    SimilarOnly         // Only substitute within same brand/subcategory
}

Substitution Preference Matrix

Original ItemSituationBest SubstituteReasoning
Organic Whole Milk 1galOOSOrganic 2% Milk 1gal (same brand)Same brand, organic, slight fat difference acceptable
Honeycrisp Apples 1lbOOSGala Apples 1lbSame category, similar price, popular alternative
Greek Yogurt Vanilla 32ozOOSGreek Yogurt Vanilla 16oz x2Same product, different size; combine to match quantity
Gluten-Free BreadOOSDo not substituteDietary restriction; wrong item could cause health issue
Coca-Cola 12-packOOSPepsi 12-pack (ask customer)Brand loyalty high; must confirm
Allergen Safety Rule: The substitution engine never substitutes an item with one that contains allergens excluded by the customer profile, even if the customer has set "Auto-Approve" as their default. This is a hard safety constraint. If the only available alternative contains an excluded allergen, the item is removed and refunded without substitution.

10. Order Lifecycle & State Machine

Every grocery order progresses through a well-defined state machine from creation to completion. Understanding this lifecycle is essential for building reliable order tracking, handling failures gracefully, and ensuring consistent behavior across the platform. The state machine must account for edge cases like slot changes, item substitutions, partial pickups, and payment adjustments.

stateDiagram-v2 [*] --> Created: Customer places order Created --> SlotConfirmed: Slot booked Created --> Cancelled: Customer cancels SlotConfirmed --> PaymentPending: Payment initiated SlotConfirmed --> Cancelled: Customer cancels PaymentPending --> Paid: Payment succeeds PaymentPending --> PaymentFailed: Payment fails PaymentFailed --> PaymentPending: Retry PaymentFailed --> Cancelled: Max retries Paid --> AssignedToShopper: Shopper accepts AssignedToShopper --> Picking: Shopper starts picking Picking --> PartiallyPicked: Some items OOS Picking --> FullyPicked: All items found PartiallyPicked --> SubstitutionPhase: Substitutions needed SubstitutionPhase --> FullyPicked: All resolved FullyPicked --> Packing: Items bagged Packing --> ReadyForDispatch: Packed ReadyForDispatch --> OutForDelivery: Driver picks up OutForDelivery --> Delivered: Delivery confirmed Delivered --> ReviewPending: Awaiting review Delivered --> RefundRequested: Issue reported RefundRequested --> RefundProcessed: Refund approved RefundProcessed --> [*] ReviewPending --> [*]: Review submitted Cancelled --> RefundDue: Payment was made RefundDue --> RefundProcessed: Refund issued

Order State Manager

C#
public class OrderStateMachine
{
    private static readonly Dictionary<OrderStatus, HashSet<OrderStatus>>
        ValidTransitions = new()
    {
        [OrderStatus.Created] = new()
            { OrderStatus.SlotConfirmed, OrderStatus.Cancelled },
        [OrderStatus.SlotConfirmed] = new()
            { OrderStatus.PaymentPending, OrderStatus.Cancelled },
        [OrderStatus.PaymentPending] = new()
            { OrderStatus.Paid, OrderStatus.PaymentFailed },
        [OrderStatus.PaymentFailed] = new()
            { OrderStatus.PaymentPending, OrderStatus.Cancelled },
        [OrderStatus.Paid] = new()
            { OrderStatus.AssignedToShopper, OrderStatus.Cancelled },
        [OrderStatus.AssignedToShopper] = new()
            { OrderStatus.Picking },
        [OrderStatus.Picking] = new()
            { OrderStatus.FullyPicked, OrderStatus.PartiallyPicked },
        [OrderStatus.PartiallyPicked] = new()
            { OrderStatus.SubstitutionPhase },
        [OrderStatus.SubstitutionPhase] = new()
            { OrderStatus.FullyPicked },
        [OrderStatus.FullyPicked] = new()
            { OrderStatus.Packing },
        [OrderStatus.Packing] = new()
            { OrderStatus.ReadyForDispatch },
        [OrderStatus.ReadyForDispatch] = new()
            { OrderStatus.OutForDelivery },
        [OrderStatus.OutForDelivery] = new()
            { OrderStatus.Delivered },
        [OrderStatus.Delivered] = new()
            { OrderStatus.ReviewPending, OrderStatus.RefundRequested },
    };

    public OrderTransitionResult Transition(
        Order order, OrderStatus targetStatus,
        string initiatedBy, Dictionary<string, string>? metadata = null)
    {
        if (!ValidTransitions.TryGetValue(order.Status,
            out var allowed) || !allowed.Contains(targetStatus))
        {
            return OrderTransitionResult.Rejected(
                $"Invalid transition: {order.Status} → {targetStatus}");
        }

        var previousStatus = order.Status;
        order.Status = targetStatus;
        order.StatusHistory.Add(new OrderStatusEntry
        {
            From = previousStatus,
            To = targetStatus,
            InitiatedBy = initiatedBy,
            Timestamp = DateTime.UtcNow,
            Metadata = metadata
        });

        return OrderTransitionResult.Accepted(order);
    }
}

11. Picker/Shopper Workflow App

The picker app is the operational backbone of the platform. A shopper (gig worker) receives an order, walks through the store aisles in an optimized sequence, scans each item's barcode to confirm identity, records actual weights for weight-based items, communicates with the customer about substitutions, and hands off the completed order to a driver. The app must work reliably on mobile devices with intermittent connectivity, as many stores have poor cellular reception.

Picking Route Optimization

Each store has a predefined aisle layout stored as a directed graph where nodes are shelf positions and edges represent walking paths. The picker app solves a Traveling Salesman Problem (TSP) variant to find the shortest route through all items in the order. Since exact TSP is NP-hard, we use the nearest-neighbor heuristic augmented with aisle-grouping: items in the same aisle are picked together, and aisles are visited in a loop pattern that minimizes backtracking.

graph LR subgraph Store Floor Plan A[Entrance] --> B[Dairy Aisle 1] B --> C[Dairy Aisle 2] C --> D[Produce Section] D --> E[Meat Counter] E --> F[Bakery] F --> G[Cereals Aisle 3] G --> H[Beverages Aisle 4] H --> I[Checkout/Packing] end

Picker App Data Model

C#
public class PickSession
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public Guid ShopperId { get; set; }
    public Guid StoreId { get; set; }
    public List<PickTask> Tasks { get; set; }
    public List<PickedItem> CompletedItems { get; set; }
    public List<SubstitutionDecision> SubstitutionDecisions { get; set; }
    public PickRoute OptimizedRoute { get; set; }
    public DateTime StartedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
    public PickSessionStatus Status { get; set; }
    public TimeSpan? EstimatedTimeRemaining { get; set; }
}

public class PickTask
{
    public int SequenceNumber { get; set; }          // Route order
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public string Barcode { get; set; }
    public int RequestedQuantity { get; set; }
    public decimal? RequestedWeight { get; set; }    // In lbs
    public string AisleLocation { get; set; }        // "Aisle 3, Shelf B, Slot 7"
    public bool IsWeightBased { get; set; }
    public bool IsPerishable { get; set; }
    public string SpecialInstruction { get; set; }
    public PickTaskStatus Status { get; set; }
}

public class PickedItem
{
    public Guid TaskId { get; set; }
    public Guid ProductId { get; set; }
    public int PickedQuantity { get; set; }
    public decimal? ActualWeight { get; set; }
    public string ScannedBarcode { get; set; }
    public DateTime PickedAt { get; set; }
    public GeoLocation? ShelfLocation { get; set; }
    public bool? CustomerApprovedSubstitution { get; set; }
    public string? SubstitutionForProductId { get; set; }
}

public class PickRoute
{
    public List<PickRouteStop> Stops { get; set; }
    public decimal TotalDistanceMeters { get; set; }
    public TimeSpan EstimatedDuration { get; set; }
    public List<string> AisleSequence { get; set; }
}

public class PickRouteStop
{
    public int Order { get; set; }
    public string Aisle { get; set; }
    public string ShelfPosition { get; set; }
    public List<Guid> ProductIds { get; set; }      // Multiple items in same spot
}

Barcode Scanning & Item Verification

When a picker scans an item, the app verifies the barcode matches the expected product. If the barcode doesn't match, the app alerts the picker and logs the mismatch. This prevents picking the wrong item (e.g., grabbing 2% milk instead of whole milk, which look identical). For items without barcodes (fresh produce), the picker selects from a visual menu of common items or weighs the item on a connected Bluetooth scale.

C#
public class BarcodeScanService
{
    private readonly IProductRepository _products;
    private readonly IInventoryService _inventory;

    public ScanResult ProcessScan(
        PickSession session, string scannedBarcode,
        GeoLocation? pickerLocation)
    {
        var task = session.Tasks.FirstOrDefault(t =>
            t.Barcode == scannedBarcode &&
            t.Status == PickTaskStatus.Pending);

        if (task == null)
        {
            // Check if this is a substitute candidate
            var substituteMatch = FindPotentialSubstitute(
                session, scannedBarcode);
            if (substituteMatch != null)
                return ScanResult.SuggestSubstitution(substituteMatch);

            return ScanResult.Mismatch("No matching task for this barcode");
        }

        if (task.IsWeightBased)
        {
            return ScanResult.RequiresWeight(task);
        }

        return ScanResult.Success(new PickedItem
        {
            TaskId = task.Id,
            ProductId = task.ProductId,
            PickedQuantity = 1,
            ScannedBarcode = scannedBarcode,
            PickedAt = DateTime.UtcNow
        });
    }

    public WeightResult RecordWeight(
        PickedItem item, decimal weightLbs)
    {
        var variance = Math.Abs(weightLbs -
            (item.RequestedWeight ?? 1m)) / (item.RequestedWeight ?? 1m);

        if (variance > 0.30m)  // More than 30% off
        {
            return WeightResult.HighVariance(
                "Weight significantly different from requested. " +
                "Customer will be notified.",
                item.RequestedWeight.Value, weightLbs);
        }

        item.ActualWeight = weightLbs;
        return WeightResult.Accepted(weightLbs);
    }
}
Offline-First Architecture: The picker app uses a local SQLite database that syncs with the server when connectivity is available. All pick actions, scans, and substitution decisions are queued locally and synced via a background service. This ensures the app works in stores with poor cellular reception. Conflict resolution uses last-writer-wins for most fields, with server authority for inventory and pricing.

12. Batch Delivery Optimization & Route Planning

To maximize efficiency, a single driver delivers multiple orders in a batch. The routing engine must cluster compatible orders (same area, similar time windows), plan optimal multi-stop routes, and handle real-time re-routing when traffic conditions change or new orders become available. Poor batching leads to late deliveries, melted frozen items, and unhappy customers.

Batch Creation Algorithm

flowchart TD A[Unassigned Deliveries] --> B[Geocluster Orders] B --> C[Group by Delivery Window] C --> D{Compatible Groups} D -->|Same area + overlapping windows| E[Create Batch Candidate] D -->|Incompatible| F[Separate Batch] E --> G[Check Vehicle Capacity] G -->|Fits| H[Assign Driver] G -->|Overflows| I[Split Batch] H --> J[Optimize Route Order] J --> K[Generate Turn-by-Turn Directions] K --> L[Assign to Driver App]

Batch and Route Data Model

C#
public class DeliveryBatch
{
    public Guid Id { get; set; }
    public Guid DriverId { get; set; }
    public List<BatchOrder> Orders { get; set; }
    public BatchRoute OptimizedRoute { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? StartedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
    public BatchStatus Status { get; set; }
    public int TotalStops => Orders.Count;
    public decimal TotalDistanceKm => OptimizedRoute?.TotalDistanceKm ?? 0;
}

public class BatchOrder
{
    public Guid OrderId { get; set; }
    public GeoLocation DeliveryAddress { get; set; }
    public DateTime WindowStart { get; set; }
    public DateTime WindowEnd { get; set; }
    public int SequenceInRoute { get; set; }
    public OrderPriority Priority { get; set; }
    public bool ContainsFrozenItems { get; set; }
    public bool ContainsPerishables { get; set; }
    public decimal OrderValue { get; set; }
}

public class BatchRoute
{
    public List<RouteStop> Stops { get; set; }
    public decimal TotalDistanceKm { get; set; }
    public TimeSpan EstimatedTotalTime { get; set; }
    public List<RouteLeg> Legs { get; set; }
}

public class RouteStop
{
    public int Sequence { get; set; }
    public GeoLocation Location { get; set; }
    public Guid? OrderId { get; set; }
    public StopType Type { get; set; }     // Pickup, Delivery
    public DateTime ArriveBy { get; set; }
    public DateTime DepartBy { get; set; }
    public string Instructions { get; set; }
}

public enum StopType { Pickup, Delivery }

Route Optimization Service

C#
public class RouteOptimizationService
{
    private readonly IGeoService _geo;
    private readonly ITrafficService _traffic;

    public async Task<BatchRoute> OptimizeRouteAsync(
        DeliveryBatch batch, GeoLocation startLocation)
    {
        var stops = batch.Orders.Select(o => new RouteStop
        {
            Location = o.DeliveryAddress,
            OrderId = o.OrderId,
            Type = StopType.Delivery,
            ArriveBy = o.WindowEnd
        }).ToList();

        // Insert pickup stop at the beginning (store)
        stops.Insert(0, new RouteStop
        {
            Location = startLocation,
            Type = StopType.Pickup,
            ArriveBy = batch.Orders.Min(o => o.WindowStart)
        });

        // Nearest-neighbor TSP heuristic with time window constraints
        var optimized = await SolveRoutingProblem(stops);

        // Add real-time traffic data
        var route = await EnrichWithTrafficData(optimized);

        // Validate all time windows can be met
        var violations = ValidateTimeWindows(route);
        if (violations.Any())
        {
            // Re-optimize with relaxed constraints or split batch
            route = await HandleViolations(route, violations);
        }

        return route;
    }

    private async Task<List<RouteStop>> SolveRoutingProblem(
        List<RouteStop> stops)
    {
        var unvisited = new List<RouteStop>(stops);
        var route = new List<RouteStop>();
        var current = unvisited.First(); // Start at pickup
        unvisited.Remove(current);
        route.Add(current);

        while (unvisited.Any())
        {
            var nearest = unvisited
                .OrderBy(s => _geo.CalculateDistance(
                    current.Location, s.Location))
                .First();

            route.Add(nearest);
            unvisited.Remove(nearest);
            current = nearest;
        }

        // Apply 2-opt local search improvement
        return Apply2Opt(route);
    }

    private List<RouteStop> Apply2Opt(List<RouteStop> route)
    {
        var improved = true;
        while (improved)
        {
            improved = false;
            for (int i = 1; i < route.Count - 1; i++)
            {
                for (int j = i + 1; j < route.Count; j++)
                {
                    var newRoute = TwoOptSwap(route, i, j);
                    if (CalculateTotalDistance(newRoute) <
                        CalculateTotalDistance(route))
                    {
                        route = newRoute;
                        improved = true;
                    }
                }
            }
        }
        return route;
    }
}
Frozen Item Priority: Batches containing frozen items are prioritized for shorter delivery routes and earlier stops. The route optimizer penalizes routes where frozen items would be in the vehicle for more than 30 minutes. If a batch has frozen items and the route would exceed this threshold, the batch is split and a separate shorter route is created for the frozen items.

13. Real-Time Order Tracking

Customers expect live visibility into their order from the moment it is placed until it arrives at their door. The tracking system must provide accurate ETAs, show the driver's live GPS position, and update order status in near real-time. The pipeline must handle high-throughput GPS data (100K+ drivers updating every 5 seconds) and translate raw coordinates into meaningful status updates for the customer.

Tracking Architecture

graph TB APP[Driver Mobile App] -->|GPS every 5s| GW[API Gateway] GW --> K[Kafka: gps-updates] K --> GP[GPS Processing Service] GP -->|Store latest position| REDIS[(Redis: driver-location)] GP -->|Persist| CH[(ClickHouse: location-log)] GP -->|Calculate ETA| ETA[ETA Service] ETA -->|Push update| WS[WebSocket Hub] WS -->|Real-time| CUST[Customer App] ETA -->|Store| REDIS2[(Redis: order-eta)] GP -->|Geofence check| GEOF[Geofence Service] GEOF -->|Notify| NOTIFY[Notification Service]

GPS Processing Pipeline

C#
public class GpsProcessingService
{
    private readonly IDistributedCache _redis;
    private readonly IClickHouseRepository _locationLog;
    private readonly IEtaService _eta;
    private readonly IWebSocketBroadcaster _ws;

    public async Task ProcessGpsUpdateAsync(GpsUpdate update)
    {
        // Store latest position with short TTL
        var locationKey = $"driver:location:{update.DriverId}";
        await _redis.SetAsync(locationKey, new DriverLocation
        {
            DriverId = update.DriverId,
            Lat = update.Lat,
            Lon = update.Lon,
            Speed = update.Speed,
            Heading = update.Heading,
            Timestamp = update.Timestamp
        }, TimeSpan.FromSeconds(30));

        // Persist to ClickHouse for analytics (async, non-blocking)
        _ = Task.Run(() => _locationLog.InsertAsync(new LocationLogEntry
        {
            DriverId = update.DriverId,
            Lat = update.Lat,
            Lon = update.Lon,
            Speed = update.Speed,
            Timestamp = update.Timestamp
        }));

        // Calculate and push ETA updates
        var activeDeliveries = await GetActiveDeliveriesAsync(
            update.DriverId);
        foreach (var order in activeDeliveries)
        {
            var newEta = await _eta.CalculateEtaAsync(
                new GeoLocation(update.Lat, update.Lon),
                order.DeliveryAddress);

            await _ws.BroadcastToOrder(order.OrderId, new TrackingUpdate
            {
                DriverLocation = new GeoLocation(update.Lat, update.Lon),
                EstimatedArrival = newEta,
                DriverName = order.DriverName,
                DriverPhoto = order.DriverPhotoUrl
            });

            // Alert if driver is going off-route
            if (order.ExpectedRoute != null &&
                IsOffRoute(update, order.ExpectedRoute))
            {
                await NotifyDispatcherAsync(order, update);
            }
        }
    }
}

public class EtaService
{
    private readonly ITrafficService _traffic;
    private readonly IGeoService _geo;

    public async Task<TimeSpan> CalculateEtaAsync(
        GeoLocation current, GeoLocation destination)
    {
        var straightLine = _geo.CalculateDistance(current, destination);
        var roadDistance = straightLine * 1.3m; // Road factor

        var traffic = await _traffic
            .GetTrafficConditionsAsync(current, destination);
        var avgSpeed = traffic switch
        {
            TrafficLevel.Free => 40m,   // km/h
            TrafficLevel.Moderate => 25m,
            TrafficLevel.Heavy => 15m,
            TrafficLevel.Standstill => 5m,
            _ => 30m
        };

        return TimeSpan.FromHours((double)(roadDistance / avgSpeed));
    }
}

Customer Tracking View States

Order StatusCustomer SeesUpdate Frequency
Order PlacedConfirmation screen with estimated delivery timeOnce
Being Picked"Your shopper is picking your items" + item counterEvery item picked
SubstitutionsSubstitution suggestions with approve/rejectPer substitution
Being Packed"Your order is being packed"Once
Out for DeliveryLive map with driver position + ETA countdownEvery 5 seconds
Arriving"Your driver is 2 minutes away" + driver detailsEvery 5 seconds
DeliveredDelivery confirmation + receipt + tip promptOnce
ETA Accuracy Strategy: ETA predictions combine three signals: (1) historical delivery times for the same route/time-of-day, (2) real-time traffic conditions, and (3) current driver speed from GPS. The ML model is retrained weekly on the latest delivery data. Target accuracy is within 5 minutes for 80% of deliveries.

14. Payment Processing & Tipping

Payment in grocery delivery is unique because the final charge often differs from the estimated charge at checkout. Weight-based items have variable final prices, substitutions may change costs, and items may be refunded if unavailable. The payment system must authorize an estimated amount upfront, then capture the final amount after order completion with the difference refunded to the customer.

Payment Flow

sequenceDiagram participant C as Customer participant API as Payment API participant PS as Payment Service participant PG as Processor (Stripe) participant BK as Banking Network C->>API: Place Order ($78.50 estimated) API->>PS: Authorize Payment PS->>PG: Auth Hold $85.00 (buffer) PG->>BK: Authorize BK-->>PG: Auth Approved PG-->>PS: Auth Token PS-->>API: Payment Authorized API-->>C: Order Confirmed Note over PS: Order Picked & Delivered Note over PS: Final amount: $81.23 API->>PS: Capture Final Amount PS->>PG: Capture $81.23 PG->>BK: Capture BK-->>PG: Captured PS->>PS: Release $3.77 hold PS-->>API: Payment Complete API-->>C: Receipt + Refund of difference

Payment Service

C#
public class PaymentService
{
    private readonly IPaymentGateway _gateway;
    private readonly IPaymentRepository _payments;

    public async Task<PaymentResult> AuthorizePaymentAsync(
        Order order, PaymentMethod method)
    {
        // Add 10% buffer for weight-based items
        var bufferAmount = order.EstimatedTotal * 0.10m;
        var authAmount = order.EstimatedTotal + bufferAmount;

        var authResult = await _gateway.AuthorizeAsync(new AuthorizationRequest
        {
            Amount = authAmount,
            Currency = "USD",
            CustomerId = order.CustomerId,
            PaymentMethodToken = method.Token,
            IdempotencyKey = $"auth:{order.Id}",
            Metadata = new Dictionary<string, string>
            {
                ["order_id"] = order.Id.ToString(),
                ["estimated_total"] = order.EstimatedTotal.ToString("F2")
            }
        });

        if (authResult.Succeeded)
        {
            await _payments.SaveAsync(new PaymentRecord
            {
                OrderId = order.Id,
                AuthorizationToken = authResult.Token,
                AuthorizedAmount = authAmount,
                EstimatedAmount = order.EstimatedTotal,
                Status = PaymentStatus.Authorized,
                AuthorizedAt = DateTime.UtcNow
            });
        }

        return authResult.Succeeded
            ? PaymentResult.Success(authResult.Token)
            : PaymentResult.Failed(authResult.FailureReason);
    }

    public async Task<CaptureResult> CaptureFinalAmountAsync(
        Guid orderId, decimal finalAmount)
    {
        var record = await _payments.GetByOrderAsync(orderId);

        var captureResult = await _gateway.CaptureAsync(
            record.AuthorizationToken, finalAmount);

        if (captureResult.Succeeded)
        {
            var refundAmount = record.AuthorizedAmount - finalAmount;
            record.CapturedAmount = finalAmount;
            record.RefundAmount = refundAmount;
            record.Status = PaymentStatus.Captured;
            record.CapturedAt = DateTime.UtcNow;
            await _payments.UpdateAsync(record);

            if (refundAmount > 0.01m)
            {
                await IssueAutomaticRefundAsync(record, refundAmount,
                    "Weight/substitution adjustment");
            }
        }

        return captureResult.Succeeded
            ? CaptureResult.Success(finalAmount, record.RefundAmount)
            : CaptureResult.Failed(captureResult.FailureReason);
    }
}

Pricing Breakdown Table (Example Order)

Line ItemQuantityEstimated PriceFinal PriceNotes
Organic Whole Milk 1gal2$11.98$11.98Fixed price, picked as-is
Bananas (organic)2 lbs$2.38$2.52Actual weight: 2.12 lbs
Chicken Breast (boneless)1.5 lbs$8.24$7.89Actual weight: 1.43 lbs
Greek Yogurt Vanilla 32oz1$5.49$5.49Picked as-is
Gluten-Free Bread1$6.99$6.99Substituted: same brand, different flavor
Avocados4 each$5.96$5.96Picked as-is
Subtotal$40.83
Delivery Fee$4.99Standard 1-hour window
Service Fee$3.995% of subtotal
Tip$6.00Pre-selected by customer
Tax$3.278% on food items
Total$59.08
Authorization Buffer: The 10% buffer on payment authorization is critical. Without it, a customer ordering 2 lbs of chicken where the picker picks 2.3 lbs would fail to capture the final amount. The buffer is released as a refund if the final charge is lower. Some platforms use a flat $15 buffer instead of a percentage for simplicity.

15. Promo Codes, Loyalty & Dynamic Pricing

Promotional mechanics drive customer acquisition and retention in the competitive grocery delivery market. The platform must support a wide range of promotions: percentage discounts, dollar-off coupons, free delivery offers, loyalty points, first-order incentives, and time-based dynamic pricing. Each promotion has complex eligibility rules, stacking policies, and expiration logic.

Promo Engine Architecture

C#
public class PromoEngine
{
    private readonly IPromoRepository _promos;
    private readonly ILoyaltyService _loyalty;

    public async Task<PromoEvaluationResult> EvaluatePromoAsync(
        ShoppingCart cart, string promoCode, Guid customerId)
    {
        var promo = await _promos.GetByCodeAsync(promoCode);
        if (promo == null)
            return PromoEvaluationResult.Invalid("Code not found");

        if (!promo.IsActive || DateTime.UtcNow > promo.ExpiresAt)
            return PromoEvaluationResult.Invalid("Code expired");

        if (promo.UsageCount >= promo.MaxUses)
            return PromoEvaluationResult.Invalid("Code fully redeemed");

        if (promo.MinOrderValue > cart.Pricing.Subtotal)
            return PromoEvaluationResult.Invalid(
                $"Minimum order ${promo.MinOrderValue} not met");

        // Check per-customer usage limit
        var customerUses = await _promos
            .GetCustomerUsageCountAsync(customerId, promo.Id);
        if (customerUses >= promo.MaxUsesPerCustomer)
            return PromoEvaluationResult.Invalid(
                "You've already used this code");

        // Check category eligibility
        if (promo.EligibleCategories?.Any() == true)
        {
            var eligibleItems = cart.Items.Where(i =>
                promo.EligibleCategories.Contains(i.Category));
            if (!eligibleItems.Any())
                return PromoEvaluationResult.Invalid(
                    "No eligible items in cart");
        }

        var discount = CalculateDiscount(promo, cart);

        return PromoEvaluationResult.Valid(new AppliedPromo
        {
            PromoId = promo.Id,
            Code = promo.Code,
            DiscountAmount = discount,
            Description = promo.Description
        });
    }

    private decimal CalculateDiscount(Promotion promo, ShoppingCart cart)
    {
        return promo.Type switch
        {
            PromoType.PercentageOff =>
                cart.Pricing.Subtotal * (promo.Value / 100m),
            PromoType.DollarOff =>
                Math.Min(promo.Value, cart.Pricing.Subtotal),
            PromoType.FreeDelivery =>
                cart.Pricing.DeliveryFee,
            PromoType.BuyXGetYFree =>
                CalculateBuyXGetYFree(promo, cart),
            PromoType.FixedOrderDiscount =>
                cart.Pricing.Subtotal >= promo.MinOrderValue
                    ? promo.Value : 0m,
            _ => 0m
        };
    }
}

public enum PromoType
{
    PercentageOff,
    DollarOff,
    FreeDelivery,
    BuyXGetYFree,
    FixedOrderDiscount,
    LoyaltyPointsRedeem,
    DynamicTimeBased
}

Dynamic Pricing Rules

Dynamic pricing adjusts delivery fees based on real-time demand, time of day, and driver availability. During peak hours or bad weather, delivery fees increase to attract more drivers and manage demand. The pricing algorithm runs every 5 minutes and publishes updated fee schedules to the slot availability cache.

ConditionMultiplierEffect
Normal demand, normal weather1.0xBase fee applies
High demand (80%+ slots booked)1.5x$3.99 becomes $5.99
Peak hour (5-8 PM weekdays)1.3xStandard peak surcharge
Heavy rain / snow1.8xWeather surcharge + driver bonus
Holiday (Thanksgiving, Christmas Eve)2.0xHoliday premium
Low demand, off-peak0.7xDiscount to fill capacity

16. Demand Forecasting for Inventory

Demand forecasting ensures stores stock the right products in the right quantities before customers place orders. Accurate forecasting reduces out-of-stock rates, minimizes waste from overstocking perishable items, and optimizes picker efficiency by reducing substitution needs. The system uses historical sales data, seasonal patterns, local events, weather forecasts, and promotional calendars to predict demand at the SKU-store-day level.

Forecasting Architecture

graph TB HIST[Historical Sales Data] --> FEAT[Feature Engineering] PROMO[Promo Calendar] --> FEAT WEATHER[Weather Forecast API] --> FEAT EVENTS[Local Events API] --> FEAT FEAT --> MODEL[ML Forecasting Model] MODEL --> PRED[Daily Demand Predictions] PRED --> INV[Inventory Reorder Engine] INV --> PO[Purchase Orders to Stores] INV --> ALERT[Low Stock Alerts] PRED --> SLOT[Slot Capacity Adjustments]

Demand Prediction Model

C#
public class DemandForecastService
{
    private readonly IFeatureStore _features;
    private readonly IModelRegistry _models;

    public async Task<DemandForecast> ForecastAsync(
        Guid storeId, Guid productId, DateOnly targetDate)
    {
        var features = await _features.GetFeaturesAsync(storeId,
            productId, targetDate);

        var modelInput = new DemandModelInput
        {
            // Historical features
            SalesLast7Days = features.HistoricalSales.Last7Days,
            SalesLast30Days = features.HistoricalSales.Last30Days,
            SalesSameDayLastYear = features.HistoricalSales
                .SameDayLastYear,
            DayOfWeek = (int)targetDate.DayOfWeek,
            Month = targetDate.Month,
            IsWeekend = targetDate.DayOfWeek is
                DayOfWeek.Saturday or DayOfWeek.Sunday,

            // Context features
            ForecastHighTemp = features.WeatherForecast.HighTempF,
            PrecipitationChance = features.WeatherForecast
                .PrecipitationPercent,
            IsHoliday = features.HolidayCalendar.IsHoliday,
            NearbyEvents = features.LocalEvents.Count,
            ActivePromos = features.ActivePromos.Count,

            // Inventory features
            CurrentStock = features.CurrentInventory.QuantityOnHand,
            AvgDailySales = features.HistoricalSales.AvgDailyLast30,
            DaysSinceRestock = features.CurrentInventory
                .DaysSinceLastRestock
        };

        var prediction = await _models.PredictAsync<
            DemandModelInput, DemandModelOutput>(
            "demand-forecast-v3", modelInput);

        return new DemandForecast
        {
            StoreId = storeId,
            ProductId = productId,
            TargetDate = targetDate,
            PredictedUnits = prediction.ExpectedDemand,
            ConfidenceInterval = (
                prediction.LowerBound,
                prediction.UpperBound),
            ReorderRecommended = prediction.ExpectedDemand >
                features.CurrentInventory.QuantityOnHand,
            SuggestedReorderQuantity = Math.Max(0,
                prediction.ExpectedDemand -
                features.CurrentInventory.QuantityOnHand +
                (int)(prediction.ExpectedDemand * 0.2m)) // 20% safety stock
        };
    }
}
Forecast Accuracy Metrics: The demand forecasting model targets a Mean Absolute Percentage Error (MAPE) below 15% at the SKU-store-day level. Weekly aggregation achieves MAPE below 8%. The model is retrained nightly on the latest 2 years of sales data using a gradient boosting framework. A/B testing compares model versions against a baseline heuristic (7-day rolling average).

17. Customer Support & Refund Flow

Customer support in grocery delivery handles a unique set of issues: missing items, wrong items delivered, quality complaints (bruised produce, expired items), weight discrepancies, delivery delays, and substitution disputes. The support system must provide agents with full order context, automate simple refunds, and escalate complex cases with evidence gathering.

Refund Flow Architecture

flowchart TD A[Customer Reports Issue] --> B{Issue Type} B -->|Missing Item| C[Auto-Refund Eligible?] B -->|Wrong Item| D[Verify via Picker Log] B -->|Quality Issue| E[Request Photo Evidence] B -->|Late Delivery| F[Check SLA Breach] C -->|Yes, under $15| G[Auto-Process Refund] C -->|No, over $15| H[Agent Review] D -->|Confirmed Wrong| I[Full Refund + Credit] E -->|Approved| J[Refund + Quality Alert] F -->|Confirmed Late| K[Partial Refund + Credit] G --> L[Refund to Original Payment] H --> L I --> L J --> L K --> L L --> M[Notify Customer] M --> N[Update Analytics Dashboard]

Refund Service

C#
public class RefundService
{
    private readonly IRefundRepository _refunds;
    private readonly IPaymentService _payments;
    private readonly IOrderRepository _orders;

    public async Task<RefundResult> ProcessRefundAsync(
        RefundRequest request)
    {
        var order = await _orders.GetByIdAsync(request.OrderId);

        // Validate refund eligibility
        if (DateTime.UtcNow - order.DeliveredAt > TimeSpan.FromDays(7))
            return RefundResult.Rejected("Refund window expired (7 days)");

        if (request.Amount > order.FinalTotal)
            return RefundResult.Rejected("Refund exceeds order total");

        // Auto-approve small refunds
        if (request.Amount <= 15.00m &&
            request.Reason == RefundReason.MissingItem)
        {
            return await ExecuteRefundAsync(order, request,
                initiatedBy: "auto-system");
        }

        // Route to agent queue
        var ticket = await CreateSupportTicketAsync(order, request);
        return RefundResult.PendingAgentReview(ticket.Id);
    }

    private async Task<RefundResult> ExecuteRefundAsync(
        Order order, RefundRequest request, string initiatedBy)
    {
        var payment = await _payments
            .GetCapturedPaymentAsync(order.Id);

        var refundResult = await _payments.RefundAsync(
            payment.CaptureToken, request.Amount,
            reason: request.Reason.ToString());

        if (refundResult.Succeeded)
        {
            var refund = new RefundRecord
            {
                OrderId = order.Id,
                Amount = request.Amount,
                Reason = request.Reason,
                Description = request.Description,
                InitiatedBy = initiatedBy,
                ProcessedAt = DateTime.UtcNow,
                RefundTransactionId = refundResult.TransactionId
            };

            await _refunds.SaveAsync(refund);

            // Issue loyalty credit for quality issues
            if (request.Reason == RefundReason.QualityIssue)
            {
                await IssueLoyaltyCreditAsync(
                    order.CustomerId, request.Amount * 0.2m,
                    "Quality issue credit");
            }

            return RefundResult.Success(refund);
        }

        return RefundResult.Failed(refundResult.FailureReason);
    }
}

public enum RefundReason
{
    MissingItem,
    WrongItem,
    QualityIssue,
    LateDelivery,
    DamagedInTransit,
    TemperatureViolation,
    CustomerDissatisfied,
    DuplicateCharge
}

Support Escalation Levels

LevelTriggerSLAResolution
L0 - AutoMissing item ≤ $15, late delivery ≤ 15 minInstantAutomatic refund + notification
L1 - Chat AgentRefund $15-$50, substitution dispute5 min responseAgent reviews picker log, issues refund
L2 - Senior AgentRefund $50-$200, repeated complaints30 min responseInvestigation + potential account credit
L3 - ManagerRefund > $200, safety concern, legal2 hour responseFull investigation + policy review

18. Review & Rating System

Reviews and ratings provide critical feedback loops for the platform. Customers rate their overall order experience, individual item quality, shopper performance, and delivery experience. This data feeds into quality scoring algorithms, shopper performance metrics, and store ranking systems. The review system must prevent fraudulent reviews, handle disputes, and surface actionable insights.

Review Data Model

C#
public class OrderReview
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public OverallRating Overall { get; set; }
    public ShopperRating Shopper { get; set; }
    public DeliveryRating Delivery { get; set; }
    public List<ItemReview> ItemReviews { get; set; }
    public string? Comment { get; set; }
    public List<string> PhotoUrls { get; set; }
    public DateTime SubmittedAt { get; set; }
    public ReviewStatus Status { get; set; }
}

public class OverallRating
{
    public int Score { get; set; }          // 1-5
    public List<QualityTag> Tags { get; set; }
    // Tags: FreshItems, OnTime, GoodSubstitutions, WellPacked, Friendly
}

public class ShopperRating
{
    public int Score { get; set; }
    public string? Comment { get; set; }
    public List<ShopperTag> Tags { get; set; }
    // Tags: CarefulPacker, GoodCommunication, FastPicker
}

public class DeliveryRating
{
    public int Score { get; set; }
    public bool WasOnTime { get; set; }
    public string? Comment { get; set; }
}

public class ItemReview
{
    public Guid ProductId { get; set; }
    public int QualityScore { get; set; }   // 1-5
    public string? Issue { get; set; }
    // Issues: Expired, Damaged, Wrong, NotFresh, GreatQuality
}

public class ShopperPerformanceMetrics
{
    public Guid ShopperId { get; set; }
    public decimal AverageRating { get; set; }
    public int TotalReviews { get; set; }
    public decimal OnTimeDeliveryRate { get; set; }
    public decimal SubstitutionAcceptanceRate { get; set; }
    public decimal AveragePickTime { get; set; }
    public int QualityComplaints { get; set; }
    public ShopperTier Tier { get; set; }   // Bronze, Silver, Gold, Platinum
}

Review Aggregation Pipeline

graph LR REV[Review Submissions] --> MOD[Moderation Queue] MOD -->|Auto-filter spam| AGG[Aggregation Service] MOD -->|Flag profanity| AGG AGG --> SHOPPER[Shopper Score] AGG --> STORE[Store Score] AGG --> PRODUCT[Product Quality Score] SHOPPER --> RANK[Shopper Ranking] STORE --> RANK2[Store Ranking] PRODUCT --> RANK3[Product Recommendations]
Gaming Prevention: The review system prevents manipulation through several measures: reviews are only accepted within 48 hours of delivery, each order allows one review per customer, shoppers with sudden rating spikes are flagged for investigation, and reviews mentioning competitors are filtered for potential astroturfing.

19. High-Level Architecture Overview

The platform follows a microservices architecture with event-driven communication via Kafka. Each domain (catalog, orders, payments, routing, tracking) operates independently with its own database. Services communicate synchronously via gRPC for latency-critical paths (inventory checks, payment authorization) and asynchronously via Kafka for non-critical flows (analytics, notifications, reporting).

graph TB subgraph Client Layer WEB[Web App - React] MOB[Mobile App - React Native] PICK[Picker App - React Native] end subgraph API Layer GW[API Gateway / Load Balancer] AUTH[Auth Service - JWT/OAuth2] RATE[Rate Limiter] end subgraph Core Services CATALOG[Catalog Service] ORDER[Order Service] INVENTORY[Inventory Service] SLOT[Slot Service] PAYMENT[Payment Service] SEARCH[Search Service] CART[Cart Service] end subgraph Fulfillment Services PICKER[Picker Workflow Service] SUBST[Substitution Engine] BATCH[Batch Optimization] ROUTE[Route Planning] TRACK[Tracking Service] end subgraph Supporting Services PROMO[Promo Engine] FORECAST[Demand Forecast] SUPPORT[Support Service] REVIEW[Review Service] NOTIFY[Notification Service] end subgraph Data Layer PG[(PostgreSQL)] REDIS[(Redis Cluster)] ES[(Elasticsearch)] KAFKA[Kafka] CH[(ClickHouse)] S3[(S3 - Images)] end WEB & MOB --> GW PICK --> GW GW --> AUTH GW --> RATE GW --> CATALOG & ORDER & INVENTORY & SLOT & PAYMENT & SEARCH & CART GW --> PICKER & SUBST & BATCH & ROUTE & TRACK GW --> PROMO & FORECAST & SUPPORT & REVIEW CATALOG & ORDER & INVENTORY --> PG SLOT & CART & TRACK --> REDIS SEARCH --> ES ORDER & INVENTORY & TRACK --> KAFKA TRACK --> CH CATALOG --> S3

Service Communication Matrix

FromToProtocolPatternLatency SLA
API GatewayOrder ServicegRPCRequest-Response<100ms
Order ServiceInventory ServicegRPCRequest-Response<50ms
Inventory ServiceKafkaKafka ProducerEvent<10ms
KafkaSearch ServiceKafka ConsumerEvent<5s
Picker ServiceOrder ServicegRPCRequest-Response<100ms
Tracking ServiceWebSocket HubWebSocketPush<500ms
Payment ServiceStripe APIHTTPSRequest-Response<3s
Batch ServiceRoute ServicegRPCRequest-Response<500ms

20. Data Model & Storage Schema

The data layer uses polyglot persistence: PostgreSQL for transactional data (orders, users, payments), Redis for real-time state (inventory, slots, sessions), Elasticsearch for search, ClickHouse for analytics and GPS logs, and S3 for product images and documents. Each store is horizontally partitioned (sharded) by store_id for inventory and order tables.

Core Schema (PostgreSQL)

SQL
CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id UUID NOT NULL REFERENCES users(id),
    store_id UUID NOT NULL REFERENCES stores(id),
    status VARCHAR(30) NOT NULL,
    estimated_total DECIMAL(10,2),
    final_total DECIMAL(10,2),
    delivery_slot_start TIMESTAMP,
    delivery_slot_end TIMESTAMP,
    delivery_address JSONB,
    shopper_id UUID REFERENCES shoppers(id),
    driver_id UUID REFERENCES drivers(id),
    substitution_policy VARCHAR(20) DEFAULT 'ask',
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    delivered_at TIMESTAMP
);

CREATE TABLE order_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL REFERENCES orders(id),
    product_id UUID NOT NULL REFERENCES products(id),
    quantity INTEGER,
    unit_price DECIMAL(10,2),
    estimated_price DECIMAL(10,2),
    final_price DECIMAL(10,2),
    actual_weight DECIMAL(8,3),
    is_weight_based BOOLEAN DEFAULT FALSE,
    substitution_for UUID REFERENCES order_items(id),
    picked_at TIMESTAMP,
    UNIQUE(order_id, product_id)
);

CREATE TABLE slot_capacity (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    store_id UUID NOT NULL REFERENCES stores(id),
    slot_date DATE NOT NULL,
    slot_hour INTEGER NOT NULL,
    total_capacity INTEGER NOT NULL,
    booked_count INTEGER DEFAULT 0,
    waitlist_count INTEGER DEFAULT 0,
    delivery_fee DECIMAL(6,2),
    status VARCHAR(20),
    UNIQUE(store_id, slot_date, slot_hour)
);

CREATE INDEX idx_orders_customer ON orders(customer_id, created_at DESC);
CREATE INDEX idx_orders_store_status ON orders(store_id, status);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_slot_capacity_lookup ON slot_capacity(store_id, slot_date, slot_hour);

Redis Key Patterns

Key PatternTypeTTLPurpose
inventory:{storeId}:{productId}Hash5 minHot inventory cache
slot:{storeId}:{date}:{hour}Hash24 hoursSlot availability
cart:{userId}Hash30 daysShopping cart state
session:{shopperId}Hash8 hoursActive pick session
driver:location:{driverId}Geo30 secLive driver GPS
eta:{orderId}String2 hoursCurrent ETA for tracking
search:cache:{hash}String2 minSearch result cache

21. API Design

The platform exposes a RESTful API with JSON payloads. All endpoints require JWT authentication except public product browsing. Rate limits are enforced per user (100 req/min for authenticated, 30 req/min for anonymous). Idempotency keys are required for all write operations (orders, payments, refunds) to prevent duplicate processing.

Core API Endpoints

REST
// Product Catalog
GET    /api/v1/products/search?q=organic+milk&store={storeId}&page=1
GET    /api/v1/products/{productId}
GET    /api/v1/products/{productId}/inventory?store={storeId}
GET    /api/v1/products/{productId}/substitutes?store={storeId}

// Shopping Cart
GET    /api/v1/cart
POST   /api/v1/cart/items
PUT    /api/v1/cart/items/{itemId}
DELETE /api/v1/cart/items/{itemId}
POST   /api/v1/cart/apply-promo
GET    /api/v1/cart/estimate

// Delivery Slots
GET    /api/v1/stores/{storeId}/slots?date=2026-07-15
POST   /api/v1/slots/reserve
DELETE /api/v1/slots/reserve/{reservationId}

// Orders
POST   /api/v1/orders                    // Place order
GET    /api/v1/orders/{orderId}
GET    /api/v1/orders/{orderId}/tracking
POST   /api/v1/orders/{orderId}/cancel
GET    /api/v1/orders/{orderId}/receipt
GET    /api/v1/orders/history?page=1

// Substitutions (Picker)
GET    /api/v1/picker/orders/{orderId}/items
POST   /api/v1/picker/items/{itemId}/scan
POST   /api/v1/picker/items/{itemId}/weight
POST   /api/v1/picker/items/{itemId}/substitute
POST   /api/v1/picker/orders/{orderId}/complete

// Payment
POST   /api/v1/payments/authorize
POST   /api/v1/payments/capture/{orderId}
POST   /api/v1/payments/refund
GET    /api/v1/payments/{orderId}/history

// Reviews
POST   /api/v1/orders/{orderId}/review
GET    /api/v1/products/{productId}/reviews
GET    /api/v1/shoppers/{shopperId}/reviews

API Response Envelope

JSON
{
    "status": "success",
    "data": {
        "orderId": "550e8400-e29b-41d4-a716-446655440000",
        "status": "paid",
        "estimatedTotal": 78.50,
        "slot": {
            "start": "2026-07-15T18:00:00Z",
            "end": "2026-07-15T19:00:00Z"
        }
    },
    "meta": {
        "requestId": "req_abc123",
        "timestamp": "2026-07-13T14:30:00Z",
        "version": "v1"
    }
}

Error Response Format

JSON
{
    "status": "error",
    "error": {
        "code": "SLOT_FULL",
        "message": "The requested delivery slot is no longer available",
        "details": {
            "requestedSlot": "2026-07-15T18:00:00Z",
            "nextAvailableSlot": "2026-07-15T19:00:00Z",
            "waitlistPosition": null
        }
    },
    "meta": {
        "requestId": "req_def456",
        "timestamp": "2026-07-13T14:30:00Z"
    }
}
API Versioning Strategy: The API uses URL-based versioning (/api/v1/, /api/v2/) for major breaking changes and header-based versioning (Accept-Version) for minor additive changes. Deprecation notices are sent 90 days before sunset. All responses include a Deprecation header when using deprecated endpoints.

22. Security, Compliance & Food Safety

Security in grocery delivery encompasses payment protection (PCI-DSS), personal data privacy (GDPR/CCPA), food safety traceability (FDA FSMA), and age verification for restricted items (alcohol). The platform must encrypt all PII at rest and in transit, maintain audit trails for all order modifications, and support data deletion requests within 30 days.

Security Architecture

  • Authentication: JWT tokens with 15-minute expiry, refresh tokens with 30-day expiry, stored in HTTP-only secure cookies. OAuth2 for third-party integrations.
  • Authorization: Role-based access control (RBAC) with roles: Customer, Shopper, Driver, StoreAdmin, PlatformAdmin. Permission checks at API gateway and service levels.
  • Payment Security: PCI-DSS Level 1 compliance. Card data tokenized via Stripe/Braintree. No raw card numbers stored. Payment API runs in isolated PCI-compliant environment.
  • Data Encryption: AES-256 at rest for all PII. TLS 1.3 in transit. Database-level column encryption for SSN, payment tokens. Key rotation every 90 days via AWS KMS.
  • Food Safety Traceability: Batch IDs on all perishable items link to supplier lot numbers. Recall events can identify affected orders within 5 minutes. Temperature violation logging for cold chain items.
  • Age Verification: Alcohol orders require ID scan at delivery. Driver app captures and verifies ID, blocks delivery if customer appears under 21 or ID is invalid.

Compliance Matrix

RegulationScopeKey RequirementsImplementation
PCI-DSS Level 1Payment processingCard data tokenization, audit logs, quarterly scansStripe SDK, no raw card storage
GDPREU customersData portability, right to deletion, consent management30-day deletion pipeline, consent DB
CCPACalifornia customersDo Not Sell, data access requestsAutomated data export tool
FDA FSMAFood traceabilityLot tracking, recall readiness, temperature logsBatch tracking, cold chain monitoring
ADAAccessibilityWCAG 2.1 AA complianceScreen reader support, keyboard nav

23. Monitoring & Alerting

Monitoring a grocery delivery platform requires tracking business metrics (order volume, conversion rates, slot utilization), operational metrics (picker throughput, substitution rates, delivery ETAs), and system metrics (latency, error rates, queue depths). A single dashboard provides visibility into the entire pipeline from customer browsing to delivery completion.

Key Metrics Dashboard

MetricTargetAlert ThresholdPagerDuty Level
API Error Rate (5xx)<0.01%>0.1%P1 - Critical
API Latency (p99)<200ms>500msP2 - Warning
Inventory Sync Lag<2 minutes>5 minutesP2 - Warning
Slot Booking Success Rate>99%<97%P2 - Warning
Order Completion Rate>99.5%<98%P1 - Critical
Substitution Rate<8%>15%P3 - Info
Delivery On-Time Rate>95%<90%P2 - Warning
Kafka Consumer Lag<1000 msgs>10000 msgsP2 - Warning
GPS Processing Latency<2s>5sP3 - Info
Payment Failure Rate<0.5%>2%P1 - Critical
Incident Response: P1 incidents trigger immediate PagerDuty alerts to the on-call engineer with a 15-minute response SLA. A dedicated Slack war room is auto-created. Post-incident reviews are mandatory within 48 hours for P1/P2 incidents, with action items tracked in Jira.

24. Testing Strategy

A robust testing strategy for a grocery delivery platform must cover unit tests for business logic, integration tests for service interactions, contract tests for API compatibility, load tests for peak-hour readiness, and chaos engineering for resilience. The platform uses a multi-layer testing pyramid with automated CI/CD gates.

Testing Layers

LayerScopeToolCoverage TargetExecution
Unit TestsIndividual classes/methodsxUnit + Moq85%+ line coverageEvery commit
Integration TestsService + DB interactionsxUnit + TestcontainersAll critical pathsEvery PR
Contract TestsAPI contract compatibilityPactAll consumer-provider pairsEvery PR
E2E TestsFull user flowsPlaywright + AppiumTop 20 user journeysNightly
Load TestsPeak-hour scenariosk6 / Gatling10K orders/min sustainedWeekly
Chaos TestsFailure resilienceChaos Monkey / LitmusCritical service failuresBi-weekly
Security TestsVulnerability scanningSnyk + OWASP ZAPNo critical CVEsEvery PR + weekly

Sample Integration Test

C#
[Fact]
public async Task PlaceOrder_WithWeightBasedItems_AdjustsFinalPrice()
{
    // Arrange
    var store = await _fixture.CreateTestStore();
    var product = await _fixture.CreateWeightProduct(
        store.Id, unitPrice: 4.99m, unit: "lb");
    var customer = await _fixture.CreateTestCustomer();
    var slot = await _fixture.CreateSlot(store.Id, capacity: 30);

    // Add item to cart
    var cart = await _cartService.AddItemAsync(customer.Id,
        product.Id, requestedWeight: 2.0m);

    // Place order
    var order = await _orderService.PlaceOrderAsync(
        customer.Id, slot.Id);

    // Simulate picker picking at different weight
    var pickSession = await _pickerService
        .StartSessionAsync(order.Id);
    await _pickerService.RecordWeightAsync(
        pickSession.Id, product.Id, actualWeight: 2.15m);
    await _pickerService.CompleteSessionAsync(pickSession.Id);

    // Assert
    var finalOrder = await _orderService.GetByIdAsync(order.Id);
    var expectedFinalPrice = 2.15m * 4.99m; // $10.73
    finalOrder.Items.First().FinalPrice
        .Should().Be(expectedFinalPrice);
    finalOrder.FinalTotal.Should()
        .BeGreaterThan(order.EstimatedTotal);
}

[Fact]
public async Task BookSlot_WhenCapacityFull_ReturnsWaitlisted()
{
    var store = await _fixture.CreateTestStore();
    var slot = await _fixture.CreateSlot(store.Id, capacity: 3);
    var customers = await _fixture.CreateCustomers(5);

    // Fill all capacity
    for (int i = 0; i < 3; i++)
    {
        var result = await _slotService.BookSlotAsync(
            store.Id, customers[i].Id, slot);
        result.Status.Should().Be(SlotBookingStatus.Success);
    }

    // 4th customer should be waitlisted
    var waitlistResult = await _slotService.BookSlotAsync(
        store.Id, customers[3].Id, slot);
    waitlistResult.Status.Should().Be(SlotBookingStatus.Waitlisted);
    waitlistResult.WaitlistPosition.Should().Be(1);
}
Test Data Management: Integration tests use Testcontainers to spin up real PostgreSQL and Redis instances in Docker. Test data is generated using Bogus (faker library) for realistic product names, addresses, and customer profiles. Each test class gets a fresh database snapshot that is rolled back after each test, ensuring complete isolation.

25. Interview Q&A Deep Dive

Below are detailed answers to common system design interview questions about grocery delivery platforms. Each answer covers trade-offs, alternative approaches, and real-world considerations.

Q1: How do you handle real-time inventory accuracy across thousands of stores?

Inventory accuracy is fundamentally a consistency problem. We use a multi-layer approach: (1) a Redis cache for sub-millisecond reads during browsing, updated via Kafka events from POS webhooks with a 2-minute staleness window; (2) a PostgreSQL source of truth for reservation and checkout operations with distributed locking via Redlock; (3) a reconciliation worker that polls POS systems every 5 minutes and alerts on drift exceeding 5%. The key insight is that browsing can tolerate slight staleness (a customer sees "5 in stock" when there are actually 4), but checkout cannot (we must not oversell). So we apply optimistic concurrency at browse time and pessimistic locking at reservation time. Real-world accuracy of 98%+ is achievable, with the remaining 2% handled by the substitution flow.

Q2: How would you design the substitution engine to balance customer satisfaction with picker efficiency?

The substitution engine must be fast (picking time pressure) and accurate (customer satisfaction). We use a three-tier approach: (1) pre-approved substitution rules set by the customer at cart time (brand-only, similar-only, auto-approve best match); (2) a scoring engine that ranks alternatives by brand match (30%), price proximity (25%), category match (20%), dietary compliance (15%), and quality tier (10%); (3) real-time customer communication via in-app chat for ambiguous cases. The scoring engine runs locally on the picker's device (no network call needed) using a pre-computed substitution graph. For dietary restrictions, we apply hard filters that never substitute allergen-containing products. Auto-approval is limited to cases where the top-scored alternative exceeds 0.85 composite score; otherwise, the customer is asked. This achieves 70% auto-resolution while maintaining >90% customer satisfaction with substitutions.

Q3: How do you optimize batch delivery routing when orders have different time windows?

Batch routing is a variant of the Vehicle Routing Problem with Time Windows (VRPTW), which is NP-hard. We use a two-phase approach: (1) clustering, where orders are grouped by geographic proximity (geohash-based, 2km radius) and time window overlap (windows must overlap by at least 30 minutes); (2) route optimization within each cluster using a nearest-neighbor heuristic with 2-opt local search improvement. The algorithm enforces hard constraints (time windows, vehicle capacity, frozen item temperature limits) and optimizes soft objectives (total distance, total time, driver balance). For real-time re-routing when new orders become available or traffic changes, we run a lightweight incremental optimization that re-sequences remaining stops without replanning the entire route. In production, this achieves routes within 15% of optimal (verified against OR-Tools solver) with O(n^2) runtime instead of exponential.

Q4: How do you handle the payment flow when the final amount differs from the estimate?

We use a two-phase payment flow: (1) authorization at checkout for the estimated total plus a 10% buffer (rounded up to the nearest dollar); (2) capture at delivery for the actual amount. The 10% buffer handles weight variance and price adjustments. After capture, any difference between the authorized and captured amounts is automatically refunded within 3-5 business days. The system logs every adjustment (weight changes, substitutions, removed items) with full audit trail. For customers with recurring orders, we maintain a rolling average of adjustment magnitude and dynamically adjust the buffer (range: 5%-20%) to minimize both declined payments and excessive holds. Stripe's incremental authorization API allows us to increase the hold if needed without re-authorizing.

Q5: How would you scale the GPS tracking pipeline to handle 100K+ concurrent drivers?

GPS tracking at scale requires a high-throughput streaming pipeline. Each driver sends GPS updates every 5 seconds = 20K updates/second at 100K drivers. We use Kafka as the backbone with a partitioned topic (partitioned by driver_id) consumed by a horizontally-scaled GPS processing service. Each partition processes updates sequentially, maintaining the latest position in Redis (Geo type for proximity queries). ETA calculations use a separate consumer group that computes ETA every 10th update (every 50 seconds) to avoid overwhelming the ETA service. Historical location data is written to ClickHouse for analytics and route replay. The WebSocket hub for customer tracking uses Redis pub/sub for fan-out: each order subscribes to a channel, and position updates are pushed to all subscribers. This architecture handles 100K drivers with 5-second updates at approximately $4,200/month in infrastructure costs (primarily ClickHouse storage and compute).

Q6: How do you ensure the picker app works reliably in stores with poor connectivity?

We use an offline-first architecture with conflict resolution. The picker app maintains a local SQLite database containing the current pick session, store layout, and product catalog. All actions (scans, weight recordings, substitution decisions) are written locally first, then synced to the server when connectivity is available. The sync service uses a queue with retry logic and exponential backoff. Conflict resolution follows these rules: (1) inventory changes from the server take precedence (server is source of truth for stock); (2) picker actions are ordered by timestamp (last-writer-wins for non-critical fields); (3) substitution decisions are flagged for customer confirmation if they conflict with customer preferences. The app shows a clear connectivity indicator and queues actions visibly so the picker knows what has been synced. In practice, stores with poor cellular reception often have WiFi available near the entrance; the app opportunistically syncs when connected to any known network.

Q7: How do you design the demand forecasting model, and what features matter most?

Our demand forecasting model predicts SKU-store-day level demand using gradient boosting (LightGBM). The most important features, ranked by SHAP importance: (1) historical sales same-day-of-week (30% importance); (2) rolling 7-day average (20%); (3) month/season (15%); (4) weather forecast temperature and precipitation (10%); (5) active promotions (8%); (6) day-of-week pattern (7%); (7) local events (5%); (8) holiday proximity (5%). We train on 2 years of daily sales data with a 3-month holdout for validation. The model retracts nightly and is deployed via a canary release (10% traffic for 24 hours, then full rollout if metrics are stable). Key metrics: MAPE below 15% at SKU-store-day level, below 8% at weekly aggregation. The forecast feeds directly into the inventory reorder engine, which generates purchase order recommendations to store managers every morning.

Q8: How do you handle a situation where a picker finds that 30% of items in an order are out of stock?

High out-of-stock rates trigger a special workflow. When the picker's substitution rate exceeds 20% during a session, the app alerts the picker and simultaneously notifies the customer: "Several items in your order are unavailable. Your shopper is working on alternatives." The customer can: (1) approve individual substitutions as they come in; (2) set a bulk substitution rule ("use best match for everything"); or (3) cancel remaining items for a partial refund. If the substitution rate exceeds 40%, we trigger an automatic order review: the system offers the customer the option to cancel the entire order with a full refund plus a $10 credit. We also analyze high substitution-rate orders to identify systemic issues: if a specific store consistently has high OOS rates, we adjust the store routing algorithm to deprioritize it; if a specific product is frequently unavailable, we escalate to the merchandising team. Post-delivery, high-OOS orders receive a quality survey that feeds back into the inventory forecasting model.

Q9: How do you prevent slot hoarding where customers book multiple time slots?

Slot hoarding wastes capacity and prevents other customers from getting deliveries. We prevent it through several mechanisms: (1) each customer can have at most 2 active reservations at a time; (2) a customer with a confirmed order cannot book another slot until 2 hours before the confirmed delivery; (3) releasing a slot within 2 hours of the window deducts a "cancellation credit" — after 3 credits, the customer is limited to same-day slots only; (4) the system detects patterns of repeated book-and-release behavior and flags accounts for review. Additionally, dynamic pricing discourages hoarding: peak slots have higher fees that are non-refundable if cancelled within 4 hours. These measures reduced hoarding by 85% in A/B testing while maintaining a 99.2% slot utilization rate during peak hours.

Q10: How would you design the system to support both marketplace stores (Instacart model) and dark stores (Blinkit model) on the same platform?

The key architectural difference is inventory ownership. Marketplace stores use POS-synced inventory with optimistic availability (slightly stale data, handled by substitutions). Dark stores use platform-controlled inventory with exact real-time counts (no POS sync needed, but picker accuracy matters). We abstract this behind an IInventoryProvider interface: MarketplaceInventoryProvider polls POS webhooks and applies a staleness tolerance, while DarkStoreInventoryProvider directly queries the platform's inventory database. The routing, slot, and picking services are agnostic to store type. The main differences: dark stores support smaller delivery radii (2km vs 15km), shorter delivery windows (30-min vs 1-hour), and higher slot density (50+ orders/hour vs 30). Dark stores also support "instant delivery" (10-minute window) by co-locating pickers and drivers in the same facility, eliminating the separate batch routing step. The platform's store routing algorithm treats both as first-class citizens, choosing the optimal store type based on customer location and order urgency.

System Design Guide by Ayodhyya — Online Grocery Delivery Platform