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

Design a Vending Machine as a State Machine

Model IDLE, HAS_MONEY, DISPENSING, and SOLD_OUT as explicit states instead of scattered booleans, and watch invalid transitions get rejected by name.

By SkillByExample · Published

lld oop python state-machine

A vending machine only has a handful of states — but the wrong dispensing sequence in the wrong state is exactly how a machine gives away a snack for free or dispenses nothing.

Modeling the states explicitly

class VendingMachine:
    def __init__(self):
        self.state = "IDLE"
        self._stock = 1

    def insert_coin(self):
        if self.state == "IDLE":
            self.state = "HAS_MONEY"
            print("Coin accepted. State -> HAS_MONEY")
        else:
            print(f"Cannot insert coin in state {self.state}")

    def select_item(self):
        if self.state != "HAS_MONEY":
            print(f"Cannot select item in state {self.state}: insert coin first")
            return
        if self._stock <= 0:
            self.state = "SOLD_OUT"
            print("Out of stock. State -> SOLD_OUT")
            return
        self.state = "DISPENSING"
        print("Item selected. State -> DISPENSING")

    def dispense(self):
        if self.state != "DISPENSING":
            print(f"Cannot dispense in state {self.state}")
            return
        self._stock -= 1
        self.state = "IDLE"
        print(f"Dispensed. Stock left: {self._stock}. State -> IDLE")

m = VendingMachine()
m.select_item()
m.insert_coin()
m.select_item()
m.dispense()
m.insert_coin()
m.select_item()
$ python vending_fsm.py
Cannot select item in state IDLE: insert coin first
Coin accepted. State -> HAS_MONEY
Item selected. State -> DISPENSING
Dispensed. Stock left: 0. State -> IDLE
Coin accepted. State -> HAS_MONEY
Out of stock. State -> SOLD_OUT

The first select_item() call is rejected outright — the machine is still IDLE, so there’s no money to spend yet. Notice the machine also catches its own last unit selling out and moves itself to SOLD_OUT, rather than letting a caller dispense() on nothing.

Every method checks “am I in the right state?”

That’s the whole pattern: each method’s first line asks whether the current state permits the requested action, and refuses — loudly, by printing what’s wrong — if it doesn’t. There’s no scenario where dispense() can run without having gone through select_item() first, because dispense() itself checks self.state != "DISPENSING" before doing anything.

Practice

1. Add a cancel method that refunds and returns to IDLE, but only from HAS_MONEY.
class VendingMachine:
    def __init__(self):
        self.state = "IDLE"
        self._stock = 1

    def insert_coin(self):
        if self.state == "IDLE":
            self.state = "HAS_MONEY"
            print("Coin accepted. State -> HAS_MONEY")
        else:
            print(f"Cannot insert coin in state {self.state}")

    def select_item(self):
        if self.state != "HAS_MONEY":
            print(f"Cannot select item in state {self.state}: insert coin first")
            return
        self.state = "DISPENSING"
        print("Item selected. State -> DISPENSING")

    def cancel(self):
        if self.state != "HAS_MONEY":
            print(f"Cannot cancel in state {self.state}")
            return
        self.state = "IDLE"
        print("Refunded. State -> IDLE")

m = VendingMachine()
m.insert_coin()
m.cancel()
m.select_item()
$ python cancel.py
Coin accepted. State -> HAS_MONEY
Refunded. State -> IDLE
Cannot select item in state IDLE: insert coin first

cancel() follows the exact same rule as every other method: check the current state first. After refunding, the machine is back to IDLE, so select_item() correctly refuses again.

2. Replace the if/elif chain with a single transition table.
class VendingMachine:
    VALID_TRANSITIONS = {
        "IDLE": {"insert_coin": "HAS_MONEY"},
        "HAS_MONEY": {"select_item": "DISPENSING"},
        "DISPENSING": {"dispense": "IDLE"},
    }

    def __init__(self):
        self.state = "IDLE"

    def transition(self, event):
        allowed = self.VALID_TRANSITIONS.get(self.state, {})
        if event not in allowed:
            print(f"Invalid event '{event}' in state {self.state}")
            return
        self.state = allowed[event]
        print(f"{event} -> {self.state}")

