Case Study

Vending Machine

Build it 7 times. Each level adds one constraint that breaks your code. By the end, you won't have memorized a design — you'll have discovered it.

7 Levels 15 Think First Challenges 64 SVG Diagrams 12 Interview Q&As C# / .NET
Section 1

A Simple Vending Machine That Teaches You Real Design

Everyone knows how a vending machine works. Insert money, press a button, get your snack. It takes 30 seconds in real life and about 20 lines of code to fake. But what happens when the machine needs to track states? When customers pay with cards instead of coins? When you need to calculate change using the fewest coins possible via a greedy algorithmA problem-solving strategy that always picks the locally best option at each step. For coin change with standard denominations (quarter, dime, nickel, penny), the greedy approach — always use the biggest coin that fits — gives the optimal result.? When 500 machines need a central dashboard? That simple vending machine becomes a real design problem with real patterns hiding inside.

We're going to build this machine 7 times — each time adding ONE constraintA real-world requirement that forces your code to evolve. Each constraint is something the BUSINESS needs, not a technical exercise. "The machine has different states" is a constraint. "Use the State pattern" is not — that's a solution you DISCOVER. that breaks your previous code. You'll feel the pain, discover the fix, and understand WHY the design exists — not just what it looks like. By Level 7, you'll have a complete, production-grade vending machine system. But more importantly, you'll have a set of reusable thinking tools that work for ANY system — elevators, chess, parking lots, anything. These are transferable skillsThe techniques you learn here (What Varies?, What If?, CREATES) work because they target the STRUCTURE of problems, not the domain. Every system has entities, varying algorithms, concurrency risks, and edge cases — regardless of whether it vends snacks or parks cars..

The Constraint Game — 7 Levels

L0: Insert a Coin
L1: Products & Prices
L2: Machine States
L3: Payment Methods
L4: Transactions
L5: Edge Cases
L6: Testability
L7: Scale It

The System Grows — Level by Level

Each level adds one constraint. Here's a thumbnail of how the class diagram expands from 1 class to a full distributed system:

L0 Machine L1 Machine Product Slot L2 IVendingState Idle HasMny Dispns OoS L3 IPaymentStrategy Coin Card NFC L4 Transaction ChangeMaker L5-6 Result<T> IVendingMachine IClock ILogger L7 MachineHub IMachineMonitor RestockEvent 1 3 7 10 14 17 23

What You'll Build

VendingMachine IVendingState Idle | HasMoney Dispensing | OutOfService IPaymentStrategy Coin | Card | NFC Transaction ChangeMaker IMachineMonitor State Strategy Data Flow Observer

System

Production-grade vending machine with state managementThe machine behaves differently depending on its current mode: Idle accepts coins, HasMoney allows product selection, Dispensing rejects new inputs, OutOfService blocks everything. The State pattern encapsulates each mode's rules into its own class., pluggable paymentsCoins, credit cards, and mobile NFC all process payments differently, but the machine doesn't care HOW you pay — it just calls Pay(amount). The Strategy pattern lets you swap payment methods without touching the core machine logic., change calculation, error handling, and DI.

Patterns

StateLets an object change its behavior when its internal state changes. The machine delegates actions (InsertMoney, SelectProduct) to a state object, and each state class defines what's allowed in that mode., StrategyDefines a family of interchangeable algorithms. Payment processing uses IPaymentStrategy — CoinPayment counts physical coins, CardPayment charges a card, NfcPayment taps a phone., ObserverWhen something interesting happens (sale completed, stock low, machine jammed), the machine notifies all registered monitors without knowing who they are. Decouples the machine from dashboards, alerts, and logging., Result<T>A functional error handling pattern that returns either a success value or an error message, instead of throwing exceptions for expected business failures like "insufficient funds" or "out of stock."

Skills

Real-world walkthrough, state machine thinkingModeling a system as a set of states (Idle, HasMoney, Dispensing, OutOfService) with explicit transitions between them. This prevents impossible operations (like selecting a product when no money is inserted) at the type level., "What Varies?" for payments, greedy change algorithms, CREATESThe 7-step interview framework: Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. Works for every LLD problem.

Stats

15 Think Firsts • 64 SVGs • 12 Q&As • 120+ tooltips
~75 min (with challenges) • ~35 min (speed read)

Section 2

Before You Code — See the Real World

Before we touch a single line of code, let's walk up to a real vending machine. Not a simulated one — the kind you see in an office break room or a train station. Watch what happens step by step, and pay attention to every thing (noun) and every action (verb) you encounter. These become your classes, methods, and data types.

Things you probably found: Machine, Display, Product, Slot, Coin slot, Card reader, Keypad, Balance display, Dispense tray, Change tray, Receipt. Actions: Browse, Insert money, Check balance, Select product, Dispense, Return change, Print receipt.

Every thing is a candidate entityIn LLD, an entity is a real-world concept that becomes a class, record, or enum in your code. Products, slots, and transactions are entities because they hold data and have identity.. Every action is a candidate method. This is noun extractionA systematic technique for finding entities: observe the real-world system, highlight every noun, and evaluate which ones need to exist in your code. Not every noun becomes a class — some become fields, some become enums, some are irrelevant. — and it works for ANY system, not just vending machines.

1. Browse πŸͺ Glass display Price labels Slot codes Product, Slot Inventory 2. Pay πŸ’° Coin slot Card reader Balance shown Payment, Balance State: HasMoney 3. Select πŸ”’ Keypad / buttons Enter code: B2 Price check Selection Validation 4. Dispense πŸ₯€ Motor turns Product falls Stock − 1 Dispenser State: Dispensing 5. Change πŸͺ™ Change tray Receipt slot Reset to Idle ChangeMaker Transaction log

Stage 1: Approach & Browse

What you SEE: A glass display showing rows of products — chips, candy, soda, water — each in a labeled slot like A1, A2, B1. Price tags sit below each product. An LED display reads "INSERT MONEY."

What you DO: Scan the products, check prices, decide what you want.

Design insight: We already found 3 nouns — Product (a thing with a name and price), Slot (a position holding a product and a quantity), and Display (shows machine state). Products are pure data — a bag of chips doesn't behave differently from a can of soda. That makes them perfect for a recordIn C#, a record is an immutable reference type perfect for data that never changes after creation. Records auto-generate Equals(), GetHashCode(), and ToString(). A product with name "Chips" and price $1.50 will always be that — it's a fact, not a mutable thing..

What you SEE: A coin slot, a bill acceptor, a contactless card reader, and maybe an NFC pad for mobile payments. As you insert coins, the balance display updates: "$0.25... $0.50... $0.75..."

What you DO: Insert coins one at a time, or tap your card. Watch the balance go up.

Design insight: There are multiple ways to pay — coins, cards, NFC. Each works completely differently under the hood, but the machine doesn't care HOW you paid — it just needs to know you have enough balance. When the same action (paying) has multiple interchangeable implementations, there's a pattern for that. Also notice: the machine changed modes. It went from "waiting for money" to "has money." That's a state transitionWhen an object moves from one behavioral mode to another. The machine goes from Idle (waiting) to HasMoney (ready for selection). Each state defines completely different rules about what operations are allowed..

What you SEE: A keypad with letters and numbers. You press "B" then "2" to select the product in slot B2. The display shows "B2 — Chips $1.50."

What you DO: Enter the slot code. The machine checks: does B2 exist? Is there stock? Do you have enough balance?

Design insight: Selection involves three checks: valid slot, in stock, sufficient balance. If any check fails, the machine stays in "has money" mode — it doesn't eat your money. The validation logic is a chain of conditions that gate whether dispensing can proceed. Also, what happens if you press a button without inserting money? The machine should reject the action entirely. Behavior depends on state.

What you SEE: A motor whirs, the coil turns, and the product drops into the pickup tray at the bottom. The display says "DISPENSING..."

What you DO: Wait a moment, then reach into the tray to grab your snack.

Design insight: During dispensing, the machine is in yet another mode. You can't insert more money, you can't select another product, and pressing "cancel" does nothing. The inventoryThe count of each product remaining in each slot. When a product is dispensed, the inventory for that slot decreases by one. When it hits zero, the slot is "out of stock." An admin must physically refill it. for that slot decreases by one. And what if the product gets stuck? That's an edge case we'll handle in Level 5.

What you SEE: Coins clatter into the change tray. A receipt pops out (optional). The display resets to "INSERT MONEY."

What you DO: Grab your change and receipt, then walk away.

Design insight: Change calculation isn't trivial — if you paid $2.00 for a $1.35 item, the machine owes $0.65. But it needs to give that using the fewest coins possible (two quarters, a dime, and a nickel — not 65 pennies). That's a greedy algorithmA problem-solving approach that makes the locally optimal choice at each step. For change-making with standard US coin denominations, always pick the largest coin that fits — this greedy approach happens to give the optimal (fewest coins) solution.. And notice: the machine returned to its initial state. The full cycle is Idle → HasMoney → Dispensing → Idle. A transactionA complete record of one purchase: which product, what was paid, how much change given, and when it happened. Transactions are immutable facts — once completed, they never change. Perfect for a record type. log captures everything that just happened.

What We Discovered

REAL WORLD CODE The vending machine VendingMachine (class) Chips, soda, candy Product (record) Quarter, dime, nickel Coin (enum) Idle / Has Money / etc. IVendingState (State) Coin / Card / NFC IPaymentStrategy

Hidden Design Concerns

Vending Walkthrough State Transitions Idle → HasMoney → Dispensing Payment Strategies Coin | Card | NFC Edge Cases Stuck product, no change, power loss Change Algorithm Fewest coins, denomination limits
Discovery Real World Code Type
MachineThe vending machine itselfVendingMachineclass (mutable state)
ProductChips, soda, candy, waterProductrecord (immutable data)
SlotPosition A1, B2 with quantitySlotclass (has stock count)
CoinQuarter, dime, nickel, pennyCoinenum (fixed denominations)
Machine StateIdle / HasMoney / DispensingIVendingStateinterface (State pattern)
Payment MethodCoins, card, NFCIPaymentStrategyinterface (Strategy pattern)
TransactionOne completed purchaseTransactionrecord (immutable fact)
ChangeCoins returned after purchaseChangeMakerclass (greedy algorithm)

Skill Unlocked: Real-World Walkthrough

Walk through the physical system before coding. List every noun (= entity) and verb (= method). This gives you a requirements checklist AND a starter class diagram — for free. Works for vending machines, parking lots, elevators, chess, anything.

Section 3 🟒 EASY

Level 0 — Insert a Coin, Get a Drink

Constraint: "A customer inserts coins and gets a drink. One product, one price, no change."
This is where it all begins. The simplest possible version — one product, fixed price, accept coins, dispense when the balance is enough. No different products, no states, no change calculation. We'll feel the pain of missing features soon enough.

Every complex system starts with a laughably simple version. For a vending machine, that means: accept money, check if it's enough, and dispense one fixed product. That's it. No variety, no states, no change. The goal of Level 0 is to get something working — then let the next constraint break it.

Think First #2

What's the simplest data structure for a vending machine that accepts money and dispenses one product? How many classes do you need? Take 60 seconds.

60 seconds — try it before peeking.

Just one class. A VendingMachine with a _balance field, a hardcoded price, and a stock count. Two methods: InsertCoin() to add money and SelectProduct() to dispense. ~15 lines total. Resist the urge to create Product, Payment, and Dispenser classes — Level 0 doesn't need them yet.

Your Internal Monologue

"Simplest possible... one product, fixed price, coins only. I literally just need: accept money, check if enough, dispense. A single class with a balance field and two methods. That's it."

"Should I create a Product class? A Payment class? A Dispenser class? ...No. There's ONE product with ONE price. Splitting that into three classes would be premature abstractionCreating abstractions (interfaces, base classes, separate classes) before you have a concrete reason to. At Level 0, a Product class adds complexity without solving any problem. Wait until a constraint FORCES you to separate concerns.. I'll keep it all in one class and let the constraints tell me when to split."

"For the balance — decimal not double. Money should always use decimalIn C#, decimal is a 128-bit type designed for financial calculations. Unlike double (which uses binary floating-point and can produce rounding errors like 0.1 + 0.2 = 0.30000000000000004), decimal uses base-10 arithmetic — perfect for money. to avoid floating-point rounding errors."

How Level 0 Works

InsertCoin(0.25) balance += 0.25 balance = $1.50 enough! balance >= price? yes Dispensed! Enjoy! stock--, balance -= price no "Insert $X more"

What Would You Do?

OverEngineered.cs
// Three classes for a single-product machine? Overkill.
public class Product { public string Name; public decimal Price; }
public class PaymentProcessor { public decimal Balance; public void Accept(decimal amount) => Balance += amount; }
public class Dispenser { public int Stock; public bool Dispense() { Stock--; return true; } }

public class VendingMachine
{
    private readonly Product _product = new() { Name = "Drink", Price = 1.50m };
    private readonly PaymentProcessor _payment = new();
    private readonly Dispenser _dispenser = new() { Stock = 10 };
    // ... orchestration code to wire them together ...
}
The catch: Three classes and none of them earn their existence. PaymentProcessor just wraps a single decimal. Dispenser just wraps an int. At Level 0, this is ceremony without purpose. The abstractions will come — but only when a constraint forces them.
VendingMachine.cs — Level 0
public class VendingMachine
{
    private decimal _balance;
    private const decimal Price = 1.50m;
    private int _stock = 10;

    public void InsertCoin(decimal amount)
    {
        _balance += amount;
    }

    public string SelectProduct()
    {
        if (_stock <= 0) return "Out of stock";
        if (_balance < Price) return $"Insert {Price - _balance:C} more";
        _balance -= Price;
        _stock--;
        return "Dispensed! Enjoy your drink.";
    }
}
Why this wins: ~15 lines. One class, two methods, zero abstractions. Every line earns its place. When Level 1 adds multiple products, then we'll extract a Product type — because the constraint demands it, not because we imagined we'd need it.

Here's the complete Level 0 code. Read every line — there are only 15 of them.

VendingMachine.cs — Level 0
public class VendingMachine
{
    private decimal _balance;
    private const decimal Price = 1.50m;
    private int _stock = 10;

    public void InsertCoin(decimal amount)
    {
        _balance += amount;
    }

    public string SelectProduct()
    {
        if (_stock <= 0) return "Out of stock";
        if (_balance < Price) return $"Insert {Price - _balance:C} more";
        _balance -= Price;
        _stock--;
        return "Dispensed! Enjoy your drink.";
    }
}

Let's walk through what each piece does:

15 lines. It works for a toy machine. But can you spot what's missing? There's no concept of different products, no state management, no change calculation, no payment options, and the balance never resets after dispensing. We'll feel each of these pains in the coming levels.

Growing Diagram — After Level 0

Class Diagram — Level 0
Class diagram after Level 0 — single VendingMachine class VendingMachine - _balance : decimal - Price : decimal (const 1.50) - _stock : int + InsertCoin(amount) : void + SelectProduct() : string 1 class — the simplest starting point

Before This Level

You see "vending machine" and think "this needs 10 classes and 3 design patterns."

After This Level

You know that Level 0 is SUPPOSED to be embarrassingly simple. Complexity comes from constraints, not imagination.

Transfer: This "start with the dumbest thing that works" approach isn't unique to vending machines. In a Parking Lot, Level 0 is: one lot, park a car, return a fee. In Tic-Tac-Toe: one board, place a mark. In an Elevator: go to a floor. The pattern is universal — build the skeleton first, then let constraints shape the design.
Section 4 🟒 EASY

Level 1 — Products & Prices

New Constraint: "The machine has 10 different products — chips, soda, water, candy — each with a different price. Products are organized in slots with codes like A1, A2, B1."
What Breaks?

Level 0 has ONE product with a hardcoded price. There's no concept of different products, different prices, or slot positions. If you wanted chips AND soda, you'd need to copy-paste the entire VendingMachine class — one for each product. The const decimal Price = 1.50m is baked into the code. There's literally no way to sell a $0.75 water AND a $2.00 soda without rewriting everything.

A real vending machine has rows of different products at different prices. Our Level 0 code treats the entire machine as a single-product dispenser — like a gumball machine. To support variety, we need to model what a product IS, where it lives, and how to look it up by slot code.

Think First #3

Products have a name, price, and slot code. Should Product be a class with inheritance (ChipsProduct, SodaProduct) or a flat data type? What's the deciding factor?

60 seconds — think about behavior vs. data.

Ask yourself: "Do chips behave differently from soda?" Does a bag of chips have a Dispense() method that works differently from a can of soda? No — the machine dispenses them all the same way. Products differ only in data (name, price, category), not behavior. When types differ only in data, use a recordA C# record is a lightweight immutable data type. It auto-generates equality comparisons, hashing, and a readable ToString(). Perfect for "data bags" like products where you care about WHAT it is, not what it DOES. or a plain class — never inheritance. Inheritance is for behavioral differences.

Your Internal Monologue

"Multiple products... I need to store them somewhere. A Product has name, price, category. Do these products behave differently? Does a bag of chips dispense differently from a can of soda? No — the machine dispenses them all the same way. They're just data."

"So Product should be a record, not a class with inheritance. And slots map codes to products. A DictionaryA key-value lookup structure in C#. Dictionary<string, Slot> lets you look up a Slot by its code (like "A1" or "B3") in constant time — exactly what you need when a customer presses a button. from slot code to Slot should work. Customer presses 'B2', we look up _slots["B2"] — instant."

"Do I need a ChipsSlot vs. SodaSlot? Same question: do slots behave differently based on product type? No. Every slot holds a product and has a quantity. One Slot class for all."

The Slot Grid

Col 1 Col 2 Col 3 A A1: Chips $1.50 • qty: 8 πŸ₯” A2: Soda $2.00 • qty: 10 πŸ₯€ A3: Water $0.75 • qty: 12 πŸ’§ B B1: Candy $1.25 • qty: 15 🍬 B2: Cookies $1.75 • qty: 6 πŸͺ B3: Juice $2.25 • qty: 0 πŸ§ƒ OUT OF STOCK Each slot = code + product + quantity _slots["B2"] → Slot { Product = Cookies, Qty = 6 }

Record or Class? The Decision Tree

Has different behavior? yes Class + inherit no Has identity? yes Class (no inherit) no Record Product lives here!

What Would You Do?

ClassHierarchy.cs
public abstract class Product { public abstract string Name { get; } public abstract decimal Price { get; } }
public class ChipsProduct : Product { public override string Name => "Chips"; public override decimal Price => 1.50m; }
public class SodaProduct : Product { public override string Name => "Soda"; public override decimal Price => 2.00m; }
public class WaterProduct : Product { public override string Name => "Water"; public override decimal Price => 0.75m; }
// Adding a new product = new class. 50 products = 50 classes. Yikes.
Dead end: Chips and Soda have ZERO behavioral differences. They're pure data. Creating a class per product is like creating a class per row in a database table — it doesn't solve any problem and creates massive ceremony. 50 products = 50 classes.
StringsEverywhere.cs
// Parallel arrays β€” fragile and error-prone
string[] names = { "Chips", "Soda", "Water" };
decimal[] prices = { 1.50m, 2.00m, 0.75m };
int[] quantities = { 8, 10, 12 };

// Accessing product 1: names[1], prices[1], quantities[1]
// Add a product? Update ALL THREE arrays in sync.
// Delete one entry? Now indices are wrong. Welcome to Bug City.
Fragile: Parallel arrays mean data for one product is scattered across three separate collections. Delete the wrong index from one array and you've silently corrupted all products. There's no compiler protection — it's all index-based hope.
RecordsAndSlots.cs
public readonly record struct Product(string Name, decimal Price, string Category);

public class Slot
{
    public string Code { get; }
    public Product Product { get; }
    public int Quantity { get; private set; }

    public Slot(string code, Product product, int quantity)
        => (Code, Product, Quantity) = (code, product, quantity);

    public bool HasStock => Quantity > 0;
    public void Decrement() => Quantity--;
}

// Usage: _slots["B2"].Product.Price β†’ $1.75
Why this wins: Product is a record structA value type that lives on the stack (no heap allocation), is immutable by default, and auto-generates equality + ToString(). Perfect for small, frequently-used data like products. The readonly keyword guarantees immutability. — lightweight, immutable, value-based equality. Slot wraps a Product with mutable quantity. Adding a new product is one line of data, not a new class. The Dictionary gives instant lookup by code.

Here's the updated VendingMachine that supports multiple products and slots.

Product.cs
/// A product is pure data β€” name, price, category.
/// No behavior, no identity, no mutability. A perfect record.
public readonly record struct Product(string Name, decimal Price, string Category);
Slot.cs
/// A slot is a physical position in the machine.
/// It holds a product and tracks how many are left.
public class Slot
{
    public string Code { get; }
    public Product Product { get; }
    public int Quantity { get; private set; }

    public Slot(string code, Product product, int quantity)
        => (Code, Product, Quantity) = (code, product, quantity);

    public bool HasStock => Quantity > 0;
    public void Decrement() => Quantity--;
}
VendingMachine.cs — Level 1
public class VendingMachine
{
    private decimal _balance;
    private readonly Dictionary<string, Slot> _slots = new();

    public VendingMachine()
    {
        // Load products into slots
        _slots["A1"] = new Slot("A1", new Product("Chips", 1.50m, "Snacks"), 8);
        _slots["A2"] = new Slot("A2", new Product("Soda", 2.00m, "Drinks"), 10);
        _slots["A3"] = new Slot("A3", new Product("Water", 0.75m, "Drinks"), 12);
        _slots["B1"] = new Slot("B1", new Product("Candy", 1.25m, "Snacks"), 15);
        _slots["B2"] = new Slot("B2", new Product("Cookies", 1.75m, "Snacks"), 6);
    }

    public void InsertCoin(decimal amount) => _balance += amount;

    public string SelectProduct(string slotCode)
    {
        if (!_slots.TryGetValue(slotCode, out var slot))
            return $"Invalid slot: {slotCode}";
        if (!slot.HasStock)
            return $"{slot.Product.Name} is out of stock";
        if (_balance < slot.Product.Price)
            return $"Insert {slot.Product.Price - _balance:C} more";

        _balance -= slot.Product.Price;
        slot.Decrement();
        return $"Dispensed {slot.Product.Name}! Enjoy.";
    }
}

Let's walk through the key changes from Level 0:

Growing Diagram — After Level 1

Class Diagram — Level 1
VendingMachine - _balance : decimal - _slots : Dict<string,Slot> + InsertCoin(amount) + SelectProduct(slotCode) Slot + Code : string + Product : Product + Quantity : int + HasStock, Decrement() «record» Product Name, Price, Category has * holds 3 types — VendingMachine + Slot + Product

Before This Level

You see "different types" and reach for class inheritance.

After This Level

You ask "do they BEHAVE differently?" If no — records/enums. Inheritance is for behavior, not categories.

Smell → Pattern: "Categories Without Behavior" — when types differ only in data, not in actions → use records/enums, not class hierarchies. Inheritance is the most abused tool in OOP. Save it for when subclasses genuinely override methods.
Transfer: This exact decision appears everywhere. In a Parking Lot, does a Motorcycle park() differently from a Car? No — the LOT parks them differently based on spot size. So Vehicle is a record, not a class hierarchy. In a Library, does a Fiction book checkout() differently from a Non-Fiction book? No — they're just data. Use records.
Section 5 🟑 MEDIUM

Level 2 — Machine States

New Constraint: "The machine has modes: Idle, HasMoney, Dispensing, OutOfService. You can't dispense if no money was inserted. You can't insert money if the machine is out of service."
What Breaks?

Level 1's SelectProduct() has no state awareness. A customer can press a button without inserting money and the code just says "Insert $X more" — which is fine for that case. But what about more serious problems?

  • What if the machine is currently dispensing (the motor is running)? A customer shouldn't be able to start a new selection mid-dispense.
  • What if a technician puts the machine in out-of-service mode for refilling? The coin slot should reject money entirely.
  • What if someone presses "cancel" to get their money back? That operation makes sense in HasMoney state but not in Idle state (nothing to cancel).

Every method would need if (_state == ...) checks for every state. 4 states × 4 methods = 16 branches. Add a "Maintenance" mode? Touch ALL 4 methods. That's a maintenance nightmare.

Here's the core insight: the vending machine behaves completely differently depending on what mode it's in. When it's idle, inserting a coin transitions it to "has money." When it's dispensing, inserting a coin should be rejected. When it's out of service, EVERYTHING should be rejected. The same action produces different results depending on the current state.

When you find yourself writing if (state == X) doThis; else if (state == Y) doThat; in every single method, that's a code smell with a name. Let's discover the fix.

Think First #4

The machine behaves completely differently depending on its current mode. InsertCoin() in Idle state accepts money. InsertCoin() in Dispensing state should reject it. Adding a "Maintenance" mode should require ZERO changes to existing code. How would you design this?

90 seconds — this one's worth thinking hard about.

Each state becomes its own class that implements the full set of operations. The machine delegates every action to its current state object. IdleState.InsertMoney() accepts the coin and transitions to HasMoneyState. DispensingState.InsertMoney() rejects it and stays put. Adding "Maintenance" means writing ONE new class — zero changes to existing states. This is the State patternA behavioral design pattern where an object delegates its behavior to a state object. Instead of checking "if state == X" everywhere, you replace the state object itself. Each state class encapsulates ALL the rules for that mode, keeping the logic clean and extensible..

Your Internal Monologue

"I could add if (_state == State.Idle) checks to every method... but that means every new state adds a new branch to every method. 4 states × 4 methods = 16 branches. Add 'Maintenance' mode? Touch all 4 methods. That violates OCPThe Open-Closed Principle: classes should be open for extension but closed for modification. Adding new behavior should mean adding new code (a new class), not modifying existing code (editing switch statements in existing methods).. And it violates SRPThe Single Responsibility Principle: each class should have one reason to change. With switch statements, the VendingMachine class changes whenever ANY state's logic changes. With the State pattern, each state class has one reason to change — its own rules. too — the machine class changes whenever ANY state's rules change."

"Wait — the behavior DEPENDS on state. Each state is its own complete behavior set. IdleState knows what to do with coins (accept them). DispensingState knows what to do with coins (reject them). Each state is a class that implements all the methods. That's literally the State pattern."

"The machine just holds a reference to its current state and delegates everything. When the state changes, we swap the object. The machine code itself never changes — only which state object it's pointing to."

The State Machine

Every vending machine has exactly four modes. Here's how they connect — each arrow is a user action or system event that causes a transition:

Idle Waiting for customer HasMoney Balance > 0, awaiting selection Dispensing Motor running, product falling OutOfService Refilling / maintenance InsertCoin() SelectProduct() dispense complete + change Cancel() — refund admin disable admin enable more coins Each state defines WHAT IS ALLOWED. Invalid actions are simply ignored. Add "Maintenance" state = 1 new class, 0 changes to existing code.

Full Purchase Sequence

Idle insert $ HasMoney select B2 Dispensing motor done Idle t=0 t=3s t=5s t=8s One complete purchase cycle — state flows left to right

States Block Invalid Operations

DispensingState ✗ InsertMoney() — rejected ✗ SelectProduct() — rejected ✗ Cancel() — rejected ✓ DispenseProduct() — allowed! HasMoneyState ✓ InsertMoney() — adds more ✓ SelectProduct() — if enough $ ✓ Cancel() — refund ✗ DispenseProduct() — rejected

