system-design51 min read

How to Design a Vending Machine System — A Senior+ Guide | Ayodhyya

How to Design a Vending Machine System

Building state-machine-driven vending machines with inventory management, payment processing, and IoT monitoring at fleet scale

Published July 14, 2026 • 25 min read • Senior+ System Design Guide

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

IDRequirementPriority
FR-01User can browse available products on a touchscreen or physical keypadP0
FR-02User can select a product by pressing a button or tapping the screenP0
FR-03System accepts payment via cash (coins and bills), credit/debit card, NFC (Apple Pay, Google Pay), QR code, and UPIP0
FR-04System dispenses the selected product after successful paymentP0
FR-05System dispenses correct change for cash paymentsP0
FR-06Admin can view real-time inventory levels for each machineP1
FR-07Admin can remotely update product pricingP1
FR-08System tracks sales data, revenue, and transaction historyP1
FR-09System sends alerts when inventory is low or machine is malfunctioningP1
FR-10System supports dynamic pricing based on time of day, location, and demandP2
FR-11System provides predictive restocking recommendations with optimized routesP2
FR-12System detects and alerts on tamper events (tilt, door forced open, coil manipulation)P1
FR-13System manages product expiry dates and FIFO rotationP1
FR-14User can request a refund if dispensing failsP0
FR-15System supports multi-tender payments (part cash, part card)P2

Non-Functional Requirements

IDRequirementTarget
NFR-01Transaction latency (selection to dispensing)< 5 seconds
NFR-02System availability99.95% (machine operates offline when cloud is unavailable)
NFR-03Data durability99.999% for transaction logs and payment records
NFR-04Concurrent transactions per machine1 (single user at a time)
NFR-05Fleet size support50,000+ machines per operator
NFR-06Offline operation durationUp to 72 hours with full functionality
NFR-07Payment PCI compliancePCI DSS Level 1
NFR-08Telemetry sync intervalEvery 60 seconds when online
NFR-09SecurityTLS 1.3 for all communications, AES-256 for stored payment data
NFR-10Energy consumptionIdle < 50W, Active < 200W

3 Capacity Estimation

