Skip to main content
Low-Level Design · lesson 1 of 18
Open the Low-Level Design page →
Low-Level Design beginner Lesson 1 of 18

Why Class Design Matters: A Naive Vending Machine vs a Designed One

A vending machine that lets stock go negative, then one that refuses cleanly — the smallest demonstration of why encapsulation is LLD's first move.

By SkillByExample · Published

lld oop python

A vending machine is the simplest possible LLD problem — one class, one method, one bug.

The naive version

class NaiveVendingMachine:
    def __init__(self):
        self.stock = {"soda": 2, "chips": 1}

    def dispense(self, item):
        self.stock[item] -= 1
        print(f"Dispensed {item}. Remaining: {self.stock[item]}")

machine = NaiveVendingMachine()
machine.dispense("chips")
machine.dispense("chips")
machine.dispense("chips")
$ python naive_vending.py
Dispensed chips. Remaining: 0
Dispensed chips. Remaining: -1
Dispensed chips. Remaining: -2

There’s only one bag of chips, but the machine “dispenses” three. self.stock is a plain public dict — nothing stops dispense from decrementing it below zero, and nothing stops some other part of the program from writing machine.stock["chips"] = 999 directly either.

The designed version

class VendingMachine:
    def __init__(self):
        self._stock = {"soda": 2, "chips": 1}

    def dispense(self, item):
        if self._stock.get(item, 0) <= 0:
            print(f"Cannot dispense {item}: out of stock")
            return
        self._stock[item] -= 1
        print(f"Dispensed {item}. Remaining: {self._stock[item]}")

machine = VendingMachine()
machine.dispense("chips")
machine.dispense("chips")
machine.dispense("chips")
$ python vending.py
Dispensed chips. Remaining: 0
Cannot dispense chips: out of stock
Cannot dispense chips: out of stock

Same three calls, same starting stock — but now the machine refuses instead of lying. The fix isn’t the if check alone; it’s that _stock is only ever touched through dispense, so the check can’t be bypassed by a caller who forgets to add it themselves. This is the whole idea of encapsulation: an object protects its own invariants instead of trusting every caller to protect them for it.

Practice

1. Add a refill method that adds stock back.
class VendingMachine:
    def __init__(self):
        self._stock = {"soda": 2, "chips": 1}

    def dispense(self, item):
        if self._stock.get(item, 0) <= 0:
            print(f"Cannot dispense {item}: out of stock")
            return
        self._stock[item] -= 1
        print(f"Dispensed {item}. Remaining: {self._stock[item]}")

    def refill(self, item, quantity):
        self._stock[item] = self._stock.get(item, 0) + quantity
        print(f"Refilled {item}. New stock: {self._stock[item]}")

machine = VendingMachine()
machine.dispense("chips")
machine.dispense("chips")
machine.refill("chips", 3)
machine.dispense("chips")
$ python refill.py
Dispensed chips. Remaining: 0
Cannot dispense chips: out of stock
Refilled chips. New stock: 3
Dispensed chips. Remaining: 2

refill is the only other place _stock is written, so the invariant (never negative) only has two gatekeepers to check instead of every line in the program.

2. Require payment before dispensing, refusing if the balance is too low.
class VendingMachine:
    PRICES = {"soda": 15, "chips": 10}

    def __init__(self):
        self._stock = {"soda": 2, "chips": 1}
        self._balance = 0

    def insert_coin(self, amount):
        self._balance += amount
        print(f"Balance: {self._balance}")

    def dispense(self, item):
        price = self.PRICES[item]
        if self._stock.get(item, 0) <= 0:
            print(f"Cannot dispense {item}: out of stock")
            return
        if self._balance < price:
            print(f"Cannot dispense {item}: need {price}, have {self._balance}")
            return
        self._stock[item] -= 1
        self._balance -= price
        print(f"Dispensed {item}. Change: {self._balance}")

machine = VendingMachine()
machine.insert_coin(10)
machine.dispense("soda")
machine.insert_coin(10)
machine.dispense("soda")
$ python priced_vending.py
Balance: 10
Cannot dispense soda: need 15, have 10
Balance: 20
Dispensed soda. Change: 5

Two invariants now, both enforced inside dispense: never dispense out-of-stock, and never dispense unpaid. Neither check can be skipped by a caller.

3. Replace the printed error with a custom OutOfStockError exception.
class OutOfStockError(Exception):
    pass

class VendingMachine:
    def __init__(self):
        self._stock = {"soda": 2, "chips": 1}

    def dispense(self, item):
        if self._stock.get(item, 0) <= 0:
            raise OutOfStockError(f"{item} is out of stock")
        self._stock[item] -= 1
        print(f"Dispensed {item}. Remaining: {self._stock[item]}")

machine = VendingMachine()
machine.dispense("chips")
try:
    machine.dispense("chips")
except OutOfStockError as e:
    print(f"Error: {e}")
$ python exception_vending.py
Dispensed chips. Remaining: 0
Error: chips is out of stock

Raising forces every caller to handle the failure explicitly — an uncaught OutOfStockError crashes loudly, where the earlier print-and-return version could be silently ignored by a caller who never checks what dispense printed.

Next: a state machine that tracks money and dispensing as explicit states, not just a stock count.

Frequently Asked Questions

What is Low-Level Design (LLD)?
LLD is the process of turning a system's requirements into concrete classes, their responsibilities, and how they interact — as opposed to High-Level Design (HLD), which is about services, databases, and network boundaries. LLD interviews and real engineering both come down to the same skill: designing objects that can't be misused.
Isn't checking `if stock <= 0` before decrementing just basic programming, not 'design'?
The check itself is trivial — the design decision is *where* it lives. Putting it inside the class (guarding `_stock` with an underscore and only mutating it through `dispense`) means every caller gets the protection automatically. Put the check outside the class instead, and every new caller has to remember to repeat it, which is exactly how the negative-stock bug happens in real codebases.
Why use a leading underscore on `_stock` instead of making it truly private?
Python has no enforced private attributes — a single leading underscore is a convention meaning 'internal, don't touch directly,' not a language-level lock. Other languages (Java, C++) enforce this with `private` keywords; Python relies on the convention plus code review, which is worth knowing going into an LLD interview in a language that does enforce it.
Does this naive-vs-designed pattern show up elsewhere in LLD?
Constantly. Almost every classic LLD problem in this track — the parking lot, the elevator, the library system — has a naive version where invariants can be violated from outside the class, and a designed version where they can't. Once you see the pattern here, watch for it in every later lesson.