What Would You Do?

EnumSwitch.cs
enum MachineState { Idle, HasMoney, Dispensing, OutOfService }

public void InsertCoin(decimal amount)
{
    switch (_state)
    {
        case MachineState.Idle:
            _balance += amount;
            _state = MachineState.HasMoney;
            break;
        case MachineState.HasMoney:
            _balance += amount;
            break;
        case MachineState.Dispensing:
            Console.WriteLine("Please wait...");
            break;
        case MachineState.OutOfService:
            Console.WriteLine("Machine is out of service");
            break;
    }
}
// Now repeat this switch in SelectProduct(), Cancel(), Dispense()...
// 4 states Γ— 4 methods = 16 switch branches. Add one state = edit 4 methods.
Grows linearly: Every new state adds a new case to EVERY method. "Maintenance" mode? Edit InsertCoin(), SelectProduct(), Cancel(), and Dispense(). Miss one and you have a silent bugA bug that doesn't crash the program but produces wrong behavior. If you forget to add the Maintenance case to Cancel(), someone could cancel a transaction on a machine being serviced — no error message, just wrong behavior..
BooleanFlags.cs
private bool _isIdle = true;
private bool _hasMoney = false;
private bool _isDispensing = false;
private bool _isOutOfService = false;

public void InsertCoin(decimal amount)
{
    if (_isOutOfService) { Console.WriteLine("Out of service"); return; }
    if (_isDispensing) { Console.WriteLine("Please wait..."); return; }
    _balance += amount;
    _isIdle = false;
    _hasMoney = true;
}
// Problem: what if _isIdle AND _hasMoney are BOTH true?
// That's an impossible state, but the compiler can't prevent it.
// Booleans can't express "exactly one of these is true."
Impossible states: With 4 booleans, there are 24 = 16 combinations, but only 4 are valid. The other 12 are illegal statesA combination of field values that should be impossible but isn't prevented by the type system. If _isIdle and _isDispensing are both true, the machine is in a contradictory mode. Booleans can't express mutual exclusion — only one should be true at a time. that the compiler can't catch. A single misplaced assignment and the machine is simultaneously idle AND dispensing.
StatePattern.cs
// Each state is a CLASS that knows its own rules.
// The machine just delegates to the current state.
machine.State.InsertMoney(machine, amount);

// IdleState.InsertMoney β†’ accept, transition to HasMoney
// HasMoneyState.InsertMoney β†’ accept, stay in HasMoney
// DispensingState.InsertMoney β†’ reject (motor is running)
// OutOfServiceState.InsertMoney β†’ reject (out of service)

// Add "Maintenance" mode?
// 1. Create MaintenanceState.cs (new file)
// 2. Done. Zero changes to existing states.
Why this wins: Each state encapsulates ALL its rules. No switch statements, no booleans. Impossible states are literally impossible — the machine is always in exactly one state. Adding a new state is adding a new class, zero changes to existing code. That's OCP in action.

Enum+Switch vs. State Pattern

Enum + Switch InsertCoin(): Idle HasMny Dispns OoS SelectProduct(): Cancel(): 16 branches (4 methods × 4 states) +1 state = edit ALL 4 methods State Pattern IdleState all 4 methods in one place HasMoneyState all 4 methods in one place DispensingState all 4 methods in one place OutOfServiceState all 4 methods in one place 4 classes (1 per state) +1 state = 1 new class, 0 edits

Here's the State pattern applied to our vending machine. Each state is its own class with all four methods. The machine delegatesDelegation means passing responsibility to another object. Instead of the machine deciding what to do with a switch, it says "hey current state, YOU handle this." The machine doesn't know or care which state it's in — it just forwards the call. every action to its current state, and each method returns the next state — a state transitionThe act of moving from one state to another. Returning new HasMoneyState() from IdleState.InsertMoney() means "after this action, the machine is now in HasMoney mode." Returning this means "stay in the current state." — to move to.

IVendingState.cs
/// Every state must implement these four operations.
/// Each method returns the NEXT state to transition to.
/// If the operation isn't allowed, return 'this' (stay put).
public interface IVendingState
{
    IVendingState InsertMoney(VendingMachine machine, decimal amount);
    IVendingState SelectProduct(VendingMachine machine, string slotCode);
    IVendingState DispenseProduct(VendingMachine machine);
    IVendingState Cancel(VendingMachine machine);
}

The interface is the contract. Every state class must define what happens for all four actions. The return type IVendingState is the key insight — each method says "after this action, the machine should be in THIS state." If the action is invalid, just return this (no transition).

IdleState.cs
/// Idle: the machine is waiting for a customer.
/// Only InsertMoney makes sense here β€” everything else is a no-op.
public sealed class IdleState : IVendingState
{
    public IVendingState InsertMoney(VendingMachine machine, decimal amount)
    {
        machine.AddBalance(amount);
        return new HasMoneyState();  // Transition: Idle β†’ HasMoney
    }

    public IVendingState SelectProduct(VendingMachine m, string code)
        => this;  // Can't select without money β€” stay Idle

    public IVendingState DispenseProduct(VendingMachine m)
        => this;  // Nothing to dispense β€” stay Idle

    public IVendingState Cancel(VendingMachine m)
        => this;  // Nothing to cancel β€” stay Idle
}

The Idle state accepts money (transitioning to HasMoney) and ignores everything else. No if-statements needed. The state is the condition.

HasMoneyState.cs
/// HasMoney: customer has inserted coins, waiting for selection.
/// InsertMoney adds more. SelectProduct checks and transitions. Cancel refunds.
public sealed class HasMoneyState : IVendingState
{
    public IVendingState InsertMoney(VendingMachine machine, decimal amount)
    {
        machine.AddBalance(amount);
        return this;  // Stay in HasMoney β€” balance increases
    }

    public IVendingState SelectProduct(VendingMachine machine, string slotCode)
    {
        var slot = machine.GetSlot(slotCode);
        if (slot is null || !slot.HasStock)
            return this;  // Invalid or out of stock β€” stay HasMoney
        if (machine.Balance < slot.Product.Price)
            return this;  // Not enough money β€” stay HasMoney

        machine.SetSelectedSlot(slot);
        return new DispensingState();  // Transition: HasMoney β†’ Dispensing
    }

    public IVendingState DispenseProduct(VendingMachine m)
        => this;  // Can't dispense without selecting β€” stay HasMoney

    public IVendingState Cancel(VendingMachine machine)
    {
        machine.RefundBalance();
        return new IdleState();  // Transition: HasMoney β†’ Idle (refund)
    }
}

HasMoney is the richest state — it handles adding more coins, validating selections, and cancellations. Notice how the three checks (valid slot, in stock, enough balance) are right here in the state class. No switch needed.

DispensingState.cs
/// Dispensing: the motor is running, product is being delivered.
/// ONLY DispenseProduct (motor completion) is valid. Everything else blocked.
public sealed class DispensingState : IVendingState
{
    public IVendingState InsertMoney(VendingMachine m, decimal amount)
        => this;  // Motor is running β€” can't accept money

    public IVendingState SelectProduct(VendingMachine m, string code)
        => this;  // Motor is running β€” can't start new selection

    public IVendingState DispenseProduct(VendingMachine machine)
    {
        var slot = machine.SelectedSlot!;
        slot.Decrement();
        machine.DeductBalance(slot.Product.Price);
        // TODO: calculate change in Level 4
        return new IdleState();  // Transition: Dispensing β†’ Idle
    }

    public IVendingState Cancel(VendingMachine m)
        => this;  // Can't cancel mid-dispense β€” motor already running
}

Dispensing is the most restrictive state — only the dispense-completion event is allowed. This prevents a customer from inserting coins while the motor is running or selecting another product mid-delivery. The state enforces the rule automatically.

OutOfServiceState.cs
/// OutOfService: technician is refilling or performing maintenance.
/// NOTHING is allowed except admin re-enabling the machine.
public sealed class OutOfServiceState : IVendingState
{
    public IVendingState InsertMoney(VendingMachine m, decimal amount)
        => this;  // Machine is out of service

    public IVendingState SelectProduct(VendingMachine m, string code)
        => this;  // Machine is out of service

    public IVendingState DispenseProduct(VendingMachine m)
        => this;  // Machine is out of service

    public IVendingState Cancel(VendingMachine m)
        => this;  // Machine is out of service

    // Admin calls machine.Enable() which sets state to IdleState
}

The simplest state — reject everything. An admin can call machine.Enable() externally to transition back to IdleState. The beauty is that this entire class can be written without touching any other state.

Growing Diagram — After Level 2

Class Diagram — Level 2
VendingMachine - _state : IVendingState - _balance, _slots delegates all actions to _state Slot Product «interface» IVendingState InsertMoney, SelectProduct, Dispense, Cancel has 1 IdleState HasMoneyState DispensingState OutOfServiceState 7 types — Machine + Slot + Product + IVendingState + 4 states +4 new

Before This Level

You see "different modes" and write switch statements in every method.

After This Level

You smell "mode-dependent behavior" and instinctively reach for the State pattern — each mode is its own class.

Smell → Pattern: "Mode-Dependent Behavior" — when an object acts completely differently based on its current state, and you see if (state == X) proliferating across multiple methods → State patternReplace the conditional with polymorphism. Each state becomes a class. The context object (VendingMachine) holds a reference to the current state and delegates all actions. Transitions happen by swapping the state object.. Each mode becomes a class. Adding a new mode means adding a new class with zero changes to existing code.
Transfer: This exact pattern appears in: Order systems (Pending → Paid → Shipped → Delivered — you can't ship a pending order), Traffic lights (Red → Green → Yellow — each color defines which lanes move), Document editors (Draft → Review → Published — you can't edit a published doc without going back to Draft), Game engines (Menu → Playing → Paused → GameOver). Whenever you see distinct modes with different rules, the State pattern is your tool.
Section 6

Level 3 — Payment Methods 🟡 MEDIUM

New Constraint: "Customers can pay with coins, credit cards, or mobile payments (Apple Pay, Google Pay)."
What breaks: Level 2 assumes coins only. InsertMoney(decimal amount) works great when someone drops quarters into a slot — but what happens when a customer taps a credit card? There's no card authorization flow. No NFC handshake for Apple Pay. And if the transaction fails, refunding coins is simple (push them back out), but refunding a card charge is a completely different process. If you start adding if (paymentType == "card") checks everywhere, every new payment method means touching all the existing code. That's a maintenance nightmare waiting to happen.

Three ways to pay, but the vending machine does the same thing every time: charge the customer and (if something goes wrong) refund them. Adding cryptocurrency tomorrow should require zero changes to existing payment code. What pattern lets you swap out the "how" while keeping the "what" the same?

Hint: think about what varies vs. what stays constant. The machine always needs to charge and refund. The mechanism of charging and refunding is what differs.

Your inner voice:

"Coins, cards, mobile... What varies? The how of payment — not the what. The vending machine doesn't care HOW it gets paid, just THAT it gets paid. Whether someone inserts quarters, taps a Visa, or waves their phone — the end result is the same: money moves."

"I could use a switch statement... switch(type) { case Coin: ... case Card: ... } But then adding crypto means editing that switch. And the switch would be in multiple places — charge AND refund AND display. That's a mess."

"Wait — same interface, different implementations. That's StrategyThe Strategy pattern defines a family of algorithms (like different payment methods), puts each one in its own class, and makes them interchangeable. The code that USES the strategy doesn't know which one it's talking to — it just calls the interface methods. Adding a new strategy means creating a new class, not editing existing ones.. An IPaymentStrategy with Charge() and Refund(). Each payment method is its own class. The vending machine holds a reference to any strategy and just calls Charge() — it doesn't know or care whether that's coins clanking or bytes flying."

What Would You Do?

Three developers, three approaches to "multiple payment types." Read the dead ends first — understanding why they fail is half the lesson.

The idea: Add a PaymentType enum and switch on it everywhere — in the charge method, the refund method, and the display method.

SwitchApproach.cs — branching on payment type
public PaymentResult Charge(PaymentType type, decimal amount)
{
    switch (type)
    {
        case PaymentType.Coin:
            if (_insertedCoins < amount)
                return PaymentResult.Fail("Insert more coins");
            _insertedCoins -= amount;
            return PaymentResult.Success(_insertedCoins);

        case PaymentType.Card:
            var auth = CreditCardGateway.Authorize(amount);
            if (!auth.Approved) return PaymentResult.Fail("Card declined");
            return PaymentResult.Success(0);

        case PaymentType.Mobile:
            var tap = NfcService.ProcessTap(amount);
            if (!tap.Ok) return PaymentResult.Fail("Tap failed");
            return PaymentResult.Success(0);

        // Adding crypto? Edit THIS method + Refund() + Display()...
    }
}

