How to Design an Online Grocery Delivery Platform
Building Instacart / BigBasket at Scale — Inventory, Routing, Picker Workflows & Real-Time Tracking
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.
Real-World Case Studies
| Company | Model | Scale | Key Innovation |
|---|---|---|---|
| Instacart | Marketplace (pick from partner stores) | 1,400+ retailers, 80K+ shoppers | Real-time shopper marketplace, batched order grouping |
| BigBasket | Dark store + delivery | 25M+ customers, 1,800 SKUs per store | Dark store model for 10-minute delivery, own inventory |
| Blinkit | Quick commerce (dark stores) | 10-minute delivery promise | Hyper-local dark stores, demand-driven inventory placement |
| Walmart Grocery | Store pickup + delivery | 4,700+ stores | Curbside pickup integration, in-store picker workforce |
| Ocado | Automated warehouse | Centralized fulfillment centers | Robotic 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
- Product Discovery: Customers can browse products by category, search with fuzzy matching, filter by dietary preferences/allergens, and view real-time availability per store.
- 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.
- 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.
- 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.
- Delivery & Tracking: Real-time GPS tracking of the delivery driver, ETA updates, proof of delivery, and contactless delivery options.
- 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.
- Payment & Tipping: Support credit/debit cards, digital wallets, tipping (pre-set or custom), and split payments. Final bill adjusted for actual weights and substitutions.
- Promos & Loyalty: Promo code system, loyalty points, first-order discounts, and dynamic pricing based on demand and time of day.
- Order Management: Order tracking, cancellation, refund flow, reorder from past orders, and scheduled reorders.
- Reviews & Ratings: Rate order experience, rate individual items, review shoppers, and flag quality issues.
- Customer Support: In-app chat support, automated refund for quality issues, and escalation workflows.
- Store Management: Admin panel for stores to manage inventory, set delivery zones, configure slot capacity, and view analytics.
Non-Functional Requirements
| Requirement | Target | Why It Matters |
|---|---|---|
| Availability | 99.95% (4.38h downtime/year) | Customers shop at all hours; downtime means lost orders |
| Inventory Accuracy | ≥98% within 2 minutes | Wrong availability = terrible UX and substitutions |
| Search Latency (p99) | <200ms | Fuzzy search must be fast for browsing |
| Slot Release Latency | <500ms | When a slot opens, next customer in queue must see it instantly |
| Order Throughput | 10K orders/minute at peak | Peak hours (Friday evening, holidays) must not degrade |
| GPS Update Frequency | Every 5 seconds | Smooth tracking experience without battery drain |
| Data Consistency | Eventual consistency for inventory; strong for payments | Inventory can lag briefly; money must be exact |
| Security | PCI-DSS Level 1, GDPR compliant | Payment data and personal info protection |
3. Capacity Estimation & Cost Analysis
Scale Assumptions
| Metric | Value | Calculation |
|---|---|---|
| Daily Active Users | 5 million | Mid-size platform (regional scale) |
| Orders per Day | 500,000 | 10% conversion rate |
| Peak Orders per Minute | 10,000 | Friday 6-8 PM surge |
| Average Items per Order | 25 | 12.5M items picked daily |
| Product Catalog Size | 50,000 SKUs | Across all stores |
| Number of Stores | 2,000 | Partner stores + dark stores |
| Active Shoppers/Drivers | 100,000 | Gig workers |
| Average Delivery Slots per Day | 14 (7am-9pm, hourly) | Per store |
| Slot Capacity per Store per Hour | 30 orders | Pick + 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)
| Service | Specification | Monthly 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 |
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.
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;
}
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.
6. Search with Fuzzy Matching & Dietary Filters
Grocery search is uniquely challenging because customers use a wide variety of terms to find the same product. "Milk," "organic milk," "horizon milk," "1% milk," "half gallon milk" — all should surface relevant results. Misspellings are extremely common: "brocoli" instead of "broccoli," "yoghurt" instead of "yogurt." The search engine must handle fuzzy matching, synonyms, brand recognition, and dietary filtering in real time.
Search Architecture
Elasticsearch Index Mapping
JSON
{
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "grocery_analyzer",
"fields": {
"fuzzy": { "type": "text", "analyzer": "fuzzy_analyzer" },
"keyword": { "type": "keyword" }
}
},
"brand": { "type": "keyword" },
"category": { "type": "keyword" },
"subcategory": { "type": "keyword" },
"allergens": { "type": "keyword" },
"dietary_tags": { "type": "keyword" },
"barcode": { "type": "keyword" },
"description": { "type": "text", "analyzer": "english" },
"store_inventory": {
"type": "nested",
"properties": {
"store_id": { "type": "keyword" },
"quantity": { "type": "integer" },
"price": { "type": "scaled_float", "scaling_factor": 100 }
}
},
"popularity_score": { "type": "float" },
"avg_rating": { "type": "float" }
}
},
"settings": {
"analysis": {
"analyzer": {
"grocery_analyzer": {
"tokenizer": "standard",
"filter": [
"lowercase",
"synonym_filter",
"asciifolding"
]
},
"fuzzy_analyzer": {
"tokenizer": "standard",
"filter": [
"lowercase",
"fuzzy_misspelling_filter",
"asciifolding"
]
}
},
"filter": {
"synonym_filter": {
"type": "synonym",
"synonyms": [
"coke, coca-cola, cola",
"tissues, kleenex",
"soda, pop, soft drink",
"aubergine, eggplant",
"courgette, zucchini"
]
},
"fuzzy_misspelling_filter": {
"type": "phonetic",
"encoder": "soundex"
}
}
}
}
}
Search Query Processing
C#
public class GrocerySearchService
{
private readonly IElasticClient _es;
private readonly IDistributedCache _cache;
public async Task<SearchResult> SearchAsync(
SearchRequest request, Guid? storeId = null)
{
var cacheKey = $"search:{request.Query}:{storeId}:{request.Page}";
var cached = await _cache.GetAsync<SearchResult>(cacheKey);
if (cached != null) return cached;
var results = await _es.SearchAsync<ProductDocument>(s => s
.Index("products")
.Size(request.PageSize)
.From(request.Offset)
.Query(q => q
.Bool(b => b
.Must(m => m
.MultiMatch(mm => mm
.Fields(f => f
.Field(p => p.Name, 3.0) // Boost name 3x
.Field(p => p.Name.Fuzzy(), 2.0)
.Field(p => p.Brand, 2.5)
.Field(p => p.Description, 1.0)
.Field(p => p.Subcategory, 1.5))
.Query(request.Query)
.Type(TextQueryType.BestFields)
.Fuzziness(Fuzziness.Auto)))
.Filter(buildFilters(request, storeId))
.Should(s => s
.Nested(n => n
.Path(p => p.StoreInventory)
.Query(nq => nq
.Term(t => t
.Field("store_inventory.store_id")
.Value(storeId)))
.Boost(2.0)))))
.Highlight(h => h
.PreTags("<em>")
.PostTags("</em>")
.Fields(f => f.Field(p => p.Name)))
.Aggregations(a => a
.Terms("categories", t => t.Field(p => p.Category))
.Terms("brands", t => t.Field(p => p.Brand))
.Filters("allergen_free", f => f
.Filters("no_gluten", nq => nq
.Bool(b => b.MustNot(m => m
.Term(t => t.Field("allergens").Value("Gluten"))))))));
var result = MapToSearchResult(results, request);
await _cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(2));
return result;
}
}
Dietary & Allergen Filtering
| Filter Type | Example Values | Implementation |
|---|---|---|
| Dietary Preference | Vegan, Vegetarian, Keto, Paleo, Halal, Kosher | Keyword filter on dietary_tags field |
| Allergen Exclusion | Gluten, Dairy, Nuts, Soy, Eggs, Shellfish | Must_not clause on allergens field |
| Nutritional | Low sugar (<5g), High protein (>15g) | Range queries on nutritional fields |
| Organic/Non-GMO | Organic certified, Non-GMO Project | Keyword filter on dietary_tags |
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 Type | Example | Calculation |
|---|---|---|
| Multi-Buy | "3 for $5" on yogurt | If quantity ≥ 3, price = (qty / 3) x $5 + (qty % 3) x unit_price |
| Buy X Get Y Free | "Buy 2 Get 1 Free" on chips | Every 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.
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);
}
}
}
}
}
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 Window | Capacity | Booked | Available | Status | Delivery Fee |
|---|---|---|---|---|---|
| 7:00 - 8:00 AM | 30 | 12 | 18 | Available | $2.99 |
| 8:00 - 9:00 AM | 30 | 25 | 5 | Limited | $2.99 |
| 9:00 - 10:00 AM | 30 | 30 | 0 | Full | $3.99 |
| 12:00 - 1:00 PM | 35 | 33 | 2 | Limited | $5.99 |
| 5:00 - 6:00 PM | 40 | 40 | 0 | Full | $7.99 |
| 6:00 - 7:00 PM | 40 | 42 | -2 | Overbooked | $7.99 |
| 8:00 - 9:00 PM | 25 | 10 | 15 | Available | $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
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 Item | Situation | Best Substitute | Reasoning |
|---|---|---|---|
| Organic Whole Milk 1gal | OOS | Organic 2% Milk 1gal (same brand) | Same brand, organic, slight fat difference acceptable |
| Honeycrisp Apples 1lb | OOS | Gala Apples 1lb | Same category, similar price, popular alternative |
| Greek Yogurt Vanilla 32oz | OOS | Greek Yogurt Vanilla 16oz x2 | Same product, different size; combine to match quantity |
| Gluten-Free Bread | OOS | Do not substitute | Dietary restriction; wrong item could cause health issue |
| Coca-Cola 12-pack | OOS | Pepsi 12-pack (ask customer) | Brand loyalty high; must confirm |
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.
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.
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);
}
}
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
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;
}
}
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
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 Status | Customer Sees | Update Frequency |
|---|---|---|
| Order Placed | Confirmation screen with estimated delivery time | Once |
| Being Picked | "Your shopper is picking your items" + item counter | Every item picked |
| Substitutions | Substitution suggestions with approve/reject | Per substitution |
| Being Packed | "Your order is being packed" | Once |
| Out for Delivery | Live map with driver position + ETA countdown | Every 5 seconds |
| Arriving | "Your driver is 2 minutes away" + driver details | Every 5 seconds |
| Delivered | Delivery confirmation + receipt + tip prompt | Once |
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
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 Item | Quantity | Estimated Price | Final Price | Notes |
|---|---|---|---|---|
| Organic Whole Milk 1gal | 2 | $11.98 | $11.98 | Fixed price, picked as-is |
| Bananas (organic) | 2 lbs | $2.38 | $2.52 | Actual weight: 2.12 lbs |
| Chicken Breast (boneless) | 1.5 lbs | $8.24 | $7.89 | Actual weight: 1.43 lbs |
| Greek Yogurt Vanilla 32oz | 1 | $5.49 | $5.49 | Picked as-is |
| Gluten-Free Bread | 1 | $6.99 | $6.99 | Substituted: same brand, different flavor |
| Avocados | 4 each | $5.96 | $5.96 | Picked as-is |
| Subtotal | $40.83 | |||
| Delivery Fee | $4.99 | Standard 1-hour window | ||
| Service Fee | $3.99 | 5% of subtotal | ||
| Tip | $6.00 | Pre-selected by customer | ||
| Tax | $3.27 | 8% on food items | ||
| Total | $59.08 | |||
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.
| Condition | Multiplier | Effect |
|---|---|---|
| Normal demand, normal weather | 1.0x | Base fee applies |
| High demand (80%+ slots booked) | 1.5x | $3.99 becomes $5.99 |
| Peak hour (5-8 PM weekdays) | 1.3x | Standard peak surcharge |
| Heavy rain / snow | 1.8x | Weather surcharge + driver bonus |
| Holiday (Thanksgiving, Christmas Eve) | 2.0x | Holiday premium |
| Low demand, off-peak | 0.7x | Discount 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
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
};
}
}
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
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
| Level | Trigger | SLA | Resolution |
|---|---|---|---|
| L0 - Auto | Missing item ≤ $15, late delivery ≤ 15 min | Instant | Automatic refund + notification |
| L1 - Chat Agent | Refund $15-$50, substitution dispute | 5 min response | Agent reviews picker log, issues refund |
| L2 - Senior Agent | Refund $50-$200, repeated complaints | 30 min response | Investigation + potential account credit |
| L3 - Manager | Refund > $200, safety concern, legal | 2 hour response | Full 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
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).
Service Communication Matrix
| From | To | Protocol | Pattern | Latency SLA |
|---|---|---|---|---|
| API Gateway | Order Service | gRPC | Request-Response | <100ms |
| Order Service | Inventory Service | gRPC | Request-Response | <50ms |
| Inventory Service | Kafka | Kafka Producer | Event | <10ms |
| Kafka | Search Service | Kafka Consumer | Event | <5s |
| Picker Service | Order Service | gRPC | Request-Response | <100ms |
| Tracking Service | WebSocket Hub | WebSocket | Push | <500ms |
| Payment Service | Stripe API | HTTPS | Request-Response | <3s |
| Batch Service | Route Service | gRPC | Request-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 Pattern | Type | TTL | Purpose |
|---|---|---|---|
inventory:{storeId}:{productId} | Hash | 5 min | Hot inventory cache |
slot:{storeId}:{date}:{hour} | Hash | 24 hours | Slot availability |
cart:{userId} | Hash | 30 days | Shopping cart state |
session:{shopperId} | Hash | 8 hours | Active pick session |
driver:location:{driverId} | Geo | 30 sec | Live driver GPS |
eta:{orderId} | String | 2 hours | Current ETA for tracking |
search:cache:{hash} | String | 2 min | Search 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"
}
}
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
| Regulation | Scope | Key Requirements | Implementation |
|---|---|---|---|
| PCI-DSS Level 1 | Payment processing | Card data tokenization, audit logs, quarterly scans | Stripe SDK, no raw card storage |
| GDPR | EU customers | Data portability, right to deletion, consent management | 30-day deletion pipeline, consent DB |
| CCPA | California customers | Do Not Sell, data access requests | Automated data export tool |
| FDA FSMA | Food traceability | Lot tracking, recall readiness, temperature logs | Batch tracking, cold chain monitoring |
| ADA | Accessibility | WCAG 2.1 AA compliance | Screen 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
| Metric | Target | Alert Threshold | PagerDuty Level |
|---|---|---|---|
| API Error Rate (5xx) | <0.01% | >0.1% | P1 - Critical |
| API Latency (p99) | <200ms | >500ms | P2 - Warning |
| Inventory Sync Lag | <2 minutes | >5 minutes | P2 - 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 msgs | P2 - Warning |
| GPS Processing Latency | <2s | >5s | P3 - Info |
| Payment Failure Rate | <0.5% | >2% | P1 - Critical |
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
| Layer | Scope | Tool | Coverage Target | Execution |
|---|---|---|---|---|
| Unit Tests | Individual classes/methods | xUnit + Moq | 85%+ line coverage | Every commit |
| Integration Tests | Service + DB interactions | xUnit + Testcontainers | All critical paths | Every PR |
| Contract Tests | API contract compatibility | Pact | All consumer-provider pairs | Every PR |
| E2E Tests | Full user flows | Playwright + Appium | Top 20 user journeys | Nightly |
| Load Tests | Peak-hour scenarios | k6 / Gatling | 10K orders/min sustained | Weekly |
| Chaos Tests | Failure resilience | Chaos Monkey / Litmus | Critical service failures | Bi-weekly |
| Security Tests | Vulnerability scanning | Snyk + OWASP ZAP | No critical CVEs | Every 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);
}
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.