Software Design Reference System Designer · Flexible & Composable Code

Lesson 0015 · System Designer · Module 1

Classes vs Functions

Not everything needs a class. The decision is not style — it's about what has to change: stateless behavior belongs in functions; state and polymorphism belong in classes.

Reading ≈ 8 minPractice ≈ 20 min

Mission tie-in: "decide when to use classes and when simple functions are enough" is the first System Designer skill — the one AI gets wrong in both directions.

Knowledge: the two habitats

Functions are the natural home for behavior without state: a transformation from input to output, no history, no configuration of its own. Pure functions — same input, same output, no side effects — are the most composable, testable units software has.

def sanitize(word: str) -> str:
    return word.strip(".,!?").lower()

def words(text: str) -> list[str]:
    return [sanitize(w) for w in text.split()]

def word_frequencies(words: list[str]) -> Counter[str]:
    return Counter(words)

# three stateless steps compose into a pipeline with no ceremony:
freqs = word_frequencies(words(raw_text))

Classes earn their keep when there is state to own (something that must stay consistent across calls) or polymorphism (several behaviors behind one interface — lesson 0004's strategy). A class with no state and one method is usually a function wearing a costume:

class TextStats:                      # stateless costume
    def compute(self, text: str) -> Counter[str]:
        return Counter(text.lower().split())

# the function version is the same thing with less ceremony:
def compute_text_stats(text: str) -> Counter[str]:
    return Counter(text.lower().split())

The test is a change question, not a religion: what will vary here? A new behavior variant → a protocol + implementations (classes earn it). New arguments to a transformation → a function with parameters (functions earn it). Lifetime and consistency across calls → state, and a class owns it.

The principle behind the principle

The two units make different promises. A function's contract is a single mapping: these arguments produce that result. A class's contract is a set of methods sharing an invariant over a lifetime — which means introducing one hands every future reader two extra things to model: a state space (what combinations of attributes are possible?) and an object lifetime (when is it created, when is it valid, who holds it?). That is the price. You should pay it when something must stay true between calls, when several implementations must be substitutable behind one name, or when a set of collaborators is bound once and used many times.

Read that list again and notice what is not on it: the number of methods. This is where the folk rule — "a class with one method and no state is a function in disguise" — stops being true. A class is also the standard way to give a seam a name and a lifetime. An object constructed at the composition root with three collaborators and used at fourteen call sites is not ceremony; it is the alternative to passing those three collaborators through fourteen call sites. The honest test is Ousterhout's, and it cuts across the whole question: prefer whichever unit hides more behind a smaller interface.

How a senior designer works the case

Two classes surface in the same review, and by the folk rule they are identical: one method, no mutable state.

class Slugifier:                                  # candidate A
    def make(self, title: str) -> str:
        return "-".join(title.lower().split())

class ReturnLabelPrinter:                         # candidate B
    def __init__(self, carrier: CarrierClient, retries: RetryPolicy) -> None:
        self._carrier = carrier
        self._retries = retries

    def print_label(self, ret: Return) -> Label:
        return self._retries.run(lambda: self._carrier.create_label(ret))

What they look at first. Not the method count — the constructor. Candidate A's is empty: nothing is bound, nothing has a lifetime, and the only thing the class contributes is a noun the caller must import before it can call a function. Candidate B binds two collaborators that were chosen elsewhere, and one of them holds a connection pool.

The question they ask. Not "does it have state?" but "does it have a lifetime, and is it a substitution point?" Candidate B is swapped for a fake in tests, for a sandbox client in staging, and for a second carrier when the contract changes — and its lifetime matters, because creating the client per call would open a connection per label.

The alternative they reject. Converting B to a free function print_label(ret, carrier, retries). It reads cleaner in isolation and moves the binding problem to the callers: five call sites today, and the fourteen a year from now each have to obtain a carrier client and a retry policy they do not care about. That is change amplification bought with tidiness. Reaching for functools.partial to fix it recovers the class with a worse name — an object with no type name in a stack trace, no place to hang a second method, and a repr that tells an on-call engineer nothing.

The consequence over time. A converts to a function and one import disappears from six modules. B stays a class and, eighteen months later, absorbs a second method (void_label) and a metric counter without a single call site changing — because the seam already had a name. The rule survives, but its real content turns out to be "pay for a class when you need a lifetime, a seam, or a family", and "no state" is only the most common symptom of not needing any of the three.

† Arjan's framing: "start with the data" — name the data flow first, and the class/function decision often answers itself. Classes are also a fine way to group functions with shared configuration (a "callable bundle") — that's still composition, not ceremony.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
__init__ that only stores callablesA function factory wearing a class costume.A closure, or functools.partial.
a method that never reads selfIt is a function that happens to live in a class.Move it out; @staticmethod is the smell, not the fix.
a growing mode="…" parameterPolymorphism by string, without the interface.A protocol plus one implementation per mode.
20 free functions sharing one module dictState with no owner (lesson 0019’s phone line).One class owning the dict, methods around it.
What people get wrong after reading this
"Classes are legacy ceremony; functions are always simpler."
Both are namespaces with different defaults. A module of thirty free functions sharing one module-level dict is a class — with worse encapsulation, no lifecycle and one instance forced on the whole process (lesson 0019). You do not avoid objects by refusing the keyword.
"A class with a single method is always a smell."
Only when it binds nothing. When the constructor holds collaborators that were selected at the composition root, the single method is the interface and the constructor is the point.
"@staticmethod fixes a method that never touches self."
It relocates the problem. Callers still import the class, still name it, and still read it as though the grouping meant something. If nothing is shared, move the function out; if something is shared, use it.
"Dataclasses aren't really classes."
They are — with generated boilerplate. They carry invariants in __post_init__, they can hold methods, and a frozen one is a value object (lesson 0012). "Just a struct" is a decision you make, not a property the syntax gives you.

When this is the wrong move

Skill: name the habitat

A transformation with no state and one method is best as:

Three payment behaviors behind one interface justify:

A counter that must stay consistent across calls needs:

Practice on your own code

Find a class with no state (no self. assignments in __init__ that vary) and convert it to a function. Then find one place where a function keeps gaining conditional branches and sketch the protocol that would house them.

In the lab: the phase-2 checkpoint has a returns codebase containing both shapes — a ReturnValidator whose methods never read self, and a handful of free functions that pass the same three collaborators to each other. You move each one to the habitat that fits and justify the two decisions differently.

Reveal: a worked conversion
# before
class Slugifier:
    def make(self, title: str) -> str:
        return "-".join(title.lower().split())

# after — same behavior, no costume
def make_slug(title: str) -> str:
    return "-".join(title.lower().split())
Teach it back
  1. In two sentences, tell a junior what a class buys that a function cannot, without using the words "encapsulation" or "object-oriented".
  2. Find one class in your code this week whose constructor binds nothing. Count the modules that import it, and name what deleting it would save them.
  3. Assume a stateless helper class keeps accreting methods for a year. Predict what its readers will get wrong about it, and what its tests will start needing.

Your win

You can classify any unit by its habitat: stateless transformations → functions; owned state and polymorphism → classes. The costume cases get undressed.

Read and watch deeper

Bring a borderline class to your agent-teacher and run the habitat test together.