Project: a drinks kiosk
A program with state and commands, built with functions, then refactored into classes.
Create a free account Try part of it here
Why it matters
Most real programs are machines that keep state and respond to commands: a shopping cart, a booking system, an agent that remembers the conversation. This project builds a small one twice: first with dictionaries and functions, then reorganized into classes. Doing both shows you what classes are for, by feeling the difference, rather than by taking it on trust.
What you'll be able to do
- Write a program that keeps state between commands: stock, money and a menu
- Store money as whole cents so sums never drift
- Refactor working functions into classes that each own one job
- Use a class you didn't write from its documentation alone
What it covers
- The brief
- Money in cents, never in dollars
- Version 1: dictionaries and functions
- Version 2: classes with one job each
- Use a class from its documentation
- The kiosk decides; the parts do the work
- Worked example: a session at the kiosk
- Go further
The brief
A mountain hut has a self-service kiosk with three hot drinks. Each drink has a recipe and a price:
| Drink | Recipe | Price |
|---|---|---|
| cocoa | 200 ml water, 30 g cocoa | $2.50 |
| chai | 180 ml water, 70 ml milk, 20 g chai | $3.00 |
| mocha | 150 ml water, 100 ml milk, 18 g coffee, 20 g cocoa | $3.50 |
The kiosk asks what you'd like and responds to three kinds of command:
- a drink's name: check the stock, take payment, give change, make the drink;
report: show the stock and the money taken;off: shut down.
If an ingredient runs short, the kiosk says which one, and doesn't take your money. If you pay too little, it refunds you. Small as it is, that's real business logic, and every rule needs a test.
Money in cents, never in dollars
Prices like 2.50 look harmless, but floats can't store most decimal fractions exactly. Add a few up and the total drifts:
print(0.10 + 0.20) print(0.10 + 0.20 == 0.30) print(10 + 20, 10 + 20 == 30)
The first two lines show floats going slightly wrong: ten cents plus twenty cents isn't quite thirty, so a comparison that should be true comes out false. The last line does the same sum in whole cents, as integers, and the answer is exact. So the kiosk stores every amount in cents: 250, not 2.50. It only turns cents into dollars when it shows a price, with an f-string: f"${cents / 100:.2f}". Banks and payment services do the same, for the same reason.
Python also has a decimal module that does exact decimal arithmetic, and you'll see it in finance code. For a kiosk, whole cents are simpler: ordinary integers, ordinary sums, and nothing to import.
price = 350. What does f"${price / 100:.2f}" show?price = 350 print(f"${price / 100:.2f}")
Show the answer
$3.50
350 cents divided by 100 is 3.5, and :.2f shows it with two decimal places: $3.50.
Version 1: dictionaries and functions
Start with plain data and small functions. The menu and the stock are dictionaries:
MENU = {
"cocoa": {"recipe": {"water": 200, "cocoa": 30}, "price": 250},
"chai": {"recipe": {"water": 180, "milk": 70, "chai": 20}, "price": 300},
}
stock = {"water": 500, "milk": 60, "cocoa": 100, "chai": 40}
def missing_for(recipe, stock):
return [item for item, amount in recipe.items() if stock.get(item, 0) < amount]
def use(recipe, stock):
for item, amount in recipe.items():
stock[item] = stock[item] - amount
print(missing_for(MENU["cocoa"]["recipe"], stock))
print(missing_for(MENU["chai"]["recipe"], stock))
use(MENU["cocoa"]["recipe"], stock)
print(stock)
missing_for returns every ingredient that's short, not just the first, so the kiosk can say "Sorry, not enough milk" accurately. use changes the stock dictionary it's given, which is what a machine that pours drinks should do.
This version works, and for a tiny program it's fine. But notice how the stock gets passed into every function, and how nothing stops some other code from changing it carelessly. As the kiosk grows, with money and reports, everything ends up depending on everything.
The kiosk's loop. Notice the order: check stock before taking money, so nobody pays for a drink that can't be made.
Version 2: classes with one job each
The fix is to give each kind of state an owner: a class that holds the data and the only functions allowed to change it.
- Inventory owns the stock. It answers "what's missing for this recipe?" and uses ingredients.
- Till owns the money. It takes payment and works out change.
- Kiosk owns neither. It holds a menu, an inventory and a till, and decides what happens for each command.
Here's the inventory as a class:
class Inventory: def __init__(self, stock): self.stock = dict(stock) def missing_for(self, recipe): return [item for item, amount in recipe.items() if self.stock.get(item, 0) < amount] def use(self, recipe): for item, amount in recipe.items(): self.stock[item] = self.stock[item] - amount shelf = Inventory({"water": 500, "cocoa": 20}) print(shelf.missing_for({"water": 200, "cocoa": 30}))
dict(stock) makes the inventory its own copy, so changing it can't change the dictionary it was built from. Now nothing outside the class needs to touch stock directly. That's the main gain of a class: one place for each rule, and one place to look when that rule seems broken.
Use a class from its documentation
The hut's accountant wrote a Till class. Run the code to read its documentation with help(Till), without reading the class itself. Then create a till, pay 300 cents for a 250-cent drink, and store the change in change and the takings in takings.
class Till: """Holds the kiosk's money, in cents. pay(coins, price): coins is a list of coin values in cents. Returns the change in cents, or None (and keeps nothing) if the coins don't cover the price. takings: the total kept so far, in cents. """ def __init__(self): self.takings = 0 def pay(self, coins, price): paid = sum(coins) if paid < price: return None self.takings = self.takings + price return paid - price help(Till) change = None takings = None
The kiosk decides; the parts do the work
With an inventory and a till, the kiosk's order method reads almost like the brief:
def order(self, name, coins): if name not in self.menu: return f"Sorry, we don't make {name}." recipe, price = self.menu[name]["recipe"], self.menu[name]["price"] missing = self.inventory.missing_for(recipe) if missing: return "Sorry, not enough " + ", ".join(missing) + "." change = self.till.pay(coins, price) if change is None: return "That's not enough money. Refunded." self.inventory.use(recipe) return f"Here's your {name}. Change: ${change / 100:.2f}"
Each line asks a part a question or tells it to act. The order of the checks carries the business rules: the stock is checked before any money is taken, and the ingredients are used only after the payment succeeds.
The method returns its message instead of printing it. The loop that talks to the customer prints it. That keeps order easy to check with known coins, the same split as in the word game.
order check the stock before calling till.pay?Show the answer
So a customer is never charged for a drink the kiosk can't make
If payment came first, a short ingredient would mean refunding money already taken. Checking first means nothing is charged unless the drink can be made.
Worked example: a session at the kiosk
The loop reads commands until off. Coins are typed as cent values separated by spaces. This session orders a cocoa, asks for a report, and shuts the kiosk down.
MENU = {"cocoa": {"recipe": {"water": 200, "cocoa": 30}, "price": 250}}
stock = {"water": 500, "cocoa": 100}
takings = 0
def missing_for(recipe):
return [i for i, a in recipe.items() if stock.get(i, 0) < a]
while True:
command = input("What would you like? ").strip().lower()
if command == "off":
print("Goodbye.")
break
if command == "report":
print(stock, f"takings ${takings / 100:.2f}")
continue
if command not in MENU:
print("Sorry, we don't make that.")
continue
drink = MENU[command]
if missing_for(drink["recipe"]):
print("Sorry, sold out.")
continue
coins = [int(c) for c in input("Coins: ").split()]
if sum(coins) < drink["price"]:
print("Not enough. Refunded.")
continue
takings = takings + drink["price"]
for item, amount in drink["recipe"].items():
stock[item] = stock[item] - amount
print(f"Here's your cocoa. Change: ${(sum(coins) - drink['price']) / 100:.2f}")This is version 1's style: one loop and some module-level variables. The coins line splits the answer on spaces and turns each piece into an int, so "100 100 100" becomes [100, 100, 100]. The continue lines send every finished command back to the question. Try typing a drink that doesn't exist, or too few coins. Then compare with the class version in the exercise: the rules are the same, but each lives in exactly one place.
Go further
Some ideas for growing the kiosk:
- Add a
restock(item, amount)method toInventory, and a hiddenrestockcommand for staff. - Give exact change in coins: work out how many dollars, quarters, dimes and nickels make up the change, starting with the biggest coin.
- Add a fourth drink by adding one menu entry, and check that nothing else needs changing. If something does, that's a sign a rule lives in the wrong place.
The habit to keep from this project: before adding a feature, ask which part owns it. Restocking belongs to the inventory, refunds belong to the till, and deciding what a command means belongs to the kiosk. When the answer is "several parts at once", the feature is probably two smaller features, each with an obvious home.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Why store prices as 250 rather than 2.50?