graph TD A[Operator Fleet] --> B[10,000 Machines] B --> C[Average 30 Transactions/Machine/Day] C --> D[300,000 Total Daily Transactions] D --> E[~3.5 QPS Average] D --> F[~20 QPS Peak Hours
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

MetricValueCalculation
Total machines10,000Given fleet size
Avg transactions/machine/day30Typical for high-traffic locations
Total daily transactions300,00010,000 × 30
Average QPS3.5300,000 / 86,400
Peak QPS (12hr active)~20300,000 / (12 × 3600) × 2
Peak QPS (30min burst)~100Lunch rush clustering
Avg transaction size$2.50Snacks and beverages
Daily revenue$750,000300,000 × $2.50
Annual revenue$273M$750K × 365

Storage Estimation

Data TypeSize per RecordDaily VolumeDaily StorageAnnual Storage
Transaction logs2 KB300,000600 MB219 GB
Telemetry snapshots1 KB14,400,000 (every 60s)14.4 GB5.3 TB
Payment records1.5 KB300,000450 MB164 GB
Inventory updates512 B1,000,000500 MB183 GB
Alerts/Events768 B500,000384 MB140 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

erDiagram MACHINE ||--o{ SLOT : contains MACHINE ||--o{ TRANSACTION : processes MACHINE ||--o{ TELEMETRY : emits MACHINE ||--o{ MAINTENANCE_EVENT : triggers SLOT ||--o| PRODUCT : holds TRANSACTION ||--o| PAYMENT : requires TRANSACTION ||--o{ TRANSACTION_ITEM : includes PRODUCT ||--o{ INVENTORY_SNAPSHOT : tracked_by OPERATOR ||--o{ MACHINE : owns LOCATION ||--o{ MACHINE : hosts MACHINE { uuid id PK string serial_number uuid operator_id FK uuid location_id FK string model enum status jsonb config timestamp last_heartbeat timestamp created_at } SLOT { uuid id PK uuid machine_id FK int row int column uuid product_id FK int current_quantity int max_capacity enum mechanism_type bool is_available } PRODUCT { uuid id PK string name string barcode decimal price decimal cost int category_id int shelf_life_days string image_url bool is_active } TRANSACTION { uuid id PK uuid machine_id FK uuid slot_id FK enum status decimal total_amount decimal tax_amount enum payment_method timestamp started_at timestamp completed_at jsonb metadata } PAYMENT { uuid id PK uuid transaction_id FK enum method decimal amount enum status string reference_id string gateway_response timestamp processed_at } TELEMETRY { uuid id PK uuid machine_id FK decimal temperature decimal humidity int door_status jsonb coil_states decimal voltage timestamp recorded_at } MAINTENANCE_EVENT { uuid id PK uuid machine_id FK enum event_type string description enum severity bool resolved timestamp triggered_at timestamp resolved_at } INVENTORY_SNAPSHOT { uuid id PK uuid machine_id FK uuid product_id FK int quantity timestamp captured_at } OPERATOR { uuid id PK string name string email string api_key jsonb settings } LOCATION { uuid id PK string name decimal latitude decimal longitude string address enum location_type }

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)

EndpointMethodDescriptionAuth
POST /api/v1/machines/{id}/heartbeatPOSTMachine sends status update with telemetryMachine certificate
POST /api/v1/machines/{id}/transactions/startPOSTInitiate a new transaction (product selected)Machine certificate
POST /api/v1/machines/{id}/transactions/{txId}/paymentPOSTProcess payment for a transactionMachine certificate
POST /api/v1/machines/{id}/transactions/{txId}/completePOSTConfirm dispensing completedMachine certificate
POST /api/v1/machines/{id}/transactions/{txId}/failPOSTReport dispensing failure (triggers refund)Machine certificate
GET /api/v1/machines/{id}/configGETFetch latest machine configuration (prices, products)Machine certificate
POST /api/v1/machines/{id}/inventoryPOSTReport inventory count changesMachine certificate
POST /api/v1/machines/{id}/alertsPOSTReport hardware alert or tamper eventMachine certificate

Admin APIs (Portal → Cloud)

EndpointMethodDescriptionAuth
GET /api/v1/admin/machinesGETList all machines in fleet with statusJWT token
GET /api/v1/admin/machines/{id}GETGet detailed machine info and current inventoryJWT token
PUT /api/v1/admin/machines/{id}/pricingPUTUpdate pricing for a machineJWT token
POST /api/v1/admin/machines/{id}/restockPOSTRecord restocking eventJWT token
GET /api/v1/admin/analytics/salesGETGet sales analytics with filtersJWT token
GET /api/v1/admin/machines/{id}/telemetryGETGet telemetry historyJWT token
POST /api/v1/admin/machines/{id}/commandPOSTSend remote command (reboot, lock, unlock)JWT token
GET /api/v1/admin/reports/restockGETGet restocking recommendations with routeJWT 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

graph TB subgraph Edge Layer VM1[Vending Machine 1] VM2[Vending Machine 2] VMN[Vending Machine N] end subgraph Edge Gateway GW[MQTT Broker / Edge Gateway] end subgraph Cloud Backend API[API Gateway
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.

graph LR subgraph Machine Firmware FW[ARM Cortex-M4
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

ComponentTechnologyRationale
Machine FirmwareC++ / Rust on FreeRTOSReal-time, low-power, reliable
Edge CommunicationMQTT (Mosquitto)Lightweight, low bandwidth, QoS levels
API GatewayKong / AWS API GatewayRate limiting, auth, routing
Backend Services.NET 8 / ASP.NET CoreHigh performance, strong typing, C# ecosystem
Event BusApache KafkaDurable, ordered, high throughput
Transactional DBPostgreSQL 16ACID, JSONB support, mature
Time-Series DBTimescaleDB (PostgreSQL extension)Telemetry compression, continuous aggregates
CacheRedis ClusterLow latency, pub/sub, distributed locks
Object StorageAWS S3 / MinIOLogs, images, backups
Payment GatewayStripe Connect / AdyenPCI compliance, global coverage
ML PlatformPython + scikit-learn / ML.NETDemand forecasting models
Admin PortalReact + TypeScriptResponsive dashboard with real-time updates
MonitoringPrometheus + GrafanaMetrics collection and visualization
Container OrchestrationKubernetes (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.

stateDiagram-v2 [*] --> IDLE: Machine Powered On IDLE --> SELECTING: User Touch / Button Press IDLE --> MAINTENANCE: Admin Mode Activated SELECTING --> PAYMENT_PENDING: Product Selected SELECTING --> IDLE: Timeout (30s) / Cancel PAYMENT_PENDING --> PAYMENT_PROCESSING: Payment Initiated PAYMENT_PENDING --> IDLE: Cancel / Timeout (60s) PAYMENT_PROCESSING --> DISPENSING: Payment Approved PAYMENT_PROCESSING --> PAYMENT_FAILED: Payment Declined PAYMENT_PROCESSING --> PAYMENT_FAILED: Payment Error PAYMENT_FAILED --> REFUNDING: Refund Required PAYMENT_FAILED --> IDLE: No Refund Needed REFUNDING --> IDLE: Refund Complete REFUNDING --> ERROR: Refund Failed DISPENSING --> COMPLETE: Product Dispensed DISPENSING --> DISPENSE_FAILED: Jam Detected / Coil Fault DISPENSE_FAILED --> REFUNDING: Refund Required DISPENSE_FAILED --> RETRY: Retry Possible DISPENSE_FAILED --> MAINTENANCE: Requires Technician RETRY --> DISPENSING: Retry Attempt RETRY --> REFUNDING: Max Retries Exceeded COMPLETE --> IDLE: Thank You / Receipt MAINTENANCE --> IDLE: Maintenance Complete ERROR --> IDLE: Error Acknowledged ERROR --> MAINTENANCE: Requires Intervention IDLE --> LOW_INVENTORY_ALERT: Stock Below Threshold LOW_INVENTORY_ALERT --> IDLE: Alert Sent

State Transitions Table

From StateEventTo StateActions
IDLEUser touch detectedSELECTINGWake display, show product catalog
SELECTINGProduct button pressedPAYMENT_PENDINGReserve slot, display price, activate payment terminal
PAYMENT_PENDINGCard tapped / Cash insertedPAYMENT_PROCESSINGLock selection, send payment to gateway
PAYMENT_PROCESSINGPayment approvedDispensingTrigger coil motor for target slot
PAYMENT_PROCESSINGPayment declinedPAYMENT_FAILEDDisplay error, check if cash needs return
DISPENSINGSpiral rotation complete, sensor confirmsC COMPLETELog sale, update inventory, print receipt
DISPENSINGTimeout / jam detectedDISPENSE_FAILEDStop motor, assess retry eligibility
DISPENSE_FAILEDRetry count < 3RETRYReverse coil, re-attempt dispense
DISPENSE_FAILEDRetry count >= 3REFUNDINGInitiate full refund via payment gateway
REFUNDINGRefund confirmedIDLEReturn cash / reverse card charge, display message
COMPLETETimeout (10s)IDLELog transaction complete, reset for next user
ANY STATEPower 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.

graph TD subgraph "Vending Machine Slot Grid (6 rows × 10 columns)" subgraph Row A A1[A1: Coke] --- A2[A2: Pepsi] --- A3[A3: Sprite] --- A4[A4: Water] --- A5[A5: Juice] A6[A6: Tea] --- A7[A7: Coffee] --- A8[A8: Energy] --- A9[A9: Milk] --- A10[A10: Smoothie] end subgraph Row B B1[B1: Lays] --- B2[B2: Doritos] --- B3[B3: Pringles] --- B4[B4: Nuts] --- B5[B5: Trail Mix] B6[B6: Popcorn] --- B7[B7: Pretzels] --- B8[B8: Crackers] --- B9[B9: Granola] --- B10[B10: Jerky] end subgraph Row C C1[C1: Snickers] --- C2[C2: KitKat] --- C3[C3: Twix] --- C4[C4: M&Ms] --- C5[C5: Reese's] C6[C6: Skittles] --- C7[C7: Gum] --- C8[C8: Mint] --- C9[C9: Dark Choc] --- C10[C10: Gummies] end subgraph Row D D1[D1: Chips Ahoy] --- D2[D2: Oreos] --- D3[D3: Nature Valley] --- D4[D4: Belvita] --- D5[D5: Rice Krispies] D6[D6: Protein Bar] --- D7[D7: Fiber Bar] --- D8[D8: Nutrigrain] --- D9[D9: Fig Bar] --- D10[D10: Energy Bar] end end

Slot Types and Mechanisms

MechanismDescriptionBest ForCost
Spiral/CoilMotor rotates coil to push product forwardSnacks, candy bars$15-25/slot
Conveyor BeltBelt moves product to drop chuteSandwiches, salads$80-120/slot
ElevatorMoving platform lifts product to dispensing pointFragile items, bottles$200-350/slot
Gravity FeedProduct slides down incline to chuteCans, uniform items$10-15/slot
Robotic ArmPrecision gripper picks and places productsMixed/fresh items$500-1000/slot
Locked DrawerElectronic lock per compartmentHigh-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

sequenceDiagram participant User participant Machine participant Cloud participant PaymentGateway participant Bank User->>Machine: Tap card / Insert cash / Scan QR Machine->>Cloud: POST /transactions/{id}/payment Cloud->>Cloud: Validate transaction state Cloud->>PaymentGateway: Authorize payment PaymentGateway->>Bank: Card authorization request Bank-->>PaymentGateway: Authorization response PaymentGateway-->>Cloud: Payment result (approved/declined) alt Payment Approved Cloud-->>Machine: Dispense command + slot details Machine->>Machine: Rotate coil / Activate mechanism Machine->>Cloud: POST /transactions/{id}/complete Cloud->>Cloud: Log sale, update inventory else Payment Declined Cloud-->>Machine: Payment failed, display error Machine-->>User: Please try another payment method end

Payment Methods

MethodTechnologyLatencyFee
Cash (Coins)Coin validator + hopperInstant (local)$0 (no processing fee)
Cash (Bills)Bill validator (ICT/MEI)Instant (local)$0
Credit/Debit CardEMV chip reader (Castles/Ingenico)2-5 seconds2.9% + $0.30
NFC (Apple/Google Pay)Contactless reader (same terminal)1-3 seconds2.9% + $0.30
QR CodeUser scans QR → pays on phone5-15 seconds1.5-2.5%
UPIUPI QR / VPA3-8 secondsFree / 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

sequenceDiagram participant Cloud participant Firmware participant MotorController participant Sensor Cloud->>Firmware: Dispense command (row B, col 3, 3 rotations) Firmware->>Firmware: Verify state = PAYMENT_APPROVED Firmware->>MotorController: Activate coil B3, 3 rotations CW MotorController->>MotorController: Energize stepper motor loop Each rotation MotorController->>Sensor: Check break-beam sensor alt Product detected falling Sensor-->>Firmware: Product dispensed! Firmware->>Firmware: Decrement inventory count else Timeout (500ms) MotorController->>MotorController: Check motor current alt Current spike (jam detected) MotorController-->>Firmware: Jam detected Firmware->>MotorController: Reverse coil 1 rotation Firmware->>Firmware: Increment retry counter end end end Firmware->>Firmware: All rotations complete Firmware->>Cloud: POST /transactions/{id}/complete

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.

graph TD A[Inventory Sources] --> B[Sell-Through Count] A --> C[Weight Sensor Reading] A --> D[Restock Event Count] A --> E[Manual Adjustment] B --> F[Consensus Engine] C --> F D --> F E --> F F --> G[Inventory State
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

StateDescriptionCustomer Can Select?
IN_STOCKProduct available, quantity above thresholdYes
LOW_STOCKQuantity below minimum threshold (e.g., < 3 items)Yes (with alert)
OUT_OF_STOCKQuantity = 0No
EXPIREDAll items in slot have passed expiry dateNo
DISABLEDSlot disabled by admin (maintenance, recall)No
RESERVEDItem selected by customer, awaiting paymentNo (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

graph TD A[Pricing Request] --> B{Pricing Rules Engine} B --> C[Base Price from Product Catalog] B --> D[Time-Based Modifier] B --> E[Location-Based Modifier] B --> F[Demand-Based Modifier] B --> G[Bundle Discount] B --> H[Loyalty Discount] D --> I{Happy Hour?
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

SensorPurposeSampling RateAlert Threshold
Temperature (DS18B20)Refrigeration monitoringEvery 30s> 8°C or < 1°C for cold drinks
Humidity (DHT22)Condensation / mold preventionEvery 60s> 80% RH
Door magnetic switchUnauthorized access detectionEvent-drivenOpen > 120s outside maintenance
Tilt sensor (ADXL345)Theft / vandalism detectionEvery 10sTilt > 15 degrees
Current sensors (per coil)Motor health, jam detectionPer dispenseCurrent > 200% rated
Light sensorInterior light monitoringEvery 60sLight on > 5min (door issue)
Voltage monitorPower supply healthEvery 60s< 10.5V or > 13.5V
PIR motion sensorCustomer presence detectionEvent-driven
Camera (optional)Theft detection, product verificationOn eventMotion + alert trigger

Telemetry Pipeline

graph LR S[Sensors] --> FW[Machine Firmware
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

LevelDescriptionResponse
CRITICALMachine offline, payment system failure, tamper detectedImmediate SMS + PagerDuty escalation
HIGHTemperature out of range, multiple jams, payment gateway errorsEmail + dashboard alert within 5 minutes
MEDIUMLow inventory, single jam event, minor sensor anomalyDashboard alert, batch notification
LOWInformational (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.

graph TD A[Historical Sales Data] --> B[ML Demand Forecasting Model
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

ComponentIdleActiveNotes
Refrigeration Compressor0W (cycling)150WRuns ~50% of time in cooling mode
Touchscreen Display5W (dimmed)15WAuto-dims after 60s inactivity
LED Lighting0W20WTurns on with motion, off after 30s
Payment Terminal3W5WAlways on for tap-to-pay
Coil Motors (60 slots)0W120W (peak)Only active during dispense (~2s each)
Embedded Computer8W12WRaspberry Pi 4 / custom ARM board
Sensors + MCU2W2WAlways-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

graph TD D[Admin Dashboard] --> F1[Fleet Overview Map] D --> F2[Machine Status Grid] D --> F3[Sales Analytics] D --> F4[Inventory Health] D --> F5[Revenue Reports] D --> F6[Alert Center] F1 --> MAP[Interactive Map
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

MetricVisualizationTime Range
Total fleet revenueKPI card with trendToday / 7d / 30d / Custom
Machines online vs. offlinePie chartReal-time
Top 10 selling productsBar chartSelectable range
Revenue per machineHeatmap on mapSelectable range
Inventory depletion rateLine chart per machine7-day rolling
Average transaction valueKPI card with distributionSelectable range
Payment method breakdownStacked bar chartSelectable range
Maintenance events timelineTimeline chart30 days
Energy consumptionArea chart7 days
Cash reconciliation varianceBar chart with tolerance bandDaily

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

20 Caching Strategy

graph LR A[Client Request] --> B{Cache Hit?} B -->|Yes| C[Return Cached Response] B -->|No| D[Query Database] D --> E[Write to Cache] E --> C subgraph Cache Layers L1[L1: In-Process Memory
TTL: 30s] L2[L2: Redis Cluster
TTL: 5min] L3[L3: CDN Edge
TTL: 1hr] end A --> L1 L1 -->|Miss| L2 L2 -->|Miss| L3 L3 -->|Miss| D

Cache Keys and TTLs

TablePrimary IndexSecondary Indexes
transactionsB-tree on (machine_id, created_at)Status, payment_method, product_id
telemetryHypertable time indexmachine_id, alert_level
inventory_snapshotsB-tree on (machine_id, product_id, captured_at)Quantity threshold queries
paymentsB-tree on (transaction_id)Status, method, processed_at
alertsB-tree on (machine_id, triggered_at)Severity, resolved, event_type
Cache Key PatternDataTTLInvalidation
machine:{id}:configProduct catalog, pricing, slot mapping5 minutesOn admin update (pub/sub push)
machine:{id}:inventoryCurrent stock levels per slot30 secondsOn each dispense event
product:{id}:priceCurrent price (may vary by machine)5 minutesOn price rule change
machine:{id}:stateCurrent FSM stateNo TTL (persistent)On state transition
fleet:dashboard:summaryAggregate fleet metrics60 secondsOn telemetry ingestion
machine:{id}:sessionsActive user sessions (distributed lock)2 minutesSession timeout

21 Multi-Region Design

graph TB subgraph "Region 1 — US East" US1[US-EAST Cluster] US1DB[(PostgreSQL Primary
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

ComponentCost (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)

ServiceConfigurationMonthly 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 Storage10 TB + requests$250
Data Transfer500 GB/month$45
CloudFront CDN1 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

Q1: How would you handle a situation where the payment succeeds but the product fails to dispense?
This is a critical reliability scenario. The system must: (1) Persist the payment success state to flash before attempting to dispense. (2) If dispensing fails after 3 retries, automatically initiate a refund through the payment gateway. (3) If the network is down and the refund cannot be processed immediately, queue it in a durable local log with a retry mechanism. (4) When connectivity is restored, the cloud backend processes queued refunds. The machine's state machine ensures that on any power failure or restart, it can determine from its WAL whether a refund is owed and process it accordingly.
Q2: How would you design the state machine to handle concurrent users or simultaneous interactions?
A single vending machine only serves one user at a time — this is enforced by the state machine itself. When the machine transitions from IDLE to SELECTING, it enters a "busy" state and ignores additional user inputs until the transaction completes or times out. The machine uses a distributed lock (Redis) to prevent the cloud from accepting commands for a machine that is already in a transaction. For touchscreen machines, the UI simply blocks additional selections during an active transaction. There's no need for full concurrency support at the machine level — the constraint is physical (one product dispenses at a time) and the state machine naturally serializes access.
Q3: How would you design the inventory system to work offline for up to 72 hours?
The machine maintains a local SQLite database on its embedded computer containing the full product catalog, current inventory levels, pricing rules, and transaction log. All dispense operations update this local database first. When connectivity is available, the machine syncs changes to the cloud via MQTT with QoS 1 (at-least-once delivery). The cloud uses idempotency keys (transaction IDs) to prevent duplicate processing. The local database is periodically compressed and pruned. On reconnection, the machine also fetches any pending config updates (price changes, new products) that were queued during the offline period.
Q4: How would you prevent double-spending (a customer paying twice for the same product)?
Double-spending is prevented through several mechanisms: (1) The state machine only accepts payment when in PAYMENT_PENDING state — once payment is initiated, the state moves to PAYMENT_PROCESSING, and no new payment can be started. (2) Each transaction has a globally unique ID, and the payment gateway enforces idempotency — the same transaction ID can only be charged once. (3) The distributed lock in Redis ensures that even if the firmware sends duplicate payment requests due to a network retry, only one is processed. (4) If a network timeout causes the machine to think payment failed when it actually succeeded, the cloud side tracks this and the machine can query the payment status before initiating a new transaction.
Q5: How would you handle IoT telemetry from 50,000 machines without overwhelming the backend?
With 50,000 machines sending telemetry every 60 seconds, we'd have ~833 messages/second. This is manageable with proper architecture: (1) Use MQTT with QoS 0 (at-most-once) for routine telemetry — losing an occasional reading is acceptable. (2) Use Kafka as a durable buffer between ingestion and processing. (3) Implement stream processing (Flink or Kafka Streams) to aggregate, compress, and filter data before it hits the database. (4) Store raw data in TimescaleDB with automatic compression after 7 days and continuous aggregates for dashboards. (5) Only route anomalous readings (temperature out of range, jam events) to real-time alerting. This reduces the active processing load from 833/sec to maybe 10-50 alerts/second.
Q6: How would you design the payment system to be PCI DSS compliant?
PCI compliance is achieved through: (1) Using a certified payment terminal (Ingenico/Verifone) that handles card data in a tamper-resistant secure element — raw card data never touches our firmware. (2) Tokenization — the payment terminal returns a token (e.g., tok_visa_4242) that represents the card, and all subsequent operations use this token. (3) The tokenization and actual card processing happen within the terminal's PCI-certified boundary. (4) Our cloud backend communicates with the payment gateway (Stripe/Adyen) over TLS 1.3 using client certificates. (5) We never log, store, or transmit raw PAN (card number), CVV, or magnetic stripe data. (6) Network segmentation ensures the payment terminal network is isolated from other systems.
Q7: How would you design the predictive restocking system? What ML model would you use?
I'd use a Gradient Boosted Decision Tree (XGBoost or LightGBM) for demand forecasting because it handles tabular data well, trains fast, and is interpretable. Features include: time features (hour, day, month, holiday flags), lagged sales (last 1h, 6h, 24h, 7d), weather data (via API), machine location type, and product category. For each product-machine pair, the model predicts daily sales volume, which we convert to "days until stockout" using current inventory. For route optimization, once we identify which machines need restocking, we solve a variant of the Traveling Salesman Problem (TSP) using Google OR-Tools or Google Maps Distance Matrix API, optimizing for minimum travel time while respecting time windows (restocking can only happen during business hours for some locations).
Q8: How would you handle a network partition where the machine can't reach the payment gateway?
When the cloud backend cannot reach the payment gateway due to a network partition: (1) For card payments, the machine must have a fallback — it can't process cards without the gateway. The machine displays "Card payments temporarily unavailable" and offers cash payment as an alternative. (2) For cash payments, no network is needed — the coin/bill validator operates locally, and the machine dispenses immediately. (3) The machine continues to log the outage as a telemetry event. (4) For advanced deployments, some EMV terminals support offline authorization for small amounts (below a configured floor limit), storing transactions for later settlement. (5) The cloud health-checks the payment gateway every 30 seconds and automatically switches to the backup gateway if the primary is unreachable.
Q9: How would you ensure data consistency between the machine's local state and the cloud's view of inventory?
We use an event-sourced approach: every inventory change on the machine (dispense, restock, expiry removal) generates an immutable event with a monotonically increasing sequence number. The machine sends these events to the cloud, which replays them to reconstruct the current state. The cloud can compare its reconstructed state with the machine's reported state and flag discrepancies. Periodic physical counts (triggered by restocking visits) reset the authoritative inventory count and reconcile any drift. This is similar to how bank ledgers reconcile — the event log is the source of truth, and the current state is derived from it.
Q10: How would you scale the system from 10,000 to 500,000 machines?
Scaling 50x requires changes at every layer: (1) Database: Shard PostgreSQL by operator ID. Move to a separate TimescaleDB cluster for telemetry with a retention policy. (2) Kafka: Increase partition count and add brokers. Partition by machine_id for ordering guarantees. (3) Services: Auto-scale Kubernetes pods based on Kafka consumer lag. (4) MQTT: Use a managed IoT platform (AWS IoT Core, Azure IoT Hub) that scales to millions of devices. (5) Caching: Add more Redis shards, use read replicas for dashboard queries. (6) Multi-region: Deploy in 3+ regions with regional MQTT brokers. (7) Cost: Implement aggressive telemetry compression and aggregation to reduce data volume by 90%.
Q11: How would you test the vending machine system end-to-end?
We'd use a multi-layer testing strategy: (1) Unit tests: State machine transitions, pricing calculations, inventory logic. (2) Integration tests: Payment gateway sandbox (Stripe test mode), MQTT message flow, database operations. (3) Hardware-in-the-loop (HIL) tests: Run firmware on actual ARM boards with simulated coil motors and sensors. (4) Chaos engineering: Kill network connections mid-transaction, simulate power failures, inject sensor faults. (5) Load tests: Simulate 50,000 machines sending concurrent heartbeats and transactions. (6) Fault injection: Payment gateway returns errors, dispense times out, coins jam. The state machine must handle every edge case gracefully. (7) A/B testing: Deploy new firmware to a subset of machines and compare error rates before rolling out fleet-wide.

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

ComponentLines of CodeKey Design Decisions
Enums & Models~80Strong typing for all states and outcomes
VendingMachine (FSM)~280State machine with lock, WAL-compatible transitions
DispensingEngine~803-retry policy, coil reversal, break-beam detection
DynamicPricingEngine~40Time-based and day-based pricing rules
InventoryManager~90Restock, expiry tracking, low-stock alerts
CashManagementService~70Expected 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 CapabilityModel / AlgorithmUpdate FrequencyBusiness Impact
Demand Pattern ClusteringK-Means (scikit-learn)Monthly retrain15-20% revenue lift via better assortment
Assortment OptimizationGreedy + Simulated AnnealingQuarterly10-15% margin improvement per machine
Price ElasticityBayesian Ridge RegressionBi-weekly5-8% revenue lift via dynamic pricing
Sales ForecastingProphet / DeepARDailyStockout rate from 8% to < 1%
Fraud DetectionIsolation ForestDaily scoring2-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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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."

© 2026 Ayodhyya. All rights reserved.

Built for system design practitioners. | ayodhyya.com