How to Design a Class in Python: Invariants and Interfaces

A good Python class protects an invariant behind a small interface. Value objects vs entities, validating before changing state, hiding storage, and testing it.

  • Course: Python study plan
  • Module: Classes and objects
  • Kind: Lesson
  • Reading time: 14 min
  • Runtime: CPython 3.11

How do I design a good class in Python?

Design a Python class around an invariant — a fact about its data that every method maintains — and a small interface of verbs and queries. Decide first whether it is a value, immutable and compared by its fields, or an entity, mutable and equal only to itself. Validate before changing state, hide the storage behind underscores, and return data rather than printing.

Lesson

The mechanics of classes are the previous five lessons; this one is about judgement. A class earns its existence by protecting an invariant — a fact about its data that every method maintains and every caller may assume — and by presenting a small interface that says what the object does rather than how it stores things. This lesson works through the design of one class, Inventory, from the questions to ask before writing it, through representation, constructor, invariants and interface, to the tests that pin it, and ends with the checklist that separates a class that helps from a class that merely groups functions.

The questions before the code

  1. What is the invariant? For an inventory: quantities are never negative; every listed item has a name and a price; the total value equals the sum over items.
  2. Is it a value or an entity? A Money is a value — equal if the amounts are equal, immutable, hashable. An Inventory is an entity — it has a life cycle, is mutated, is equal only to itself.
  3. What must callers be able to do? Add stock, remove stock (and be told if there is not enough), ask what is there, ask the total value. That is the interface; everything else is internal.
  4. **What must callers not do?** Set a quantity negative, corrupt the total, depend on the storage layout.

If there is no invariant and no behaviour — just fields — the answer is a dataclass, or a dict, not a class with methods.

Representation

from dataclasses import dataclass

@dataclass(frozen=True)
class Item:                       # a value: what an item *is*
    name: str
    price_cents: int

class Inventory:                  # an entity: what the stock *does*
    def __init__(self):
        self._stock: dict[str, tuple[Item, int]] = {}   # name -> (item, quantity)

The storage is a dict keyed by name because lookups by name are what the operations need; it is prefixed with an underscore because no caller should touch it; the value type is a tuple of the item and its count. Prices are integer cents (Module 2). The representation is chosen for the operations, and it can change later without changing the interface.