m = VendingMachine()
m.transition("select_item")
m.transition("insert_coin")
m.transition("insert_coin")
m.transition("select_item")
$ python transition_table.py
Invalid event 'select_item' in state IDLE
insert_coin -> HAS_MONEY
Invalid event 'insert_coin' in state HAS_MONEY
select_item -> DISPENSING

One dictionary now encodes every legal move. Adding a fifth state later means adding one entry to VALID_TRANSITIONS, not editing five separate methods.

3. Track total revenue across multiple purchases, requiring exact payment.
class VendingMachine:
    PRICE = 15

    def __init__(self):
        self.state = "IDLE"
        self._stock = 2
        self.revenue = 0

    def insert_coin(self, amount):
        if self.state == "IDLE":
            self.state = "HAS_MONEY"
            self._balance = amount
            print(f"Coin accepted ({amount}). State -> HAS_MONEY")
        else:
            print(f"Cannot insert coin in state {self.state}")

    def select_item(self):
        if self.state != "HAS_MONEY":
            print(f"Cannot select item in state {self.state}: insert coin first")
            return
        if self._balance < self.PRICE:
            print(f"Cannot select item: need {self.PRICE}, have {self._balance}")
            return
        self.state = "DISPENSING"
        print("Item selected. State -> DISPENSING")

    def dispense(self):
        if self.state != "DISPENSING":
            print(f"Cannot dispense in state {self.state}")
            return
        self._stock -= 1
        self.revenue += self.PRICE
        self.state = "IDLE"
        print(f"Dispensed. Revenue so far: {self.revenue}. State -> IDLE")

m = VendingMachine()
m.insert_coin(15)
m.select_item()
m.dispense()
m.insert_coin(15)
m.select_item()
m.dispense()
print(f"Total revenue: {m.revenue}")
$ python revenue.py
Coin accepted (15). State -> HAS_MONEY
Item selected. State -> DISPENSING
Dispensed. Revenue so far: 15. State -> IDLE
Coin accepted (15). State -> HAS_MONEY
Item selected. State -> DISPENSING
Dispensed. Revenue so far: 30. State -> IDLE
Total revenue: 30

revenue only ever increases inside dispense(), the one place a sale is actually confirmed — so it can’t drift out of sync with how many items were really handed out.

Next: Tic-Tac-Toe — a board, two players, and win detection that has to check every row, column, and diagonal.

Frequently Asked Questions

Why use a string like 'HAS_MONEY' instead of a boolean flag like has_money?
A vending machine really has four mutually exclusive states, not two independent booleans. Modeling them as one `state` variable makes impossible combinations (dispensing while also idle) structurally impossible instead of just something you have to remember not to create. It also gives every method one obvious place to check: 'am I allowed to do this from my current state?'
What's the difference between the if/elif version and the transition-table version in Practice?
The if/elif version (the main lesson) hardcodes the allowed transitions inside each method's body. The transition-table version (Practice #2) pulls those same rules into one dictionary that every event checks against. Neither is 'more correct' at this size, but the table version scales better once a machine has many states and events, because adding a new one means adding a dictionary entry instead of editing every method.
Is this the same pattern used in real vending machines or elevator controllers?
Yes — finite state machines are the standard model for exactly this class of hardware controller: elevators, traffic lights, garage door openers, and vending machines all have a small, fixed set of states and well-defined legal transitions between them. The next lesson in this track designs an elevator scheduler using the same underlying idea.
Why does insert_coin fail instead of just adding to an existing balance when already in HAS_MONEY?
This lesson's state machine treats a single-coin machine, where a second coin while already in HAS_MONEY should either add to the balance or be rejected depending on the exact spec — this lesson chose rejection for simplicity. Practice #3 extends the balance logic to accumulate instead, which is the more realistic real-machine behavior.