How to Design a Vending Machine System
Building state-machine-driven vending machines with inventory management, payment processing, and IoT monitoring at fleet scale
Table of Contents
- Introduction
- Functional & Non-Functional Requirements
- Capacity Estimation
- Data Model
- API Design
- High-Level Architecture
- Vending Machine State Machine
- Product Selection & Slot Management
- Payment Processing
- Dispensing Mechanism
- Inventory Management
- Pricing Engine
- IoT Sensors & Monitoring
- Predictive Restocking
- Cash Management
- Anti-Theft & Tamper Detection
- Energy Management
- Admin Portal & Dashboard
- Database Design
- Caching Strategy
- Multi-Region Design
- Cost Estimation
- Interview Q&A
- Full C# Implementation
- Vending Machine Fleet Analytics & Machine Learning
- Conclusion
1 Introduction
Vending machines are everywhere — from office break rooms to subway stations, hospitals, airports, and university campuses. There are over 10 million vending machines operating globally, generating more than $130 billion in annual revenue. The industry is undergoing a dramatic transformation driven by three converging forces: the shift to cashless payments, the rise of smart vending with touchscreens and IoT connectivity, and the emergence of predictive analytics for inventory management and route optimization.
Modern vending machines are no longer simple coil-and-coin contraptions. A smart vending machine today is an IoT edge device that runs embedded firmware, communicates with a cloud backend over MQTT or HTTPS, processes credit card and NFC payments through PCI-DSS-compliant gateways, monitors temperature sensors for cold-chain compliance, and uses machine learning models to predict when a product will sell out — all while running on a small ARM processor powered by a 12V supply.
From a system design perspective, vending machines present fascinating challenges: we must design a reliable state machine that gracefully handles power failures, network outages, and mechanical jams; we must build a distributed fleet management system that can monitor and control thousands of machines across geographies; we need real-time inventory tracking with offline-first capabilities; and we need payment processing that supports cash, cards, NFC, QR codes, and UPI — all while maintaining audit trails and financial reconciliation.
This article walks through the complete system design of a modern smart vending machine platform — from the firmware-level state machine to the cloud architecture, from database schema to IoT sensor integration, and from payment processing to predictive restocking algorithms. Whether you are preparing for a senior system design interview or building an actual vending machine fleet, this guide covers everything you need to know.
Key Design Challenges
- Reliability: The machine must dispense correctly every time, even during network outages or power failures.
- Payment Integrity: Money must never be lost — if a payment succeeds, the product must dispense; if it fails, the customer must be refunded instantly.
- Fleet Scale: A single operator may manage 5,000+ machines across hundreds of locations, each with unique product assortments.
- Offline-First: Machines in basements or rural areas may have intermittent connectivity — the system must queue telemetry and sync when online.
- Security: Physical tampering, payment fraud, and data privacy must all be addressed.
2 Functional & Non-Functional Requirements
Functional Requirements
| ID | Requirement | Priority |
|---|---|---|
| FR-01 | User can browse available products on a touchscreen or physical keypad | P0 |
| FR-02 | User can select a product by pressing a button or tapping the screen | P0 |
| FR-03 | System accepts payment via cash (coins and bills), credit/debit card, NFC (Apple Pay, Google Pay), QR code, and UPI | P0 |
| FR-04 | System dispenses the selected product after successful payment | P0 |
| FR-05 | System dispenses correct change for cash payments | P0 |
| FR-06 | Admin can view real-time inventory levels for each machine | P1 |
| FR-07 | Admin can remotely update product pricing | P1 |
| FR-08 | System tracks sales data, revenue, and transaction history | P1 |
| FR-09 | System sends alerts when inventory is low or machine is malfunctioning | P1 |
| FR-10 | System supports dynamic pricing based on time of day, location, and demand | P2 |
| FR-11 | System provides predictive restocking recommendations with optimized routes | P2 |
| FR-12 | System detects and alerts on tamper events (tilt, door forced open, coil manipulation) | P1 |
| FR-13 | System manages product expiry dates and FIFO rotation | P1 |
| FR-14 | User can request a refund if dispensing fails | P0 |
| FR-15 | System supports multi-tender payments (part cash, part card) | P2 |
Non-Functional Requirements
| ID | Requirement | Target |
|---|---|---|
| NFR-01 | Transaction latency (selection to dispensing) | < 5 seconds |
| NFR-02 | System availability | 99.95% (machine operates offline when cloud is unavailable) |
| NFR-03 | Data durability | 99.999% for transaction logs and payment records |
| NFR-04 | Concurrent transactions per machine | 1 (single user at a time) |
| NFR-05 | Fleet size support | 50,000+ machines per operator |
| NFR-06 | Offline operation duration | Up to 72 hours with full functionality |
| NFR-07 | Payment PCI compliance | PCI DSS Level 1 |
| NFR-08 | Telemetry sync interval | Every 60 seconds when online |
| NFR-09 | Security | TLS 1.3 for all communications, AES-256 for stored payment data |
| NFR-10 | Energy consumption | Idle < 50W, Active < 200W |
3 Capacity Estimation
8AM-9PM] B --> G[100 KB Telemetry/Machine/Hour] G --> H[1 GB Daily Telemetry Ingestion] B --> I[2 MB Transaction Log/Machine/Day] I --> J[20 GB Daily Transaction Storage]
Transaction Volume
| Metric | Value | Calculation |
|---|---|---|
| Total machines | 10,000 | Given fleet size |
| Avg transactions/machine/day | 30 | Typical for high-traffic locations |
| Total daily transactions | 300,000 | 10,000 × 30 |
| Average QPS | 3.5 | 300,000 / 86,400 |
| Peak QPS (12hr active) | ~20 | 300,000 / (12 × 3600) × 2 |
| Peak QPS (30min burst) | ~100 | Lunch rush clustering |
| Avg transaction size | $2.50 | Snacks and beverages |
| Daily revenue | $750,000 | 300,000 × $2.50 |
| Annual revenue | $273M | $750K × 365 |
Storage Estimation
| Data Type | Size per Record | Daily Volume | Daily Storage | Annual Storage |
|---|---|---|---|---|
| Transaction logs | 2 KB | 300,000 | 600 MB | 219 GB |
| Telemetry snapshots | 1 KB | 14,400,000 (every 60s) | 14.4 GB | 5.3 TB |
| Payment records | 1.5 KB | 300,000 | 450 MB | 164 GB |
| Inventory updates | 512 B | 1,000,000 | 500 MB | 183 GB |
| Alerts/Events | 768 B | 500,000 | 384 MB | 140 GB |
| Total | ~16 GB | ~6 TB |
Pro Tip
Telemetry data dominates storage. Implement a tiered retention policy: keep 1-second granularity for 7 days, 1-minute aggregates for 90 days, and hourly aggregates for 2 years. This reduces annual storage from 5.3 TB to under 500 GB for telemetry alone.
Bandwidth Estimation
Each machine sends telemetry every 60 seconds over MQTT (lightweight binary protocol). A typical telemetry payload is 256 bytes compressed. With 10,000 machines:
- Inbound (machine → cloud): 10,000 × 256 bytes / 60s = ~43 KB/s = ~3.4 Mbps
- Outbound (cloud → machine): Price updates, commands = ~5 KB/s = ~40 Kbps
- Daily data transfer: ~3.7 GB in + ~0.4 GB out = ~4.1 GB/day
4 Data Model
Key Relationships
Each Machine belongs to an Operator and is placed at a Location. A machine contains multiple Slots arranged in a grid (rows × columns). Each slot holds a specific Product and tracks its current quantity. Every customer interaction generates a Transaction with associated Payment records. The machine continuously emits Telemetry data, and any hardware or software issues create Maintenance Events.
5 API Design
Machine-facing APIs (Firmware → Cloud)
| Endpoint | Method | Description | Auth |
|---|---|---|---|
| POST /api/v1/machines/{id}/heartbeat | POST | Machine sends status update with telemetry | Machine certificate |
| POST /api/v1/machines/{id}/transactions/start | POST | Initiate a new transaction (product selected) | Machine certificate |
| POST /api/v1/machines/{id}/transactions/{txId}/payment | POST | Process payment for a transaction | Machine certificate |
| POST /api/v1/machines/{id}/transactions/{txId}/complete | POST | Confirm dispensing completed | Machine certificate |
| POST /api/v1/machines/{id}/transactions/{txId}/fail | POST | Report dispensing failure (triggers refund) | Machine certificate |
| GET /api/v1/machines/{id}/config | GET | Fetch latest machine configuration (prices, products) | Machine certificate |
| POST /api/v1/machines/{id}/inventory | POST | Report inventory count changes | Machine certificate |
| POST /api/v1/machines/{id}/alerts | POST | Report hardware alert or tamper event | Machine certificate |
Admin APIs (Portal → Cloud)
| Endpoint | Method | Description | Auth |
|---|---|---|---|
| GET /api/v1/admin/machines | GET | List all machines in fleet with status | JWT token |
| GET /api/v1/admin/machines/{id} | GET | Get detailed machine info and current inventory | JWT token |
| PUT /api/v1/admin/machines/{id}/pricing | PUT | Update pricing for a machine | JWT token |
| POST /api/v1/admin/machines/{id}/restock | POST | Record restocking event | JWT token |
| GET /api/v1/admin/analytics/sales | GET | Get sales analytics with filters | JWT token |
| GET /api/v1/admin/machines/{id}/telemetry | GET | Get telemetry history | JWT token |
| POST /api/v1/admin/machines/{id}/command | POST | Send remote command (reboot, lock, unlock) | JWT token |
| GET /api/v1/admin/reports/restock | GET | Get restocking recommendations with route | JWT token |
Sample API Request — Process Payment
POST /api/v1/machines/a1b2c3d4/transactions/tx-9f8e7d6c/payment
Content-Type: application/json
X-Machine-Cert: machine_cert_here
{
"transaction_id": "tx-9f8e7d6c",
"payment_method": "CARD",
"card_token": "tok_visa_4242",
"amount": 2.50,
"currency": "USD",
"machine_timestamp": "2026-07-14T10:32:15Z"
}
Sample API Response — Payment Success
{
"status": "SUCCESS",
"payment_id": "pay-abc123",
"amount_charged": 2.50,
"authorization_code": "AUTH78901",
"dispense_command": {
"slot_row": "B",
"slot_column": 3,
"coil_rotations": 3,
"timeout_ms": 10000
},
"timestamp": "2026-07-14T10:32:15.432Z"
}
6 High-Level Architecture
Rate Limiting + Auth] SVC1[Transaction Service] SVC2[Inventory Service] SVC3[Payment Service] SVC4[Telemetry Service] SVC5[Pricing Service] SVC6[Alert Service] SVC7[ML Prediction Service] end subgraph Data Layer PG[(PostgreSQL
Transactional Data)] TS[(TimescaleDB
Telemetry Data)] RD[(Redis
Cache + State)] S3[(S3 Blob Storage
Logs + Images)] KAFKA[Kafka Event Bus] end subgraph External PGW[Payment Gateway
Stripe / Adyen] SMS[SMS / Push
Notifications] end subgraph Admin PORTAL[Admin Web Portal
React Dashboard] MOBILE[Admin Mobile App] end VM1 & VM2 & VMN -->|MQTT| GW GW -->|HTTPS| API API --> SVC1 & SVC2 & SVC3 & SVC4 & SVC5 & SVC6 & SVC7 SVC3 -->|PCI Tunnel| PGW SVC6 --> SMS SVC1 & SVC2 & SVC3 & SVC4 --> KAFKA KAFKA --> PG & TS & S3 SVC5 & SVC7 --> RD PORTAL & MOBILE -->|HTTPS| API
Architecture Components
The system follows a microservices architecture with event-driven communication via Kafka. The edge layer consists of vending machine firmware that communicates with the cloud through an MQTT broker. Each service handles a bounded context: transactions, inventory, payments, telemetry, pricing, alerts, and ML predictions.
The API Gateway handles authentication (machine certificates for edge devices, JWT tokens for admin users), rate limiting, request routing, and SSL termination. The Kafka event bus provides reliable, ordered event streaming between services, enabling eventual consistency and audit logging.
RTOS] DISPLAY[7-inch Touch LCD] PAY_READER[Card/NFC Reader] COIL_CTRL[Coil Motor Controller] SENSORS[Temp/Humidity/Door Sensors] CASH_VALID[Coin/Bill Validator] CASH_DISP[Change Dispenser] CAMERA[Pi Camera Module] end FW --> DISPLAY FW --> PAY_READER FW --> COIL_CTRL FW --> SENSORS FW --> CASH_VALID FW --> CASH_DISP FW --> CAMERA FW -->|LTE/Ethernet| CLOUD[Cloud Backend]
Technology Stack
| Component | Technology | Rationale |
|---|---|---|
| Machine Firmware | C++ / Rust on FreeRTOS | Real-time, low-power, reliable |
| Edge Communication | MQTT (Mosquitto) | Lightweight, low bandwidth, QoS levels |
| API Gateway | Kong / AWS API Gateway | Rate limiting, auth, routing |
| Backend Services | .NET 8 / ASP.NET Core | High performance, strong typing, C# ecosystem |
| Event Bus | Apache Kafka | Durable, ordered, high throughput |
| Transactional DB | PostgreSQL 16 | ACID, JSONB support, mature |
| Time-Series DB | TimescaleDB (PostgreSQL extension) | Telemetry compression, continuous aggregates |
| Cache | Redis Cluster | Low latency, pub/sub, distributed locks |
| Object Storage | AWS S3 / MinIO | Logs, images, backups |
| Payment Gateway | Stripe Connect / Adyen | PCI compliance, global coverage |
| ML Platform | Python + scikit-learn / ML.NET | Demand forecasting models |
| Admin Portal | React + TypeScript | Responsive dashboard with real-time updates |
| Monitoring | Prometheus + Grafana | Metrics collection and visualization |
| Container Orchestration | Kubernetes (EKS/GKE) | Scalable, self-healing deployments |
7 Vending Machine State Machine
The vending machine's behavior is modeled as a finite state machine (FSM). This is one of the most critical design decisions because the state machine must be deterministic, resilient to power failures, and recoverable from any intermediate state. Every state transition is persisted to durable storage before any physical action is taken.
State Transitions Table
| From State | Event | To State | Actions |
|---|---|---|---|
| IDLE | User touch detected | SELECTING | Wake display, show product catalog |
| SELECTING | Product button pressed | PAYMENT_PENDING | Reserve slot, display price, activate payment terminal |
| PAYMENT_PENDING | Card tapped / Cash inserted | PAYMENT_PROCESSING | Lock selection, send payment to gateway |
| PAYMENT_PROCESSING | Payment approved | Dispensing | Trigger coil motor for target slot |
| PAYMENT_PROCESSING | Payment declined | PAYMENT_FAILED | Display error, check if cash needs return |
| DISPENSING | Spiral rotation complete, sensor confirms | C COMPLETE | Log sale, update inventory, print receipt |
| DISPENSING | Timeout / jam detected | DISPENSE_FAILED | Stop motor, assess retry eligibility |
| DISPENSE_FAILED | Retry count < 3 | RETRY | Reverse coil, re-attempt dispense |
| DISPENSE_FAILED | Retry count >= 3 | REFUNDING | Initiate full refund via payment gateway |
| REFUNDING | Refund confirmed | IDLE | Return cash / reverse card charge, display message |
| COMPLETE | Timeout (10s) | IDLE | Log transaction complete, reset for next user |
| ANY STATE | Power failure | (persisted state) | Save state to flash, resume on power restore |
Critical: State Persistence
Every state transition must be persisted to flash storage before any physical action. If power is lost during dispensing, the machine must be able to determine on reboot: (a) was payment received? (b) was the product dispensed? (c) does a refund need to be issued? This requires a write-ahead log (WAL) on the embedded flash, similar to how databases ensure durability.
8 Product Selection & Slot Management
Slot Layout (Grid System)
Each vending machine organizes products in a grid of rows (A-F) and columns (1-10). The customer identifies products by their grid position — for example, "B3" refers to row B, column 3. Modern machines with touchscreens show product images, but the underlying slot addressing remains grid-based.
Slot Types and Mechanisms
| Mechanism | Description | Best For | Cost |
|---|---|---|---|
| Spiral/Coil | Motor rotates coil to push product forward | Snacks, candy bars | $15-25/slot |
| Conveyor Belt | Belt moves product to drop chute | Sandwiches, salads | $80-120/slot |
| Elevator | Moving platform lifts product to dispensing point | Fragile items, bottles | $200-350/slot |
| Gravity Feed | Product slides down incline to chute | Cans, uniform items | $10-15/slot |
| Robotic Arm | Precision gripper picks and places products | Mixed/fresh items | $500-1000/slot |
| Locked Drawer | Electronic lock per compartment | High-value items, electronics | $30-50/slot |
Sensor-Assisted Slot Monitoring
Each slot is equipped with sensors to detect product presence and dispense completion:
- Infrared break-beam sensors: Detect when a product falls through the chute
- Weight sensors (load cells): Measure slot weight to track quantity without counting
- Motor current monitoring: Detect jams by measuring coil resistance — a stuck coil draws abnormal current
- Camera-based vision: Advanced machines use small cameras with CV models to verify product dispensing
9 Payment Processing
Payment Methods
| Method | Technology | Latency | Fee |
|---|---|---|---|
| Cash (Coins) | Coin validator + hopper | Instant (local) | $0 (no processing fee) |
| Cash (Bills) | Bill validator (ICT/MEI) | Instant (local) | $0 |
| Credit/Debit Card | EMV chip reader (Castles/Ingenico) | 2-5 seconds | 2.9% + $0.30 |
| NFC (Apple/Google Pay) | Contactless reader (same terminal) | 1-3 seconds | 2.9% + $0.30 |
| QR Code | User scans QR → pays on phone | 5-15 seconds | 1.5-2.5% |
| UPI | UPI QR / VPA | 3-8 seconds | Free / minimal |
Payment Flow — Cash Handling
public class CashPaymentProcessor
{
private readonly ICoinValidator _coinValidator;
private readonly IBillValidator _billValidator;
private readonly IChangeDispenser _changeDispenser;
public async Task<PaymentResult> ProcessCashPayment(
decimal totalAmount, CancellationToken ct)
{
decimal insertedAmount = 0;
var insertedCoins = new List<CoinDenomination>();
while (insertedAmount < totalAmount)
{
var coin = await _coinValidator.WaitForCoinAsync(
TimeSpan.FromSeconds(30), ct);
if (coin == null)
break;
insertedAmount += coin.Value;
insertedCoins.Add(coin.Denomination);
if (insertedAmount > totalAmount)
{
decimal change = insertedAmount - totalAmount;
bool changeDispensed = await _changeDispenser
.DispenseChangeAsync(change, ct);
if (!changeDispensed)
{
return new PaymentResult
{
Success = false,
Reason = "Unable to dispense exact change"
};
}
}
}
if (insertedAmount < totalAmount)
{
await ReturnCoinsAsync(insertedCoins, ct);
return new PaymentResult
{
Success = false,
Reason = "Insufficient payment"
};
}
return new PaymentResult
{
Success = true,
AmountPaid = insertedAmount,
ChangeGiven = Math.Max(0, insertedAmount - totalAmount),
Method = PaymentMethod.Cash
};
}
}
Multi-Tender Support
Modern machines support splitting payments across methods. For example, a customer might pay $1.00 in coins and $1.50 on a card. The system tracks each tender separately and ensures the sum covers the total before dispensing.
10 Dispensing Mechanism
Dispensing Failure Handling
The dispensing mechanism implements a 3-strike retry policy. On the first jam, the coil reverses one rotation and tries again. After 3 failed attempts, the system gives up, logs the failure, and initiates a refund. The machine operator receives an alert to physically inspect the slot.
public class DispensingEngine
{
private const int MaxRetries = 3;
private const int CoilPauseMs = 200;
private const int BreakBeamTimeoutMs = 500;
public async Task<DispenseResult> DispenseProduct(
Slot slot, int coilRotations, CancellationToken ct)
{
for (int attempt = 1; attempt <= MaxRetries; attempt++)
{
for (int rotation = 0; rotation < coilRotations; rotation++)
{
await _motorController.RotateAsync(
slot.Row, slot.Column,
direction: RotationDirection.Clockwise,
degrees: 360, ct);
bool productDetected = await _breakBeamSensor
.WaitForTriggerAsync(BreakBeamTimeoutMs, ct);
if (productDetected)
{
return new DispenseResult
{
Success = true,
AttemptsRequired = attempt,
DispensedAt = DateTime.UtcNow
};
}
double motorCurrent = await _motorController
.ReadCurrentAsync(slot.Row, slot.Column);
if (motorCurrent > slot.MotorJamThreshold)
{
await _motorController.RotateAsync(
slot.Row, slot.Column,
direction: RotationDirection.CounterClockwise,
degrees: 360, ct);
break;
}
}
}
return new DispenseResult
{
Success = false,
FailureReason = "Product jammed after max retries",
RequiresMaintenance = true
};
}
}
11 Inventory Management
Real-Time Stock Tracking
Inventory is tracked through a combination of sell-through counting (each successful dispense decrements the count) and periodic physical verification (weight sensors and manual restocking counts). The system maintains a consensus inventory by cross-referencing these sources.
Quantity per Slot] G --> H{Below Threshold?} H -->|Yes| I[Low Stock Alert] H -->|No| J[OK] G --> K[Expiry Check] K --> L{Expired?} L -->|Yes| M[Mark as Unavailable
Block Selection] L -->|No| N[Available for Sale]
Inventory States
| State | Description | Customer Can Select? |
|---|---|---|
| IN_STOCK | Product available, quantity above threshold | Yes |
| LOW_STOCK | Quantity below minimum threshold (e.g., < 3 items) | Yes (with alert) |
| OUT_OF_STOCK | Quantity = 0 | No |
| EXPIRED | All items in slot have passed expiry date | No |
| DISABLED | Slot disabled by admin (maintenance, recall) | No |
| RESERVED | Item selected by customer, awaiting payment | No (temporarily) |
FIFO Rotation and Expiry Management
The system enforces First-In-First-Out (FIFO) product rotation. When a restocking event occurs, new products are loaded behind existing ones. The inventory system tracks batch IDs and expiry dates per batch. Products approaching expiry are flagged and, if not sold by the expiry date, automatically marked as unavailable and an alert is sent to the operator for removal.
12 Pricing Engine
Dynamic Pricing Strategies
2PM-4PM} I -->|Yes| J[10% Discount] I -->|No| K[No Change] E --> L{Premium Location?
Airport / Hospital} L -->|Yes| M[+20% Markup] L -->|No| N[No Change] F --> O{High Demand Slot?
80% sold today} O -->|Yes| P[+10% Markup] O -->|No| Q[No Change] J & M & P --> R[Final Price] K & N & Q --> R R --> S[Apply Bundle Rules] S --> T[Apply Loyalty Tiers] T --> U[Final Adjusted Price]
Pricing Rules Configuration
public class PricingEngine
{
private readonly IPricingRuleRepository _rules;
private readonly IDiscountRepository _discounts;
public async Task<decimal> CalculatePrice(
Product product, Machine machine, string? loyaltyId)
{
decimal basePrice = product.BasePrice;
decimal finalPrice = basePrice;
var timeRules = await _rules
.GetActiveTimeRules(machine.Id, DateTime.UtcNow);
foreach (var rule in timeRules)
{
finalPrice = rule.Type switch
{
RuleType.PercentageDiscount =>
finalPrice * (1 - rule.Value / 100m),
RuleType.PercentageMarkup =>
finalPrice * (1 + rule.Value / 100m),
RuleType.FixedDiscount =>
finalPrice - rule.Value,
RuleType.FixedPrice =>
rule.Value,
_ => finalPrice
};
}
if (machine.IsPremiumLocation)
finalPrice *= 1.20m;
decimal dailySalesRatio = await _rules
.GetDailySalesRatio(machine.Id, product.Id);
if (dailySalesRatio > 0.80m)
finalPrice *= 1.10m;
if (loyaltyId != null)
{
var tier = await _discounts.GetLoyaltyTier(loyaltyId);
finalPrice *= (1 - tier.DiscountPercent / 100m);
}
return Math.Round(finalPrice, 2);
}
}
13 IoT Sensors & Monitoring
Sensor Inventory per Machine
| Sensor | Purpose | Sampling Rate | Alert Threshold |
|---|---|---|---|
| Temperature (DS18B20) | Refrigeration monitoring | Every 30s | > 8°C or < 1°C for cold drinks |
| Humidity (DHT22) | Condensation / mold prevention | Every 60s | > 80% RH |
| Door magnetic switch | Unauthorized access detection | Event-driven | Open > 120s outside maintenance |
| Tilt sensor (ADXL345) | Theft / vandalism detection | Every 10s | Tilt > 15 degrees |
| Current sensors (per coil) | Motor health, jam detection | Per dispense | Current > 200% rated |
| Light sensor | Interior light monitoring | Every 60s | Light on > 5min (door issue) |
| Voltage monitor | Power supply health | Every 60s | < 10.5V or > 13.5V |
| PIR motion sensor | Customer presence detection | Event-driven | — |
| Camera (optional) | Theft detection, product verification | On event | Motion + alert trigger |
Telemetry Pipeline
Aggregation] FW -->|MQTT QoS 1| BROKER[MQTT Broker
AWS IoT Core] BROKER --> KAFKA[Kafka Topic
telemetry.raw] KAFKA --> PROCESS[Stream Processor
Flink / Kafka Streams] PROCESS --> TDB[(TimescaleDB
Hypertable)] PROCESS --> ALERT[Alert Engine] PROCESS --> ML[ML Feature Store] ALERT --> NOTIFY[Notifications
SMS / Email / PagerDuty] ALERT --> ADMINALERT[Admin Dashboard
Real-time Alerts] TDB --> GRAFANA[Grafana Dashboards] TDB --> AGGREGATE[Continuous Aggregates
5min / 1hr rollups]
Alert Severity Levels
| Level | Description | Response |
|---|---|---|
| CRITICAL | Machine offline, payment system failure, tamper detected | Immediate SMS + PagerDuty escalation |
| HIGH | Temperature out of range, multiple jams, payment gateway errors | Email + dashboard alert within 5 minutes |
| MEDIUM | Low inventory, single jam event, minor sensor anomaly | Dashboard alert, batch notification |
| LOW | Informational (firmware update available, usage statistics) | Dashboard log entry only |
14 Predictive Restocking
Predictive restocking uses machine learning to forecast when each product in each machine will sell out, then generates optimized restocking routes that minimize travel time while ensuring no machine runs empty.
XGBoost / Prophet] C[Current Inventory Levels] --> B D[Day of Week / Season / Events] --> B E[Machine Location Features] --> B B --> F[Days Until Stockout
per Product per Machine] F --> G{Stockout within 24h?} G -->|Yes| H[PRIORITY RESTOCK] G -->|No| I{Stockout within 72h?} I -->|Yes| J[SCHEDULE RESTOCK] I -->|No| K[MONITOR] H --> L[Route Optimization
OR-Tools / Google Maps API] J --> L L --> M[Optimized Route] M --> N[Driver Mobile App] N --> O[Restock Completion
QR Scan per Slot] O --> P[Inventory Update]
Demand Forecasting Features
- Temporal: Hour of day, day of week, month, season, holidays
- Location: Office vs. hospital vs. school vs. transit station
- Weather: Temperature, rain, snow (ice cream sells more on hot days)
- Events: Local events, sports games, conferences
- Product: Category, brand, price point, placement position
- Lagged features: Sales in last 1h, 6h, 24h, 7 days
15 Cash Management
Cash management is a critical financial control function. The machine's bill validator and coin dispenser must be reconciled against transaction records daily.
Cash Reconciliation Flow
public class CashReconciliationService
{
public async Task<ReconciliationReport> Reconcile(
Guid machineId, DateTime date)
{
var transactions = await _transactionRepo
.GetCashTransactionsAsync(machineId, date);
decimal expectedCash = transactions
.Where(t => t.Status == TransactionStatus.Completed)
.Sum(t => t.CashReceived);
decimal expectedChange = transactions
.Where(t => t.ChangeGiven > 0)
.Sum(t => t.ChangeGiven);
decimal expectedCashInBox = expectedCash - expectedChange;
var physicalCount = await _cashCountRepo
.GetPhysicalCountAsync(machineId, date);
decimal variance = physicalCount.TotalInBox - expectedCashInBox;
decimal variancePercent = expectedCashInBox != 0
? Math.Abs(variance) / expectedCashInBox * 100
: 0;
var report = new ReconciliationReport
{
MachineId = machineId,
Date = date,
TotalCashReceived = expectedCash,
TotalChangeDispensed = expectedChange,
ExpectedCashInBox = expectedCashInBox,
ActualCashInBox = physicalCount.TotalInBox,
Variance = variance,
VariancePercent = variancePercent,
IsWithinTolerance = variancePercent < 2.0m,
TransactionCount = transactions.Count
};
if (!report.IsWithinTolerance)
{
await _alertService.SendAsync(new CashVarianceAlert
{
MachineId = machineId,
Variance = variance,
Report = report
});
}
return report;
}
}
16 Anti-Theft & Tamper Detection
Physical security is paramount for unattended vending machines. The system employs multiple layers of detection:
Security Layers
- Tilt/Impact Sensor: Detects if someone tries to tip or shake the machine. Triggers immediate alarm with camera capture.
- Door Contact Sensor: Magnetic reed switch on the service door. Any unauthorized opening triggers an alert. Maintenance access requires authentication via NFC badge or mobile app.
- Coil Tamper Detection: If a coil is manually rotated without a payment transaction, the system logs an anomaly and alerts the operator.
- GPS Tracking: If the machine is moved (e.g., stolen), GPS coordinates are reported in real-time.
- Camera System: Optional Raspberry Pi camera captures images on motion detection events and uploads to S3 for review.
- Kensington Lock: Physical cable lock securing the machine to a fixed structure.
17 Energy Management
Power Consumption Profile
| Component | Idle | Active | Notes |
|---|---|---|---|
| Refrigeration Compressor | 0W (cycling) | 150W | Runs ~50% of time in cooling mode |
| Touchscreen Display | 5W (dimmed) | 15W | Auto-dims after 60s inactivity |
| LED Lighting | 0W | 20W | Turns on with motion, off after 30s |
| Payment Terminal | 3W | 5W | Always on for tap-to-pay |
| Coil Motors (60 slots) | 0W | 120W (peak) | Only active during dispense (~2s each) |
| Embedded Computer | 8W | 12W | Raspberry Pi 4 / custom ARM board |
| Sensors + MCU | 2W | 2W | Always-on low-power microcontroller |
| Total | ~18W (compressor off) | ~220W (peak) |
Energy Optimization Strategies
- Night Mode (10PM-6AM): Display off, lighting off, payment terminal in low-power listening mode. Only compressor and sensors remain active. Reduces consumption by ~60%.
- Smart Compressor Scheduling: Predict cooling demand based on time of day and ambient temperature. Pre-cool during off-peak electricity hours (late night) when rates are lower.
- Solar Panels: Outdoor machines can integrate 100W solar panels with battery backup, reducing grid dependency by 30-40%.
- Sleep Mode: If no customer interaction for 10 minutes AND temperature is stable, the embedded computer enters a low-power state, waking on sensor interrupt.
18 Admin Portal & Dashboard
Dashboard Components
Green/Yellow/Red markers] F2 --> GRID[Sortable Grid
Online/Offline/Jam/Error] F3 --> SALES[Time Series Charts
Daily/Weekly/Monthly] F4 --> INV[Stock Health Matrix
Color-coded by level] F5 --> REV[Revenue by Machine/Location/Category] F6 --> ALERTS[Alert Queue
Severity-based prioritization]
Key Dashboard Metrics
| Metric | Visualization | Time Range |
|---|---|---|
| Total fleet revenue | KPI card with trend | Today / 7d / 30d / Custom |
| Machines online vs. offline | Pie chart | Real-time |
| Top 10 selling products | Bar chart | Selectable range |
| Revenue per machine | Heatmap on map | Selectable range |
| Inventory depletion rate | Line chart per machine | 7-day rolling |
| Average transaction value | KPI card with distribution | Selectable range |
| Payment method breakdown | Stacked bar chart | Selectable range |
| Maintenance events timeline | Timeline chart | 30 days |
| Energy consumption | Area chart | 7 days |
| Cash reconciliation variance | Bar chart with tolerance band | Daily |
19 Database Design
Partitioning Strategy
High-volume tables are partitioned by time and machine ID for optimal query performance:
- Telemetry: Hypertable partitioned by time (1-day chunks) with compression after 7 days
- Transaction Logs: Range-partitioned by month, with archive to cold storage after 90 days
- Inventory Snapshots: Continuous aggregates: raw data for 7 days, 5-minute aggregates for 90 days, hourly for 2 years
Indexing Strategy
| Table | Primary Index | Secondary Indexes | |
|---|---|---|---|
| transactions | B-tree on (machine_id, created_at) | Status, payment_method, product_id | |
| telemetry | Hypertable time index | machine_id, alert_level | |
| inventory_snapshots | B-tree on (machine_id, product_id, captured_at) | Quantity threshold queries | |
| payments | B-tree on (transaction_id) | Status, method, processed_at | |
| alerts | B-tree on (machine_id, triggered_at) | Severity, resolved, event_type |
| Cache Key Pattern | Data | TTL | Invalidation |
|---|---|---|---|
| machine:{id}:config | Product catalog, pricing, slot mapping | 5 minutes | On admin update (pub/sub push) |
| machine:{id}:inventory | Current stock levels per slot | 30 seconds | On each dispense event |
| product:{id}:price | Current price (may vary by machine) | 5 minutes | On price rule change |
| machine:{id}:state | Current FSM state | No TTL (persistent) | On state transition |
| fleet:dashboard:summary | Aggregate fleet metrics | 60 seconds | On telemetry ingestion |
| machine:{id}:sessions | Active user sessions (distributed lock) | 2 minutes | Session timeout |
21 Multi-Region Design
US-EAST)] US1K[Kafka US-EAST] US1M[MQTT Broker US-EAST] end subgraph "Region 2 — EU West" EU1[EU-WEST Cluster] EU1DB[(PostgreSQL Primary
EU-WEST)] EU1K[Kafka EU-WEST] EU1M[MQTT Broker EU-WEST] end subgraph "Region 3 — APAC" AP1[APAC Cluster] AP1DB[(PostgreSQL Primary
APAC)] AP1K[Kafka APAC] AP1M[MQTT Broker APAC] end US1DB <-->|Async Replication
Conflict Resolution| EU1DB EU1DB <-->|Async Replication| AP1DB US1DB <-->|Async Replication| AP1DB US1K --> US1DB EU1K --> EU1DB AP1K --> AP1DB
Multi-Region Considerations
- Data Sovereignty: Payment data and PII must stay within regulatory boundaries (GDPR for EU, data localization laws for India).
- Conflict Resolution: Machine configuration updates use last-writer-wins with vector clocks. Pricing changes are region-scoped.
- Failover: If a region goes down, machines queue telemetry locally and sync when connectivity is restored. Payment processing falls back to the nearest available region.
- Latency: MQTT brokers are deployed per-region to keep edge-to-cloud latency under 100ms.
22 Cost Estimation
Per-Machine Hardware Cost
| Component | Cost (USD) |
|---|---|
| ARM embedded computer (Raspberry Pi 4 / custom) | $50-100 |
| 7-inch touchscreen LCD | $40-60 |
| EMV payment terminal (Ingenico Lane 3000) | $200-350 |
| Coin validator + dispenser | $150-250 |
| Bill validator + dispenser | $200-350 |
| Coil motors (60 slots × $15) | $900 |
| Sensors (temp, humidity, door, tilt, etc.) | $30-50 |
| 4G LTE modem | $25-40 |
| Wiring, PCB, power supply | $100-150 |
| Enclosure + frame | $500-800 |
| Total per machine | $2,200-$3,050 |
Cloud Infrastructure Monthly Cost (10,000 machines)
| Service | Configuration | Monthly Cost |
|---|---|---|
| Kubernetes (EKS) | 6 nodes, m5.xlarge | $1,800 |
| PostgreSQL (RDS) | db.r5.2xlarge, Multi-AZ | $1,200 |
| TimescaleDB (RDS) | db.r5.xlarge + 2TB storage | $900 |
| Redis (ElastiCache) | 3-node cluster, r5.large | $600 |
| Kafka (MSK) | 3 brokers, kafka.m5.large | $750 |
| MQTT (AWS IoT Core) | 10,000 devices, 1M msgs/day | $400 |
| S3 Storage | 10 TB + requests | $250 |
| Data Transfer | 500 GB/month | $45 |
| CloudFront CDN | 1 TB transfer | $85 |
| Monitoring (CloudWatch + Prometheus) | Custom metrics | $300 |
| Total Monthly Cloud | ~$6,330 |
Per-machine monthly cloud cost: ~$0.63/machine/month
Per-transaction cloud cost: ~$0.0007/transaction
23 Interview Q&A
24 Full C# Implementation
Below is a comprehensive C# implementation of the core vending machine system — including the state machine, payment processor, inventory manager, pricing engine, and slot management. This implementation follows production patterns with proper error handling, logging, and dependency injection.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace VendingMachine.Core
{
// ============================================================
// ENUMS
// ============================================================
public enum MachineState
{
Idle,
Selecting,
PaymentPending,
PaymentProcessing,
Dispensing,
Complete,
PaymentFailed,
DispenseFailed,
Refunding,
Retrying,
Maintenance,
Error,
LowInventoryAlert
}
public enum PaymentMethod
{
Cash,
CreditCard,
DebitCard,
Nfc,
QrCode,
Upi,
MultiTender
}
public enum DispenseResult
{
Success,
JamDetected,
Timeout,
SlotUnavailable,
MaxRetriesExceeded
}
public enum AlertSeverity
{
Critical,
High,
Medium,
Low
}
// ============================================================
// MODELS
// ============================================================
public class Slot
{
public Guid Id { get; set; }
public int Row { get; set; }
public int Column { get; set; }
public Product Product { get; set; }
public int CurrentQuantity { get; set; }
public int MaxCapacity { get; set; }
public bool IsAvailable { get; set; } = true;
public int CoilRotationsRequired { get; set; } = 3;
public DateTime? LastRestockedAt { get; set; }
public bool IsEmpty => CurrentQuantity <= 0;
public bool IsLowStock => CurrentQuantity <= 3;
public double FillPercentage => MaxCapacity > 0
? (double)CurrentQuantity / MaxCapacity * 100
: 0;
}
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Barcode { get; set; }
public decimal BasePrice { get; set; }
public decimal Cost { get; set; }
public string Category { get; set; }
public int ShelfLifeDays { get; set; }
public bool IsActive { get; set; } = true;
}
public class Transaction
{
public Guid Id { get; set; }
public Guid MachineId { get; set; }
public Guid SlotId { get; set; }
public Product Product { get; set; }
public decimal TotalAmount { get; set; }
public decimal TaxAmount { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public MachineState Status { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public int DispenseRetries { get; set; }
public string FailureReason { get; set; }
}
public class PaymentResult
{
public bool Success { get; set; }
public decimal AmountCharged { get; set; }
public decimal ChangeGiven { get; set; }
public string AuthorizationCode { get; set; }
public string FailureReason { get; set; }
public PaymentMethod Method { get; set; }
}
public class DispenseOutcome
{
public DispenseResult Result { get; set; }
public int AttemptsRequired { get; set; }
public TimeSpan Duration { get; set; }
public string FailureReason { get; set; }
}
public class MachineTelemetry
{
public decimal Temperature { get; set; }
public decimal Humidity { get; set; }
public bool DoorOpen { get; set; }
public bool TiltDetected { get; set; }
public decimal Voltage { get; set; }
public DateTime RecordedAt { get; set; }
}
public class MaintenanceAlert
{
public Guid Id { get; set; }
public Guid MachineId { get; set; }
public AlertSeverity Severity { get; set; }
public string Message { get; set; }
public DateTime TriggeredAt { get; set; }
public bool Resolved { get; set; }
}
// ============================================================
// INTERFACES
// ============================================================
public interface IPaymentGateway
{
Task<PaymentResult> ProcessCardPaymentAsync(
decimal amount, string cardToken, CancellationToken ct);
Task<bool> RefundAsync(
string authorizationCode, decimal amount, CancellationToken ct);
}
public interface IInventoryRepository
{
Task<Slot> GetSlotAsync(Guid machineId, int row, int column);
Task<List<Slot>> GetAllSlotsAsync(Guid machineId);
Task UpdateQuantityAsync(Guid slotId, int newQuantity);
Task<bool> DecrementQuantityAsync(Guid slotId);
}
public interface ITransactionRepository
{
Task SaveTransactionAsync(Transaction transaction);
Task<Transaction> GetTransactionAsync(Guid transactionId);
Task<List<Transaction>> GetPendingRefundsAsync(Guid machineId);
}
public interface IAlertService
{
Task SendAlertAsync(MaintenanceAlert alert);
}
public interface ITelemetryService
{
Task ReportTelemetryAsync(Guid machineId, MachineTelemetry telemetry);
}
public interface IPricingEngine
{
Task<decimal> CalculatePriceAsync(
Product product, Guid machineId, DateTime timestamp);
}
// ============================================================
// PRICING ENGINE
// ============================================================
public class DynamicPricingEngine : IPricingEngine
{
private readonly ILogger<DynamicPricingEngine> _logger;
private const decimal AirportMarkup = 0.20m;
private const decimal HappyHourDiscount = 0.10m;
private const decimal HighDemandMarkup = 0.10m;
public DynamicPricingEngine(ILogger<DynamicPricingEngine> logger)
{
_logger = logger;
}
public Task<decimal> CalculatePriceAsync(
Product product, Guid machineId, DateTime timestamp)
{
decimal price = product.BasePrice;
if (timestamp.Hour >= 14 && timestamp.Hour < 16)
{
price *= (1 - HappyHourDiscount);
_logger.LogDebug(
"Happy hour discount applied: {Price}", price);
}
if (timestamp.DayOfWeek == DayOfWeek.Wednesday)
{
price *= 0.95m;
_logger.LogDebug(
"Wednesday 5% discount applied: {Price}", price);
}
price = Math.Round(price, 2);
return Task.FromResult(price);
}
}
// ============================================================
// DISPENSING ENGINE
// ============================================================
public class DispensingEngine
{
private readonly ILogger<DispensingEngine> _logger;
private const int MaxRetries = 3;
private const int BreakBeamTimeoutMs = 500;
private const int MotorJamCurrentThreshold = 500;
public DispensingEngine(ILogger<DispensingEngine> logger)
{
_logger = logger;
}
public async Task<DispenseOutcome> DispenseAsync(
Slot slot, CancellationToken ct)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
int totalAttempts = 0;
for (int attempt = 1; attempt <= MaxRetries; attempt++)
{
totalAttempts = attempt;
_logger.LogInformation(
"Dispensing attempt {Attempt}/{Max} for slot {Row}-{Col}",
attempt, MaxRetries, slot.Row, slot.Column);
bool success = await SimulateCoilRotationAsync(
slot, slot.CoilRotationsRequired, ct);
if (success)
{
stopwatch.Stop();
return new DispenseOutcome
{
Result = DispenseResult.Success,
AttemptsRequired = totalAttempts,
Duration = stopwatch.Elapsed
};
}
_logger.LogWarning(
"Dispense attempt {Attempt} failed for slot {Row}-{Col}, " +
"reversing coil",
attempt, slot.Row, slot.Column);
await SimulateCoilReverseAsync(slot, 1, ct);
await Task.Delay(500, ct);
}
stopwatch.Stop();
return new DispenseOutcome
{
Result = DispenseResult.MaxRetriesExceeded,
AttemptsRequired = totalAttempts,
Duration = stopwatch.Elapsed,
FailureReason = "Product jammed after " + MaxRetries + " attempts"
};
}
private async Task<bool> SimulateCoilRotationAsync(
Slot slot, int rotations, CancellationToken ct)
{
for (int i = 0; i < rotations; i++)
{
_logger.LogDebug(
"Rotating coil at {Row}-{Col}, rotation {Rotation}",
slot.Row, slot.Column, i + 1);
await Task.Delay(200, ct);
if (await SimulateBreakBeamDetectionAsync())
return true;
}
return false;
}
private async Task SimulateCoilReverseAsync(
Slot slot, int rotations, CancellationToken ct)
{
_logger.LogDebug(
"Reversing coil at {Row}-{Col}", slot.Row, slot.Column);
await Task.Delay(300 * rotations, ct);
}
private Task<bool> SimulateBreakBeamDetectionAsync()
{
return Task.FromResult(new Random().Next(100) < 85);
}
}
// ============================================================
// VENDING MACHINE (CORE STATE MACHINE)
// ============================================================
public class VendingMachine
{
private readonly ILogger<VendingMachine> _logger;
private readonly IPaymentGateway _paymentGateway;
private readonly IInventoryRepository _inventoryRepo;
private readonly ITransactionRepository _transactionRepo;
private readonly IAlertService _alertService;
private readonly ITelemetryService _telemetryService;
private readonly IPricingEngine _pricingEngine;
private readonly DispensingEngine _dispensingEngine;
private MachineState _currentState;
private Transaction _activeTransaction;
private readonly object _stateLock = new object();
private readonly Guid _machineId;
public MachineState CurrentState => _currentState;
public Guid MachineId => _machineId;
public Transaction ActiveTransaction => _activeTransaction;
public VendingMachine(
Guid machineId,
ILogger<VendingMachine> logger,
IPaymentGateway paymentGateway,
IInventoryRepository inventoryRepo,
ITransactionRepository transactionRepo,
IAlertService alertService,
ITelemetryService telemetryService,
IPricingEngine pricingEngine,
DispensingEngine dispensingEngine)
{
_machineId = machineId;
_logger = logger;
_paymentGateway = paymentGateway;
_inventoryRepo = inventoryRepo;
_transactionRepo = transactionRepo;
_alertService = alertService;
_telemetryService = telemetryService;
_pricingEngine = pricingEngine;
_dispensingEngine = dispensingEngine;
_currentState = MachineState.Idle;
}
// --------------------------------------------------------
// STATE TRANSITION
// --------------------------------------------------------
private void TransitionTo(MachineState newState, string reason = null)
{
var oldState = _currentState;
lock (_stateLock)
{
_currentState = newState;
}
_logger.LogInformation(
"State transition: {OldState} -> {NewState} " +
"(Machine: {MachineId}, Reason: {Reason})",
oldState, newState, _machineId, reason ?? "N/A");
}
// --------------------------------------------------------
// PRODUCT SELECTION
// --------------------------------------------------------
public async Task<Transaction> SelectProductAsync(
int row, int column, CancellationToken ct)
{
if (_currentState != MachineState.Idle)
{
throw new InvalidOperationException(
$"Cannot select product in state {_currentState}. " +
$"Expected: Idle");
}
TransitionTo(MachineState.Selecting, "User initiated selection");
var slot = await _inventoryRepo
.GetSlotAsync(_machineId, row, column);
if (slot == null || !slot.IsAvailable)
{
TransitionTo(MachineState.Idle, "Slot unavailable");
throw new InvalidOperationException(
$"Slot {row}-{column} is not available.");
}
if (slot.IsEmpty)
{
TransitionTo(MachineState.Idle, "Slot empty");
throw new InvalidOperationException(
$"Slot {row}-{column} is empty. " +
$"Product: {slot.Product?.Name}");
}
decimal price = await _pricingEngine
.CalculatePriceAsync(slot.Product, _machineId, DateTime.UtcNow);
_activeTransaction = new Transaction
{
Id = Guid.NewGuid(),
MachineId = _machineId,
SlotId = slot.Id,
Product = slot.Product,
TotalAmount = price,
TaxAmount = Math.Round(price * 0.08m, 2),
StartedAt = DateTime.UtcNow,
Status = MachineState.PaymentPending
};
await _transactionRepo.SaveTransactionAsync(_activeTransaction);
TransitionTo(MachineState.PaymentPending,
$"Product selected: {slot.Product.Name}");
return _activeTransaction;
}
// --------------------------------------------------------
// CARD PAYMENT
// --------------------------------------------------------
public async Task<PaymentResult> ProcessCardPaymentAsync(
string cardToken, CancellationToken ct)
{
if (_currentState != MachineState.PaymentPending)
{
throw new InvalidOperationException(
$"Cannot process payment in state {_currentState}");
}
TransitionTo(MachineState.PaymentProcessing,
"Card payment initiated");
try
{
decimal totalAmount =
_activeTransaction.TotalAmount +
_activeTransaction.TaxAmount;
var result = await _paymentGateway.ProcessCardPaymentAsync(
totalAmount, cardToken, ct);
if (result.Success)
{
_activeTransaction.PaymentMethod = PaymentMethod.CreditCard;
await _transactionRepo.SaveTransactionAsync(
_activeTransaction);
TransitionTo(MachineState.Dispensing,
$"Payment approved: {result.AuthorizationCode}");
return result;
}
else
{
TransitionTo(MachineState.PaymentFailed,
result.FailureReason);
return result;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Payment processing error for transaction {TxId}",
_activeTransaction.Id);
TransitionTo(MachineState.PaymentFailed, ex.Message);
return new PaymentResult
{
Success = false,
FailureReason = $"Payment error: {ex.Message}"
};
}
}
// --------------------------------------------------------
// CASH PAYMENT
// --------------------------------------------------------
public async Task<PaymentResult> ProcessCashPaymentAsync(
decimal amountInserted, CancellationToken ct)
{
if (_currentState != MachineState.PaymentPending)
{
throw new InvalidOperationException(
$"Cannot process payment in state {_currentState}");
}
TransitionTo(MachineState.PaymentProcessing,
"Cash payment initiated");
decimal totalAmount =
_activeTransaction.TotalAmount +
_activeTransaction.TaxAmount;
if (amountInserted >= totalAmount)
{
decimal change = amountInserted - totalAmount;
_activeTransaction.PaymentMethod = PaymentMethod.Cash;
await _transactionRepo.SaveTransactionAsync(
_activeTransaction);
TransitionTo(MachineState.Dispensing,
$"Cash accepted, change: ${change:F2}");
return new PaymentResult
{
Success = true,
AmountCharged = totalAmount,
ChangeGiven = change,
Method = PaymentMethod.Cash
};
}
else
{
TransitionTo(MachineState.PaymentFailed,
"Insufficient cash");
return new PaymentResult
{
Success = false,
FailureReason = "Insufficient cash inserted",
Method = PaymentMethod.Cash
};
}
}
// --------------------------------------------------------
// DISPENSING
// --------------------------------------------------------
public async Task<DispenseOutcome> CompleteDispensingAsync(
CancellationToken ct)
{
if (_currentState != MachineState.Dispensing)
{
throw new InvalidOperationException(
$"Cannot dispense in state {_currentState}");
}
var slot = await _inventoryRepo
.GetSlotAsync(
_machineId,
_activeTransaction.SlotId);
var outcome = await _dispensingEngine
.DispenseAsync(slot, ct);
if (outcome.Result == DispenseResult.Success)
{
await _inventoryRepo.DecrementQuantityAsync(slot.Id);
_activeTransaction.Status = MachineState.Complete;
_activeTransaction.CompletedAt = DateTime.UtcNow;
await _transactionRepo.SaveTransactionAsync(
_activeTransaction);
TransitionTo(MachineState.Complete,
"Product dispensed successfully");
if (slot.IsLowStock)
{
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
MachineId = _machineId,
Severity = AlertSeverity.Medium,
Message =
$"Low stock in slot {slot.Row}-{slot.Column}" +
$" ({slot.Product.Name}): " +
$"{slot.CurrentQuantity} remaining",
TriggeredAt = DateTime.UtcNow
});
}
}
else if (outcome.Result == DispenseResult.MaxRetriesExceeded)
{
_activeTransaction.Status =
MachineState.DispenseFailed;
_activeTransaction.FailureReason =
outcome.FailureReason;
_activeTransaction.DispenseRetries =
outcome.AttemptsRequired;
await _transactionRepo.SaveTransactionAsync(
_activeTransaction);
TransitionTo(MachineState.DispenseFailed,
outcome.FailureReason);
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
MachineId = _machineId,
Severity = AlertSeverity.High,
Message =
$"Dispense failure in slot {slot.Row}-" +
$"{slot.Column}: {outcome.FailureReason}",
TriggeredAt = DateTime.UtcNow
});
}
return outcome;
}
// --------------------------------------------------------
// REFUND
// --------------------------------------------------------
public async Task<bool> ProcessRefundAsync(
CancellationToken ct)
{
if (_currentState != MachineState.DispenseFailed &&
_currentState != MachineState.Refunding)
{
throw new InvalidOperationException(
$"Cannot refund in state {_currentState}");
}
TransitionTo(MachineState.Refunding,
"Initiating refund");
try
{
if (_activeTransaction.PaymentMethod ==
PaymentMethod.Cash)
{
_logger.LogInformation(
"Cash refund — change should be returned " +
"to customer via coin dispenser");
TransitionTo(MachineState.Idle,
"Cash refund complete (coins returned)");
return true;
}
else
{
decimal refundAmount =
_activeTransaction.TotalAmount +
_activeTransaction.TaxAmount;
bool refunded = await _paymentGateway.RefundAsync(
_activeTransaction.Id.ToString(),
refundAmount, ct);
if (refunded)
{
_activeTransaction.Status = MachineState.Idle;
_activeTransaction.CompletedAt = DateTime.UtcNow;
await _transactionRepo.SaveTransactionAsync(
_activeTransaction);
TransitionTo(MachineState.Idle,
$"Refund of ${refundAmount:F2} processed");
return true;
}
else
{
TransitionTo(MachineState.Error,
"Refund failed");
return false;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Refund error for transaction {TxId}",
_activeTransaction.Id);
TransitionTo(MachineState.Error,
$"Refund error: {ex.Message}");
return false;
}
}
// --------------------------------------------------------
// CANCEL / TIMEOUT
// --------------------------------------------------------
public void CancelTransaction()
{
_logger.LogInformation(
"Transaction cancelled in state {State}", _currentState);
if (_currentState == MachineState.PaymentPending)
{
TransitionTo(MachineState.Idle, "User cancelled");
_activeTransaction = null;
}
else if (_currentState == MachineState.Selecting)
{
TransitionTo(MachineState.Idle, "User cancelled selection");
_activeTransaction = null;
}
}
// --------------------------------------------------------
// TELEMETRY
// --------------------------------------------------------
public async Task ReportTelemetryAsync(
MachineTelemetry telemetry, CancellationToken ct)
{
await _telemetryService.ReportTelemetryAsync(
_machineId, telemetry);
if (telemetry.Temperature > 8.0m)
{
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
MachineId = _machineId,
Severity = AlertSeverity.High,
Message =
$"Temperature high: {telemetry.Temperature}°C",
TriggeredAt = DateTime.UtcNow
});
}
if (telemetry.TiltDetected)
{
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
MachineId = _machineId,
Severity = AlertSeverity.Critical,
Message =
"TILT/TAMPER DETECTED — possible theft " +
"or vandalism",
TriggeredAt = DateTime.UtcNow
});
}
if (telemetry.Voltage < 10.5m || telemetry.Voltage > 13.5m)
{
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
MachineId = _machineId,
Severity = AlertSeverity.High,
Message =
$"Voltage anomaly: {telemetry.Voltage}V",
TriggeredAt = DateTime.UtcNow
});
}
}
// --------------------------------------------------------
// MAINTENANCE MODE
// --------------------------------------------------------
public void EnterMaintenanceMode()
{
_logger.LogWarning(
"Entering maintenance mode from state {State}",
_currentState);
TransitionTo(MachineState.Maintenance,
"Admin activated maintenance");
}
public void ExitMaintenanceMode()
{
TransitionTo(MachineState.Idle,
"Maintenance complete");
}
// --------------------------------------------------------
// RECOVERY
// --------------------------------------------------------
public async Task RecoverFromPowerFailureAsync(
Transaction persistedTransaction,
bool productDispensed,
CancellationToken ct)
{
_logger.LogWarning(
"Recovering from power failure. " +
"Last state: {State}, TxId: {TxId}",
persistedTransaction.Status, persistedTransaction.Id);
_activeTransaction = persistedTransaction;
if (persistedTransaction.Status ==
MachineState.Dispensing &&
!productDispensed)
{
_logger.LogWarning(
"Power lost during dispense, product NOT " +
"dispensed — processing refund");
await ProcessRefundAsync(ct);
}
else if (persistedTransaction.Status ==
MachineState.PaymentProcessing)
{
_logger.LogWarning(
"Power lost during payment processing — " +
"payment status unknown, queuing for " +
"reconciliation");
TransitionTo(MachineState.Error,
"Recovery: payment status unknown");
}
else
{
TransitionTo(MachineState.Idle,
"Recovered to idle");
}
}
}
// ============================================================
// INVENTORY MANAGER
// ============================================================
public class InventoryManager
{
private readonly ILogger<InventoryManager> _logger;
private readonly IInventoryRepository _repo;
private readonly IAlertService _alertService;
private const int LowStockThreshold = 3;
private const int CriticalStockThreshold = 1;
public InventoryManager(
ILogger<InventoryManager> logger,
IInventoryRepository repo,
IAlertService alertService)
{
_logger = logger;
_repo = repo;
_alertService = alertService;
}
public async Task RestockSlotAsync(
Guid slotId, int quantityAdded,
string batchId, DateTime? expiryDate,
CancellationToken ct)
{
var slots = await _repo.GetAllSlotsAsync(Guid.Empty);
var slot = slots.FirstOrDefault(s => s.Id == slotId);
if (slot == null)
throw new ArgumentException(
$"Slot {slotId} not found");
int previousQuantity = slot.CurrentQuantity;
int newQuantity = Math.Min(
slot.CurrentQuantity + quantityAdded,
slot.MaxCapacity);
await _repo.UpdateQuantityAsync(slotId, newQuantity);
_logger.LogInformation(
"Slot {Row}-{Col} restocked: {Previous} -> {New} " +
"(added {Added}, batch {Batch})",
slot.Row, slot.Column, previousQuantity,
newQuantity, quantityAdded, batchId);
if (expiryDate.HasValue &&
expiryDate.Value < DateTime.UtcNow.AddDays(3))
{
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
Severity = AlertSeverity.Medium,
Message =
$"Slot {slot.Row}-{slot.Column} restocked " +
$"with near-expiry product (expires: " +
$"{expiryDate:yyyy-MM-dd})",
TriggeredAt = DateTime.UtcNow
});
}
}
public async Task<List<Slot>> GetLowStockSlotsAsync(
Guid machineId, CancellationToken ct)
{
var allSlots = await _repo.GetAllSlotsAsync(machineId);
return allSlots
.Where(s => s.CurrentQuantity <= LowStockThreshold
&& s.CurrentQuantity > 0)
.ToList();
}
public async Task<List<Slot>> GetExpiredSlotsAsync(
Guid machineId, CancellationToken ct)
{
var allSlots = await _repo.GetAllSlotsAsync(machineId);
return allSlots.Where(s => s.IsEmpty).ToList();
}
public async Task ProcessDispensedSlotAsync(
Guid slotId, CancellationToken ct)
{
await _repo.DecrementQuantityAsync(slotId);
var slots = await _repo.GetAllSlotsAsync(Guid.Empty);
var slot = slots.FirstOrDefault(s => s.Id == slotId);
if (slot != null && slot.CurrentQuantity <= CriticalStockThreshold)
{
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
Severity = AlertSeverity.High,
Message =
$"CRITICAL: Slot {slot.Row}-{slot.Column} " +
$"({slot.Product?.Name}) nearly empty: " +
$"{slot.CurrentQuantity} remaining",
TriggeredAt = DateTime.UtcNow
});
}
}
public async Task<Dictionary<string, int>>
GetInventorySummaryAsync(
Guid machineId, CancellationToken ct)
{
var slots = await _repo.GetAllSlotsAsync(machineId);
return slots
.GroupBy(s => s.Product?.Category ?? "Unknown")
.ToDictionary(
g => g.Key,
g => g.Sum(s => s.CurrentQuantity));
}
}
// ============================================================
// CASH MANAGEMENT SERVICE
// ============================================================
public class CashManagementService
{
private readonly ILogger<CashManagementService> _logger;
private readonly ITransactionRepository _transactionRepo;
private readonly IAlertService _alertService;
public CashManagementService(
ILogger<CashManagementService> logger,
ITransactionRepository transactionRepo,
IAlertService alertService)
{
_logger = logger;
_transactionRepo = transactionRepo;
_alertService = alertService;
}
public async Task<decimal> CalculateExpectedCashAsync(
Guid machineId, DateTime date, CancellationToken ct)
{
var transactions = await _transactionRepo
.GetPendingRefundsAsync(machineId);
decimal totalCashReceived = transactions
.Where(t => t.PaymentMethod == PaymentMethod.Cash
&& t.Status == MachineState.Complete)
.Sum(t => t.TotalAmount + t.TaxAmount);
return totalCashReceived;
}
public async Task ValidateChangeInventoryAsync(
Guid machineId,
int[] currentCoins,
int[] currentBills,
CancellationToken ct)
{
decimal[] coinValues =
{ 0.01m, 0.05m, 0.10m, 0.25m, 0.50m, 1.00m };
decimal[] billValues =
{ 1m, 5m, 10m, 20m, 50m, 100m };
decimal coinTotal = currentCoins
.Select((count, i) => count * coinValues[i])
.Sum();
decimal billTotal = currentBills
.Select((count, i) => count * billValues[i])
.Sum();
decimal totalChange = coinTotal + billTotal;
_logger.LogInformation(
"Change inventory for machine {Id}: " +
"coins=${Coins:F2}, bills=${Bills:F2}, " +
"total=${Total:F2}",
machineId, coinTotal, billTotal, totalChange);
if (totalChange < 20.00m)
{
await _alertService.SendAlertAsync(
new MaintenanceAlert
{
Id = Guid.NewGuid(),
MachineId = machineId,
Severity = AlertSeverity.Medium,
Message =
$"Low change inventory: ${totalChange:F2} " +
$"remaining",
TriggeredAt = DateTime.UtcNow
});
}
}
}
}
Implementation Highlights
| Component | Lines of Code | Key Design Decisions |
|---|---|---|
| Enums & Models | ~80 | Strong typing for all states and outcomes |
| VendingMachine (FSM) | ~280 | State machine with lock, WAL-compatible transitions |
| DispensingEngine | ~80 | 3-retry policy, coil reversal, break-beam detection |
| DynamicPricingEngine | ~40 | Time-based and day-based pricing rules |
| InventoryManager | ~90 | Restock, expiry tracking, low-stock alerts |
| CashManagementService | ~70 | Expected cash calculation, change inventory alerts |
| Total | ~640 |
26 Vending Machine Fleet Analytics & Machine Learning
Operating a large vending machine fleet without analytics is like flying blind. Raw telemetry and transaction logs are useful, but the real competitive advantage comes from extracting actionable intelligence — understanding what sells, where it sells, when it sells, and why certain machines outperform others. A mature analytics and machine learning platform transforms a vending operator from a logistics company into a data-driven retail organization.
Demand Pattern Analysis per Machine Location
Every machine location has a unique demand fingerprint shaped by its surroundings — office buildings spike at morning coffee breaks and lunchtime, hospital lobbies run steady all day, university campuses surge between classes, and transit stations peak during commute hours. The analytics pipeline aggregates transaction data into hourly demand profiles per machine, then clusters machines into location archetypes using k-means clustering on features like peak hour distribution, category mix ratios, and average transaction value. These archetypes inform product assortment, pricing strategy, and restocking schedules. For instance, machines in gym-adjacent locations show 3× higher protein bar sales and 40% lower candy bar volume compared to office locations — data that directly drives slot allocation decisions.
ML-Based Product Assortment Optimization
Slot space is the most constrained resource in a vending machine — typically 40 to 60 slots. The assortment optimization model determines the revenue-maximizing product mix for each machine given its location archetype, historical sales velocity, profit margins, and supplier constraints. This is formulated as a constrained integer optimization problem: maximize total expected revenue subject to minimum category diversity requirements, supplier minimum order quantities, and slot capacity limits. The model uses a greedy heuristic enhanced with simulated annealing to escape local optima, re-evaluating assortment quarterly or whenever location characteristics change significantly.
Price Elasticity Modeling
Different products and locations exhibit different price elasticity of demand. A Coca-Cola in a hospital with no nearby alternatives is highly inelastic — raising the price by 15% reduces volume by only 3%. The same product in a break room with a competing refrigerator is highly elastic. The pricing service estimates elasticity coefficients per product-location pair using historical price variation experiments (A/B testing across machine groups) and Bayesian regression. These coefficients feed the dynamic pricing engine introduced in Section 12, enabling revenue-optimal pricing that balances margin against volume.
Time-Series Forecasting for Restocking
The restocking system described in Section 14 uses point forecasts (expected daily sales), but a more robust approach employs probabilistic time-series forecasting with prediction intervals. Using Prophet or DeepAR, the system generates P10, P50, and P90 sales forecasts for each product-machine pair over a 7-day horizon. The restocking trigger uses the P90 forecast — meaning there is a 90% probability the product will not sell out before the next restock visit. This conservative approach reduces stockout rates from 8% to under 1% while only modestly increasing restocking frequency.
Anomaly Detection for Sales Fraud
Fleet-wide anomaly detection identifies suspicious patterns that may indicate employee theft, transaction manipulation, or mechanical fraud. The system runs a real-time Isolation Forest model over a feature window of daily transaction summaries per machine. Features include revenue-to-restock ratio, refund rate, average transaction value, cash-to-card ratio, and dispense-sensor confirmation rate. Machines flagged as anomalies receive automatic investigation routing. Common fraud patterns detected include: restocking fewer items than logged (inventory shrinkage), manual coil rotation without payment (free product), and excessive voided transactions during off-hours.
Analytics Feature Summary
| Analytics Capability | Model / Algorithm | Update Frequency | Business Impact |
|---|---|---|---|
| Demand Pattern Clustering | K-Means (scikit-learn) | Monthly retrain | 15-20% revenue lift via better assortment |
| Assortment Optimization | Greedy + Simulated Annealing | Quarterly | 10-15% margin improvement per machine |
| Price Elasticity | Bayesian Ridge Regression | Bi-weekly | 5-8% revenue lift via dynamic pricing |
| Sales Forecasting | Prophet / DeepAR | Daily | Stockout rate from 8% to < 1% |
| Fraud Detection | Isolation Forest | Daily scoring | 2-4% shrinkage reduction fleet-wide |
Fleet Analytics Pipeline — C# Feature Engineering
public class FleetAnalyticsService
{
private readonly ITransactionRepository _transactions;
private readonly IInventoryRepository _inventory;
private readonly ITelemetryService _telemetry;
private readonly ILogger<FleetAnalyticsService> _logger;
public FleetAnalyticsService(
ITransactionRepository transactions,
IInventoryRepository inventory,
ITelemetryService telemetry,
ILogger<FleetAnalyticsService> logger)
{
_transactions = transactions;
_inventory = inventory;
_telemetry = telemetry;
_logger = logger;
}
public async Task<MachineDemandProfile> BuildDemandProfileAsync(
Guid machineId, DateTime from, DateTime to, CancellationToken ct)
{
var transactions = await _transactions
.GetTransactionsAsync(machineId, from, to);
var hourlyBuckets = transactions
.GroupBy(t => t.CompletedAt.Hour)
.ToDictionary(
g => g.Key,
g => new HourlyDemand
{
Hour = g.Key,
TransactionCount = g.Count(),
Revenue = g.Sum(t => t.TotalAmount),
AverageValue = g.Average(t => t.TotalAmount),
TopCategory = g.GroupBy(t => t.Product.Category)
.OrderByDescending(cg => cg.Count())
.First().Key
});
var categoryMix = transactions
.GroupBy(t => t.Product.Category)
.ToDictionary(
g => g.Key,
g => (decimal)g.Count() / transactions.Count);
var refundRate = transactions.Count > 0
? (decimal)transactions.Count(t =>
t.Status == MachineState.DispenseFailed)
/ transactions.Count
: 0m;
return new MachineDemandProfile
{
MachineId = machineId,
PeriodFrom = from,
PeriodTo = to,
TotalTransactions = transactions.Count,
TotalRevenue = transactions.Sum(t => t.TotalAmount),
PeakHour = hourlyBuckets.Values
.OrderByDescending(h => h.TransactionCount)
.First()?.Hour ?? 0,
HourlyBreakdown = hourlyBuckets,
CategoryMix = categoryMix,
RefundRate = refundRate,
AverageTransactionValue = transactions.Any()
? transactions.Average(t => t.TotalAmount)
: 0m
};
}
public async Task<List<AnomalyAlert>> DetectFraudAnomaliesAsync(
Guid operatorId, CancellationToken ct)
{
var machines = await _transactions
.GetMachinesByOperatorAsync(operatorId);
var features = new List<MachineFraudFeatures>();
foreach (var machineId in machines)
{
var recent = await _transactions
.GetTransactionsAsync(machineId,
DateTime.UtcNow.AddDays(-7), DateTime.UtcNow);
decimal totalRevenue = recent
.Sum(t => t.TotalAmount);
int totalTx = recent.Count;
int refunds = recent.Count(t =>
t.Status == MachineState.DispenseFailed);
decimal cashRatio = totalTx > 0
? (decimal)recent.Count(t =>
t.PaymentMethod == PaymentMethod.Cash) / totalTx
: 0m;
decimal offHoursRatio = totalTx > 0
? (decimal)recent.Count(t =>
t.StartedAt.Hour < 6 || t.StartedAt.Hour > 22)
/ totalTx
: 0m;
features.Add(new MachineFraudFeatures
{
MachineId = machineId,
DailyRevenue = totalRevenue / 7m,
TransactionCount = totalTx,
RefundRate = totalTx > 0
? (decimal)refunds / totalTx : 0m,
CashToCardRatio = cashRatio,
OffHoursActivityRatio = offHoursRatio
});
}
var alerts = new List<AnomalyAlert>();
decimal meanRevenue = features.Average(f => f.DailyRevenue);
decimal stdRevenue = CalculateStdDev(
features.Select(f => f.DailyRevenue).ToList());
foreach (var f in features)
{
bool isAnomaly = false;
var reasons = new List<string>();
if (f.RefundRate > 0.10m)
{
isAnomaly = true;
reasons.Add(
$"High refund rate: {f.RefundRate:P1}");
}
if (f.DailyRevenue > meanRevenue + 3 * stdRevenue)
{
isAnomaly = true;
reasons.Add(
$"Revenue 3σ above mean: ${f.DailyRevenue:F2}/day");
}
if (f.OffHoursActivityRatio > 0.30m)
{
isAnomaly = true;
reasons.Add(
$"Excessive off-hours activity: " +
$"{f.OffHoursActivityRatio:P1}");
}
if (isAnomaly)
{
alerts.Add(new AnomalyAlert
{
MachineId = f.MachineId,
Severity = AlertSeverity.High,
Reasons = reasons,
DetectedAt = DateTime.UtcNow
});
}
}
_logger.LogInformation(
"Fraud scan complete for operator {Op}: " +
"{Count} anomalies detected across {Total} machines",
operatorId, alerts.Count, machines.Count);
return alerts;
}
private decimal CalculateStdDev(List<decimal> values)
{
decimal mean = values.Average();
decimal variance = values
.Average(v => (v - mean) * (v - mean));
return (decimal)Math.Sqrt((double)variance);
}
}
public class MachineDemandProfile
{
public Guid MachineId { get; set; }
public DateTime PeriodFrom { get; set; }
public DateTime PeriodTo { get; set; }
public int TotalTransactions { get; set; }
public decimal TotalRevenue { get; set; }
public int PeakHour { get; set; }
public Dictionary<int, HourlyDemand> HourlyBreakdown { get; set; }
public Dictionary<string, decimal> CategoryMix { get; set; }
public decimal RefundRate { get; set; }
public decimal AverageTransactionValue { get; set; }
}
public class HourlyDemand
{
public int Hour { get; set; }
public int TransactionCount { get; set; }
public decimal Revenue { get; set; }
public decimal AverageValue { get; set; }
public string TopCategory { get; set; }
}
public class MachineFraudFeatures
{
public Guid MachineId { get; set; }
public decimal DailyRevenue { get; set; }
public int TransactionCount { get; set; }
public decimal RefundRate { get; set; }
public decimal CashToCardRatio { get; set; }
public decimal OffHoursActivityRatio { get; set; }
}
public class AnomalyAlert
{
public Guid MachineId { get; set; }
public AlertSeverity Severity { get; set; }
public List<string> Reasons { get; set; }
public DateTime DetectedAt { get; set; }
}
ML Model Deployment Strategy
All models are trained offline on historical data using Python (scikit-learn, Prophet) and serialized to ONNX format for cross-platform inference. The ML Prediction Service (Section 6 architecture) loads ONNX models via ML.NET and serves predictions through the same gRPC interface used by other microservices. Models are retrained on a weekly schedule using Apache Airflow DAGs, with automated evaluation gates — a new model version is only promoted to production if its holdout accuracy exceeds the current champion by at least 2%. This prevents model drift from degrading forecasting quality over time.
27 Conclusion
Designing a vending machine system is far more than building a simple product dispenser — it is a comprehensive exercise in embedded systems design, distributed systems engineering, real-time state management, and fleet-scale IoT operations. The key takeaways from this design are:
- State Machine is King: The finite state machine governs every interaction. It must be deterministic, persist every transition, and gracefully handle power failures and network outages. Getting the state machine right is the single most important design decision.
- Offline-First Architecture: Vending machines operate in environments with unreliable connectivity. The system must queue transactions locally, sync when online, and handle reconciliation on reconnection.
- Payment Integrity: Money must never be lost. Every payment state must be persisted, and the system must automatically handle refunds for failed dispenses. PCI compliance is non-negotiable.
- Fleet Observability: With thousands of machines in the field, comprehensive telemetry, alerting, and dashboards are essential. IoT sensors provide the eyes and ears for remote monitoring.
- Predictive Operations: Moving from reactive (fix when broken) to predictive (prevent from breaking) reduces downtime and increases revenue. ML-driven restocking optimization saves significant logistics costs.
- Clean Architecture: Separating concerns into microservices (transaction, inventory, payment, telemetry, pricing, alerts) allows independent scaling, deployment, and evolution of each component.
For System Design Interviews
The vending machine is an excellent interview topic because it tests your ability to design at multiple levels of abstraction: hardware and firmware, state machine design, API design, distributed systems, database design, IoT scale, and financial systems. Always start with requirements, design the state machine first, then expand to the full system architecture.
This design supports a fleet of 50,000+ machines, processes 1.5 million transactions daily, maintains 72-hour offline capability, and provides real-time visibility across the entire fleet — all while ensuring every customer gets their product and every dollar is accounted for.
"The best vending machine is one you never have to think about — it just works, dispenses correctly, accepts any payment method, and restocks itself before it runs empty."