Constructor and invariants

    def add(self, item: Item, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError(f"quantity must be positive, got {quantity}")
        _, current = self._stock.get(item.name, (item, 0))
        self._stock[item.name] = (item, current + quantity)

    def remove(self, name: str, quantity: int = 1) -> None:
        if name not in self._stock:
            raise KeyError(name)
        item, current = self._stock[name]
        if quantity > current:
            raise ValueError(f"only {current} {name} in stock, cannot remove {quantity}")
        if current == quantity:
            del self._stock[name]
        else:
            self._stock[name] = (item, current - quantity)

Every path that changes _stock checks the invariant before changing anything, so a failed call leaves the inventory exactly as it was — the strong guarantee, which is easy here because each method makes one assignment after all its checks. The exceptions are the built-in ones with the right meaning (ValueError for a bad value, KeyError for a missing key) and carry messages a caller can act on; Module 10 discusses custom exceptions for when these are not specific enough.

Interface

    def quantity(self, name: str) -> int:
        item_and_count = self._stock.get(name)
        return item_and_count[1] if item_and_count else 0

    def total_value_cents(self) -> int:
        return sum(item.price_cents * n for item, n in self._stock.values())

    def __len__(self) -> int:              # number of distinct items
        return len(self._stock)

    def __contains__(self, name: str) -> bool:
        return name in self._stock

    def __iter__(self):                    # (item, quantity) pairs, by name
        for name in sorted(self._stock):
            yield self._stock[name]

    def __repr__(self) -> str:
        return f"Inventory({len(self)} items, {self.total_value_cents()} cents)"

Queries are read-only methods with clear names; the dunders make the object usable with len, in and for (Module 8 lesson 2). Note what is not there: no get_stock() returning the dict (that would hand the invariant to the caller), no set_quantity (the operations are add and remove, which is what the domain has). Iteration sorts by name so that output is deterministic. A report() that prints would be a mistake — printing is the caller's job; the class returns data.

Tests as the specification

inv = Inventory()
bolt = Item("bolt", 5)
inv.add(bolt, 100)
inv.remove("bolt", 30)
assert inv.quantity("bolt") == 70
assert inv.total_value_cents() == 350
try:
    inv.remove("bolt", 1000)
except ValueError:
    pass
assert inv.quantity("bolt") == 70          # unchanged after the failed call
assert "bolt" in inv and len(inv) == 1

Each test is an invariant made executable: the failed removal did not change anything; the count and the value agree with the operations. Module 18 turns these into a real test suite; the point here is that the tests were written from the invariants, and a class whose invariants you cannot state is a class you cannot test.

The checklist

  • The class has a one-sentence purpose and an invariant you can write down.
  • __init__ establishes the invariant; every mutating method checks before it changes.
  • Internal state has underscores; the interface is a handful of verbs (mutators) and nouns (queries).
  • __repr__ always; __eq__/__hash__ for values, not for entities; the collection dunders when the object is a collection.
  • Methods return data, never print; I/O stays in main.
  • Value types are frozen dataclasses; an object with no behaviour is a dataclass or a dict.
  • Composition over inheritance by default (Module 9): an Inventory has a dict, it is not one.

Pitfalls

  • A class that is a bag of loosely related functions with no shared state.
  • Exposing the internal collection through a getter.
  • Mutating state before validating, so a failed call leaves a half-updated object.
  • Methods that print, making the class unusable in any other context.
  • Inheriting from dict or list to "reuse" their methods — you inherit forty methods that can break the invariant.
  • Naming by mechanism (process, handle, data) instead of by meaning (remove, total_value_cents).

Key takeaways

  • A class protects an invariant and offers a small interface; without an invariant, use a dataclass or a dict.
  • Decide value versus entity first: values are frozen and compare by fields; entities are mutable and compare by identity.
  • Check before changing, so a failed call changes nothing; raise the built-in exception with the right meaning.
  • Hide the representation, expose verbs and queries, add the collection dunders when they fit, return data rather than printing.
  • Write the tests from the invariants — they are the specification.

Common questions

What is a class invariant?

A class invariant is a condition on an object's data that holds after every method call, such as an inventory's quantities never being negative. The constructor establishes it, every mutating method checks before it changes anything, and callers may rely on it everywhere else.

What is the difference between a value object and an entity?

A value object, such as an amount of money, is defined by its fields: two with equal fields are equal, and it should be immutable and hashable, like a frozen dataclass. An entity, such as an account or an inventory, has a life cycle, is mutated, and is equal only to itself.

When should I not write a class in Python?

When there is no invariant and no behaviour, only fields, use a dataclass or a dict instead. And a class that is just a bag of loosely related functions with no shared state belongs in a module.

Should I inherit from dict or list to reuse their methods?

Usually not. Subclassing dict or list hands callers dozens of inherited methods, any of which can break your invariant. Hold the collection in a private attribute instead — an inventory has a dict, it is not one — and expose only the operations the domain needs.

Why should a class return data instead of printing it?

A method that prints ties the class to one output format and makes it unusable anywhere else, tests included. Return the data and let the caller, usually main, decide how to display it.

Exercises

Inventory

Implement the lesson's Inventory: add(name, price_cents, qty) (rejects qty <= 0 with ValueError; a repeated name adds to the quantity and keeps the first price), remove(name, qty) (KeyError for an unknown name, ValueError when there is not enough), quantity(name), total_value_cents(), __len__, __contains__ and __iter__ yielding (name, price_cents, qty) sorted by name. Commands: add name price qty, remove name qty, qty name, value, list; errors print unknown item, bad quantity or not enough.

Input: commands. Output: one line per query and per error; list prints name qty price per item or empty.

add bolt 5 100
add nut 2 250
remove bolt 30
remove nut 999
remove screw 1
qty bolt
value
list

prints

not enough
unknown item
70
850
bolt 70 5
nut 250 2

A bounded stack

Implement BoundedStack(capacity) whose invariant is 0 <= len(stack) <= capacity: push(x) raises OverflowError when full, pop() and peek() raise IndexError when empty, __len__ and __repr__ (BoundedStack([1, 2], capacity=3)). Commands: push x, pop, peek, len, show; errors print full or empty.

Input: the capacity, then commands. Output: one line per pop, peek, len, show and per error.

2
push 1
push 2
push 3
pop
show

prints

full
2
BoundedStack([1], capacity=2)

In this module: Classes and objects

← Dataclasses — classes that are mostly data · Checkpoint — Classes and objects →