Verdict: This violates the Open/Closed PrincipleSoftware entities should be open for extension (you can add new behavior) but closed for modification (you don't have to change existing code). A switch statement forces you to modify existing code every time you add a new case — exactly what OCP says not to do.. Every new payment method means editing every switch statement in the codebase — Charge(), Refund(), DisplayName(), validation. With 3 methods and 4 payment types, that's 12 places to update. Miss one? Silent bug.

When IS a switch actually OK? For a small, truly fixed set — like mapping HTTP status codes to messages. Those don't grow. Payment methods do.

The idea: Make CardPayment extend CoinPayment. They're both payments, right? So inheritance makes sense... right?

InheritanceApproach.cs — forcing a hierarchy
public class CoinPayment
{
    protected decimal _inserted;
    public void AcceptCoin(decimal coin) => _inserted += coin;
    public virtual PaymentResult Charge(decimal amount) { ... }
}

// Card inherits from Coin?! Cards don't have "inserted coins"...
public class CardPayment : CoinPayment
{
    // _inserted makes no sense here
    // AcceptCoin() makes no sense for a credit card
    // We'd have to override AND ignore the parent's logic
    public override PaymentResult Charge(decimal amount)
    {
        // Completely different logic β€” nothing reused from parent
        return CreditCardGateway.Authorize(amount);
    }
}

Verdict: A credit card is NOT a "kind of" coin payment. They share no implementation details. CardPayment inherits AcceptCoin() and _inserted — methods and fields that make zero sense for a card. This is a classic LSP violationThe Liskov Substitution Principle says: if you replace a parent with its child, the program should still make sense. If CoinPayment has AcceptCoin() and CardPayment inherits it but the method is meaningless for cards, then CardPayment can't truly substitute for CoinPayment. The hierarchy is a lie. — the hierarchy is a lie. Inheritance means "is a." A credit card is NOT a coin.

The idea: Define an IPaymentStrategy interface with two methods: Charge() and Refund(). Each payment type is its own class that implements the interface. The vending machine only knows the interface — it never knows (or cares) which concrete payment it's talking to.

StrategyApproach.cs — same interface, different implementations
public interface IPaymentStrategy
{
    PaymentResult Charge(decimal amount);
    PaymentResult Refund(decimal amount);
    string DisplayName { get; }
}

// Machine only knows the interface β€” not the concrete type
public void ProcessPayment(IPaymentStrategy payment, decimal price)
{
    var result = payment.Charge(price);
    if (!result.IsSuccess)
        Console.WriteLine(result.Error);
    else
        DispenseProduct();
}

// Adding crypto? Just create a new class. Zero changes elsewhere.
machine.ProcessPayment(new CryptoPayment(wallet), 1.50m);

Verdict: This is the winner. Each payment method encapsulates its own charge/refund logic. The vending machine calls Charge() and Refund() without knowing whether it's coins clanking or bytes flying. Adding a new payment type? Create one new class. No existing code changes. The Strategy patternThe Strategy pattern encapsulates a family of algorithms behind a common interface, making them interchangeable at runtime. The client code depends on the interface, not the implementations. New strategies can be added without modifying existing code — perfect OCP compliance. makes the machine future-proof.

Decision Compass: "Will the algorithm change independently from the code that uses it?" → Strategy. Same interface, different guts.

The Solution

The Strategy patternA behavioral design pattern that lets you define a family of algorithms, put each one in a separate class, and make their objects interchangeable. The key insight: the code that USES the algorithm only depends on the interface, so you can swap implementations at runtime without changing the caller. gives each payment method its own class. They all speak the same language — IPaymentStrategy — but each one implements Charge() and Refund() in its own way. Coins track a running balance. Cards call an authorization gateway. Mobile payments talk to an NFC service. The vending machine doesn't care which one it's using.

IPaymentStrategy.cs — the contract every payment method signs
// Every payment method must be able to do two things:
// 1. Charge the customer a specific amount
// 2. Refund the customer if something goes wrong
public interface IPaymentStrategy
{
    PaymentResult Charge(decimal amount);
    PaymentResult Refund(decimal amount);
    string DisplayName { get; }
}

// The result of any payment operation β€” success or failure, never ambiguous
public sealed record PaymentResult
{
    public bool IsSuccess { get; }
    public decimal RemainingBalance { get; }
    public string? Error { get; }

    private PaymentResult(bool success, decimal balance, string? error)
        => (IsSuccess, RemainingBalance, Error) = (success, balance, error);

    public static PaymentResult Success(decimal remaining)
        => new(true, remaining, null);

    public static PaymentResult Fail(string error)
        => new(false, 0, error);
}

This is the contract. Every payment method — coins, cards, mobile, future crypto — must implement Charge() and Refund(). The PaymentResult record ensures the caller always gets a clear answer: it worked (here's the remaining balance) or it didn't (here's why). No exceptions, no magic numbers, no guessing.

CoinPayment.cs — physical coins with a running balance
public sealed class CoinPayment : IPaymentStrategy
{
    private decimal _inserted;

    public string DisplayName => "Coins";

    // Coins are special β€” you insert them BEFORE selecting a product
    public void AcceptCoin(decimal coinValue)
    {
        if (coinValue <= 0)
            throw new ArgumentException("Coin value must be positive.");
        _inserted += coinValue;
    }

    public decimal InsertedAmount => _inserted;

    public PaymentResult Charge(decimal amount)
    {
        // Have they inserted enough coins?
        if (_inserted < amount)
            return PaymentResult.Fail(
                $"Insert {amount - _inserted:C} more.");

        // Deduct the price β€” leftover is the customer's change
        _inserted -= amount;
        return PaymentResult.Success(_inserted);
    }

    public PaymentResult Refund(decimal amount)
    {
        // Refunding coins = pushing them back out the slot
        _inserted += amount;
        return PaymentResult.Success(_inserted);
    }
}

Coins are unique because the customer inserts them before choosing a product. The AcceptCoin() method builds up a running balance. When the machine calls Charge(), it checks whether enough coins are in the tray. If yes, it deducts the price and returns the leftover as change. If not, it tells the customer exactly how much more to insert. Simple, physical, tangible.

CardPayment.cs — credit/debit card authorization
public sealed class CardPayment : IPaymentStrategy
{
    private readonly ICardGateway _gateway;
    private string? _lastTransactionId;

    public CardPayment(ICardGateway gateway)
        => _gateway = gateway;

    public string DisplayName => "Credit/Debit Card";

    public PaymentResult Charge(decimal amount)
    {
        // Cards work differently β€” authorize the exact amount
        var auth = _gateway.Authorize(amount);

        if (!auth.Approved)
            return PaymentResult.Fail(
                $"Card declined: {auth.Reason}");

        // Save the transaction ID so we can refund later
        _lastTransactionId = auth.TransactionId;

        // No "remaining balance" for cards β€” they pay exact
        return PaymentResult.Success(0);
    }

    public PaymentResult Refund(decimal amount)
    {
        if (_lastTransactionId is null)
            return PaymentResult.Fail("No transaction to refund.");

        var refund = _gateway.Refund(_lastTransactionId, amount);
        return refund.Approved
            ? PaymentResult.Success(0)
            : PaymentResult.Fail($"Refund failed: {refund.Reason}");
    }
}

Cards are fundamentally different from coins. There's no "running balance" — the card is authorized for the exact purchase amount. Refunds go through the same gateway but need the original transaction ID. Notice how CardPayment depends on an ICardGateway interface, not a concrete gateway — this makes it testable (we can inject a fake gateway in tests).

MobilePayment.cs — Apple Pay / Google Pay via NFC
public sealed class MobilePayment : IPaymentStrategy
{
    private readonly INfcService _nfc;
    private string? _paymentToken;

    public MobilePayment(INfcService nfc)
        => _nfc = nfc;

    public string DisplayName => "Mobile (Apple Pay / Google Pay)";

    public PaymentResult Charge(decimal amount)
    {
        // Mobile payments use NFC tap β€” different protocol entirely
        var tap = _nfc.RequestPayment(amount);

        if (!tap.Completed)
            return PaymentResult.Fail(
                tap.TimedOut ? "Tap timed out. Try again."
                             : $"Payment failed: {tap.Error}");

        _paymentToken = tap.Token;
        return PaymentResult.Success(0);
    }

    public PaymentResult Refund(decimal amount)
    {
        if (_paymentToken is null)
            return PaymentResult.Fail("No mobile payment to refund.");

        var result = _nfc.RefundPayment(_paymentToken, amount);
        return result.Completed
            ? PaymentResult.Success(0)
            : PaymentResult.Fail($"Refund failed: {result.Error}");
    }
}

Mobile payments use yet another mechanism — NFCNear Field Communication β€” the technology that lets you tap your phone to pay. The phone and the payment terminal exchange encrypted data over a very short range (a few centimeters). Apple Pay and Google Pay both use NFC for contactless payments. taps instead of coin slots or card readers. They can time out (the customer held their phone too far away), and refunds go through a token-based system. Completely different internals, but from the vending machine's perspective? Same Charge() and Refund() interface. That's the power of Strategy.

Diagrams

Strategy Fan-Out — One Interface, Three Implementations

The vending machine depends on IPaymentStrategy — not on any concrete payment class. This means you can plug in coins, cards, mobile, or any future payment without changing the machine.

VendingMachine ProcessPayment(IPaymentStrategy, price) «interface» IPaymentStrategy Charge(amount) | Refund(amount) | DisplayName CoinPayment AcceptCoin(value) _inserted (running balance) Physical coins CardPayment ICardGateway (injected) _lastTransactionId Authorize → Capture MobilePayment INfcService (injected) _paymentToken NFC tap → token

Payment Flow — From Customer to Dispensing

Regardless of which payment method the customer chooses, the flow through the vending machine is identical. The machine asks the strategy to charge, checks the result, and either dispenses or shows an error.

Customer selects payment type Machine gets the right IPaymentStrategy strategy .Charge(price) returns PaymentResult OK? Dispense! + return change yes Show Error result.Error no The machine never knows whether it's coins, cards, or NFC — same flow for all.

Runtime Swap — Same Machine, Different "Battery"

Think of each payment strategy like a battery you plug into the machine. The machine works the same regardless of which battery is installed. Swap in a different one at runtime and everything keeps working.

VendingMachine CoinPayment Drop quarters in the slot VendingMachine CardPayment Swipe or insert credit card VendingMachine MobilePayment Tap phone near NFC reader Same machine code. Different strategy plugged in. Zero code changes.

Growing Diagram — Level 3

Four new pieces join the system: the IPaymentStrategy interface and three concrete implementations. The vending machine now depends on the interface, not on any specific payment type.

VendingMachine SelectProduct() | ProcessPayment() | Dispense() Slot Product | Qty | Code IVendingState 4 states (L2) «interface» IPaymentStrategy Charge(amount) | Refund(amount) | DisplayName NEW CoinPayment AcceptCoin() | _inserted NEW CardPayment ICardGateway | _txnId NEW MobilePayment INfcService | _token NEW PaymentResult IsSuccess | RemainingBalance | Error NEW bright boxes = new in Level 3 | dimmed = from previous levels | dashed = interface

Before / After Your Brain

Before This Level

You see "multiple payment types" and add if/else branches.

After This Level

You smell "multiple algorithms, same interface" and instinctively reach for Strategy.

Smell → Pattern: "Multiple Algorithms, Same Interface" — When you have 3+ ways to do the same thing and the choice happens at runtime → Strategy pattern. Define the interface once, implement it many times. The caller never knows which concrete class it's using.
Transfer: Same technique in a Parking Lot: multiple pricing strategies (hourly, daily, monthly) behind IPricingStrategy. Same in Uber: surge pricing, flat rate, subscription — all behind IFareCalculator. Same in a Compression Library: ZIP, GZIP, LZ4 — all behind ICompressor. The pattern is universal: same operation, different algorithms.
Section 7

Level 4 — Transactions & Change 🟡 MEDIUM

New Constraint: "Every purchase generates a receipt. Change must be calculated using the fewest coins possible. The machine tracks its cash float (coins available for change)."
What breaks: Level 3 processes payments but has no transaction record. No receipt. And CoinPayment.Charge() just returns the leftover balance as a decimal — it doesn't figure out which physical coins to return. If someone pays $2.00 for a $1.25 item, the machine needs to return 3 quarters, not just say "0.75." And what if the machine is out of quarters? It needs to fall back to dimes and nickels. We're modeling physical reality now, not abstract numbers.

Design a Transaction record. What fields does a complete purchase need? Think about everything that should appear on a receipt: what was bought, when, how much was paid, how much change was returned, and which payment method was used.

Also: the "fewest coins" problem is a classic algorithm. You have denominations: $1, $0.25, $0.10, $0.05. Can you figure out the algorithm before looking? Hint: start with the biggest denomination and work your way down.

Your inner voice:

"I need a Transaction that captures everything: timestamp, product, amount paid, change given, payment method. And the change problem... I have denominations: $1, $0.25, $0.10, $0.05."

"Greedy approach: use the largest denomination first. $0.75 change = 0×$1 + 3×$0.25. But what if I'm out of quarters? I need to check how many of each denomination I actually HAVE in the machine."

"So I need a CoinInventoryA CoinInventory tracks how many of each denomination the machine currently holds. When the machine gives change, it subtracts from inventory. When a customer inserts coins, it adds to inventory. If the machine runs out of quarters, the change calculator must fall back to dimes and nickels. — a mapping of denomination to count. The ChangeCalculator walks through denominations largest to smallest, takes as many as it needs (up to what's available), and moves to the next denomination. If it can't make exact change... that's a real problem we'll handle in Level 5."

What Would You Do?

Two approaches to the change problem. One pretends coins are just numbers. The other models the physical reality of a coin hopper.

The idea: Return $0.75 as a number and let "someone else" figure out the coins.

LazyChange.cs — ignore the physical reality
public decimal CalculateChange(decimal paid, decimal price)
{
    return paid - price;  // "Here's 0.75. Figure it out yourself."
}

// Problems:
// 1. Machine has no quarters β€” can't actually give 3 quarters
// 2. Machine has 100 nickels but this code doesn't know
// 3. Receipt says "Change: $0.75" but... in what coins?
// 4. No inventory tracking = machine can't warn "low on change"

Verdict: This is incomplete. A real vending machine has coin hoppers with finite supplies. If it's out of quarters, it must give 7 dimes and 1 nickel. If it can't make exact change at all, it must tell the customer BEFORE they buy. Pretending coins are just decimal numbers ignores the physical world.

The idea: Track every denomination the machine holds. Walk through them largest-to-smallest, taking as many as needed (up to what's available). This is the greedy algorithmA greedy algorithm makes the locally optimal choice at each step. For US denominations ($1, $0.25, $0.10, $0.05), this always gives the minimum number of coins. Fun fact: this doesn't work for all currency systems, but US/UK/EU coins are designed to work with greedy. for coin change.

GreedyChange.cs — real coins from a real hopper
// $0.75 change with only dimes and nickels available:
// Try $1.00 β€” need 0, skip
// Try $0.25 β€” need 3, have 0, take 0
// Try $0.10 β€” need 7, have 10, take 7 β†’ $0.05 left
// Try $0.05 β€” need 1, have 5, take 1 β†’ done!
// Result: 7 dimes + 1 nickel = 8 coins

Verdict: This is the winner. It models the physical reality: coins are tangible objects with limited supply. The algorithm is simple (greedy, largest-first), tracks what's actually available, and can report "can't make change" when it runs out.

The Solution

We need four new pieces: a Denomination type to represent coin types, a CoinInventory to track the machine's cash float, a ChangeCalculator with the greedy algorithm, and a Transaction record to capture every purchase.

Denomination.cs — modeling physical coin types
public readonly record struct Denomination(string Name, decimal Value);

public static class Denominations
{
    public static readonly Denomination Dollar  = new("Dollar",  1.00m);
    public static readonly Denomination Quarter = new("Quarter", 0.25m);
    public static readonly Denomination Dime    = new("Dime",    0.10m);
    public static readonly Denomination Nickel  = new("Nickel",  0.05m);

    // Greedy order: always try the biggest coin first
    public static readonly Denomination[] All =
        [Dollar, Quarter, Dime, Nickel];
}

Each denomination is a record structA record struct in C# is a lightweight value type that's immutable by default. It generates equality, hashing, and ToString() automatically. Perfect for small, data-carrying types like a coin denomination — no heap allocation, no reference overhead.. The static All array is ordered largest-to-smallest for the greedy algorithm.

CoinInventory.cs — the machine's cash float
public sealed class CoinInventory
{
    private readonly Dictionary<Denomination, int> _coins = new();

    public int CountOf(Denomination denom)
        => _coins.GetValueOrDefault(denom, 0);

    public void Add(Denomination denom, int count)
        => _coins[denom] = CountOf(denom) + count;

    public void Remove(Denomination denom, int count)
    {
        var current = CountOf(denom);
        if (count > current)
            throw new InvalidOperationException(
                $"Can't remove {count} {denom.Name}s β€” only {current} available.");
        _coins[denom] = current - count;
    }

    public decimal TotalValue =>
        _coins.Sum(kv => kv.Key.Value * kv.Value);
}

The CoinInventory is the machine's physical coin hopper. When a customer inserts coins, they get Add()ed. When the machine gives change, coins get Remove()d. If you try to remove more than available, you get an immediate error.

ChangeCalculator.cs — greedy algorithm
public sealed class ChangeCalculator
{
    public ChangeResult Calculate(decimal amount, CoinInventory inventory)
    {
        var coins = new List<(Denomination Denom, int Count)>();
        var remaining = amount;

        foreach (var denom in Denominations.All)
        {
            if (remaining <= 0) break;

            var available = inventory.CountOf(denom);
            var needed = (int)(remaining / denom.Value);
            var used = Math.Min(needed, available);

            if (used > 0)
            {
                coins.Add((denom, used));
                remaining -= used * denom.Value;
                inventory.Remove(denom, used);
            }
        }

        return remaining > 0
            ? ChangeResult.CannotMakeChange(remaining)
            : ChangeResult.Success(coins);
    }
}

public sealed record ChangeResult
{
    public bool IsSuccess { get; }
    public IReadOnlyList<(Denomination Denom, int Count)> Coins { get; }
    public decimal Shortfall { get; }

    private ChangeResult(bool ok, IReadOnlyList<(Denomination, int)> coins, decimal shortfall)
        => (IsSuccess, Coins, Shortfall) = (ok, coins, shortfall);

    public static ChangeResult Success(List<(Denomination, int)> coins)
        => new(true, coins, 0);

    public static ChangeResult CannotMakeChange(decimal shortfall)
        => new(false, Array.Empty<(Denomination, int)>(), shortfall);
}

The greedy algorithm walks denominations largest-first. For each one, it figures out how many it needs, checks how many are available, and uses the minimum. If there's money left over after trying all denominations, the machine can't make exact change — and ChangeResult reports the shortfall honestly.

Transaction.cs — the complete purchase record
public sealed record Transaction
{
    public required Guid Id { get; init; }
    public required DateTimeOffset Timestamp { get; init; }
    public required Product Product { get; init; }
    public required string SlotCode { get; init; }
    public required decimal AmountCharged { get; init; }
    public required string PaymentMethod { get; init; }
    public required ChangeResult? Change { get; init; }
}

public sealed record Receipt(Transaction Txn)
{
    public string Format() =>
        $"""
        ================================
          VENDING MACHINE RECEIPT
        ================================
        Date:    {Txn.Timestamp:g}
        Product: {Txn.Product.Name}
        Slot:    {Txn.SlotCode}
        Price:   {Txn.Product.Price:C}
        Paid:    {Txn.AmountCharged:C}
        Change:  {FormatChange()}
        Method:  {Txn.PaymentMethod}
        Txn ID:  {Txn.Id:N}
        ================================
        """;

    private string FormatChange() =>
        Txn.Change is null ? "N/A"
        : string.Join(", ",
            Txn.Change.Coins.Select(c => $"{c.Count}x {c.Denom.Name}"));
}

The Transaction record captures every detail of a purchase using DateTimeOffset (not DateTime) — because vending machines exist in time zones. The Receipt formats the transaction for display, including the change breakdown by denomination.

Diagrams

Transaction Flow — From Insert to Receipt

Every purchase follows this pipeline. The transaction record is created at the end, capturing the complete story.

Insert coins/card/tap Select slot code Charge strategy.Pay() Dispense slot.Release() Change greedy calc Receipt Transaction logged Transaction captures the entire pipeline: who paid, what, when, how, and the change breakdown. Always use DateTimeOffset.UtcNow — never DateTime.Now (timezone bugs!)

Change Calculation — Step by Step

The customer needs $0.75 in change, but the machine is out of quarters. Watch how the greedy algorithm falls back to dimes and a nickel.

Making $0.75 Change (out of quarters!) Machine Inventory $1: 5 | $0.25: 0 | $0.10: 10 | $0.05: 5 Step 1: Try $1.00 — need 0 (0.75 < 1.00) skip Remaining: $0.75 Step 2: Try $0.25 — need 3, have 0 0 used! Remaining: $0.75 (no quarters available) Step 3: Try $0.10 — need 7, have 10, take 7 7 dimes! Remaining: $0.05 Step 4: Try $0.05 — need 1, have 5, take 1 1 nickel! Remaining: $0.00 ✅ Result: 7 dimes + 1 nickel = 8 coins dispensed

What the Receipt Looks Like

================================ VENDING MACHINE RECEIPT ================================ Date: 2026-03-14 14:32 Product: Coca-Cola Slot: A3 Price: $1.25 Paid: $2.00 Change: 3x Quarter ($0.75) Method: Coins Txn: a4f2b1c8-9e3d-4a1b... ================================

Growing Diagram — Level 4

Four new types join: CoinInventory, ChangeCalculator, Transaction, and Receipt.

VendingMachine Slot (L1) IVendingState (L2) + IPayment (L3) ChangeCalculator Calculate(amount, inv) NEW CoinInventory Add() | Remove() | CountOf() NEW Transaction Id | Timestamp | Product NEW Receipt Format() NEW Denomination NEW ChangeResult NEW bright = new in Level 4 | dimmed = from previous levels

Before / After Your Brain

Before This Level

You return change as a decimal number and call it done.

After This Level

You model the physical reality — coins have denominations, the machine has finite coins, and the greedy algorithm adapts when inventory is low.

Smell → Pattern: "Fixed After Creation" — A transaction is born once and never changes. That's a record (immutable type). Records give you free equality, free hashing, and the guarantee that data won't be corrupted after creation.
Transfer: Same technique in a Parking Lot: each exit creates a ParkingTicket record. Same in Banking: immutable LedgerEntry. Same in E-Commerce: OrderConfirmation with items, total, and payment. The pattern: capture the full story as an immutable record.
Section 8

Level 5 — Edge Cases 🔴 HARD

New Constraint: "Handle everything that can go wrong: out of stock, insufficient payment, card declined, item gets physically stuck, machine can't make change, two customers pressing buttons simultaneously."
What breaks: Level 4 handles the happy path beautifully. But SelectProduct() for a slot that's empty? You get a null reference or try to dispense air. Card gets declined mid-transaction? The machine already started dispensing but the money never arrived. Two customers at a touchscreen kiosk both tap "Buy" on the last Coke simultaneously? Race conditionA race condition happens when two operations compete to access the same resource, and the outcome depends on which one finishes first. Imagine two people reaching for the last item on a shelf at the same time — only one can get it, but without coordination both might think they succeeded. on the last item — one customer gets the drink, the other gets charged with nothing dispensed.

For each category below, list at least 2 things that could go wrong:

  • Concurrency: Two customers at a dual-touchscreen kiosk...
  • Failure: Card declined, motor jams, NFC timeout...
  • Boundary: Machine can't make change, coin hopper full...
  • Weird Input: Invalid slot code, negative coin amount...

This is the What If? frameworkA systematic way to discover edge cases by asking "what if?" across four categories: Concurrency (multiple actors), Failure (things breaking), Boundary (limits), and Weird Input (unexpected data). Every production bug maps to at least one of these categories. Asking these questions BEFORE coding saves you 2 AM pager alerts.. You'll use it in every system you ever build.

Your inner voice:

"Let me walk through the What If? categories systematically..."

"Concurrency: Two customers hit 'Buy' on the same last item. Without a lock, both get past the stock check, both try to dispense. One gets the Coke, the other gets nothing but still gets charged. I need lock around the check-and-dispense operation to make it atomicAn atomic operation is one that either happens completely or not at all — nothing can interrupt it halfway through. When you check stock and dispense in one lock, no other thread can sneak in between. Without atomicity, a second customer can pass the stock check right before the first customer takes the last item.."

"Failure: Card declined after we started the flow? Refund and reset to idle. Motor jams and the item gets stuck? Retry once, then refund and flag the slot as out of service. NFC timeout? Same — refund and reset."

"Boundary: Machine can't make change? Check BEFORE dispensing, not after. Machine's coin hopper is full? Reject new coins, suggest card payment."

"I don't want exceptions flying everywhere. I need a clean result type that says 'this worked, here's the result' or 'this failed, here's exactly why.' Something like VendingResult<T>."

What Would You Do?

Three approaches to error handling. One crashes, one is cryptic, and one is just right.

The idea: Throw InvalidOperationException whenever something goes wrong. Let the caller catch it.

ExceptionApproach.cs — throw and pray
public Product SelectProduct(string slotCode)
{
    var slot = _slots[slotCode]; // KeyNotFoundException if invalid!
    if (slot.Quantity == 0)
        throw new InvalidOperationException("Out of stock");
    if (_balance < slot.Product.Price)
        throw new InvalidOperationException("Insufficient funds");
    // ... dispense ...
}

// Caller needs try-catch everywhere
try { machine.SelectProduct("A3"); }
catch (KeyNotFoundException) { /* invalid slot */ }
catch (InvalidOperationException ex) { /* which one?! */ }

Verdict: Exceptions are expensive (each captures a full stack trace) and using them for expected situations (out of stock isn't exceptional — it's Tuesday afternoon) is like pulling the fire alarm when you burn toast. Plus, the caller can't distinguish between "out of stock" and "insufficient funds" without parsing the error message string. Fragile.

The idea: Return null when something fails. The caller checks for null.

NullApproach.cs — null means... something went wrong?
public Product? SelectProduct(string slotCode)
{
    if (!_slots.ContainsKey(slotCode)) return null;  // Invalid slot
    if (_slots[slotCode].Quantity == 0) return null;  // Out of stock
    if (_balance < _slots[slotCode].Product.Price) return null; // No $
    // ... dispense ...
    return product;
}

// Caller: "it returned null. Was it invalid? Out of stock? No money?"
// Nobody knows. The null is silent.

Verdict: Null tells you nothing. It doesn't distinguish between "slot doesn't exist," "out of stock," and "not enough money." The caller has to guess. And forgetting to check for null — which happens constantly — gives you a NullReferenceException later, far from the actual problem.

The idea: Create a VendingResult<T> type that says either "here's your result" or "here's what went wrong." The method validates upfront and returns a clean, descriptive result.

ResultApproach.cs — success or failure, never ambiguous
public VendingResult<Receipt> Purchase(string slotCode)
{
    if (!_slots.ContainsKey(slotCode))
        return VendingResult<Receipt>.Fail("Invalid slot code.");

    var slot = _slots[slotCode];
    if (slot.Quantity == 0)
        return VendingResult<Receipt>.Fail($"{slot.Product.Name} is sold out.");

    if (_balance < slot.Product.Price)
        return VendingResult<Receipt>.Fail(
            $"Insert {slot.Product.Price - _balance:C} more.");

    // All checks passed β€” safe to dispense
    var receipt = DispenseAndRecord(slot);
    return VendingResult<Receipt>.Ok(receipt);
}

// Caller: crystal clear
var result = machine.Purchase("A3");
if (result.IsSuccess) Show(result.Value);
else ShowError(result.Error); // Knows exactly what's wrong

Verdict: This is the winner. The caller always gets a clear answer: success (with data) or failure (with a human-readable reason). No exceptions for expected scenarios. No silent nulls. No guessing. Every failure path is an explicit, describable condition.

The Solution

We need VendingResult<T> as the universal return type, plus validation guards and a lock for concurrency safety.

VendingResult.cs — the universal result type
public sealed record VendingResult<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private VendingResult(bool success, T? value, string? error)
        => (IsSuccess, Value, Error) = (success, value, error);

    public static VendingResult<T> Ok(T value) => new(true, value, null);
    public static VendingResult<T> Fail(string error) => new(false, default, error);

    // Convenience: map success to a new type
    public VendingResult<TNext> Then<TNext>(Func<T, VendingResult<TNext>> next)
        => IsSuccess ? next(Value!) : VendingResult<TNext>.Fail(Error!);
}

This is a generic Result typeA Result type is a pattern (common in Rust, F#, and modern C#) that wraps either a success value OR an error — never both, never neither. It forces the caller to check the outcome. Unlike exceptions, it's part of the method signature, so you can't accidentally forget to handle failure.. Every operation returns either Ok(value) or Fail(reason). The Then() method lets you chain operations — if the first succeeds, run the next; if it fails, short-circuit with the error. This is how you build a pipeline of validations.

VendingMachine.cs — thread-safe purchase with validation gates
private readonly object _purchaseLock = new();

public VendingResult<Receipt> Purchase(string slotCode, IPaymentStrategy payment)
{
    // Lock the entire check-and-dispense operation
    lock (_purchaseLock)
    {
        // Gate 1: Valid slot?
        if (!_slots.TryGetValue(slotCode, out var slot))
            return VendingResult<Receipt>.Fail("Invalid slot code.");

        // Gate 2: In stock?
        if (slot.Quantity == 0)
            return VendingResult<Receipt>.Fail(
                $"{slot.Product.Name} is sold out.");

        // Gate 3: Can we make change? (check BEFORE charging)
        var changeNeeded = payment is CoinPayment cp
            ? cp.InsertedAmount - slot.Product.Price : 0m;
        if (changeNeeded > 0)
        {
            var changeCheck = _changeCalc.CanMakeChange(
                changeNeeded, _coinInventory);
            if (!changeCheck)
                return VendingResult<Receipt>.Fail(
                    "Machine cannot make exact change. Try card payment.");
        }

        // Gate 4: Charge the customer
        var payResult = payment.Charge(slot.Product.Price);
        if (!payResult.IsSuccess)
            return VendingResult<Receipt>.Fail(payResult.Error!);

        // Gate 5: Dispense (with stuck-item retry)
        var dispensed = slot.TryDispense();
        if (!dispensed)
        {
            payment.Refund(slot.Product.Price);
            return VendingResult<Receipt>.Fail(
                "Item stuck. Refund issued. Please try again.");
        }

        // All gates passed β€” log transaction
        var txn = CreateTransaction(slot, payment, changeNeeded);
        return VendingResult<Receipt>.Ok(new Receipt(txn));
    }
}

Five validation gates, each one a potential failure point. The lock keyword ensures that the entire check-and-dispense sequence is atomicAtomic means "all or nothing." Inside a lock, no other thread can execute the same block simultaneously. This prevents two customers from both passing the stock check before either one dispenses — only one thread enters the lock at a time, so the second customer will see the updated (zero) quantity.. Notice we check change availability before charging — discovering "can't make change" after the customer already paid would be terrible UX.

Slot.cs — stuck item recovery with retry
public sealed class Slot
{
    private const int MaxDispenseRetries = 2;

    public bool TryDispense()
    {
        for (int attempt = 1; attempt <= MaxDispenseRetries; attempt++)
        {
            var success = _motor.Activate(); // Physical motor push

            if (success)
            {
                Quantity--;
                return true;
            }

            // Item stuck β€” wait briefly, try again
            Thread.Sleep(500); // Brief pause before retry
        }

        // All retries failed β€” item is truly stuck
        IsOutOfService = true;
        _notifier?.NotifyTechnician(
            $"Slot {Code}: item stuck after {MaxDispenseRetries} retries");
        return false;
    }
}

Real vending machines deal with stuck items. The motor pushes, but sometimes the spiral doesn't rotate enough, or the item gets wedged. We retry once (brief pause, try again). If it's still stuck, we flag the slot as out of service and notify a technician. The customer gets an automatic refund.

Diagrams

The What If? Framework — Four Quadrants

Every production bug falls into one of these four categories. Ask these questions BEFORE writing code, and you'll catch edge cases before they catch you at 2 AM.

What If? — Vending Machine Concurrency • Two customers select same last item simultaneously • Coin inserted while card payment is processing • Technician restocks while customer is buying Fix: lock around check-and-dispense Fix: state pattern prevents mixed operations Failure • Card payment declined mid-transaction • Mobile payment NFC timeout • Dispenser motor jams — item physically stuck Fix: refund on charge failure, retry on jam Fix: OutOfService state after repeated jams Boundary • Machine can't make change (out of quarters) • Coin hopper full — can't accept more coins • All slots empty — entire machine sold out Fix: check change BEFORE charging Fix: reject coins when hopper full Weird Input • Invalid slot code ("Z99", empty string, null) • Negative coin amount (-$0.25) • Selecting while already dispensing Fix: validate at system boundary Fix: state pattern rejects invalid transitions

Stuck Item Recovery Flow

When the dispenser motor can't push the item out, the machine doesn't just give up. It retries, and if that fails, it refunds the customer and notifies a technician.

Motor Activate() OK? Dispensed! Qty-- yes Retry wait 500ms no Still stuck? Refund Customer payment.Refund() Out of Service notify technician Retry once. If still stuck: refund + flag slot + alert human.

Validation Gate Chain — Every Step Can Reject

The purchase flow is a chain of gates. Each gate can reject the request with a clear error. Only when ALL gates pass does the item actually dispense.

Valid Slot? Gate 1 "Invalid slot" In Stock? Gate 2 "Sold out" Can Change? Gate 3 "Can't make change" Payment OK? Gate 4 "Card declined" Dispensed? Gate 5 "Stuck + refund" Each gate returns VendingResult.Fail() with a specific error message. Only all-green = success.

Growing Diagram — Level 5

VendingMachine Purchase() returns VendingResult<Receipt> 🔒 VendingResult<T> Ok(value) | Fail(error) NEW L1-L4 types (Slot, State, Payment, Transaction, Change) 5 Validation Gates + lock + retry + refund NEW Level 5 adds robustness: result types, validation, concurrency safety, stuck-item recovery

Before / After Your Brain

Before This Level

You code the happy path and hope nothing goes wrong.

After This Level

You systematically ask "What If?" across four categories (concurrency, failure, boundary, weird input) and handle every case with a clean Result<T> before writing any "real" code.

Smell → Pattern: "Happy Path Only" — When your code only handles success and ignores failure → Apply the What If? framework. Check concurrency, failure, boundary, and weird input. Return Result<T> instead of throwing exceptions for expected failures.
Transfer: The What If? framework works for EVERY system. Elevator: what if two people press the same floor + someone holds the door open + the cable sensor detects overweight? Parking Lot: what if two cars arrive at the same gate + the barrier doesn't lift + the ticket printer is out of paper? The four categories never change — only the specific scenarios do.
Section 9 🔴 HARD

Level 6 — Make It Testable

New Constraint: "Make the vending machine fully testable. You must be able to: set up any machine state, mock payment gateways, control which coins are available for change, and verify exact transaction outcomes."
What breaks: Level 5's VendingMachine creates its own CoinPayment(), ChangeCalculator(), and state objects internally. In tests, you can't inject a fake payment gateway that always fails, or a coin inventory that's empty. You can't test "what happens when the card is declined" without hitting a real card processor. You can't test "machine can't make change" without carefully loading exact coin counts. Every hardcoded dependencyA hardcoded dependency is when a class creates its collaborators internally (using 'new') instead of receiving them from the outside. This means you can't swap them for test doubles. It's like a car with its engine welded shut — you can't inspect or replace it without cutting the whole thing apart. is a testing dead end.

Think First #8

Your ChangeCalculator uses a real CoinInventory. How would you test: "when the machine has only nickels, it returns 15 nickels for $0.75 change"? What about: "when the card gateway returns 'declined', the machine refunds the customer"? What concrete things need to become injectable?

60 seconds — list every "global" thing that needs to become an interface.

Four things are hardcoded that need to become injectable:

Hardcoded ThingWhy It's a ProblemWhat to Inject Instead
ICardGateway (concrete)Can't simulate declined cardsInject via constructor — tests pass a FakeCardGateway
INfcService (concrete)Can't simulate NFC timeoutInject via constructor — tests pass a FakeNfcService
CoinInventory (private)Can't pre-load specific coinsAccept CoinInventory in constructor or expose LoadCoins()
DateTimeOffset.UtcNowCan't freeze time for receipt testsTimeProvider (built into .NET 8)

Your inner voice:

"The core problem is that the machine creates its own collaborators. I need to flip this: instead of the machine creating them, they get passed in. That's Dependency InjectionDependency Injection means giving an object the things it needs from the outside instead of letting it create them internally. Think of a restaurant: the chef doesn't grow the vegetables — they're delivered (injected). This makes the chef testable: swap in plastic vegetables (fakes) and verify the chef follows the recipe correctly.."

"For card payments, I already have ICardGateway — I just need to inject it. For NFC, same thing with INfcService. For the coin inventory, I pass a pre-loaded one in the constructor. For time, .NET 8 has TimeProvider built in — tests use FakeTimeProvider."

"The machine itself should sit behind an IVendingMachine interface too. That way, if someone builds a UI controller that talks to the machine, they can test the controller with a fake machine."

What Would You Do?

Two developers, two approaches to making the card gateway testable.

The idea: Leave the real CardGateway in place. Write tests that call the real payment processor.

HardcodedGateway.cs
public sealed class CardPayment : IPaymentStrategy
{
    // Gateway created internally β€” can't swap it!
    private readonly CardGateway _gateway = new CardGateway();

    public PaymentResult Charge(decimal amount)
        => _gateway.Authorize(amount); // Hits the real network
}

Verdict: Tests hit a real payment network — slow, flaky, and costs real money. You can't test "card declined" without actually having a declined card. Tests fail when the network is down. This is the opposite of reliable, fast unit tests.

The idea: Accept ICardGateway in the constructor. Production passes the real one. Tests pass a fake with predetermined responses.

InjectableGateway.cs
public sealed class CardPayment(ICardGateway gateway) : IPaymentStrategy
{
    // Gateway INJECTED β€” fully controllable from outside
    public PaymentResult Charge(decimal amount)
        => gateway.Authorize(amount);
}

// In tests:
var fakeGateway = new FakeCardGateway(approved: false, reason: "Declined");
var payment = new CardPayment(fakeGateway);
var result = payment.Charge(1.50m);
Assert.False(result.IsSuccess);
Assert.Equal("Card declined: Declined", result.Error);

Verdict: This is the winner. No network, no flakiness, no cost. Each test controls exactly what the gateway returns. Tests run in milliseconds, in parallel, with perfect determinism.

The Solution — Injectable Everything

The idea is simple: every "thing" your machine talks to becomes a constructor parameter behind an interface. In production, you pass real implementations. In tests, you pass fakes.

Abstractions.cs — interfaces for all external dependencies
// The machine itself becomes an interface
public interface IVendingMachine
{
    VendingResult<Receipt> Purchase(string slotCode, IPaymentStrategy payment);
    VendingResult<decimal> InsertCoin(decimal amount);
    IReadOnlyList<SlotInfo> GetAvailableProducts();
}

// External card processor
public interface ICardGateway
{
    AuthResult Authorize(decimal amount);
    AuthResult Refund(string transactionId, decimal amount);
}

// NFC/mobile payment processor
public interface INfcService
{
    NfcResult RequestPayment(decimal amount);
    NfcResult RefundPayment(string token, decimal amount);
}

// Time provider (use .NET 8 built-in TimeProvider)
// Production: TimeProvider.System
// Tests: FakeTimeProvider with frozen clock

These interfaces are the seamsA seam is a point in your code where you can change behavior without editing the code itself. Interfaces are seams: you can swap the implementation behind them. Constructor injection is how you reach those seams. Tests exploit seams to inject controlled behavior. where tests plug in. Every external dependency has an interface. The machine itself has one too, so controllers can be tested with a fake machine.

Program.cs — production DI wiring
// Production wiring β€” real implementations
var services = new ServiceCollection();
services.AddSingleton<ICardGateway, StripeCardGateway>();
services.AddSingleton<INfcService, ApplePayNfcService>();
services.AddSingleton<TimeProvider>(TimeProvider.System);
services.AddSingleton<CoinInventory>(sp =>
{
    var inv = new CoinInventory();
    inv.Add(Denominations.Quarter, 40);
    inv.Add(Denominations.Dime, 50);
    inv.Add(Denominations.Nickel, 30);
    inv.Add(Denominations.Dollar, 10);
    return inv;
});
services.AddSingleton<ChangeCalculator>();
services.AddSingleton<IVendingMachine, VendingMachine>();

var provider = services.BuildServiceProvider();
var machine = provider.GetRequiredService<IVendingMachine>();

In production, the DI containerA Dependency Injection container is a framework that automatically creates objects and wires their dependencies together. You register "when someone asks for ICardGateway, give them a StripeCardGateway" and the container handles the rest. In .NET, this is Microsoft.Extensions.DependencyInjection. wires real implementations: Stripe for cards, Apple Pay for NFC, actual coins loaded into the inventory. The machine never knows which implementations it's using — it only sees interfaces.

VendingMachineTests.cs — deterministic, fast, parallel-safe
[Fact]
public void Only_nickels_returns_15_nickels_for_75_cents_change()
{
    // Arrange: inventory with ONLY nickels
    var inventory = new CoinInventory();
    inventory.Add(Denominations.Nickel, 20);
    var calc = new ChangeCalculator();

    // Act
    var result = calc.Calculate(0.75m, inventory);

    // Assert
    Assert.True(result.IsSuccess);
    Assert.Single(result.Coins); // Only one denomination used
    Assert.Equal("Nickel", result.Coins[0].Denom.Name);
    Assert.Equal(15, result.Coins[0].Count);
}

[Fact]
public void Card_declined_returns_failure_and_does_not_dispense()
{
    // Arrange: fake gateway that always declines
    var fakeGateway = new FakeCardGateway(approved: false, reason: "Insufficient funds");
    var payment = new CardPayment(fakeGateway);
    var machine = CreateTestMachine(stockedWith: "Coke", price: 1.50m);

    // Act
    var result = machine.Purchase("A1", payment);

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Contains("Insufficient funds", result.Error);
    Assert.Equal(1, machine.GetSlot("A1").Quantity); // Nothing dispensed!
}

[Fact]
public void Cannot_make_change_rejects_before_charging()
{
    // Arrange: empty coin inventory
    var emptyInventory = new CoinInventory();
    var machine = CreateTestMachine(coinInventory: emptyInventory);
    var coins = new CoinPayment();
    coins.AcceptCoin(2.00m); // Paying $2 for a $1.25 item = $0.75 change

    // Act
    var result = machine.Purchase("A1", coins);

    // Assert: rejected BEFORE charging β€” coins returned
    Assert.False(result.IsSuccess);
    Assert.Contains("cannot make exact change", result.Error);
}

Each test creates exactly the conditions it needs. No network, no real payments, no randomness. Tests run in milliseconds and produce the same result every time. The key insight: if you can't control it from outside, you can't test it.

Diagrams

Dependency Injection Graph

The vending machine depends on interfaces (dashed boxes). In production, real implementations plug in. In tests, fakes plug in. The machine never knows the difference.

VendingMachine ICardGateway INfcService CoinInventory TimeProvider Production Tests StripeGateway ApplePayNfc FakeCardGateway FakeNfcService new FakeCardGateway(approved: false) Test controls EXACTLY what happens Same machine code. Different implementations injected. Zero code changes.

Test Setup: Fake Inventory with Only Nickels

Test creates: CoinInventory 20 nickels ONLY inject ChangeCalculator .Calculate(0.75m, inv) returns ChangeResult 15 x Nickel = $0.75 ✅ PASS Deterministic: same input → same output → every time.

Growing Diagram — Level 6

IVendingMachine (interface) EXTRACTED ICardGateway INfcService CoinInventory TimeProvider DI Container NEW Level 6: everything injectable. Machine behind IVendingMachine. DI wires production. Tests inject fakes.

Before / After Your Brain

Before This Level

You test by running the app and clicking buttons. "It works on my machine."

After This Level

You ask "can I control this from outside?" for every dependency. If no → extract an interface and inject it. Tests become fast, deterministic, and trustworthy.

Smell → Pattern: "Need One Instance But Testable" — When you have global state (payment gateway, time, random) that must be mockable in tests → DI Singleton, not a static singleton. Register it once in the DI container. Tests create their own instance with fakes.
Transfer: Same technique everywhere. Parking Lot: inject ITicketPrinter and IBarrierGateway — test "barrier doesn't lift" without a physical barrier. Elevator: inject IMotorController — test "motor failure at floor 7" without breaking a motor. Banking App: inject ITransferService — test "transfer times out" without waiting for a network timeout. The pattern: if you can't control it from outside, you can't test it.
Section 10 🔴 HARD

Level 7 — Scale It

New Constraint: "The company operates 500 vending machines. A central dashboard monitors inventory, revenue, errors, and temperature. When a machine runs low on a popular item, it should auto-notify the warehouse."
What breaks: Level 6 is one machine. There's no concept of a fleet, no event system, no central monitoring. Each machine is an island. The dashboard would need to poll 500 machines every few seconds to check inventory — wasteful and slow. When Machine #47 runs out of Coke, nobody knows until a customer complains. We need the machines to announce what's happening, and let any listener react.

Think First #9

How does a machine notify the dashboard when something happens (sale, error, low stock)? The machine shouldn't know about dashboards, warehouses, or analytics systems. It should just announce "something happened" and let whoever cares react. What pattern makes this possible?

60 seconds — think about who publishes, who subscribes, and what the "event" looks like.

This is the Observer patternThe Observer pattern lets an object (the subject) announce "something happened" without knowing who's listening. Any number of observers can subscribe. Adding a new observer never requires changing the subject. Think of a radio station: it broadcasts, and anyone with a receiver can tune in.. The machine emits events. The dashboard, warehouse, and analytics system subscribe. Adding a new listener is one line of code — zero changes to the machine.

ComponentRoleWhat It Does
VendingMachinePublisherEmits events: SaleCompleted, LowStock, ErrorOccurred
IMachineObserverInterfaceDefines what listeners can react to
DashboardObserverSubscriberUpdates the central monitoring dashboard
WarehouseObserverSubscriberTriggers restocking when inventory is low
FleetManagerOrchestratorManages 500 machines, routes events

Your inner voice:

"500 machines... I need a FleetManager that tracks them all by ID. Each machine already has its own state and inventory from previous levels, so they're naturally isolated."

"But notifications are the key. When Machine #47 sells the last Coke, the dashboard needs to update, the warehouse needs to schedule a restock, and the analytics system needs to log it. The machine should NOT know about any of these systems — that would be tight couplingTight coupling means two components know too much about each other. If the machine directly calls dashboard.Update(), then adding a warehouse notifier means editing the machine class. Loose coupling means the machine just says "something happened" and anyone who cares can listen.."

"Observer pattern: the machine emits events, and any number of listeners subscribe. Adding analytics later? One line: Subscribe(new AnalyticsObserver()). No machine code changes. This is also the bridge to HLDHigh-Level Design — the architecture of distributed systems. While LLD focuses on classes and patterns within a single application, HLD deals with how services communicate across networks. The Observer pattern in LLD maps directly to message queues and event buses in HLD. — in a distributed system, these events become messages on a message queue."

What Would You Do?

Three approaches to getting information from 500 machines to a central dashboard.

The idea: Each machine directly calls the dashboard, warehouse, and analytics after every sale.

DirectCalls.cs
// Machine knows about everything
private readonly Dashboard _dashboard;
private readonly Warehouse _warehouse;

public void AfterSale(Transaction txn)
{
    _dashboard.Update(MachineId, txn);    // Tight coupling
    _warehouse.CheckRestock(MachineId);   // More coupling
    // Adding analytics? Edit THIS class again...
}

Verdict: Every new consumer means editing the machine class. The machine becomes a God class with references to every system in the company. Adding a feature requires modifying core business logic.

The idea: The dashboard polls every machine every 5 seconds to check for changes.

Polling.cs
// Dashboard checks 500 machines every 5 seconds
while (true)
{
    foreach (var machine in fleet)  // 500 iterations
    {
        var state = machine.GetStatus();
        if (state != lastKnown[machine.Id])
            UpdateDashboard(machine.Id, state);
    }
    await Task.Delay(5000);  // 500 wasted checks most of the time
}

Verdict: 500 machines polled every 5 seconds = 100 checks per second. Most find nothing new. There's up to 5 seconds of delay before anyone knows the last Coke sold out. This doesn't scale.

The idea: Each machine emits events. Any number of listeners subscribe. The machine never knows WHO is listening or what they do with the events.

ObserverEvents.cs — publish/subscribe
public interface IMachineObserver
{
    void OnSaleCompleted(string machineId, Transaction txn);
    void OnLowStock(string machineId, string productName, int remaining);
    void OnError(string machineId, string error);
}

// Machine just announces β€” doesn't know who listens
public void Subscribe(IMachineObserver observer)
    => _observers.Add(observer);

// One line to add a new listener β€” zero machine changes
fleet.Subscribe(new DashboardObserver());
fleet.Subscribe(new WarehouseObserver(threshold: 3));
fleet.Subscribe(new AnalyticsObserver());
fleet.Subscribe(new TemperatureAlertObserver());

Verdict: This is the winner. Zero wasted checks. Instant notification. Adding a new consumer is one line. No machine code changes. The Observer pattern perfectly decouples producers from consumers. And in a distributed system, this maps directly to a message queueA message queue (like RabbitMQ, Azure Service Bus, or AWS SQS) is the distributed version of the Observer pattern. The machine publishes events to a queue. Services subscribe to the queue. The machine and the services don't know about each other — the queue sits in between. This is how real-world fleet management works at scale..

The Solution — FleetManager + Observer + Events

We add three new pieces: MachineEvent records to describe what happened, an IMachineObserver interface for listeners, and a FleetManager to orchestrate the fleet.

Events.cs — what happened, as immutable records
// Base event β€” every event knows which machine and when
public abstract record MachineEvent(
    string MachineId,
    DateTimeOffset Timestamp);

// A sale was completed
public sealed record SaleEvent(
    string MachineId, DateTimeOffset Timestamp,
    Transaction Transaction) : MachineEvent(MachineId, Timestamp);

// Stock is running low
public sealed record LowStockEvent(
    string MachineId, DateTimeOffset Timestamp,
    string ProductName, int Remaining) : MachineEvent(MachineId, Timestamp);

// Something went wrong
public sealed record ErrorEvent(
    string MachineId, DateTimeOffset Timestamp,
    string ErrorType, string Message) : MachineEvent(MachineId, Timestamp);

// Observer interface β€” implement this to listen for events
public interface IMachineObserver
{
    void OnEvent(MachineEvent machineEvent);
}

Each event is an immutable record — it captures what happened, which machine, and when. The observer interface has a single method: OnEvent(). Simple and extensible. Adding a new event type (like TemperatureAlert) requires zero changes to existing observers — they can just ignore event types they don't care about.

FleetManager.cs — managing 500 machines
public sealed class FleetManager
{
    private readonly ConcurrentDictionary<string, IVendingMachine> _machines = new();
    private readonly List<IMachineObserver> _observers = [];
    private const int LowStockThreshold = 3;

    public string RegisterMachine(IVendingMachine machine)
    {
        var id = $"VM-{_machines.Count + 1:D4}"; // VM-0001, VM-0002, ...
        _machines[id] = machine;
        return id;
    }

    public void Subscribe(IMachineObserver observer)
        => _observers.Add(observer);

    // Called after each purchase β€” emit events to all observers
    public void OnPurchaseCompleted(string machineId, Transaction txn)
    {
        var timestamp = TimeProvider.System.GetUtcNow();

        // Sale event
        Emit(new SaleEvent(machineId, timestamp, txn));

        // Check if stock is low
        var machine = _machines[machineId];
        foreach (var slot in machine.GetAvailableProducts())
        {
            if (slot.Quantity > 0 && slot.Quantity <= LowStockThreshold)
                Emit(new LowStockEvent(
                    machineId, timestamp, slot.ProductName, slot.Quantity));
        }
    }

    private void Emit(MachineEvent evt)
    {
        foreach (var observer in _observers)
            observer.OnEvent(evt);
    }
}

The FleetManager is the hub. It tracks all machines by ID, accepts observers, and after each purchase checks if any stock is low. The Emit() method broadcasts events to all subscribers. This is the single-process version — in a distributed system, Emit() would publish to a message queue instead of calling methods directly.

Observers.cs — listeners that react to events
// Dashboard: updates the central monitoring screen
public sealed class DashboardObserver : IMachineObserver
{
    public void OnEvent(MachineEvent evt)
    {
        switch (evt)
        {
            case SaleEvent sale:
                UpdateRevenue(sale.MachineId, sale.Transaction.AmountCharged);
                break;
            case ErrorEvent error:
                ShowAlert(error.MachineId, error.Message);
                break;
        }
    }
}

// Warehouse: triggers restocking when inventory is low
public sealed class WarehouseObserver : IMachineObserver
{
    public void OnEvent(MachineEvent evt)
    {
        if (evt is LowStockEvent low)
        {
            Console.WriteLine(
                $"[WAREHOUSE] Restock {low.ProductName} at {low.MachineId}" +
                $" β€” only {low.Remaining} left!");
            ScheduleRestock(low.MachineId, low.ProductName);
        }
    }
}

// Analytics: logs everything for reporting
public sealed class AnalyticsObserver : IMachineObserver
{
    public void OnEvent(MachineEvent evt)
        => _eventStore.Append(evt); // Immutable event log
}

Each observer reacts to the events it cares about and ignores the rest. The dashboard updates revenue and shows alerts. The warehouse schedules restocking. Analytics logs everything. Adding a new observer — say, a temperature monitor — is one new class. No existing code changes.

Diagrams

Fleet Architecture — 500 Machines, One Dashboard

500 Machines VM-0001 VM-0002 VM-... VM-0500 FleetManager RegisterMachine() | Subscribe() | Emit() events Dashboard Revenue | Errors | Map Warehouse Auto-restock alerts Analytics Immutable event log HLD: FleetManager → Message Queue (RabbitMQ, Azure Service Bus)

Observer Flow — One Event, Many Reactions

VM-0047 emits SaleEvent DashboardObserver → update revenue WarehouseObserver → check restock AnalyticsObserver → log event Future: TemperatureAlert? add later, zero changes Machine doesn't know who listens. Observers don't know about each other. Pure decoupling.

Growing Diagram — Level 7 (Complete System)

FleetManager 500 machines | Subscribe() | Emit() NEW L7 IVendingMachine Slot L1 State L2 Payment L3 Txn/Change L4 Result<T> L5 DI/Ifaces L6 IMachineObserver NEW L7 Dashboard Warehouse Analytics MachineEvent records NEW L7

Before / After Your Brain

Before This Level

You build a perfect single machine. Scaling means... copy-pasting the code 500 times?

After This Level

You think in terms of events and observers. Machines announce what happened. Consumers subscribe to what they care about. Adding a new consumer never touches existing code. And you see how this maps to HLD: Observer → message queue, FleetManager → microservice.

Smell → Pattern: "Notify When Something Happens" — When other parts of the system need to react to events without the source knowing who they are → Observer pattern. The publisher emits events. Subscribers listen. Zero coupling between them.
Transfer: Same technique in Parking Lot: lot emits SpotFreed events, the availability display subscribes. Same in Uber: ride emits LocationUpdated, the map and ETA calculator both subscribe. Same in E-Commerce: order emits OrderPlaced, inventory deduction, email confirmation, and analytics all subscribe independently. At HLD scale, the Observer becomes a message bus (Kafka, RabbitMQ) — the pattern is identical, just distributed.
Section 11

The Full Code — Everything Assembled

You've built this vending machine one constraint at a time across seven levels. Now it's time to see the whole thing assembled in one place. Every file below is the final, production-ready version — incorporating every pattern and refinement we discovered along the way.

Before we dive into the code, here's a bird's-eye view of every type in the system, color-coded by the level that introduced it. Gray types came first (Level 0), green from Level 1, blue from Level 2 (State), and so on all the way to yellow for Level 7 (Observer). Notice how the system grew organically — each type was forced into existenceNo type was planned in advance. Each one appeared because a real constraint made the previous design painful. Product appeared when we needed multiple items. IVendingState appeared when boolean flags couldn't handle 4 modes. This is emergent design — letting constraints drive architecture. by a real constraint, not by upfront planning.

COMPLETE TYPE MAP — COLOR = LEVEL INTRODUCED L0 (Base) L1 (Models) L2 (State) L3 (Strategy) L4 (Data) L5 (Errors) L6 (DI) L7 (Observer) MODELS & DATA Product record · L1 Slot class · L1 Denomination enum · L4 CoinInventory class · L4 Transaction record · L4 Receipt record · L4 PaymentResult record · L4 VendingResult<T> record · L5 INTERFACES IVendingState L2 IPaymentStrategy L3 IInventoryRepository L6 IPaymentGateway L6 IChangeCalculator L6 IMachineObserver L7 VENDING STATES (L2) IdleState L2 HasMoneyState L2 DispensingState L2 OutOfServiceState L2 PAYMENT STRATEGIES (L3) CoinPayment L3 CardPayment L3 MobilePayment L3 DATA FLOW (L4) ChangeCalculator class · L4 OBSERVER (L7) MachineEvent record · L7 FleetManager class · L7 ENGINE VendingMachine 25 types total 8 models · 6 interfaces · 4 states · 3 strategies · 3 observer · 1 engine

Now let's see the actual code. Each file is organized by responsibility — models in one place, states in another, strategies in a third. Click through the tabs to read each file. Every line is annotated with a // Level N comment so you can trace which constraint introduced that code.

Models.cs — All data types the system carries around
namespace VendingMachine.Models;

// ─── Product ──────────────────────────────────────────
// Level 1 β€” A product is pure data: a name and a price.
// Records give us value equality and immutability for free.
public sealed record Product(string Name, decimal Price);

// ─── Slot ─────────────────────────────────────────────
// Level 1 β€” A physical slot in the machine holds a product
// and tracks how many units are left. The slot code (like
// "A1", "B3") is how customers identify what they want.
public sealed class Slot
{
    public string Code { get; }                  // Level 1 β€” "A1", "B3"
    public Product Product { get; }              // Level 1 β€” what this slot sells
    public int Quantity { get; private set; }     // Level 1 β€” how many left

    public Slot(string code, Product product, int quantity)
    {
        Code = code;
        Product = product;
        Quantity = quantity;
    }

    public bool IsAvailable => Quantity > 0;     // Level 5 β€” stock check

    public void Dispense()                        // Level 1 β€” reduce inventory
    {
        if (Quantity <= 0)                        // Level 5 β€” guard
            throw new InvalidOperationException($"Slot {Code} is empty.");
        Quantity--;
    }

    public void Restock(int amount) => Quantity += amount; // Level 7 β€” fleet ops
}

// ─── Denomination ─────────────────────────────────────
// Level 4 β€” The physical coins the machine can hold.
// Modeled as an enum because denominations are fixed
// categories, not objects with behavior.
public enum Denomination
{
    Penny   = 1,
    Nickel  = 5,
    Dime    = 10,
    Quarter = 25,
    Dollar  = 100
}

// ─── CoinInventory ────────────────────────────────────
// Level 4 β€” Tracks REAL coins inside the machine.
// This matters because change isn't an abstract number;
// the machine must have physical coins to return.
public sealed class CoinInventory
{
    private readonly Dictionary<Denomination, int> _coins = new()
    {
        [Denomination.Penny]   = 100,             // Level 4 β€” default stock
        [Denomination.Nickel]  = 40,
        [Denomination.Dime]    = 40,
        [Denomination.Quarter] = 40,
        [Denomination.Dollar]  = 20
    };

    public void Add(Denomination coin, int count = 1)   // Level 4
        => _coins[coin] += count;

    public bool TryRemove(Denomination coin, int count = 1) // Level 4
    {
        if (_coins[coin] < count) return false;
        _coins[coin] -= count;
        return true;
    }

    public int CountOf(Denomination coin) => _coins[coin]; // Level 5 β€” diagnostics
}

// ─── VendingResult<T> ─────────────────────────────────
// Level 5 β€” Instead of throwing exceptions for expected
// failures (out of stock, not enough money), we return a
// result object. The caller checks Success, never catches.
public sealed record VendingResult<T>(
    bool Success, T? Value, string? Error)
{
    public static VendingResult<T> Ok(T value)
        => new(true, value, null);
    public static VendingResult<T> Fail(string error)
        => new(false, default, error);
}

// ─── Transaction ──────────────────────────────────────
// Level 4 β€” An immutable snapshot of a completed purchase.
// Records are perfect here: once a transaction happens,
// it should never be modified.
public sealed record Transaction(
    string TransactionId,                         // Level 4 β€” unique ID
    string SlotCode,                              // Level 1 β€” which slot
    string ProductName,                           // Level 1 β€” what was bought
    decimal AmountPaid,                           // Level 3 β€” how much inserted
    decimal ProductPrice,                         // Level 1 β€” item cost
    decimal ChangeGiven,                          // Level 4 β€” difference returned
    DateTimeOffset Timestamp);                    // Level 4 β€” when it happened

// ─── Receipt ──────────────────────────────────────────
// Level 4 β€” Customer-facing summary of the transaction.
public sealed record Receipt(
    Transaction Transaction,                      // Level 4 β€” full details
    IReadOnlyList<(Denomination Coin, int Count)> ChangeBreakdown); // Level 4

// ─── PaymentResult ────────────────────────────────────
// Level 4 β€” Returned by payment strategies to tell the
// machine whether the payment went through and how much
// was actually charged.
public sealed record PaymentResult(
    bool Accepted,                                // Level 3 β€” success/fail
    decimal AmountCharged,                        // Level 3 β€” how much collected
    string? DeclineReason);                       // Level 5 β€” why it failed
VendingStates.cs — State pattern: one behavior per mode
namespace VendingMachine.States;

using VendingMachine.Models;

// ─── IVendingState ────────────────────────────────────
// Level 2 β€” The contract every state must follow.
// The machine delegates ALL behavior to the current state.
// When you call machine.InsertMoney(), it's really calling
// _currentState.InsertMoney(this).
public interface IVendingState
{
    VendingResult<string> InsertMoney(VendingMachine machine, decimal amount);
    VendingResult<Receipt> SelectProduct(VendingMachine machine, string slotCode);
    VendingResult<decimal> ReturnMoney(VendingMachine machine);
    string StateName { get; }                     // Level 7 β€” for observers
}

// ─── IdleState ────────────────────────────────────────
// Level 2 β€” Machine is waiting. Only InsertMoney makes sense.
public sealed class IdleState : IVendingState
{
    public string StateName => "Idle";

    public VendingResult<string> InsertMoney(
        VendingMachine machine, decimal amount)
    {
        if (amount <= 0)                          // Level 5 β€” validation
            return VendingResult<string>.Fail("Amount must be positive.");

        machine.CurrentBalance += amount;         // Level 2
        machine.TransitionTo(new HasMoneyState()); // Level 2 β€” state change
        return VendingResult<string>.Ok(
            $"Inserted {amount:C}. Balance: {machine.CurrentBalance:C}");
    }

    public VendingResult<Receipt> SelectProduct(
        VendingMachine machine, string slotCode)
    {
        return VendingResult<Receipt>.Fail(       // Level 2 β€” rejected
            "Please insert money first.");
    }

    public VendingResult<decimal> ReturnMoney(VendingMachine machine)
    {
        return VendingResult<decimal>.Fail(        // Level 2 β€” nothing to return
            "No money inserted.");
    }
}

// ─── HasMoneyState ────────────────────────────────────
// Level 2 β€” Customer has inserted money. Now they can
// select a product, insert more, or ask for their money back.
public sealed class HasMoneyState : IVendingState
{
    public string StateName => "HasMoney";

    public VendingResult<string> InsertMoney(
        VendingMachine machine, decimal amount)
    {
        if (amount <= 0)                          // Level 5
            return VendingResult<string>.Fail("Amount must be positive.");

        machine.CurrentBalance += amount;         // Level 2 β€” add more
        return VendingResult<string>.Ok(
            $"Added {amount:C}. Balance: {machine.CurrentBalance:C}");
    }

    public VendingResult<Receipt> SelectProduct(
        VendingMachine machine, string slotCode)
    {
        // Level 5 β€” validate slot exists
        if (string.IsNullOrWhiteSpace(slotCode))
            return VendingResult<Receipt>.Fail("Slot code is required.");

        var slot = machine.FindSlot(slotCode);    // Level 1
        if (slot is null)
            return VendingResult<Receipt>.Fail($"No slot '{slotCode}' found.");

        // Level 5 β€” validate stock
        if (!slot.IsAvailable)
            return VendingResult<Receipt>.Fail($"{slot.Product.Name} is sold out.");

        // Level 5 β€” validate balance
        if (machine.CurrentBalance < slot.Product.Price)
            return VendingResult<Receipt>.Fail(
                $"Insufficient funds. Need {slot.Product.Price:C}, " +
                $"have {machine.CurrentBalance:C}.");

        // Level 2 β€” transition to dispensing
        machine.TransitionTo(new DispensingState());
        return machine.DispenseProduct(slot);     // Level 4 β€” actual dispense
    }

    public VendingResult<decimal> ReturnMoney(VendingMachine machine)
    {
        var amount = machine.CurrentBalance;      // Level 2
        machine.CurrentBalance = 0;
        machine.TransitionTo(new IdleState());    // Level 2 β€” back to idle
        return VendingResult<decimal>.Ok(amount);
    }
}

// ─── DispensingState ──────────────────────────────────
// Level 2 β€” Machine is physically delivering the product.
// ALL operations are rejected during dispensing.
public sealed class DispensingState : IVendingState
{
    public string StateName => "Dispensing";

    public VendingResult<string> InsertMoney(
        VendingMachine machine, decimal amount)
        => VendingResult<string>.Fail("Please wait, dispensing in progress.");

    public VendingResult<Receipt> SelectProduct(
        VendingMachine machine, string slotCode)
        => VendingResult<Receipt>.Fail("Please wait, dispensing in progress.");

    public VendingResult<decimal> ReturnMoney(VendingMachine machine)
        => VendingResult<decimal>.Fail("Please wait, dispensing in progress.");
}

// ─── OutOfServiceState ────────────────────────────────
// Level 2 β€” Machine is broken or being maintained.
// This is the Null Object pattern: every method returns a
// polite rejection instead of throwing an exception.
public sealed class OutOfServiceState : IVendingState
{
    public string StateName => "OutOfService";

    public VendingResult<string> InsertMoney(
        VendingMachine machine, decimal amount)
        => VendingResult<string>.Fail("Machine is out of service.");

    public VendingResult<Receipt> SelectProduct(
        VendingMachine machine, string slotCode)
        => VendingResult<Receipt>.Fail("Machine is out of service.");

    public VendingResult<decimal> ReturnMoney(VendingMachine machine)
        => VendingResult<decimal>.Fail("Machine is out of service.");
}
PaymentStrategies.cs — Strategy pattern: swappable payment methods
namespace VendingMachine.Payments;

using VendingMachine.Models;

// ─── IPaymentStrategy ─────────────────────────────────
// Level 3 β€” Every payment method must implement this.
// The machine doesn't care HOW you pay, only that the
// strategy returns a PaymentResult saying yes or no.
public interface IPaymentStrategy
{
    PaymentResult Process(decimal amount);        // Level 3
    string MethodName { get; }                    // Level 7 β€” for receipts
}

// ─── CoinPayment ──────────────────────────────────────
// Level 3 β€” Physical coins inserted into the machine.
// Coins are already in the balance, so this always succeeds.
public sealed class CoinPayment : IPaymentStrategy
{
    public string MethodName => "Coin";

    public PaymentResult Process(decimal amount)
    {
        // Level 3 β€” Coins were pre-inserted, always accepted
        return new PaymentResult(
            Accepted: true,
            AmountCharged: amount,
            DeclineReason: null);
    }
}

// ─── CardPayment ──────────────────────────────────────
// Level 3 β€” Credit/debit card via the payment gateway.
// In production, this talks to a real payment processor.
// Level 6 β€” The gateway is injected (not hardcoded).
public sealed class CardPayment : IPaymentStrategy
{
    private readonly IPaymentGateway _gateway;    // Level 6 β€” DI

    public CardPayment(IPaymentGateway gateway)
        => _gateway = gateway;

    public string MethodName => "Card";

    public PaymentResult Process(decimal amount)
    {
        // Level 6 β€” delegate to injected gateway
        var success = _gateway.Charge(amount);    // Level 3
        return new PaymentResult(
            Accepted: success,
            AmountCharged: success ? amount : 0m,
            DeclineReason: success ? null : "Card declined.");
    }
}

// ─── MobilePayment ────────────────────────────────────
// Level 3 β€” NFC / QR code / Apple Pay / Google Pay.
// Same interface, completely different implementation.
public sealed class MobilePayment : IPaymentStrategy
{
    private readonly IPaymentGateway _gateway;    // Level 6

    public MobilePayment(IPaymentGateway gateway)
        => _gateway = gateway;

    public string MethodName => "Mobile";

    public PaymentResult Process(decimal amount)
    {
        var success = _gateway.Charge(amount);    // Level 3
        return new PaymentResult(
            Accepted: success,
            AmountCharged: success ? amount : 0m,
            DeclineReason: success ? null : "Mobile payment failed.");
    }
}

// ─── IPaymentGateway ──────────────────────────────────
// Level 6 β€” Abstraction for the actual payment processor.
// In tests, you inject a mock. In production, the real API.
public interface IPaymentGateway
{
    bool Charge(decimal amount);
}
VendingMachine.cs — The main orchestrator
namespace VendingMachine;

using VendingMachine.Models;
using VendingMachine.States;
using VendingMachine.Payments;

// Level 0-7 β€” The machine itself. Delegates almost
// everything to the current state, payment strategy,
// and change calculator. It's the GLUE, not the logic.
public sealed class VendingMachine
{
    // ─── Dependencies (injected) ──────────────────────
    private readonly Dictionary<string, Slot> _slots;         // Level 1
    private readonly IChangeCalculator _changeCalc;            // Level 6
    private readonly CoinInventory _coinInventory;             // Level 4
    private readonly List<IMachineObserver> _observers = [];   // Level 7
    private readonly object _lock = new();                     // Level 5

    // ─── State ────────────────────────────────────────
    private IVendingState _currentState;                       // Level 2
    public decimal CurrentBalance { get; set; }                // Level 2
    public string CurrentStateName => _currentState.StateName; // Level 7

    public VendingMachine(
        IEnumerable<Slot> slots,                               // Level 1
        IChangeCalculator changeCalculator,                    // Level 6
        CoinInventory? coinInventory = null)                   // Level 4
    {
        _slots = slots.ToDictionary(s => s.Code);            // Level 1
        _changeCalc = changeCalculator;                        // Level 6
        _coinInventory = coinInventory ?? new CoinInventory(); // Level 4
        _currentState = new IdleState();                       // Level 2
    }

    // ─── Public API (delegates to current state) ──────
    public VendingResult<string> InsertMoney(decimal amount)
    {
        lock (_lock)                                           // Level 5
        {
            var result = _currentState.InsertMoney(this, amount);
            if (result.Success)
                Notify(new MachineEvent("MoneyInserted",       // Level 7
                    $"Inserted {amount:C}"));
            return result;
        }
    }

    public VendingResult<Receipt> SelectProduct(string slotCode)
    {
        lock (_lock)                                           // Level 5
        {
            return _currentState.SelectProduct(this, slotCode);
        }
    }

    public VendingResult<decimal> ReturnMoney()
    {
        lock (_lock)                                           // Level 5
        {
            var result = _currentState.ReturnMoney(this);
            if (result.Success)
                Notify(new MachineEvent("MoneyReturned",       // Level 7
                    $"Returned {result.Value:C}"));
            return result;
        }
    }

    // ─── Internal helpers (used by states) ────────────
    public Slot? FindSlot(string code)                         // Level 1
        => _slots.GetValueOrDefault(code);

    public void TransitionTo(IVendingState newState)           // Level 2
    {
        var oldName = _currentState.StateName;
        _currentState = newState;
        Notify(new MachineEvent("StateChanged",                // Level 7
            $"{oldName} β†’ {newState.StateName}"));
    }

    public VendingResult<Receipt> DispenseProduct(Slot slot)   // Level 4
    {
        // Level 4 β€” Calculate change
        var change = CurrentBalance - slot.Product.Price;
        var breakdown = _changeCalc.Calculate(change, _coinInventory);

        // Level 5 β€” Can we actually make change?
        if (breakdown is null)
        {
            TransitionTo(new HasMoneyState());
            return VendingResult<Receipt>.Fail(
                "Cannot make exact change. Please use a different amount.");
        }

        // Level 1 β€” Dispense the item
        slot.Dispense();

        // Level 4 β€” Build transaction + receipt
        var txn = new Transaction(
            TransactionId: Guid.NewGuid().ToString("N")[..8],
            SlotCode: slot.Code,
            ProductName: slot.Product.Name,
            AmountPaid: CurrentBalance,
            ProductPrice: slot.Product.Price,
            ChangeGiven: change,
            Timestamp: DateTimeOffset.UtcNow);

        var receipt = new Receipt(txn, breakdown);

        // Level 4 β€” Remove coins from inventory for change
        foreach (var (coin, count) in breakdown)
            _coinInventory.TryRemove(coin, count);

        // Level 2 β€” Reset
        CurrentBalance = 0;
        TransitionTo(new IdleState());

        // Level 7 β€” Notify observers
        Notify(new MachineEvent("ProductDispensed",
            $"{slot.Product.Name} from {slot.Code}. Change: {change:C}"));

        return VendingResult<Receipt>.Ok(receipt);
    }

    // ─── Observer management ──────────────────────────
    public void Subscribe(IMachineObserver observer)           // Level 7
        => _observers.Add(observer);

    public void Unsubscribe(IMachineObserver observer)         // Level 7
        => _observers.Remove(observer);

    private void Notify(MachineEvent evt)                      // Level 7
    {
        foreach (var obs in _observers)
            obs.OnMachineEvent(evt);
    }

    // ─── Fleet operations ─────────────────────────────
    public void SetOutOfService()                              // Level 7
        => TransitionTo(new OutOfServiceState());

    public void RestoreService()                               // Level 7
        => TransitionTo(new IdleState());
}

// ─── IChangeCalculator ────────────────────────────────
// Level 6 β€” Abstraction for change logic. Greedy algorithm
// in production, mock in tests.
public interface IChangeCalculator
{
    IReadOnlyList<(Denomination Coin, int Count)>? Calculate(
        decimal amount, CoinInventory inventory);
}

// ─── GreedyChangeCalculator ───────────────────────────
// Level 4 β€” Uses largest-first greedy approach.
public sealed class GreedyChangeCalculator : IChangeCalculator
{
    public IReadOnlyList<(Denomination Coin, int Count)>? Calculate(
        decimal amount, CoinInventory inventory)
    {
        var cents = (int)(amount * 100);
        if (cents <= 0) return [];                // Level 5 β€” no change needed

        var result = new List<(Denomination, int)>();
        var denoms = Enum.GetValues<Denomination>()
            .OrderByDescending(d => (int)d);

        foreach (var denom in denoms)
        {
            var coinValue = (int)denom;
            var needed = cents / coinValue;
            var available = inventory.CountOf(denom);
            var use = Math.Min(needed, available);
            if (use > 0)
            {
                result.Add((denom, use));
                cents -= use * coinValue;
            }
        }

        return cents == 0 ? result : null;         // Level 5 β€” null = can't make change
    }
}

// ─── IMachineObserver ─────────────────────────────────
// Level 7 β€” Anyone who wants to know what the machine
// is doing implements this. Dashboard, warehouse, analytics.
public interface IMachineObserver
{
    void OnMachineEvent(MachineEvent evt);
}

// ─── MachineEvent ─────────────────────────────────────
// Level 7 β€” Immutable event record. The machine fires
// these; observers consume them.
public sealed record MachineEvent(
    string EventType,                              // Level 7
    string Description,                            // Level 7
    DateTimeOffset Timestamp = default)            // Level 7
{
    public DateTimeOffset Timestamp { get; init; }
        = Timestamp == default ? DateTimeOffset.UtcNow : Timestamp;
}

// ─── FleetManager ─────────────────────────────────────
// Level 7 β€” Monitors multiple machines. In production this
// would be a dashboard service; here it logs events.
public sealed class FleetManager : IMachineObserver
{
    private readonly string _managerId;

    public FleetManager(string managerId) => _managerId = managerId;

    public void OnMachineEvent(MachineEvent evt)
    {
        Console.WriteLine(
            $"[Fleet:{_managerId}] {evt.Timestamp:HH:mm:ss} " +
            $"| {evt.EventType}: {evt.Description}");
    }
}
Program.cs — DI wiring and demo scenario
using Microsoft.Extensions.DependencyInjection;
using VendingMachine;
using VendingMachine.Models;
using VendingMachine.Payments;

// ─── DI Container Setup ──────────────────────────────
// Level 6 β€” Everything is wired through DI. No "new" calls
// scattered through business logic. This makes testing trivial:
// swap any registration with a mock.
var services = new ServiceCollection();

// Level 6 β€” Register change calculator as singleton
services.AddSingleton<IChangeCalculator, GreedyChangeCalculator>();

// Level 6 β€” Register payment gateway (could be mock in tests)
services.AddSingleton<IPaymentGateway>(new SimplePaymentGateway());

// Level 3 β€” Register payment strategies
services.AddTransient<CoinPayment>();
services.AddTransient<CardPayment>();
services.AddTransient<MobilePayment>();

// Level 1 β€” Configure product inventory
var slots = new List<Slot>
{
    new("A1", new Product("Cola", 1.25m), 10),
    new("A2", new Product("Chips", 1.50m), 8),
    new("B1", new Product("Water", 1.00m), 15),
    new("B2", new Product("Candy Bar", 0.75m), 12),
    new("C1", new Product("Energy Drink", 2.50m), 5),
};

// Level 4 + Level 6 β€” Build the machine with DI
services.AddSingleton(sp =>
{
    var calc = sp.GetRequiredService<IChangeCalculator>();
    return new VendingMachine.VendingMachine(slots, calc);
});

var provider = services.BuildServiceProvider();
var machine = provider.GetRequiredService<VendingMachine.VendingMachine>();

// ─── Level 7: Subscribe observers ────────────────────
var fleet = new FleetManager("HQ-01");
machine.Subscribe(fleet);

// ─── Demo Scenario ────────────────────────────────────
Console.WriteLine("=== Vending Machine Demo ===\n");

// Happy path: insert money, buy a cola
var insert1 = machine.InsertMoney(1.00m);
Console.WriteLine(insert1.Value);

var insert2 = machine.InsertMoney(0.50m);
Console.WriteLine(insert2.Value);

var purchase = machine.SelectProduct("A1");
if (purchase.Success)
{
    var r = purchase.Value!;
    Console.WriteLine($"\nReceipt:");
    Console.WriteLine($"  Product: {r.Transaction.ProductName}");
    Console.WriteLine($"  Paid:    {r.Transaction.AmountPaid:C}");
    Console.WriteLine($"  Price:   {r.Transaction.ProductPrice:C}");
    Console.WriteLine($"  Change:  {r.Transaction.ChangeGiven:C}");
    Console.WriteLine($"  Coins returned:");
    foreach (var (coin, count) in r.ChangeBreakdown)
        Console.WriteLine($"    {count}x {coin}");
}

// Edge case: try to buy without money
Console.WriteLine($"\n--- No money inserted ---");
var fail = machine.SelectProduct("A2");
Console.WriteLine(fail.Error);

// Edge case: return inserted money
machine.InsertMoney(2.00m);
var returned = machine.ReturnMoney();
Console.WriteLine($"\nReturned: {returned.Value:C}");

// ─── Simple gateway for demo ─────────────────────────
public sealed class SimplePaymentGateway : IPaymentGateway
{
    public bool Charge(decimal amount) => true;   // Always succeeds in demo
}
Notice the pattern: Every // Level N comment traces back to a specific constraint from the game. Nothing was planned up front — each piece of code exists because a real requirement demanded it. That's emergent designEmergent design means the architecture grows from real constraints rather than being imagined up front. You don't draw a 25-class diagram on day one. You start with 1 class, hit a wall, add what's needed, hit another wall, add more. The result is the same 25 classes — but each one is justified by a real problem, not a guess. in action.
Section 12

Pattern Spotting — X-Ray Vision

You've been using design patterns for the last seven levels. Some were obvious — we named them as we built them. Others are hiding in the code, doing their job quietly without anyone putting a label on them. This section is about developing pattern recognitionThe ability to look at code and see the underlying design patterns at work. Senior engineers do this unconsciously — they glance at code and immediately see "that's a Strategy" or "that's a State pattern." This skill comes from practice: once you've built patterns yourself, you spot them everywhere. — the skill of looking at code and seeing the structural bones underneath.

Think First #10

You've used 3 main patterns — State, Strategy, and Observer. Can you spot 2 MORE patterns hiding in the code that you didn't intentionally add? Hint: look at how state objects get created, and think about what OutOfServiceState does compared to throwing exceptions.

Take 60 seconds.

Factory MethodVendingMachine.TransitionTo() doesn't just switch states, it accepts newly created state objects from the calling state. Each state decides which state comes next: HasMoneyState creates new DispensingState(), IdleState creates new HasMoneyState(). That's clean separation of state creation from state usage.

Null ObjectOutOfServiceState silently rejects every operation with a polite message instead of throwing exceptions. The machine doesn't need if (isOutOfService) throw checks everywhere — the state itself handles rejection gracefully. That's the Null Object pattern: a "do-nothing" implementation that satisfies the interface contract without crashing.

The Four Explicit Patterns

These are the patterns we named during the build. For each one, we'll look at where it lives in the code, what it enables, and what would happen without it.

State Pattern — "Behavior changes when the mode changes"

WhereIVendingState + IdleState, HasMoneyState, DispensingState, OutOfServiceState
EnablesAdd new machine modes (Maintenance, StuckItem, CoinJam) with zero changes to existing code. Each mode is its own class with its own rules. The machine never asks "what mode am I in?" — it just delegates to the current state.
Without itEvery method in VendingMachine would start with if (state == "idle") ... else if (state == "hasMoney") ... else if (state == "dispensing") .... With 4 states and 3 methods, that's 16+ if/else branches. Adding a new state like "Maintenance" would mean editing every method.

Strategy Pattern — "Swap the payment method without touching the machine"

WhereIPaymentStrategy + CoinPayment, CardPayment, MobilePayment
EnablesAdd cryptocurrencyImagine a CryptoPayment class that implements IPaymentStrategy. It calls a blockchain API in its Process() method. The machine doesn't know or care — it just calls Process() and gets back a PaymentResult. That's the power of Strategy: new algorithms slot in without changing existing code. payment with one new class and zero changes to existing files. Each payment method is completely independent — CoinPayment knows nothing about CardPayment.
Without itA switch(paymentType) inside the machine. Every time you add a new payment method, you crack open the switch statement, add a case, and risk breaking the existing cases. Classic OCP violationThe Open-Closed Principle says code should be open for extension (adding new behavior) but closed for modification (touching existing code). A switch statement violates OCP because adding a new case modifies existing code. Strategy fixes this by letting you add new classes instead of modifying existing ones..

Observer Pattern — "Notify everyone without knowing who's listening"

WhereIMachineObserver + MachineEvent + FleetManager
EnablesA dashboard, a warehouse restocking system, an analytics service, and a maintenance alert system can all listen to the same machine without the machine knowing any of them exist. Subscribe, get events, done.
Without itThe machine would need direct references to the dashboard, the warehouse system, and every consumer. Adding a new consumer means modifying the machine class. The machine becomes coupledCoupling means two pieces of code depend on each other. If the machine directly calls dashboard.Update(), warehouse.CheckStock(), and analytics.LogEvent(), it's coupled to all three. Change any of them and the machine breaks. Observer eliminates this: the machine fires events into the void, and whoever subscribes gets them. to every consumer.

Factory Method — "Each state creates the next state"

WhereVendingMachine.TransitionTo() — each state creates the next state object. IdleState creates new HasMoneyState(). HasMoneyState creates new DispensingState(). The machine doesn't decide which state comes next — the current state does.
EnablesClean separation of state creation from state usage. If a new state needs constructor parameters, only the creating state changes — the machine's TransitionTo() method stays untouched.
Without itA central switch in the machine that decides which state object to create based on the old state. Every new state means editing that switch. The state-creation logic would be tangled with state-usage logic.

Pattern X-Ray — See Through the Code

Here's the class diagram again, but this time with colored overlays showing which pattern each type belongs to. Blue is State, orange is Strategy, yellow is Observer, and purple marks the hidden Factory Method. Some types serve multiple patterns — that's normal and healthy.

PATTERN X-RAY OVERLAY State Strategy Observer Factory Method STATE PATTERN IVendingState IdleState HasMoneyState DispensingState OutOfServiceState STRATEGY PATTERN IPaymentStrategy CoinPayment CardPayment MobilePayment OBSERVER PATTERN IMachineObserver MachineEvent FleetManager FACTORY METHOD (hidden) TransitionTo() new XxxState() VendingMachine delegates to all four patterns

How the Patterns Interact

Patterns don't live in isolation. Here's how they collaborateIn well-designed systems, patterns work together like gears in a machine. State delegates to Strategy (the HasMoneyState asks the payment strategy to process). Strategy produces a PaymentResult. Observer notifies on state transitions. Each pattern handles one concern and passes the result to the next. in a single purchase cycle: the State decides IF the action is allowed, Strategy handles HOW payment works, and Observer announces WHAT happened.

ONE PURCHASE CYCLE — PATTERNS COLLABORATING Customer SelectProduct() STATE HasMoneyState "Is this purchase allowed right now?" yes DISPENSE Calc change, give product FACTORY TransitionTo() "Create next state: IdleState" Receipt VendingResult OBSERVER IMachineObserver.OnMachineEvent() "ProductDispensed: Cola from A1. Change: $0.25" fires events FleetManager Dashboard STRATEGY IPaymentStrategy "HOW to pay" pre-paid

Template Method

Where: The level rhythm itself! Every level followed the same structure: Constraint → What Breaks → Think First → Solution → Growing Diagram. That's a template methodTemplate Method defines a skeleton algorithm in a base class (or process) and lets subclasses (or instances) override specific steps. Here, each level is an "instance" of the same template — the structure is fixed, but the specific constraint and solution vary. — a fixed skeleton where each step's content varies.

Why it matters: The template made every level learnable in the same rhythm. Your brain knew what to expect, so you could focus on the new concept instead of figuring out the structure.

Null Object

Where: OutOfServiceState silently rejects all operations instead of throwing exceptions. It returns polite error messages ("Machine is out of service") for every method call. The machine doesn't need special "is the machine broken?" checks anywhere.

Why it matters: Without the Null Object pattern, every method in VendingMachine would need if (_isBroken) throw new InvalidOperationException(...). With OutOfServiceState, the state itself handles rejection gracefully — no special cases, no exceptions, just a well-behaved implementation that says "no" to everything.

The Takeaway: Six patterns in a vending machine. None of them were "applied" from a textbook. Each one was discovered by hitting a constraint that made the simpler approach painful. That's how patterns work in real projects: you feel the pain first, and the pattern is the relief.
Section 13

The Growing Diagram — Complete Evolution

You've just spent seven levels building a vending machine. At Level 0 it was a single class that took money and gave items. By Level 7, it's a clean architecture with 25 types spanning state management, payment strategies, change calculation, and fleet monitoring. But it didn't happen all at once — it grew one constraint at a time. Let's zoom out and watch the whole journey unfold.

Think First #11

Which level caused the biggest design explosion — the most new types added at once? Why do you think that level needed so many new types compared to the others?

Take 30 seconds.

Level 2 (State) and Level 4 (Data Flow) tied with 5 types each. Level 2 needed the IVendingState interface plus 4 concrete state classes — that's the nature of the State pattern, you need one class per mode. Level 4 needed 5 data-related types (Transaction, Receipt, ChangeCalculator, CoinInventory, Denomination) because tracking money flow requires modeling the physical reality of coins, receipts, and transactions. Both levels represent a fundamental shift in how the system thinks — Level 2 shifts from "one monolithic behavior" to "behavior varies by mode," and Level 4 shifts from "abstract numbers" to "physical reality."

Each level below shows what was added at that stage. Glowing boxes are new arrivals; the count shows the running total. Pay attention to the growth curveThe growth curve shows how many types exist at each level. A healthy design grows gradually — 1-5 new types per level. An unhealthy design dumps 20 types in one go because someone tried to "design everything up front." Our curve is gentle because we never added more than one concept at a time. — it stays gentle because we never added more than one concept at a time.

Design Evolution — L0 through L7
L0 Basic Machine VendingMachine 1 type L1 Products & Slots Product Slot +2 = 3 L2 State Pattern IVendingState 4 state classes +5 = 8 L3 Strategy Pattern IPaymentStrategy 3 payment classes +4 = 12 L4 Data Flow Transaction +4 more types +5 = 17 L5 Error Handling VendingResult<T> +validations +2 = 19 L6 DI & Testing 3 DI interfaces +3 = 22 L7 Observer IMachineObserver +2 more types +3 = 25 CUMULATIVE TYPE COUNT 1 L0 3 L1 8 L2 12 L3 17 L4 19 L5 22 L6 25 L7 TOTAL: 25 types across 8 levels

Here's the complete picture — every entity, its type, and the level that introduced it.

EntityKindLevelWhy This Kind?
VendingMachinesealed classL0Has mutable state (balance, slots) and orchestrates all behavior
ProductrecordL1Immutable data — a product's name and price never change mid-transaction
Slotsealed classL1Has mutable state (quantity decrements on each sale)
IVendingStateinterfaceL2Contract for mode-dependent behavior (State pattern)
IdleStatesealed classL2Waiting for money — only accepts InsertMoney
HasMoneyStatesealed classL2Money inserted — accepts product selection or money return
DispensingStatesealed classL2Delivering product — rejects all other actions
OutOfServiceStatesealed classL2Null ObjectThe Null Object pattern provides a "do-nothing" implementation of an interface. Instead of checking for null or throwing exceptions, OutOfServiceState politely rejects every operation. The machine doesn't need special handling for the broken state. — rejects everything gracefully
IPaymentStrategyinterfaceL3Contract for interchangeable payment methods (Strategy pattern)
CoinPaymentsealed classL3Coins already in balance — always succeeds
CardPaymentsealed classL3Delegates to IPaymentGateway — can fail
MobilePaymentsealed classL3NFC/QR — same interface, different implementation
DenominationenumL4Fixed categories (Penny, Nickel, Dime, Quarter, Dollar) with no behavior
CoinInventorysealed classL4Mutable state — coin counts change as change is given
TransactionrecordL4Immutable snapshot — once a purchase happens, it's history
ReceiptrecordL4Immutable — customer-facing summary, never modified
PaymentResultrecordL4Immutable response from payment strategy
ChangeCalculatorsealed classL4Has algorithm (greedy) but no persistent state
VendingResult<T>recordL5Immutable result carrier — no exceptions for expected failures
IInventoryRepositoryinterfaceL6DI seam — mock in tests, real storage in production
IPaymentGatewayinterfaceL6DI seam — mock payment processor in tests
IChangeCalculatorinterfaceL6DI seam — swap change algorithm without touching machine
IMachineObserverinterfaceL7Observer contract — subscribe to machine events
MachineEventrecordL7Immutable event data — what happened and when
FleetManagersealed classL7Concrete observer — monitors multiple machines
The Growth Story: 1 → 3 → 8 → 12 → 17 → 19 → 22 → 25. Each jump was forced by a real constraint. Level 2 added the most types (5) because the State pattern requires one class per mode. Level 4 added 5 types because modeling physical realityA vending machine deals with physical coins, not abstract numbers. You can't return $0.75 in change if you only have dimes. Modeling physical reality (Denomination enum, CoinInventory class) catches bugs that abstract models miss. (coins, receipts, transactions) requires dedicated types.
Section 14

Five Bad Solutions — Learn What NOT to Do

You've seen the good solution — built incrementally over 7 levels. Now let's study five bad approaches that people commonly reach for. Each one is tempting for a different reason, and each one breaks in a different way. We'll show the exact code, an SVG of the problem, and the fix.

FIVE BAD SOLUTIONS — DIFFERENT FAILURE MODES God Class Too much in one place Over-Engineer Too many abstractions Happy-Path Hero Too trusting Stringly-Typed Too loose No Change Calc Too abstract SRP YAGNI Robustness Type Safety Reality Gap Most dangerous: #3 (looks professional, fails silently)

Bad Solution 1: "The God Class"

Imagine a single restaurant employee who takes orders, cooks, serves tables, handles the cash register, AND does the dishes. On a quiet Monday, it works. On a Friday night, everything falls apart because one person can't juggle six jobs at once.

That's what happens when you put everything in a single VendingMachineSystem class: state checks, payment processing, change calculation, inventory management, receipt printing, and monitoring all mixed together. At first it feels productive — everything in one file. But the moment you try to add mobile payment, you're modifying the same file your teammate is editing to add a new product type. Merge conflictA merge conflict happens when two developers edit the same part of the same file at the same time. Git can't figure out which version to keep. With a God Class, EVERY change touches the same file, so merge conflicts are constant. guaranteed.

VendingMachineSystem.cs — 800 lines, 6 responsibilities State Checks (if/else) Payment Processing Change Calculation Inventory Management Receipt Printing Fleet Monitoring 6 reasons to change · 1 file · constant merge conflicts Add mobile payment? Modify 3 methods. Add new product? Same 3 methods. Both at once? Merge conflict.
GodClass.cs — Everything in One Place
public class VendingMachineSystem
{
    private string _state = "idle";
    private decimal _balance;
    private Dictionary<string, (string Name, decimal Price, int Qty)> _slots = new();

    public string InsertMoney(decimal amount)
    {
        if (_state == "out-of-service") return "Machine broken";
        if (_state == "dispensing") return "Wait...";
        _balance += amount;
        _state = "has-money";
        Console.WriteLine($"Balance: {_balance:C}");      // display logic HERE
        return "OK";
    }

    public string BuyProduct(string code, string paymentType)
    {
        if (_state != "has-money") return "Insert money first";
        if (!_slots.ContainsKey(code)) return "Bad code";
        var (name, price, qty) = _slots[code];
        if (qty <= 0) return "Sold out";
        if (_balance < price) return "Not enough money";

        // Payment logic INLINE
        if (paymentType == "coin") { /* ... */ }
        else if (paymentType == "card") { /* ... */ }
        else if (paymentType == "mobile") { /* ... */ }

        // Change logic INLINE
        var change = _balance - price;
        // ... 40 lines of coin math ...

        // Receipt logic INLINE
        Console.WriteLine($"Receipt: {name} - {price:C}");
        Console.WriteLine($"Change: {change:C}");

        _slots[code] = (name, price, qty - 1);
        _balance = 0;
        _state = "idle";
        return "Enjoy!";
    }
    // ... 500 more lines of monitoring, restocking, diagnostics ...
}

The moment it dies: "Add mobile payment" requires modifying BuyProduct(). Your teammate is adding a new product type in the same method. Merge conflict. Now multiply this by every feature request for the next 6 months.

CleanSeparation.cs — Each Class Has One Job
// State: "Is this action allowed right now?"
public interface IVendingState { ... }

// Strategy: "How do we process this payment?"
public interface IPaymentStrategy { ... }

// Data: "What happened?" (immutable records)
public record Transaction(...);
public record Receipt(...);

// Observer: "Who needs to know?"
public interface IMachineObserver { ... }

// Engine: ONLY orchestration β€” delegates everything
public sealed class VendingMachine { ... }

Why the fix works: Add mobile payment? Create MobilePayment : IPaymentStrategy. Add a new product? Only Slot changes. Add monitoring? Implement IMachineObserver. Zero merge conflicts because each concern lives in its own file.

Lesson — SRP: One class, one reason to change. If your class description uses "and" more than once, split it.

The opposite extreme: instead of cramming everything into one class, you create 20 classes for a machine that sells chips. Every noun gets its own abstract factory. Every method gets a mediator. The codebase looks impressive on a UML diagram but is impossible to navigate.

THE OVER-ENGINEER'S DREAM AbstractPaymentProcessingStrategyFactoryProvider IVendingStateTransitionMediatorObserverChain CoinInsertionEventBusCommandHandler InventorySlotValidationSpecificationBuilder ChangeCalculatorDecoratorProxy "Where does coin insertion happen?" — "Well, trace through 12 classes..."
OverEngineered.cs — 20 Classes for a Chip Machine
// Layer 1: Abstract factories
public interface IPaymentStrategyFactory { ... }
public class PaymentStrategyFactoryProvider { ... }
public class AbstractPaymentProcessingPipeline { ... }

// Layer 2: Mediators
public interface IStateTransitionMediator { ... }
public class VendingStateMachineMediator { ... }

// Layer 3: Event bus
public interface IVendingEventBus { ... }
public class InMemoryVendingEventBus { ... }
public class CoinInsertedEventHandler { ... }
public class ProductSelectedEventHandler { ... }
public class ChangeRequestedEventHandler { ... }

// Layer 4: Specifications
public interface ISlotSpecification { ... }
public class AvailableSlotSpecification { ... }
public class PriceMatchSpecification { ... }
public class CompositeSlotSpecification { ... }

// ... and the actual VendingMachine?
// It's somewhere on line 400, buried under abstractions.

The moment it dies: A new hire asks "where does coin insertion happen?" The answer: CoinInsertedEventHandler processes the event from InMemoryVendingEventBus which was fired by VendingStateMachineMediator which received the command from AbstractPaymentProcessingPipeline. The new hire quits.

JustEnough.cs — Patterns That Solve Real Problems
// State pattern β€” because we HAVE 4 modes
public interface IVendingState { ... }

// Strategy pattern β€” because we HAVE 3 payment methods
public interface IPaymentStrategy { ... }

// Observer β€” because fleet monitoring IS a real requirement
public interface IMachineObserver { ... }

// No factories, no mediators, no event buses, no specifications.
// Why? Because we don't HAVE those problems.
// Patterns solve problems. No problem β†’ no pattern.

Why the fix works: Every pattern in our solution exists because a real constraint demanded it. State exists because boolean flags couldn't handle 4 modes. Strategy exists because 3 payment methods need different logic. Observer exists because fleet monitoring was a requirement. No pattern was added "just in case."

Lesson — YAGNI: You Aren't Gonna Need ItYAGNI (You Aren't Gonna Need It) is a principle from Extreme Programming. Don't build infrastructure for problems you don't have yet. Each abstraction layer has a cost: learning curve, indirection, debugging difficulty. Only pay that cost when a real constraint demands it.. Patterns solve problems. No problem? No pattern.

This one's sneaky. The code looks great. Clean State pattern, proper Strategy, good naming, small classes. It would pass a code review. But it has a fatal flaw: it only handles the happy pathThe happy path is the scenario where everything goes right: the customer inserts exact change, picks an in-stock item, the machine dispenses perfectly. Real life includes wrong change, sold-out items, stuck motors, card declines, and two customers pressing buttons simultaneously.. No validation, no change verification, no concurrent access protection, no stuck-item handling.

FIRST DAY IN A BUSY OFFICE Deploy "Looks great!" 9:15 AM Null slot code NullReferenceException 12:00 PM Two people, one Snickers Both succeed β†’ dispenses air 3:30 PM Machine has only dimes Returns "$0.75" but can't 5:00 PM Motor jams Stuck in Dispensing forever 4 production failures in 8 hours Clean code β‰  robust code
HappyPath.cs — Clean but Fragile
// Looks clean! State + Strategy, good naming.
// But watch what's MISSING:

public VendingResult<Receipt> SelectProduct(string slotCode)
{
    var slot = _slots[slotCode];  // No null check β†’ crash on bad code
    slot.Dispense();               // No stock check β†’ negative inventory
    var change = _balance - slot.Product.Price;
    // Returns decimal, not actual coins β†’ can't verify physical change
    // No lock β†’ two threads can buy the last item simultaneously
    // No timeout on dispensing β†’ motor jam = stuck forever
}

The moment it dies: First day in a busy office. Two people at the dual-screen kiosk buy the last Snickers simultaneously. Both succeed. Machine dispenses air. Customer calls angry. Then the machine jams, stays in Dispensing state forever, and nobody can use it until maintenance arrives.

RobustCode.cs — Handles Reality
public VendingResult<Receipt> SelectProduct(string slotCode)
{
    if (string.IsNullOrWhiteSpace(slotCode))          // L5: validate input
        return VendingResult<Receipt>.Fail("Slot code required.");

    lock (_lock)                                       // L5: atomic operation
    {
        var slot = FindSlot(slotCode);                 // L5: safe lookup
        if (slot is null) return Fail("Invalid slot.");
        if (!slot.IsAvailable) return Fail("Sold out.");
        if (_balance < slot.Product.Price) return Fail("Not enough.");

        var breakdown = _changeCalc.Calculate(         // L4: REAL coins
            _balance - slot.Product.Price, _coinInventory);
        if (breakdown is null) return Fail("Can't make change.");

        slot.Dispense();                               // Only NOW dispense
        // ... build receipt, reset state ...
    }
}

Why the fix works: Validation before action. Lock for atomicity. Real coin tracking. The Result pattern instead of exceptions. Every edge case from Level 5 is handled. Clean code AND robust code.

Lesson: Clean code ≠ robust code. Edge cases aren't optional. The happy path is 20% of the work and 80% of the code review praise. The other 80% of the work is what keeps you off the 2 AM pager.

State stored as string _currentState = "idle". Payment type is a string. Products identified by name strings. Slot codes matched with Contains(). The type systemC#'s type system (enums, interfaces, records) catches errors at compile time — before your code ever runs. Strings catch errors at runtime — usually at 2 AM in production. The type system is your free safety net. Use it. gives you a free safety net, and this approach throws it away.

STRINGLY-TYPED FAILURES "Idle" != "idle" "coin" vs "Coin" vs "COIN" "A1".Contains("1") = true Strings: compiles fine, breaks at runtime, silently Enums: compile-time error Interfaces: compile-time error
StringlyTyped.cs — Everything Is a String
public class VendingMachine
{
    private string _state = "idle";       // Not an enum, not a class

    public string Process(string action, string slotCode, string paymentType)
    {
        if (_state == "Idle") { ... }     // Bug: capital I vs lowercase i
        // _state was set to "idle" but we check "Idle" β†’ always false

        if (paymentType == "coin") { ... }
        else if (paymentType == "card") { ... }
        // Someone passes "Card" β†’ falls through, no payment processed

        if (slotCode.Contains("A")) { ... } // Matches "A1" AND "BA3"
    }
}

The moment it dies: Someone types "Idle" instead of "idle" and the state machine breaks silently. No compiler error, no runtime exception — just wrong behavior that takes hours to debug because "Idle" != "idle" is invisible in logs.

TypeSafe.cs — The Compiler Is Your Friend
// State: IVendingState interface β†’ typos are impossible
private IVendingState _currentState = new IdleState();
// Misspell "IdleState"? Compiler error. Immediately. For free.

// Payment: IPaymentStrategy β†’ no string matching
public PaymentResult Process(IPaymentStrategy strategy)
// Pass wrong type? Compiler error. Can't pass "Card" as a string.

// Slots: Dictionary lookup by exact code
var slot = _slots.GetValueOrDefault(slotCode);
// Returns null if not found β€” no Contains() ambiguity

Why the fix works: Enums, records, and interfaces catch errors at compile time. Strings catch them at 2 AM. The type system is free bug prevention. Use it.

Lesson: Use the type system. Stringly-typedA play on "strongly-typed." Stringly-typed code uses strings where it should use enums, interfaces, or records. It compiles happily, looks correct, and fails silently at runtime because strings have no compile-time constraints. code compiles fine and breaks at 2 AM.

The machine returns the change amount as a decimal but doesn't track actual coins. It says "$0.75 change" but has no idea whether it physically has the coins to make that happen. This is the gap between modeling abstract numbers and modeling physical reality.

Abstract Number change = $3.75 Machine has: $2 in coins total Customer gets shorted $1.75 Physical Coins 3x Quarter + 0x Dime? Only 2 quarters left β†’ can't Greedy algorithm checks REAL inventory Returns null if impossible β†’ reject sale
NoChangeCalc.cs — Abstract Numbers
public Receipt Buy(string slotCode)
{
    var slot = _slots[slotCode];
    var change = _balance - slot.Product.Price;

    // "Here's your $3.75 change!"
    // But... does the machine HAVE $3.75 in coins?
    // It doesn't know. It doesn't track individual coins.
    // It just returns a decimal and hopes for the best.

    return new Receipt(slot.Product.Name, change);
    // Customer expects 3 quarters but machine has only dimes.
    // 37.5 dimes is not a real amount of change.
}

The moment it dies: Customer pays $5 for a $1.25 item. Machine says "Change: $3.75" but has only $2 in coins. Customer gets shorted. Repeat 50 times a day = angry customers, refund requests, and a vending machine company that loses trust.

PhysicalCoins.cs — Model Reality
// CoinInventory tracks REAL coins
public sealed class CoinInventory
{
    private readonly Dictionary<Denomination, int> _coins = new();
    public bool TryRemove(Denomination coin, int count) { ... }
}

// GreedyChangeCalculator checks what's physically available
public IReadOnlyList<(Denomination, int)>? Calculate(
    decimal amount, CoinInventory inventory)
{
    // Try largest coins first, fall back to smaller ones
    // If we can't make exact change β†’ return null
    // Null means "reject the sale" β€” don't promise what you can't deliver
}

// In DispenseProduct:
var breakdown = _changeCalc.Calculate(change, _coinInventory);
if (breakdown is null)
    return VendingResult<Receipt>.Fail("Cannot make exact change.");

Why the fix works: The CoinInventory tracks real coins. The GreedyChangeCalculator tries to make change with what's actually available. If it can't, the sale is rejected before the product is dispensed. No false promises, no shorted customers.

Lesson: Model physical reality. The machine has REAL coins, not abstract numbers. Whenever your software interacts with the physical world (money, inventory, time), your data model must reflect that physical reality.

Think First #12

Which of these five bad solutions is the most dangerous? Why?

Take 30 seconds.

Bad Solution #3 (The Happy-Path Hero) is the most dangerous. Solutions #1 and #2 are obviously bad — any code reviewer spots a God Class or an over-engineered hierarchy. Solution #4 (stringly-typed) is caught by anyone who knows C# idioms. Solution #5 is domain-specific but visible once you think about physical coins.

But Solution #3 looks professional. It has clean patterns, good naming, small classes. It would pass a code review from a senior developer who doesn't think about edge cases. It's a wolf in sheep's clothing — it sails through review and then fails silently in production. The most dangerous bugs aren't the ones that look wrong; they're the ones that look right.

Section 15

Code Review Challenge — Find 5 Bugs

A junior developer submitted this vending machine implementation as a pull request. It compiles. It runs. It handles a basic purchase from coin insertion to item dispensing. But there are exactly 5 bugs hiding in it — issues that would cause real problems in production. Can you find them all before scrolling down?

PR Submitted "LGTM!" Under Review Reading code... 5 Issues Found Request changes Fixes Applied Push new commit Approved + Merged Ship it! Thread safety · Race condition · OCP violation · Missing validation · State corruption

Read the code below carefully. Try to find all 5 issues before revealing the answers.

CandidateSolution.cs — Find 5 Bugs
public class VendingMachine                                        // Line 1
{
    private Dictionary<string, Slot> _slots = new();               // Line 3
    private decimal _balance;
    private string _currentState = "idle";                          // Line 5

    public string InsertMoney(decimal amount)                       // Line 7
    {
        _balance += amount;
        _currentState = "has-money";
        return $"Balance: {_balance:C}";
    }

    public string SelectProduct(string slotCode)                    // Line 13
    {
        if (_currentState != "has-money") return "Insert money first";

        var slot = _slots[slotCode];                                // Line 16
        // No null check β€” what if slotCode doesn't exist?

        if (_balance < slot.Product.Price)
            return "Not enough money";

        // Check stock and dispense β€” but NOT atomic                // Line 21
        if (slot.Quantity > 0)
        {
            slot.Quantity--;                                        // Line 23
            // Another thread could have decremented between check and here!

            var change = _balance - slot.Product.Price;
            _balance = 0;

            _currentState = "dispensing";                            // Line 28
            // What if the motor jams here?
            // _currentState stays "dispensing" FOREVER
            // No timeout, no recovery β€” machine is bricked

            Console.WriteLine($"Dispensing {slot.Product.Name}");
            Console.WriteLine($"Change: {change:C}");

            // _currentState should go back to "idle" but only       // Line 35
            // if dispensing SUCCEEDS β€” what about failures?
            _currentState = "idle";

            return "Enjoy!";
        }
        return "Sold out";
    }

    public string ProcessPayment(string paymentType, decimal amount) // Line 42
    {
        switch (paymentType)                                         // Line 44
        {
            case "coin":
                _balance += amount;
                return "Coins accepted";
            case "card":
                // Call payment API...
                return "Card charged";
            case "mobile":
                // Call mobile API...
                return "Mobile payment OK";
            default:
                return "Unknown payment type";
            // Adding cryptocurrency = modify this switch             // Line 55
        }
    }
}
BUG SEVERITY 1 Thread Safety Line 3: Dictionary, not ConcurrentDictionary 2 Race Condition Lines 21-23: Check-then-act not atomic 3 OCP Violation Line 44: switch on payment type string 4 Missing Validation Line 16: No null check on slot lookup 5 State Corruption Line 28: Dispensing forever if motor jams Design flaws are harder to fix than logic bugs — catch them in review

Found them? Reveal one at a time:

Problem: Dictionary<string, Slot> is not thread-safe. If two customers use the machine simultaneously (or if the fleet management system reads slot data while a purchase is happening), you get corrupted stateDictionary's internal data structures (hash buckets, linked lists) can become corrupted when two threads modify it at the same time. The result isn't just a wrong answer — it's an InvalidOperationException or, worse, silently wrong data that passes all checks but represents an impossible state. or runtime exceptions. This isn't hypothetical — kiosk-style vending machines with touchscreens can absolutely receive concurrent requests.

Fix: Use ConcurrentDictionary<string, Slot> for the slot collection, AND wrap the check-stock + dispense operation in a lock to make the composite operation atomic. Thread-safe collections alone aren't enough — you need the lock for multi-step operations.

SOLID principle: This is a robustness concernRobustness means the code handles unexpected conditions gracefully. Thread safety is one dimension of robustness. Others include input validation, error handling, and timeout management. Robust code doesn't just work when everything goes right — it works when things go wrong. from Level 5 — the system must handle concurrent access safely.

Problem: The code checks if (slot.Quantity > 0) on line 21, then decrements slot.Quantity-- on line 23. Between those two lines, another thread could also check and see quantity = 1, then both threads decrement, leaving quantity at -1. Two customers "buy" the last Snickers. One gets a product; the other gets nothing but was still charged.

This is the classic check-then-act race conditionA check-then-act race condition happens when two steps (checking a condition and acting on it) are not atomic. Between the check and the act, another thread can change the condition. The fix is to make the entire check-and-act sequence happen inside a lock, so no other thread can interfere. — the most common concurrency bug in software.

Fix: Wrap the entire check-stock + decrement + dispense operation in a lock. Inside the lock, re-check the condition and only then proceed. This is exactly what our Level 5 solution does.

Taught in: Level 5 — Concurrency protection.

Problem: switch(paymentType) with string matching for "coin", "card", "mobile". Want to add cryptocurrency? You must modify this switch statement — opening existing, tested code for editing. That violates the Open-Closed PrincipleThe Open-Closed Principle (OCP) says code should be open for extension (you can add new behavior) but closed for modification (you don't touch existing code). A switch/case violates OCP because adding a new case means modifying existing code. The Strategy pattern fixes this: each payment method is its own class, and new methods are added without touching existing ones.. Plus, the payment types are strings, so a typo like "Card" instead of "card" fails silently (see Bug #4's cousin).

Fix: Replace the switch with the Strategy pattern: IPaymentStrategy interface, one class per payment method. Adding cryptocurrency means creating a new class that implements IPaymentStrategy — zero changes to existing code.

Taught in: Level 3 — Strategy Pattern.

Problem: _slots[slotCode] throws a KeyNotFoundException if the slot code doesn't exist. There's no null check, no TryGetValue, no validation that slotCode is non-null. A customer typing "Z9" on the keypad crashes the machine with an unhandled exception. Even worse, a null slotCode throws a different exception that might not be caught.

Fix: Validate inputs at system boundaries. Use _slots.GetValueOrDefault(slotCode) and check for null. Return a VendingResult.Fail() with a friendly error message instead of letting exceptions propagate. This is defensive programming — assume all external input is hostile.

Taught in: Level 5 — Edge case validation.

Problem: After setting _currentState = "dispensing" on line 28, the code proceeds to print output and then sets the state back to "idle" on line 37. But what if the physical dispense mechanism fails? A motor jam, a stuck item, a power glitch — any of these means the code never reaches line 37. The state stays "dispensing" forever. No new purchases can be made. No money can be returned. The machine is brickedA bricked device is one that's stuck in an unrecoverable state and is essentially a useless brick. In software, state corruption can brick a system by putting it in a state that no normal user action can exit. The only fix is a manual restart by maintenance staff. until maintenance physically resets it.

Fix: Use the State pattern with proper transitions. The DispensingState should have a timeout mechanism, and any failure should transition to either IdleState (with refund) or OutOfServiceState (with alert). The state object manages its own recovery, not the calling code.

Taught in: Level 2 — State Pattern (proper transitions prevent stuck states).

How did you do?
  • All 5 found: Senior-level code review skills. You think about concurrency, extensibility, and failure recovery.
  • 3–4 found: Solid mid-level. You catch most issues — probably missed the subtler thread safety or state corruption bugs.
  • 1–2 found: Review Levels 2, 3, and 5 — that's where these patterns were introduced.
  • 0 found: Don't worry! That's exactly why this challenge exists. Go back through the Constraint Game and these bugs will jump out at you next time.
Section 16

The Interview — Both Sides of the Table

You’ve built the entire vending machine system. Now comes the real test: can you talk through it under pressure? Below are two interview runs — the polished one and the realistic one (with stumbles, pauses, and recovery). Both get hired. The difference is how they handle the unexpected. Watch the interviewer’s inner monologue in the right column — that’s what’s actually being evaluated.

30-Minute Interview Timeline 2m Clarify 4m F/NF Reqs 8m Entities 12m Patterns 20m Code 25m Edge Cases 30m Scale First 12 minutes = NO CODE. Just thinking, asking, modeling. Candidates who code before minute 8 almost always have to backtrack and redesign.
TimeCandidate SaysInterviewer Thinks
0:00 “Before I start coding, let me clarify scope. Is this a single standalone machine or a fleet? Coin-only or multi-payment — cards, mobile wallets? Do we need a screen UI, or just the engine behind it?” Great — scoping first. Three focused questions that reveal hidden complexity.
2:00 Functional requirementsWhat the system DOES — the features. Accept payment, select product, dispense item, return change. These are the operations visible to users standing in front of the machine.: accept payment, select product, dispense item, return change. Non-functionalHow the system PERFORMS — the qualities. Thread safety for dual screens, testable states, extensible payment methods. Not features, but constraints that shape architecture.: concurrent-safe for dual-screen kiosks, testable without real money, extensible for new payment types.” Structured F/NF split. Concurrency and testability mentioned early. +1.
4:00 “Entities from the nounsThe Noun Technique: highlight every noun in the requirements. Each noun is a potential entity. “Machine has slots with products, accepts payment, gives change” → VendingMachine, Slot, Product, Payment, Change.: VendingMachine, Product, Slot, Transaction. Product is a record — it never changes once stocked. Slot is a class because its quantity mutates when items are dispensed.” Systematic noun extraction. Record vs class reasoning is sharp — shows deliberate type choices.
8:00 “The machine behaves completely differently depending on its mode — you can’t insert money during dispensing, you can’t select a product when idle. That’s the State patternWhen an object’s behavior changes based on its internal mode/state. Each mode becomes its own class implementing the same interface. The vending machine delegates to its current state — no giant switch statements.. Multiple payment methods share the same interface but different logic — that’s StrategyExtract algorithms that vary independently into separate classes behind a common interface. CoinPayment, CardPayment, MobilePayment all implement IPaymentStrategy — adding crypto means one new class, zero changes to existing code.. Fleet monitoring publishes events — that’s ObserverWhen one object changes state and needs to notify multiple interested parties without knowing who they are. The vending machine publishes MachineEvents; the dashboard, warehouse, and analytics each subscribe independently..” Three patterns, each motivated by a specific problem. Not name-dropping — explaining WHY.
12:00 Starts coding: IVendingState with 4 implementations, IPaymentStrategy with 3 implementations, ChangeCalculator with greedy algorithm, DI wiring... Watching for: clean naming, sealed classes, Result<T> types, separation of concerns
20:00 “Edge cases before you ask: out of stock — checked before dispensing. Can’t make exact change — the greedy algorithmStart with the largest denomination and work down. For $0.35 change with quarters/dimes/nickels: 1 quarter ($0.25) + 1 dime ($0.10) = $0.35. Fast and works for standard US denominations, but can fail with unusual coin sets. fails gracefully and returns a Result error. Item stuck in dispenser — retry once, then refund. Card payment declined — state rolls back to HasMoney so they can try another method.” Proactive! Most candidates wait to be asked. Four edge cases with specific handling — strong signal.
25:00 “For scaling to 500 machines: each machine publishes events to a message bus. A central dashboard subscribes for real-time status. The warehouse subscribes for restock notifications when inventory drops below threshold. LLD→HLD bridgeShowing how your low-level design fits into a larger distributed system. Going from “VendingMachine class with Observer” to “event bus + dashboard microservice + warehouse notifications” shows architectural range. — the Observer pattern we built in-process becomes a distributed event bus at scale.” LLD→HLD bridge. Shows breadth. Strong HireThe top outcome in a structured interview. It means the candidate showed range: structured thinking, clean code, proactive edge cases, and the ability to zoom from code to architecture. signal.
TimeCandidate SaysInterviewer Thinks
0:00 “A vending machine... OK, so we need products and some way to take money. Let me start with a Product class...” Jumped to code immediately. No scope questions. Let’s see if they recover.
1:30 “Actually, wait — let me step back. Are we designing for coin-only or do we need card and mobile payments too? And is this one machine or a fleet?” Good recovery. Self-corrected to scope first. That self-awareness is a positive signal.
4:00 “The machine has different modes — waiting for money, has money, dispensing. I could track that with an enum and switch statements... hmm, but that means every method needs a switch. 4 states times 4 methods is 16 branches.” Explored the simple path, counted the cost, found the limit. That’s real engineering judgment.
5:00 Interviewer: “Why not just use the enum? It’s simpler.” Challenging the candidate’s pattern choice. Testing conviction.
5:30 “Fair point — the enum IS simpler upfront. But each state has fundamentally different rules: you can’t select a product without money, you can’t insert money while dispensing. With an enum, I’d need to guard every operation in every method. The State pattern costs one class per mode but each class only has valid operations. The trade-off is worth it for 4+ states.” Defended with actual reasoning, not just “it’s the right pattern.” Acknowledged the trade-off cost. Excellent.
10:00 Coding state classes, payment interface... Clean separation. Good naming conventions.
18:00 “For the dispense flow, after deducting the price... oh wait, I forgot about change calculation. I need to figure out how to return the right coins.” Missed change initially. But caught it themselves — self-review is a good signal.
19:00 “Change calculation — I’ll use a greedy algorithm: start with the largest denomination, work down. Track the physical coins in the machine, not just a decimal balance. The machine needs to know it has 10 quarters and 5 dimes, not just ‘$3.00 available.’” Recovered well. Physical coin tracking shows real-world thinking, not just math.
22:00 Pauses... “Let me think about what happens with concurrent access... if two screens on a kiosk both try to buy the last Coke...” Pausing is fine. Thinking about concurrency unprompted is excellent.
23:00 “The dispense operation needs to be atomic — check stock AND decrement in a lock. Otherwise two threads could both see quantity=1 and both try to dispense.” Identified the exact race condition and the fix. Needed a moment but the answer is precise.
26:00 “For refunds — actually, I need to rethink this. If a card payment is declined, the machine should stay in the HasMoney state if they inserted coins first, not reset completely. The refund needs to know which payment methods contributed.” Self-corrected the refund flow mid-explanation. Shows they’re actually thinking, not reciting.
28:00 “For scaling, I’d use an event bus. Each machine publishes status events. A dashboard subscribes for monitoring, the warehouse subscribes for restock alerts. I haven’t built IoT fleets specifically, but the Observer pattern maps directly to a message bus at scale.” Honest about gaps. Showed how the pattern bridges. Strong Hire.
Key lesson: The realistic run has stumbles, a missed change calculation, a pause for concurrency, and a mid-stream refund correction — and STILL gets a Strong Hire. Interviewers don’t expect perfection. They expect good thinking, self-correction, and honesty about gaps.

Scoring Summary

The Clean Run

Strong Hire

  • Scoped first — clarified fleet, payments, UI
  • F/NF split stated before any code
  • Three patterns, each motivated by a specific need
  • Proactive edge cases — four without prompting
  • LLD→HLD bridge with event bus scaling
The Realistic Run

Strong Hire

  • Slow start — recovered with scope questions
  • Defended State vs enum with real cost analysis
  • Forgot change calc — caught it themselves
  • Self-corrected refund flow mid-explanation
  • Honest about IoT gaps + showed how patterns bridge

Interviewer Scoring Rubric

Strong Hire Hire Lean No No Hire Requirements Gathering Asks 3+ scope Qs F/NF split upfront Asks 1-2 questions Some F/NF awareness Asks after prompting Vague requirements Jumps to code No clarification Entity Modeling Record vs class justified Noun extraction method Reasonable entities Some type reasoning One big class No type justification God class, no entities Everything in one blob Pattern Usage 2-3 patterns, each motivated by problem 1-2 patterns used Mostly justified Name-drops patterns Can't justify choices No patterns or if/else everywhere Edge Cases & Error Handling 3+ proactive, unprompted Result types for errors 1-2 edge cases after prompting Mentions “error handling” No specific cases Happy path only No error consideration Scaling LLD→HLD Bridge Event bus, dashboard, persistence strategy Mentions database and multi-machine “Add a database” No architecture Never mentioned Only in-memory
Key Takeaway: Two very different styles. Same outcome. Interviewers don’t grade on polish — they grade on THINKING. A stumble you recover from is often more impressive than a flawless run — because it shows how you handle real-world ambiguity.
Section 17

Articulation Guide — What to SAY

Here’s something most candidates get wrong: they treat articulation as a side-effect of knowing the design. It isn’t. Design skill and communication skill are separate muscles — you can have a brilliant vending machine design locked in your head and still tank the interview because you can’t narrate it under pressure. The 8 cards below cover the exact situations where phrasing matters most. For each one: the situation, what to say, what kills your credibility, and why the good version works.

The Conversation Arc — How a Great Interview Flows 1. OPENING Scope & Clarify 2. ENTITIES Record vs Class decisions 3. STATE Machine modes 4. STRATEGY Payment methods 5. EDGE Stuck item, no change 6. SCALE Fleet & events Each step EARNS the right to move to the next. Skip Opening and your entities are guesses. Skip Entities and your patterns are unjustified. The arc builds credibility cumulatively — each step makes the next one stronger.
1. Opening the Problem

Situation: The interviewer says “Design a vending machine.” There’s silence. You feel the urge to start typing.

Say: “Before I start, let me understand the scope. Is this a single standalone machine, or are we designing for a fleet? Coin-only or multi-payment — cards, mobile wallets? Do we need a screen UI, or just the engine?”

Don’t say: “OK so I’ll make a Product class and a list of items...” (jumping to code without scoping)

Why it works: Vending machines seem simple. Asking questions on a “simple” problem is MORE impressive — it shows the habit is automatic, not performative.

2. Entity Decisions

Situation: You’re modeling Product, Slot, and Transaction. The interviewer watches silently.

Say: “Product is a recordA C# record is immutable after creation — once you set the name and price, they can’t change. Perfect for data that represents a fixed fact, like “Coke costs $1.50.” Contrast with a class, which allows mutation. because it never changes after the machine is stocked — name and price are fixed facts. Slot is a class because its quantity mutates every time we dispense. Transaction is a record because it’s a historical snapshot — what was bought, when, for how much.”

Don’t say: “I’ll make a Product class with getters and setters.” (no reasoning behind the type choice)

Why it works: Shows you choose types deliberately — record vs class vs enum is a real design decision, not a default.

3. Pattern Choice: State

Situation: You’re about to introduce the State pattern. The interviewer might push back on complexity.

Say: “The machine behaves completely differently in each mode — you can’t insert money during dispensing, you can’t select during out-of-service. Each mode is a class that implements all operations. Invalid operations return a Result error instead of throwing.”

Don’t say: “I’ll use the State pattern because it’s good for state machines.” (pattern name without problem motivation)

Why it works: The pattern emerged from the problem, not from memorization. Interviewers can always tell the difference.

4. Trade-off Defense

Situation: The interviewer pushes back: “State pattern adds one class per mode. Why not a switch statement?”

Say: “Fair point — the cost is more files. The alternative is a switch statement in every method — 4 states times 4 methods equals 16 branches, and they grow with every new state. State pattern costs 4 classes now, but adding a fifth state is one new class with zero changes to existing code. The cost is worth the extensibilityThe ability to add new behavior without modifying existing code. With State pattern, adding a “Maintenance” mode means creating one new class — no existing state classes are touched. With switch statements, you’d modify every method..”

Don’t say: “State pattern is the right pattern for this.” (asserts conclusion without arguing it)

Why it works: You weigh actual costs against actual gains. That’s engineering judgment, not pattern worship.

5. Pattern Choice: Strategy

Situation: You’re explaining how the machine handles different payment methods.

Say: “Payment methods share the same interface — Charge() and Refund() — but the implementation differs completely. Coins count physical denominations, cards call a gateway, mobile wallets use QR codes. Adding cryptocurrencyA hypothetical future payment method. The point isn’t that crypto is likely — it’s that Strategy means adding ANY new payment type is one new class implementing IPaymentStrategy. Zero changes to the machine or existing payments. means one new class implementing IPaymentStrategy. Zero changes to the machine or to existing payment classes.”

Don’t say: “I use Strategy because there are multiple payment types.” (too vague — explain what “same interface, different implementation” means)

Why it works: You showed the concrete benefit (zero changes) and gave a future scenario (crypto) that makes the extensibility tangible.

6. Edge Cases

Situation: You’ve finished the happy path. The interviewer hasn’t asked about errors yet. Most candidates stop here.

Say: “What if the machine can’t make change? The greedy algorithm checks available coins — if it can’t reach exact change, it returns an error before dispensing, so the customer can cancel. What if the dispenser jams? Retry once, then refund and publish a maintenance event. I use a Result typeA type that represents either success or failure with a specific error message. Instead of throwing exceptions for expected failures (out of stock, can’t make change), the method returns VendingResult<T> with Success or Failure. The caller pattern-matches on the result. so every operation returns success or a specific error.”

Don’t say: (nothing — most candidates never mention edge cases unprompted)

Why it works: Proactive edge-case thinking is a Strong Hire signal. It shows production-readiness, not just whiteboard-readiness.

7. Scaling Bridge

Situation: The interviewer asks “How would this change if we needed 500 machines?”

Say: “Single machine works with in-process events. For 500 machines, I’d use a message busInfrastructure that decouples event producers from consumers. Each vending machine publishes MachineEvents (LowStock, SaleCompleted, Error) without knowing who listens. Dashboard, warehouse, and analytics each subscribe independently. Examples: RabbitMQ, Azure Service Bus, Kafka. — each machine publishes events, the dashboard subscribes for real-time status, the warehouse subscribes for restock alerts. The Observer pattern we built in-process becomes a distributed event bus at scale. The core domain model stays the same — we just change where events travel.”

Don’t say: “We’d add a database.” (no structure, no reasoning about what changes vs. what stays)

Why it works: Shows you see both LLD and HLD, and can bridge between them. Breadth signals senior-level thinking.

8. “I Don’t Know”

Situation: The interviewer asks about IoT device networking protocols for the fleet. You’ve never built IoT.

Say: “I haven’t built IoT device networks, but I’d start by identifying the communication protocol — MQTTA lightweight messaging protocol designed for constrained devices and low-bandwidth networks. Common in IoT because it’s efficient and supports publish-subscribe. Vending machines would publish status events over MQTT to a broker; the dashboard subscribes. for low-bandwidth devices, HTTP for richer ones. The event-driven architecture we designed maps directly — the machine publishes status events, the protocol is just the transport layer.”

Don’t say: “I don’t know IoT.” (full stop) — or worse, bluffing through a fake answer

Why it works: Honesty about a specific technology + clear reasoning about the underlying approach = respect. Bluffing or shutting down = red flag.

SAY THIS, NOT THAT — Three Critical Moments MOMENT ❌ DON’T SAY ✅ SAY THIS Choosing State pattern “I’ll use State pattern because it’s the right pattern for this.” “The machine acts differently by mode — so each mode becomes its own class.” Handling edge cases “I’d add try-catch blocks around everything.” “What if the machine can’t make change? I’d use Result<T> so callers handle it.” Scaling to 500 machines “We could use a database to store everything centrally.” “Each machine publishes events. A central dashboard subscribes — Observer at scale.” Explain WHY, not just WHAT. Name the problem before the pattern.
Pro Tip — Practice OUT LOUD, not just in your head

Reading these phrases silently builds passive knowledge — recognition. Saying them aloud builds muscle memory — production. There’s a real difference: passive knowledge fails under interview pressure because retrieval competes with anxiety. Muscle memory is automatic. Try this: close this page, set a 5-minute timer, and explain this vending machine design to an imaginary interviewer out loud. You’ll immediately hear where you hesitate.

Target these three phrases for fluency first: the State pattern trade-off defense (4 states × 4 methods = 16 branches), the change calculation as physical coins (not decimals), and the scaling bridge from in-process Observer to message bus. These are the exact three spots where vending machine candidates most often go blank.

Section 18

Interview Questions & Answers

12 questions ranked by difficulty. Each has a “Think” prompt to try first, a solid answer, and the great answer that gets “Strong Hire.” Start with the green questions to build confidence, then tackle the yellows and reds.

Think: What happens when someone tries to select a product before inserting money? How does each state handle that?

Imagine a physical vending machine. When the display says “Insert Coins,” pressing a product button does nothing — the machine is in the wrong mode. When it says “Make Your Selection,” pressing a button works. The machine’s behavior depends entirely on which mode it’s in. That’s exactly what the State pattern does in code.

Answer: Each state class only implements the operations valid for that mode. Invalid operations return a Result error instead of throwing.

Great Answer: “Each state is its own class implementing IVendingState. In IdleState, calling SelectProduct() returns VendingResult.Fail("Insert money first"). In HasMoneyState, calling SelectProduct() checks stock, deducts price, and transitions to DispensingState. The compiler and type system enforce what’s valid — not runtime checks scattered across methods. Adding a new state like ‘Maintenance’ means one new class, zero changes to existing states.”

State Pattern — Valid vs Invalid Operations per Mode IdleState ✓ InsertMoney() ✗ SelectProduct() ✗ Dispense() ✗ ReturnChange() “Insert money first” HasMoneyState ✓ InsertMoney() ✓ SelectProduct() ✗ Dispense() ✓ ReturnChange() Most operations valid DispensingState ✗ InsertMoney() ✗ SelectProduct() ✓ Dispense() ✗ ReturnChange() Locked during dispense OutOfService ✗ InsertMoney() ✗ SelectProduct() ✗ Dispense() ✗ ReturnChange() Everything blocked $ select dispensed → back to Idle (or HasMoney if overpaid) Invalid ops return Result errors — no exceptions, no runtime crashes. The type system enforces rules. Adding a 5th state = one new class, zero changes to existing states.
What to SAY: “Each state class only knows its own valid operations. Invalid calls return Result errors. No scattered switch statements. Adding Maintenance mode = one new class, zero changes.”
Think: Does a product’s name or price ever change after it’s loaded into the machine?

Think of a product label in a real vending machine. The sticker says “Coke — $1.50.” That label doesn’t change while the machine is running. Someone doesn’t walk up and scribble “$2.00” on it mid-day. The price is set when the machine is stocked, and it stays fixed until someone restocks. That’s the definition of immutable data — and that’s what records are for.

Answer: Products don’t change after creation. Records enforce immutability at the type level.

Great Answer: “A Product represents a fixed fact: ‘Coke costs $1.50, slot A1.’ That fact shouldn’t change at runtime. Records give me immutability by defaultC# records with positional syntax generate init-only properties. Once created, the values can’t change. This prevents bugs like accidentally modifying a product’s price during a transaction., value equalityTwo Product records with identical fields are Equal, regardless of whether they’re the same object in memory. This makes testing and dictionary lookups intuitive., and built-in ToString() for logging. The Slot holds the Product reference and a mutable quantity — THAT’s a class because quantity changes every dispense. The decision: if it mutates, class. If it’s a snapshot, record.”

Product (record) vs Slot (class)
// Record: immutable -- name, price, code are FIXED facts
public record Product(string Name, decimal Price, string Code);

var coke = new Product("Coke", 1.50m, "A1");
// coke.Price = 2.00m;  // Compiler ERROR! Can't mutate a record.

// Class: mutable -- quantity changes every time we dispense
public class Slot
{
    public Product Product { get; }
    public int Quantity { get; private set; }

    public bool TryDispense()
    {
        if (Quantity <= 0) return false;
        Quantity--;  // This NEEDS to mutate -- that's why it's a class
        return true;
    }
}
What to SAY: “Mutates → class. Fixed after creation → record. Product is data, Slot has behavior. Let the compiler enforce what comments can’t.”
Think: How many existing files would you need to modify? What does that tell you about your design?

This question tests the Strategy pattern’s real payoff. If the answer is “modify multiple files,” your design has a problem. If the answer is “add one new file, modify zero existing files,” your Strategy is doing its job.

Answer: Create a new CryptoPayment class implementing IPaymentStrategy. Register it in DI. Done.

Great Answer: “One new class: CryptoPayment : IPaymentStrategy. It implements Charge() by calling a blockchain wallet API and Refund() by sending crypto back. I register it in DI configuration. Zero changes to VendingMachine, zero changes to CoinPayment or CardPayment. This is OCPOpen/Closed Principle — the system is open for extension (new payment types) but closed for modification (existing code doesn’t change). Strategy pattern is the mechanism that makes OCP practical. in action — the system is open for new payment types without modifying anything that already works.”

Adding Crypto — One Class, Zero Changes
// New file: CryptoPayment.cs -- this is ALL you write
public sealed class CryptoPayment(ICryptoWallet wallet) : IPaymentStrategy
{
    public VendingResult<PaymentReceipt> Charge(decimal amount)
    {
        var txn = wallet.SendPayment(amount);
        return txn.Confirmed
            ? VendingResult<PaymentReceipt>.Ok(new(txn.Id, amount))
            : VendingResult<PaymentReceipt>.Fail("Crypto transaction failed");
    }

    public VendingResult<decimal> Refund(decimal amount)
        => wallet.SendRefund(amount).Confirmed
            ? VendingResult<decimal>.Ok(amount)
            : VendingResult<decimal>.Fail("Crypto refund failed");
}

// DI registration -- one line in config
services.AddTransient<IPaymentStrategy, CryptoPayment>();
// VendingMachine.cs: ZERO changes. CoinPayment.cs: ZERO changes.
What to SAY: “One new class, zero changes to existing code. That’s the OCP payoff of Strategy. The machine doesn’t know or care what payment type it’s using.”
Think: Why track physical coins instead of just a decimal balance? When does the greedy algorithm fail?

This is the question that separates vending machine designs that “work on paper” from designs that work in reality. A decimal balance of $3.00 means nothing if the machine’s coin hopper has 12 quarters and zero dimes. The customer needs $0.30 in change, but you can’t make it with quarters alone. The machine needs to know its physical inventory of coins, not just a number.

Answer: Greedy algorithm: start with the largest denomination, take as many as available, work down. Track physical coin inventory.

Great Answer: “The ChangeCalculatorA dedicated service that takes an amount and the machine’s current coin inventory, and returns either a list of physical coins that sum to the amount, or an error if exact change can’t be made. Separating this into its own class makes it independently testable. takes the change amount and the machine’s current coin inventory. It uses a greedy algorithm: try the largest denomination first, take as many coins of that type as needed (limited by what’s available), then move to the next smaller denomination. If the remaining amount is non-zero after exhausting all denominations, it can’t make change — return an error BEFORE dispensing. The customer can then cancel or try exact change. Key: we track Dictionary<Denomination, int> not decimal balance. A machine with $3.00 in quarters can’t make $0.30 in change.”

ChangeCalculator.cs
public sealed class ChangeCalculator
{
    // Greedy: largest denomination first, limited by physical inventory
    public VendingResult<IReadOnlyList<Coin>> MakeChange(
        decimal amount,
        Dictionary<Denomination, int> inventory)
    {
        var coins = new List<Coin>();
        var remaining = amount;

        foreach (var denom in Denomination.AllDescending()) // $1, $0.25, $0.10, $0.05
        {
            while (remaining >= denom.Value && inventory[denom] > 0)
            {
                coins.Add(new Coin(denom));
                inventory[denom]--;
                remaining -= denom.Value;
            }
        }

        return remaining == 0m
            ? VendingResult<IReadOnlyList<Coin>>.Ok(coins)
            : VendingResult<IReadOnlyList<Coin>>.Fail(
                $"Cannot make exact change. Short by {remaining:C}");
    }
}
What to SAY: “Physical coins, not decimals. Greedy algorithm, largest first. If it can’t reach zero, fail BEFORE dispensing. The customer keeps their money.”
Think: The customer might have inserted coins AND swiped a card. What state should the machine return to?

This is trickier than it looks. If the customer inserted $0.50 in coins and then swiped a card that gets declined, the machine shouldn’t reset to Idle — they still have $0.50 in credit. The machine should stay in HasMoney state so the customer can try a different card, add more coins, or cancel and get their coins back.

Answer: Card decline doesn’t reset the machine. Stay in HasMoney if there’s remaining balance. Let the customer retry or cancel.

Great Answer: “The card’s Charge() returns a VendingResult.Fail(). The state machine checks: is there still money in the balance from coins? If yes, stay in HasMoneyState — the customer can try another card or add more coins. If the balance is zero (pure card attempt), transition back to IdleState. The key insight: state transitions depend on the machine’s balance, not on which payment method failed. The Strategy pattern means the machine doesn’t know or care WHICH payment type declined — it just sees a Result.Fail and decides the next state based on remaining balance.”

What to SAY: “Card decline ≠ reset. Check remaining balance. If coins were inserted, stay in HasMoney. The state transition depends on balance, not payment type.”
Think: Both screens show “1 Coke left.” Both press “Buy” at the same time. What happens?

This is the classic race conditionTwo threads read shared state simultaneously, both see “1 item available,” and both proceed to dispense. Result: negative inventory, one customer gets air. Fix: lock the check-and-decrement as an atomic operation.. Both threads check slot.Quantity, both see 1, both call Dispense(). One customer gets a Coke. The other gets nothing — or worse, the machine tries to dispense from an empty slot.

Answer: Lock around check-stock-and-dispense. Make it atomic so the second thread sees 0.

Great Answer: “The SelectProduct() operation in HasMoneyState must be atomic: check quantity AND decrement in a single lock block. The first thread enters the lock, sees quantity=1, decrements to 0, and dispenses. The second thread enters the lock, sees quantity=0, and gets VendingResult.Fail("Out of stock"). Their money stays in the balance — they can pick a different product or cancel for a refund.”

Race Condition: Dual-Screen Kiosk, Last Item time Screen A Check: qty = 1 Dispense! qty = 0 Screen B Check: qty = 1 Dispense! qty = -1 BUG! Negative inventory Empty dispense Fix: lock(_lock) { check qty + decrement } = atomic Fixed: A enters lock → sees 1 → dispenses → exits lock B enters lock → sees 0 → returns “Out of stock” → money stays in balance
What to SAY: “Lock the check-and-dispense. Second thread sees quantity=0. Money stays in their balance — they pick another item or cancel.”
Think: Does a loyalty card change how payments work, or is it a wrapper around existing payments?

A loyalty card doesn’t replace payments — it augments them. You buy with coins and ALSO earn points. You can later spend points INSTEAD of cash. This smells like two separate concerns: earning points (which wraps any payment) and spending points (which IS a payment strategy).

Answer: Two additions: a LoyaltyPayment strategy for spending points, and a DecoratorThe Decorator pattern wraps an existing object to add behavior without modifying it. A LoyaltyWrapper wraps any IPaymentStrategy — after the real payment succeeds, it awards points. The original payment doesn’t know about loyalty. that wraps any payment to earn points.

Great Answer: “Two-part solution. Part 1: LoyaltyPayment : IPaymentStrategy for SPENDING points — it checks the loyalty balance, deducts points, and the machine treats it like any other payment. Part 2: A Decorator LoyaltyEarningWrapper : IPaymentStrategy that wraps CoinPayment or CardPayment. After the inner payment succeeds, it awards points to the customer’s account. The machine doesn’t know about loyalty at all — it just calls Charge() on whatever strategy it receives. Zero changes to the machine or existing payments.”

What to SAY: “Spend points = new Strategy. Earn points = Decorator wrapping existing strategies. Machine sees IPaymentStrategy either way — zero changes.”
Think: The motor ran but the sensor didn’t detect an item dropping. What now?

Real vending machines have infrared sensors at the bottom of the dispensing chute. If the motor spins but the sensor doesn’t detect an item passing through within a timeout, the machine knows the item is stuck. Your design needs to handle this gracefully — the customer paid, they expect their product or their money back.

Answer: Retry the motor once. If still stuck, refund the customer and transition to OutOfService for that slot.

Great Answer: “The DispensingState uses a timeout mechanismA timer that starts when the dispense motor activates. If the item-detected sensor doesn’t trigger within N seconds, the dispense is considered failed. This prevents the machine from hanging forever waiting for an item that will never arrive.. If the sensor doesn’t detect an item within 5 seconds: retry once (sometimes items just need a nudge). If still stuck: (1) refund the customer via their payment strategy’s Refund() method, (2) mark that specific slot as jammed, (3) publish a MachineEvent.DispenserJam so the fleet dashboard alerts maintenance, (4) transition back to IdleState — other slots still work. The machine doesn’t go fully out of service for one jammed slot.”

DispensingState — Jam Recovery
public VendingResult<Product> Dispense(string slotCode)
{
    var slot = _machine.GetSlot(slotCode);

    // Attempt 1
    var dispensed = _dispenser.TryDispense(slotCode, timeout: TimeSpan.FromSeconds(5));
    if (!dispensed)
    {
        // Retry once -- sometimes items just need a nudge
        dispensed = _dispenser.TryDispense(slotCode, timeout: TimeSpan.FromSeconds(5));
    }

    if (!dispensed)
    {
        // Stuck item -- refund, mark slot, alert maintenance
        slot.MarkJammed();
        _machine.CurrentPayment.Refund(_machine.InsertedAmount);
        _machine.PublishEvent(MachineEvent.DispenserJam(slotCode));
        _machine.TransitionTo<IdleState>();  // other slots still work
        return VendingResult<Product>.Fail("Item stuck. Full refund issued.");
    }

    // Success path -- dispense, calculate change, transition
    slot.DecrementQuantity();
    var change = _changeCalc.MakeChange(overpayment, _machine.CoinInventory);
    _machine.TransitionTo<IdleState>();
    return VendingResult<Product>.Ok(slot.Product);
}
What to SAY: “Retry once, then refund + mark slot jammed + publish event for maintenance. Other slots stay active. Per-slot jam, not whole-machine shutdown.”
Think: The customer is owed $0.30 but the machine only has quarters. What happens?

The greedy algorithm works well for standard denominations ($1, $0.25, $0.10, $0.05) because they’re designed to be greedy-friendly. But when the machine runs low on certain coins, the algorithm can fail. If you owe $0.30 and only have quarters, the greedy picks one quarter ($0.25), leaving $0.05 — but you have no nickels. You’re stuck with $0.05 that can’t be made.

Answer: The algorithm detects failure before dispensing. Customer can cancel or pay exact amount.

Great Answer: “The ChangeCalculator is called BEFORE dispensing, not after. If MakeChange() returns Result.Fail, the product hasn’t been dispensed yet — the customer still has their money. The machine displays ‘Exact change only’ or lets them choose a different product whose price works out to even change. For a more robust solution: use dynamic programmingA technique that solves the coin change problem optimally by building up solutions for all amounts from 0 to the target. Unlike greedy, it finds a solution if one exists, even with unusual denominations. Trade-off: more complex and slower, but guaranteed to find change if possible. instead of greedy — it finds a solution if one exists, even with unusual denomination combinations. The greedy is good enough for standard US coins; DP is the fallback for edge cases.”

Greedy Algorithm Failure — $0.30 Change, Only Quarters Need: $0.30 Machine has: 8 quarters, 0 dimes, 0 nickels greedy Take 1 quarter $0.30 - $0.25 = $0.05 Remaining: $0.05 STUCK! No nickels available Can’t make $0.05 SAFE! Check BEFORE dispensing Key: MakeChange() is called BEFORE Dispense(). If it fails, the product stays. The customer still has their money. They can cancel, add exact change, or pick a different item. For unusual denominations: replace greedy with dynamic programming for guaranteed correctness.
What to SAY: “Check change BEFORE dispensing, not after. Greedy works for standard coins. DP as fallback for unusual sets. Customer never loses money.”
Think: A customer inserted $2.00, power goes out. When power returns, what should happen?

A vending machine sitting in a lobby doesn’t have a UPS backup. Power outages happen — lightning, breaker trips, someone unplugs it to use the outlet. If the machine was mid-transaction with $2.00 inserted and a product selected, what does it do when power returns? Forget everything and keep the money? That’s theft. Your design needs durabilityThe ability to survive restarts without losing state. For a vending machine, this means persisting the current state, balance, and inventory to non-volatile storage (flash memory, small SSD) so the machine can recover exactly where it left off..

Answer: Save state to non-volatile storage on every state transition. On power-up, reload and resume or refund.

Great Answer: “I’d add an IStatePersistence interface with Save(MachineSnapshot) and Load(). On every state transition (Idle→HasMoney, HasMoney→Dispensing), serialize a snapshot: current state enum, balance, selected product, inventory. Implementation writes to local flash memory (not a remote DB — the machine needs to work offline). On power-up: load the snapshot. If state was Dispensing, assume failure — refund the balance and return to Idle. If state was HasMoney, restore the balance and let the customer continue or cancel. This is write-aheadPersist the intent BEFORE executing the action. Save “about to dispense Coke, balance $2.00” before actually dispensing. If power fails during dispense, the recovery logic knows to refund. Without write-ahead, you don’t know if the product was dispensed or not. thinking: save the intent before the action.”

IStatePersistence — Power Failure Recovery
public interface IStatePersistence
{
    void Save(MachineSnapshot snapshot);
    MachineSnapshot? Load();
}

public record MachineSnapshot(
    string CurrentState,       // "Idle", "HasMoney", "Dispensing"
    decimal Balance,
    string? SelectedSlotCode,
    Dictionary<string, int> Inventory,
    DateTimeOffset Timestamp);

// On every state transition:
_persistence.Save(new MachineSnapshot(
    CurrentState: newState.GetType().Name,
    Balance: _balance,
    SelectedSlotCode: _selectedSlot?.Code,
    Inventory: _slots.ToDictionary(s => s.Code, s => s.Quantity),
    Timestamp: _clock.UtcNow));

// On power-up recovery:
var snapshot = _persistence.Load();
if (snapshot is not null)
{
    RestoreInventory(snapshot.Inventory);
    if (snapshot.CurrentState == "Dispensing")
    {
        // Assume dispense failed -- refund
        IssueRefund(snapshot.Balance);
        TransitionTo<IdleState>();
    }
    else if (snapshot.Balance > 0)
    {
        // Customer had money in -- restore balance
        _balance = snapshot.Balance;
        TransitionTo<HasMoneyState>();
    }
}
What to SAY: “Save snapshot on every state transition to local flash. On recovery: Dispensing = assume failure, refund. HasMoney = restore balance. Write-ahead prevents money loss.”
Think: What changes when you go from one machine in-process to 500 machines distributed across a city?

This is the LLD→HLD bridge question. The interviewer wants to see if you can zoom out from code to architecture. The good news: the Observer pattern you built in-process maps directly to a distributed event bus. The bad news: you need to think about network latency, eventual consistency, and offline operation.

Answer: Message bus for events. Dashboard subscribes. Warehouse subscribes for restock alerts.

Great Answer: “The architecture has three layers. Layer 1: Each machine runs the same LLD code we built, publishing MachineEvent records to a message busInfrastructure that decouples producers from consumers. Each machine publishes events without knowing who listens. RabbitMQ for simple setups, Kafka for high-throughput analytics. The LLD Observer pattern becomes a distributed message bus at scale. (RabbitMQ for simplicity, Kafka if we need analytics replay). Layer 2: A FleetManager service subscribes to all 500 machine streams, aggregates status, and serves the dashboard via WebSocket. Layer 3: A WarehouseService subscribes to LowStock events and generates restock orders. The machines work offline — they queue events locally and sync when connectivity returns. The core domain model is identical. What changes is the transport.”

Fleet Architecture — 500 Machines, Central Dashboard Machine #1 Same LLD code Machine #2 Same LLD code ... Machine #500 Message Bus SaleCompleted, LowStock, Error RabbitMQ / Kafka Fleet Dashboard (WebSocket) Real-time status for all 500 machines Warehouse Service LowStock → generate restock order Analytics Service Sales trends, peak hours, revenue Key: Same LLD code inside every machine. The Observer pattern we built in-process becomes the message bus at scale. Machines work offline — queue events locally, sync when connected. Good LLD makes scaling a transport change, not a rewrite.
What to SAY: “Same LLD code in every machine. Observer becomes a message bus. Dashboard and warehouse subscribe. Machines work offline and sync later.”
Think: How would you make Machine #42 charge $1.25 for Coke while Machine #43 charges $1.75?

This question tests whether your design can support per-machine configuration. If prices are hardcoded in the Product record, you can’t A/B test. If prices come from a configurable source, you can. This is where separation of concernsThe principle that each component should handle exactly one concern. Product identity (name, code) is separate from pricing (what it costs in this machine). This separation lets you change prices without changing product definitions. pays off.

Answer: Separate product identity from pricing. Each machine loads pricing from a configurable source.

Great Answer: “I’d separate product identity from pricing. Product stays a record with name and code — no price. Each machine has a IPricingProvider that maps product codes to prices. For A/B testing: the fleet manager assigns each machine to a pricing group. Machine #42 loads Group A prices ($1.25 for Coke), Machine #43 loads Group B prices ($1.75). The SaleCompleted events include the pricing group ID, so the analytics service can compare conversion rates between groups. On the LLD side: the change is minimal — move price out of Product into a dictionary that the machine loads on startup from its configuration endpoint.”

What to SAY: “Separate product from pricing. IPricingProvider per machine loaded from config. SaleCompleted events tag the pricing group for analytics comparison.”
Section 19

10 Deadliest Interview Mistakes

These aren’t hypothetical mistakes — every one of them has ended real interviews. Interviewers who’ve seen hundreds of candidates can spot them in the first five minutes. Here’s exactly what they see, and what to do instead.

Think First #13

Before looking at the list: what’s the single biggest red flag an interviewer watches for in a vending machine design? Think about what would make you immediately say “No Hire.”

30 seconds — commit to your answer before scrolling.

Mistake Severity Pyramid 2 Minor lose points 4 Serious significant red flags 4 Fatal interview enders #9 Happy path only #10 No HLD bridge #5 Over-engineering #6 No tests mentioned #7 Hardcoded prices #8 Change as decimal #1 No requirements #2 God class #3 Enum + switch #4 No concurrency

Fatal Mistakes — Interview Enders

Why this happens: Anxiety. You hear “design a vending machine” and your brain panics: “I need to show output fast.” So you start typing class VendingMachine. You haven’t asked: coin-only or multi-payment? Single machine or fleet? Physical UI or just the engine? The interviewer watches you build a solution to a problem you never confirmed.

What goes wrong: 10 minutes in, you realize you need card payments but you’ve hardcoded coin logic everywhere. Backtracking under time pressure looks terrible. The interviewer has already decided you don’t think before you build.

What the interviewer thinks: “No system thinking. This person codes before understanding. They’ll build the wrong feature for two sprints before asking what the customer actually needed.”

Fix: Open with CREATESThe 7-step LLD interview structure: Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. Use it as your opening ritual every single interview.. Spend the first 3 minutes asking scope questions. Summarize back before touching code.

Why this happens: It feels efficient to put everything in one place. Payment processing, inventory tracking, state management, change calculation, event publishing — all in one class. Early on, it’s 50 lines and readable. By the end of the interview, it’s 300 lines and nobody can follow it.

What goes wrong: A change to the payment flow might accidentally break inventory tracking because both share the same state. Testing any one piece requires instantiating the entire system.

Bad — God Class
public class VendingMachineSystem  // does EVERYTHING
{
    public void InsertMoney(decimal amount) { ... }
    public void SelectProduct(string code) { ... }
    public decimal CalculateChange(decimal paid, decimal price) { ... }  // change logic
    public bool ProcessCardPayment(string cardNumber) { ... }            // payment logic
    public void PublishEvent(string eventType) { ... }                   // event logic
    public void UpdateDisplay(string message) { ... }                    // UI logic
    // 300 more lines...
}
What the interviewer thinks: “Doesn’t understand SRP. This person will create unmaintainable systems where every change risks breaking something unrelated.”

Fix: Split responsibilities: VendingMachine (orchestration + state), IVendingState (mode behavior), IPaymentStrategy (payment logic), ChangeCalculator (coin math), IEventPublisher (notifications). Each class has one reason to change.

Why this happens: Enums with switch statements feel like the “simple” approach. if (state == MachineState.Idle) is easy to write and easy to read — at first. But you need that switch in InsertMoney(), SelectProduct(), Dispense(), AND ReturnChange(). That’s 4 methods × 4 states = 16 branches. Add a 5th state (Maintenance) and you touch ALL 4 methods.

Bad — Enum + Switch vs Good — State Pattern
// BAD: switch in EVERY method, grows with every new state
public void InsertMoney(decimal amount)
{
    switch (_state)
    {
        case MachineState.Idle: _balance += amount; _state = MachineState.HasMoney; break;
        case MachineState.HasMoney: _balance += amount; break;
        case MachineState.Dispensing: throw new InvalidOperationException(); break;
        case MachineState.OutOfService: throw new InvalidOperationException(); break;
        // Add Maintenance? Touch THIS method AND SelectProduct AND Dispense AND...
    }
}

// GOOD: State pattern -- one class per mode, OCP-compliant
public sealed class IdleState : IVendingState
{
    public VendingResult InsertMoney(decimal amount, VendingMachine machine)
    {
        machine.AddToBalance(amount);
        machine.TransitionTo<HasMoneyState>();
        return VendingResult.Ok();
    }
    // Adding Maintenance = new class. THIS class: zero changes.
}
What the interviewer thinks: “Doesn’t understand OCP. Will produce switch-heavy code that’s fragile to extend and painful to test.”

Fix: Each state is its own class implementing IVendingState. Adding a new state = one new class, zero changes to existing states. The trade-off (more files) is worth the extensibility.

Why this happens: Most developers learn in a single-threaded world. Your tutorials run one request at a time. But modern vending kiosks have dual screens. Two customers can press “Buy Coke” at the exact same instant when there’s only one Coke left.

Both threads check slot.Quantity, both see 1, both call Dispense(). Result: negative inventory, one customer gets air. This isn’t a rare edge case — it’s what happens every time a popular item runs low.

What the interviewer thinks: “Will write race conditions in production. This is a senior-level concern they’re completely missing. No Hire if they can’t discuss it even when prompted.”

Fix: Any “check then act” operation (check stock, then dispense) must be wrapped in a lock. The check and the action must be atomic — no thread can slip in between.

Serious Mistakes — Significant Red Flags

Why this happens: You studied design patterns hard. Now you want to show them all. So you add a Factory for creating states, an Abstract Factory for creating factories, a Chain of Responsibility for product selection — none of which solve a real problem.

The interviewer asks: “Why is there a factory here?” You hesitate. Because there’s no real answer. You used it because you know it, not because you need it.

What the interviewer thinks: “Cargo-cult design. Knows pattern names but not when to apply them. This person adds complexity without justification.”

Fix: Only introduce a pattern when you can say: “I’m using [Pattern] because [specific problem]. Without it, [this bad thing happens].” State for machine modes = justified. Strategy for payments = justified. Factory for… what exactly? If you can’t finish the sentence, don’t add it.

Why this happens: Testing feels like something you do after the design is done. So you never ask: “Could I write a unit test for this?” The result: your state classes create their own dependencies, your change calculator calls a real coin hopper, and nothing can be tested in isolation.

What the interviewer thinks: “No quality mindset. Will write code that’s impossible to test and unsafe to refactor.”

Fix: After coding a class, say one sentence: “This is testable because I inject IPaymentStrategy and ITimeProvider — in tests I swap in fakes to isolate behavior.” One sentence on testability signals seniority.

Why this happens: Under time pressure, you write if (amount >= 1.50m) instead of if (amount >= product.Price). You write new decimal[] { 1.00m, 0.25m, 0.10m, 0.05m } inline instead of using a Denomination enum. It’s faster in the moment, but it signals that you don’t think about maintainability.

What the interviewer thinks: “Magic numbers everywhere. This person will produce code that’s impossible to maintain or configure.”

Fix: Prices live on the Product record. Denominations are an enum or a configured collection. The machine loads its configuration, not hardcoded values. Say it: “Prices come from Product, not from magic numbers.”

Why this happens: In your head, “give $0.35 change” is just subtraction. paid - price = change. Done. But a real vending machine can’t dispense $0.35 as a concept — it dispenses physical coins. And it might not have the right combination of coins available.

Writing return paid - price; ignores the entire physical reality of a vending machine. It’s the difference between a math problem and a system design.

What the interviewer thinks: “Thinks in abstractions but misses physical constraints. Will design systems that work on paper but fail in production.”

Fix: Track a Dictionary<Denomination, int> for the coin inventory. The ChangeCalculator takes this inventory and returns a list of specific coins, not a decimal. If it can’t make change, it fails explicitly.

Minor Mistakes — You Lose Points, Not the Interview

Your code works perfectly when everything goes right. Customer inserts money, selects a product, gets their item and change. But what about: out of stock? Can’t make change? Card declined? Dispenser jammed? Power failure mid-transaction? Real vending machines handle dozens of failure modes. Mentioning even 2-3 proactively sets you apart.

What the interviewer thinks: “Only tested the happy path. Will ship features that break in production at the first unexpected input.”

Fix: Use the What If? framework: Concurrency (two screens, one item), Failure (card declined, motor jam), Boundary (can’t make change), Weird Input (invalid slot code). Mention 2-3 proactively before the interviewer asks.

You designed a perfect single vending machine. But the interviewer asks “What about 500 machines?” and you have nothing. You’ve never thought about how your in-memory design scales to a distributed fleet. This doesn’t kill the interview, but it caps you at “Hire” instead of “Strong Hire.”

What the interviewer thinks: “Good LLD engineer. Can’t zoom out to architecture yet. May struggle at senior level.”

Fix: In the last 3 minutes, say: “If I had more time, here’s how this scales: event bus for machine-to-dashboard communication, per-machine state persistence, warehouse integration for restock alerts. The Observer pattern becomes a distributed message bus.”

What Interviewers Actually Think Sees: God class “Won’t scale past a junior role” Sees: No concurrency “Will write race conditions in prod” Sees: Pattern dump “Memorized, doesn’t understand” Sees: Edge cases first “Production mindset. Strong Hire.” The patterns are predictable. Interviewers have seen thousands of candidates. They know EXACTLY what a God class or missing concurrency means for your codebase. The good news: now you know what they’re looking for. Avoid these 10 and you’re ahead of 80% of candidates.
LevelMistakesOutcome
Strong Hire0-1 minor, self-correctedStructured, proactive, shows range
Hire1-2 serious, recovered when promptedGood design, needed nudges on edge cases
Lean No2-3 serious, didn’t recoverKnows patterns but can’t apply them to the problem
No Hire2+ fatalJumped to code, God class, no concurrency awareness
Section 20

Memory Anchors — Never Forget This

The CREATES Approach — Your Universal LLD Structure

Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale

“Every system design CREATES a solution.” — Use this in every interview, every case study, every design discussion.

C Clarify R Requirements E Entities A API T Trade-offs E Edge cases S Scale Single or fleet? Pay, select, dispense Machine, Product Insert / Select State vs enum Stuck item, no $ Fleet + events “Every system design CREATES a solution.”

Memory Palace — Walk Through a Vending Machine

Picture a real vending machine in front of you. Each physical part maps to a design step. Walk through it top to bottom, and you walk through the entire CREATES framework.

Memory Palace — Vending Machine → Design Concepts
THE VENDING MACHINE Coin Slot C = Clarify What payments accepted? Display Window E = Entities Products, Slots, Inventory Keypad A = API Insert / Select / Dispense Motor & Gears T = Trade-offs State vs enum, Strategy vs switch Coin Return E = Edge cases Can’t make change, stuck item Power Plug S = Scale Fleet, monitoring, events “Walk through the machine, and you walk through the design.”

Smell → Pattern Quick Reference

Categories Without Behavior Records / Enums Mode-Dependent Behavior State Pattern Multiple Algorithms, Same Interface Strategy Pattern Notify When Something Happens Observer Pattern

5 Things to ALWAYS Mention in a Vending Machine Interview

✓ State pattern for machine modes (Idle, HasMoney, Dispensing, OutOfService)
✓ Strategy for payment methods (coin, card, mobile)
✓ Change calculation with physical coin tracking, not decimals
✓ Edge cases: stuck item, can’t make change, concurrent access
✓ Scaling: event-driven fleet monitoring with message bus

Pattern Detection Flowchart

“What design smell do I see?” Behavior changes by mode? STATE One class per mode Multiple ways to do same thing? STRATEGY Interface per algorithm Need to notify on changes? OBSERVER Publish-subscribe Name the PROBLEM first, then the pattern. Never lead with the pattern name.

Flashcard Quiz — Test Your Recall

Try to answer each question before expanding. If you can nail all 5 without peeking, the vending machine is locked in your long-term memory.

A: 4 states × 4 methods = 16 switch branches that grow with every new state. State pattern: one class per mode, adding a 5th state = one new class, zero changes to existing states. OCP-compliant.

A: Product is immutable after stocking (name, price never change) → record. Slot’s quantity mutates on every dispense → class. The rule: mutates = class, fixed = record.

A: Track PHYSICAL COINS (Dictionary<Denomination, int>), not a decimal balance. A machine with $3.00 in quarters can’t make $0.30 in change. And always check BEFORE dispensing, not after.

A: One new class implementing IPaymentStrategy. Register in DI. Zero changes to VendingMachine, zero changes to existing payment classes. That’s Strategy + OCP.

A: The in-process Observer becomes a distributed message bus (RabbitMQ/Kafka). Each machine publishes MachineEvents. Dashboard, warehouse, and analytics subscribe. Same pattern, different transport. Machines work offline and sync when connected.

Section 21

Transfer — These Techniques Work Everywhere

You didn’t just learn a vending machine. You learned a PROCESS — a repeatable way to approach any system. The state thinking, the “what varies?” question, the physical-constraint awareness, the lock instinct, the scale bridge — none of those are vending machine tricks. They’re engineering reflexes. Below is the proof: the exact same 7 thinking tools, applied to 4 different systems. Same structure, different domain.

Vending Machine State + Strategy + Observer Your foundation Elevator System State + Strategy State for modes Strategy for scheduling ATM Machine State + Strategy State for transaction flow Strategy for txn types Ticket Booth State + Strategy State for booking flow Strategy for pricing tiers Same Patterns Different Domains Skills transfer via structure
TechniqueVending MachineElevatorATM MachineTicket Booth
Walkthrough Approach→Insert→Select→Dispense→Change Button→Wait→Board→Ride→Exit Card→PIN→Select→Dispense→Receipt Queue→Select→Pay→Print→Collect
Every system starts with a physical walkthrough. Walk the flow and you find the nouns, verbs, and sequence before touching code.
Key entities Machine, Product, Slot, Payment Elevator, Floor, Request, Direction ATM, Account, Card, Cash Booth, Event, Ticket, Seat
Nouns become classes. The concrete things users interact with in the real world are your core entities.
What varies? Payment method Scheduling algorithm Transaction type Pricing tier
“Is there more than one way to do this?” One question surfaces every Strategy candidate in any design.
Primary pattern State + Strategy State + Strategy State + Strategy State + Strategy
State + Strategy is the most common pattern pair in LLD interviews. Master it once, apply it everywhere.
Concurrency Two customers, one item Multiple requests, one elevator Two ATMs, one account balance Multiple agents, same seats
Shared resource + concurrent access = race conditionTwo threads read shared state simultaneously, both see a valid value, and both proceed. Result: double-booking, negative inventory, or overdraft. Fix: lock the check-and-act as an atomic operation. risk. The lock pattern is the answer — every time, every domain.
Key edge case Can’t make change Overweight, stuck doors Insufficient funds, card retained Event sold out, payment timeout
Edge cases cluster around 4 categories in every system: concurrency, failure, boundary, and weird input.
Scale path Fleet monitoring, IoT Multi-building, peak-hour Network of ATMs, bank integration Multi-venue, online + booth
Scale always means: decouple, distribute, and coordinate with events. The topology changes; the pattern doesn’t.
Technique Reuse Heatmap Vending Elevator ATM Ticket State Pattern Primary Primary Primary Used Strategy Pattern Primary Primary Used Primary Concurrency / Lock Critical Critical Essential Essential Observer / Events Used Scale Scale Used Green = same pattern applies. The domain changes; the structural skill doesn’t.
The insight: Systems share STRUCTUREEvery system has: entities (nouns), operations (verbs), algorithms that vary (Strategy candidates), modes of behavior (State candidates), shared resources (concurrency risks), and failure modes (edge cases). This structural similarity is why learning ONE system deeply teaches you how to approach ALL systems. even when domains differ. The skills transfer because they target the structure, not the domain. “What mode is the system in?” works for vending machines, elevators, ATMs, and any future system you’ll design.
Section 22

The Reusable Toolkit

Six cognitive frameworks you picked up in this case study. Each one is a portable thinking tool — not a vending machine trick, but a repeatable move you can make in any LLD interview or real-world design session.

SCOPE

Size · Complexity · Operations · Performance · Extensions

How to use: Before any design, ask one question per category until you have enough to draw entities.

Vending Machine use: Size (single/fleet), Complexity (products/payments/change), Operations (insert/select/dispense/refund), Performance (response time), Extensions (new products/payments).

What If?

Concurrency · Failure · Boundary · Weird Input — the 4 edge case categoriesA structured way to find edge cases: (1) What if two things happen at once? (2) What if a step fails mid-way? (3) What if input is at min/max? (4) What if input is unexpected? These four categories cover the vast majority of real-world bugs..

How to use: After happy path, run all 4 categories. Ask “what breaks?” for each operation.

Vending Machine use: Concurrency (dual screens), Failure (card declined, motor jam), Boundary (can’t make change), Weird Input (invalid slot code).

What Varies?

Ask: “Is there more than one way to do this?” and “Does it change at runtime?” → Strategy candidateAny operation where the answer to “what varies?” is yes. Extract it into an interface and inject the chosen implementation. Keeps the host class stable when algorithms change..

How to use: List every verb. For each, ask the two questions. Yes to either → extract an interface.

Vending Machine use: Payment method varies → IPaymentStrategy. Machine mode varies → IVendingState. Monitoring varies → IEventPublisher.

Record vs Class

MutableCan change after creation. Slot.Quantity changes every dispense — that’s mutable state requiring a class. + behavior → class. Immutable data → record. Categories → enum.

How to use: For every noun: “Does it change after creation?” and “Does it have methods?” Answer determines the C# type.

Vending Machine use: Product → record (immutable). Slot → class (quantity mutates). Transaction → record (historical snapshot). Denomination → enum (fixed set).

CREATES

Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale

How to use: Verbal checklist in interviews. When you finish one letter, say it aloud and move to the next.

Vending Machine use: C: single or fleet? R: payments, products, change. E: Machine, Product, Slot. A: Insert/Select/Dispense. T: State vs enum. E: stuck item, no change. S: fleet events.

Smell → Pattern

4 smells from this case study. Each maps to a design response.

How to use: When code feels wrong but you can’t name why, run through the smell list. Each has a pattern response.

Vending Machine use: “Mode-dependent behavior” → State. “Multiple algorithms” → Strategy. “Notify on change” → Observer. “Categories without behavior” → Records/Enums.

Your Toolkit Checklist

Before you leave this page, do a quick self-check. Can you explain each tool to a teammate — without looking at the page?

SCOPE — Can you name all 5 categories and ask one vending machine question for each?
What If? — Can you describe the dual-screen race condition AND the stuck-item failure, from memory?
What Varies? — Can you explain why IPaymentStrategy exists and what adding crypto costs?
Record vs Class — Given a new noun (say, Receipt), can you immediately say which type it is and why?
CREATES — Can you walk through all 7 steps in order, applying each to the vending machine?
Smell → Pattern — Can you name the 4 smells from this case study and which pattern each triggers?
YOUR 6 PORTABLE THINKING TOOLS SCOPE Clarify What If? Stress-test Varies? Find Strategy Rec/Class Pick type CREATES Interview Smell →Pattern Instinct Domains change. These 6 questions don’t.
These 6 tools are your permanent inventory. They work for every LLD problem — vending machines, parking lots, elevators, ATMs, chat apps, ride-hailing. Domains change. The structural questions don’t. If you remember nothing else from this page, remember THESEThe six tools: (1) SCOPE — clarify. (2) What If? — stress-test. (3) What Varies? — find Strategy interfaces. (4) Record vs Class — pick the right type. (5) CREATES — interview framework. (6) Smell → Pattern — turn intuition into action..
Section 23

Practice Exercises

Three exercises that test whether you truly learned the thinking, not just memorized the code. Each one extends the vending machine with a new constraint — the kind of follow-up an interviewer might throw at you.

Exercise 1: Buy 2, Get 1 Free Promotion System Medium

New constraint: The vending machine now supports promotions. A manager can configure deals like “Buy 2 Cokes, get 1 free,” “15% off all drinks after 9 PM,” or “Buy any 3 items, cheapest is free.” Different promotions can run simultaneously.

Think: What varies here? Is a promotion a Strategy? How do you apply multiple promotions without them conflicting? How does the price calculation change?

Promotions are strategiesEach promotion type (BOGO, percentage off, bundle deal) implements the same IPromotionStrategy interface. The method takes a list of selected items and returns a discount amount. Multiple promotions can be checked in sequence — the machine applies the best one or stacks compatible ones.. Create IPromotionStrategy with a method like CalculateDiscount(IReadOnlyList<Product> selectedItems). Implementations: BuyXGetYFree, PercentOffAfterHour, BundleDeal. The machine holds a list of active promotions and applies them during price calculation. For conflict resolution: either “best single promotion wins” or “stack compatible promotions” — this is a design decision you should state aloud.

PromotionSystem.cs
public interface IPromotionStrategy
{
    string Name { get; }
    decimal CalculateDiscount(IReadOnlyList<Product> items, DateTimeOffset now);
}

public sealed class BuyXGetYFree(string targetCode, int buyCount, int freeCount)
    : IPromotionStrategy
{
    public string Name => $"Buy {buyCount} {targetCode}, get {freeCount} free";

    public decimal CalculateDiscount(IReadOnlyList<Product> items, DateTimeOffset now)
    {
        var matching = items.Where(p => p.Code == targetCode).ToList();
        int sets = matching.Count / (buyCount + freeCount);
        return sets * freeCount * matching.First().Price;
    }
}

public sealed class PercentOffAfterHour(decimal percent, int afterHour)
    : IPromotionStrategy
{
    public string Name => $"{percent}% off after {afterHour}:00";

    public decimal CalculateDiscount(IReadOnlyList<Product> items, DateTimeOffset now)
    {
        if (now.Hour < afterHour) return 0m;
        return items.Sum(p => p.Price) * (percent / 100m);
    }
}

// In VendingMachine: apply best promotion
public decimal CalculateTotal(IReadOnlyList<Product> items)
{
    var subtotal = items.Sum(p => p.Price);
    var bestDiscount = _promotions
        .Select(p => p.CalculateDiscount(items, _clock.UtcNow))
        .DefaultIfEmpty(0m)
        .Max();
    return subtotal - bestDiscount;
}
Exercise 2: Handle Power Failure Mid-Transaction Medium

Scenario: A customer inserted $3.50, selected a $2.00 item. The machine is about to dispense when the power goes out. When power returns 30 seconds later, what should happen? What if power failed DURING dispensing — did the item drop or not?

Think: What state must be persisted? How often? Where (remote DB or local storage)? How do you handle the ambiguous “was it dispensed?” scenario?

Persist a MachineSnapshot to local flash memory on every state transition. The snapshot includes: current state, balance, selected product, inventory. On power-up: if state was Dispensing, assume failure and refund (safe default — customer might get a free item if it actually dropped, but that’s better than stealing their money). If state was HasMoney, restore balance and let them continue. Key insight: save BEFORE the action (write-aheadPersist the intent before executing the action. Write “about to dispense Coke for $2.00, balance $3.50” before activating the motor. If power fails during dispense, recovery knows to refund. Without write-ahead, you don’t know what happened.), not after.

Exercise 3: Multi-Machine Inventory Sync Hard

Scenario: 500 machines share a warehouse. When Machine #47 runs low on Snickers (below restock threshold), it requests a restock. When the warehouse runs completely out of Snickers, ALL machines showing Snickers must update their displays to remove it. How?

Think: What communication pattern? How do machines learn about warehouse inventory changes? What about machines that are temporarily offline? Is this eventually consistent or strongly consistent?

EVENTUAL CONSISTENCY FLOW Machine #47 Snickers: 1 left LowStock Event Bus Warehouse Snickers: 0 left! ProductUnavailable #1 #47 #203 ...#500 t=0s: sell t=2s: detect t=5s: broadcast t=8s: all updated

Event-driven with eventual consistencyMachines don’t all update at the same instant. Machine #47 publishes LowStock, the warehouse processes it, and if out of stock, publishes ProductUnavailable. Machines receive this event and update their displays. There’s a brief window where some machines still show Snickers. This is acceptable — the customer just gets an “out of stock” at purchase time.. Central EventBus carries LowStockEvent (machine → warehouse) and ProductUnavailableEvent (warehouse → all machines). Machines subscribe to global product events and update their display catalog. Offline machines queue events locally and process them on reconnect. The fleet doesn’t need strong consistency — showing Snickers for 30 extra seconds is fine; the machine handles “out of stock” gracefully at purchase time anyway.

FleetInventorySync.cs
// Events
public record LowStockEvent(string MachineId, string ProductCode, int RemainingQty);
public record RestockRequestEvent(string MachineId, string ProductCode, int RequestedQty);
public record ProductUnavailableEvent(string ProductCode, string Reason);

// Machine publishes when inventory drops below threshold
if (slot.Quantity <= _restockThreshold)
{
    await _eventBus.PublishAsync(new LowStockEvent(
        _machineId, slot.Product.Code, slot.Quantity));
}

// Warehouse subscribes to LowStockEvent
public class WarehouseService : IEventHandler<LowStockEvent>
{
    public async Task HandleAsync(LowStockEvent evt)
    {
        var available = _inventory.GetStock(evt.ProductCode);
        if (available > 0)
        {
            await ScheduleRestock(evt.MachineId, evt.ProductCode);
        }
        else
        {
            // Warehouse is out -- tell ALL machines
            await _eventBus.PublishAsync(new ProductUnavailableEvent(
                evt.ProductCode, "Warehouse depleted"));
        }
    }
}

// Each machine subscribes to ProductUnavailableEvent
public class MachineDisplayUpdater : IEventHandler<ProductUnavailableEvent>
{
    public Task HandleAsync(ProductUnavailableEvent evt)
    {
        _display.MarkUnavailable(evt.ProductCode);
        return Task.CompletedTask;
        // Eventually consistent -- machines update within seconds
    }
}
Key design decision: Eventual consistency is acceptable here. A machine showing Snickers for 30 extra seconds isn’t catastrophic — the purchase flow already handles “out of stock” gracefully. Strong consistency (checking warehouse on every purchase) would add network latency to every sale and wouldn’t work when machines are offline.