PYTH-270 · Lesson 3 of 12 · 24 min

Designing with functions and objects

▶ Video coming soon A video version of this lesson is on the way.

Python lets you design with plain functions, with classes, with modules, or with any mixture of them. That freedom is a strength, but it also means nobody forces a sensible choice on you. Jordan's early code put everything in functions that passed dictionaries around; Sam, fresh from a Java course, wanted a class for every noun. Lena's advice was that both styles are useful and that the skill is knowing which one fits each part of a program.

This lesson gives you the questions to ask. It covers when a function is enough and when a class earns its place, the difference between values and entities, pure functions and immutability, composition versus inheritance, and a widely used shape for programs called a functional core with an imperative shell. Along the way, Ridgeline's pricing rules move from scattered dictionaries into a small set of well-designed functions and value objects.

Start with functions

A function is the smallest unit of design in Python, and many problems need nothing more. A function with a clear name, a few parameters and a return value is easy to read, easy to test and easy to reuse. If a piece of logic takes inputs and produces an output, and does not need to remember anything between calls, a function is the right choice. Ridgeline's tax calculation, the conversion from green to roasted weight and the loyalty tier lookup are all best written as plain functions.

Functions are also first-class objects in Python: you can store them in variables, put them in dictionaries and pass them to other functions. That means many designs that need a class hierarchy in other languages need only a dictionary of functions in Python, as the shipping registry in lesson 2 showed. Before reaching for a class, ask whether a function, or a function that takes another function as a parameter, would do.

When a class earns its place

A class bundles data with the behaviour that belongs to it. It earns its place when several functions keep passing the same group of values around, when the data has rules that must always hold (an invariant), when the object has state that changes over time, or when you need several interchangeable implementations of the same behaviour. Ridgeline's Inventory class qualifies on every count: it holds a collection of coffees, enforces rules such as stock never going below zero, changes as sales happen, and has a test double in the test suite.

A useful smell in the other direction is a class with one method besides __init__. If a class exists only to hold arguments until you call its single method, it is usually a function wearing a costume. Jack Diederich's talk Stop Writing Classes made this point memorably: a class named DiscountCalculator with a calculate method is just a function called calculate_discount.

A one-method class versus a function; functools.partial covers the configured case
# A class that is really a function:
class DiscountCalculator:
    def __init__(self, rate):
        self.rate = rate

    def calculate(self, amount):
        return round(amount * (1 - self.rate), 2)

print(DiscountCalculator(0.15).calculate(120.00))

# The same idea as a function, which is simpler to read and to test:
def discounted(amount, rate):
    return round(amount * (1 - rate), 2)

print(discounted(120.00, 0.15))

# If a configured version is useful, functools.partial makes one:
from functools import partial
wholesale = partial(discounted, rate=0.15)
print(wholesale(120.00))
Output
102.0
102.0
102.0

Modules are units of design too

Between a function and a whole package sits the module, and in Python it is often the most natural unit of design. A module groups related functions, constants and classes behind one name, gives them a namespace, and lets you decide what is public. Names without a leading underscore form the module's interface; names with one, such as _round_half_up, are details other modules should not use. A module can also hold state that would otherwise need a singleton class, such as a configured connection or a cache, created once on import.

Ridgeline's money handling is a good example. Instead of a MoneyHelper class with static methods, money.py offers to_money, fmt and the constant ZERO, and the rest of the package imports what it needs: from ridgeline.money import to_money. Asking what the module promises its callers, and keeping everything else private, is the same discipline as designing a class interface, applied one level up. Lesson 8 builds on this when whole layers of an application become packages.

Values and entities

Domain-driven design, an approach popularised by Eric Evans, distinguishes two kinds of objects. A value object is defined entirely by its attributes: two amounts of 18.50 dollars are interchangeable, and so are two addresses with the same lines. Value objects should be immutable, so that once created they never change; operations return new values instead. An entity has an identity that persists while its attributes change: order 1041 is the same order whether its status is new, packed or shipped.

Making the distinction explicit removes a whole class of bugs. Ridgeline once stored prices as floats in dictionaries, and a function that applied a discount changed the price in place, so a later report showed the discounted price as the list price. Money as an immutable value object cannot be changed by accident. In Python, a frozen dataclass is the simplest way to write one, and Decimal avoids the rounding surprises of binary floats.

Money as an immutable value object: equal by value, changed only by creating new values
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str = "CAD"

    def __post_init__(self):
        object.__setattr__(self, "amount", Decimal(self.amount).quantize(CENT, ROUND_HALF_UP))

    def __add__(self, other):
        if other.currency != self.currency:
            raise ValueError("cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)

    def times(self, factor):
        return Money(self.amount * Decimal(str(factor)), self.currency)

price = Money("18.50")
total = price.times(3) + Money("8.95")
print(total)
print(price == Money("18.5"))
try:
    price.amount = Decimal("1.00")
except Exception as e:
    print(type(e).__name__)
Output
Money(amount=Decimal('64.45'), currency='CAD')
True
FrozenInstanceError

Because Money is frozen, it is also hashable and safe to share: two orders can refer to the same Money object without either being able to change the other's price. Entities, by contrast, are usually ordinary classes or mutable dataclasses with an id field, compared by identity or by id rather than by every attribute.

Try it yourself

A weight value object

Write a frozen dataclass Weight with one field grams (an int). Give it a method plus(other) that returns a new Weight with the grams added, and a method kg() returning the weight in kilograms as a float.

Weights must be immutable and compare equal by value, so Weight(340) == Weight(340). Negative weights make no sense: raise ValueError in __post_init__ when grams is below zero.

from dataclasses import dataclass

@dataclass
class Weight:
    grams: int

    def plus(self, other):
        self.grams += other.grams
        return self

print(Weight(340).plus(Weight(340)))

Pure functions and side effects

A pure function returns a result that depends only on its arguments and does nothing else: it does not change its arguments, read or write files, print, read the clock or touch global variables. The original process_order was the opposite. It changed the customer dictionary, printed, and depended on global discount settings. Pure functions are the easiest code in the world to test and to reason about, because the same inputs always give the same output and nothing else can be affected.

No useful program is entirely pure: at some point it must read input, save data and show results. The design goal is to push those side effects to the edges and keep the decision-making logic pure. Instead of a function that loads a customer, adds loyalty points and saves the customer, write a pure function that takes a points balance and an order total and returns the new balance, and let the outer code load and save.

Try it yourself

Pure loyalty points

The starter add_points(customer, total) changes the customer dictionary it is given and prints a message. Rewrite it as a pure function points_after(points, total, tier_bonus=0) that returns the new points balance: one point per whole dollar of total, plus tier_bonus points, added to points.

It must not print and must not change anything outside itself. Keep int(total) for whole dollars.

def add_points(customer, total):
    customer["points"] += int(total)
    print("Points now", customer["points"])
    return customer

def points_after(points, total, tier_bonus=0):
    customer = {"points": points}
    add_points(customer, total)
    return customer["points"]

print(points_after(120, 45.80))

Composition over inheritance

Inheritance says a Coffee is a Product; composition says an Order has OrderLines. Both are useful, but the Gang of Four's 1994 book Design Patterns gave advice that still holds: favour composition over inheritance. Inheritance couples a subclass tightly to its parent's internal details, so changes to the parent ripple into every subclass, and deep hierarchies become hard to follow. Composition builds behaviour from small parts that can be swapped independently.

Ridgeline's first attempt at discounts used inheritance: Order, then WholesaleOrder overriding total(), then WholesaleOnlineOrder, then WholesaleOnlinePromoOrder. Every combination of customer type and channel needed a new subclass, a problem called class explosion. Composition fixes it: an Order holds a list of pricing rules, each rule is a small function, and any combination is just a different list.

Composition: an order is configured with rules instead of being subclassed for every combination
def wholesale(total):
    return total * 0.85

def promo(code):
    def rule(total):
        return total * 0.9 if code == "WELCOME10" else total
    return rule

def online_shipping(total):
    return total + 8.95 if total < 50 else total

class Order:
    def __init__(self, lines, rules=()):
        self.lines = lines            # list of (sku, qty, unit_price)
        self.rules = list(rules)      # composed behaviour

    def total(self):
        amount = sum(qty * price for _, qty, price in self.lines)
        for rule in self.rules:
            amount = rule(amount)
        return round(amount, 2)

lines = [("RC-101", 2, 18.50)]
print(Order(lines).total())
print(Order(lines, [promo("WELCOME10"), online_shipping]).total())
print(Order(lines * 5, [wholesale]).total())
Output
37.0
42.25
157.25

Inheritance remains the right tool for a genuine is-a relationship with a stable parent, such as a family of exceptions (RidgelineError, then UnknownCoffee and OutOfStock) or a framework that expects you to subclass one of its classes. Keep hierarchies shallow, one or two levels deep, and inherit to share an interface rather than to reuse a few lines of code.

A functional core with an imperative shell

Gary Bernhardt named a program shape that combines these ideas: a functional core surrounded by an imperative shell. The core contains the business decisions as pure functions and immutable values: how to price an order, which coffees need reordering, how many points a customer earns. The shell is a thin layer that does the input and output: reads files or databases, calls the core, and writes the results. The core holds most of the logic and is tested with fast, simple unit tests; the shell holds little logic and is tested with a few integration tests.

Two concentric rounded rectangles. The inner core is labelled pure functions and values, with examples: price an order, choose reorders, earn points. The outer shell is labelled input and output, with examples: read the database, call the core, save results, print. Arrows show data flowing in through the shell, into the core, and results flowing back out.
The functional core makes decisions; the imperative shell does the input and output
The shell gathers input and sends output; the core decides
# Functional core: pure decisions
def needs_reorder(stock, reorder_point=10):
    return sorted(sku for sku, bags in stock.items() if bags < reorder_point)

def reorder_message(skus):
    return "Reorder: " + ", ".join(skus) if skus else "Stock is fine"

# Imperative shell: input and output only
def main(read_stock, send):
    stock = read_stock()
    send(reorder_message(needs_reorder(stock)))

main(lambda: {"RC-101": 42, "RC-104": 9, "RC-105": 7}, print)
Output
Reorder: RC-104, RC-105

Note that main receives read_stock and send as parameters. In production they read the database and send an email; in a test they are a lambda and a list's append method. The shell is so thin that there is little left to go wrong.

Worked problem

Function, value or entity?

Sam lists five things the subscription feature needs and asks Lena how to model each one.

Show solution
  1. The price of a 340 g bag: defined only by its amount and currency, so a value object (Money).
  2. A customer's subscription: it keeps its identity while the coffee, frequency and status change, so an entity with an id.
  3. Working out the next delivery date from the last one and the frequency: inputs to output with no memory, so a pure function.
  4. Choosing the discount for 6-month versus 12-month plans: a small rule, so a function, or a dictionary of rules if more plans are coming.
  5. Sending the shipping notice: a side effect, so it belongs in the shell, called after the core decides what to send.

Answer: Money is a value, Subscription is an entity, next_delivery and plan_discount are pure functions, and notifying is shell code.

Common mistakes

  • MistakeWriting a class with only __init__ and one other method.

    InsteadWrite a function, and use functools.partial if you need a pre-configured version.

  • MistakeLetting value objects such as prices change in place.

    InsteadMake them immutable, for example with a frozen dataclass, and return new values.

  • MistakeMixing decisions with printing, saving and reading the clock.

    InsteadKeep decisions pure and move input and output to a thin outer layer.

  • MistakeBuilding deep inheritance hierarchies for every combination of features.

    InsteadCompose behaviour from small parts, such as a list of rule functions.

  • MistakeComparing entities by every attribute.

    InsteadCompare entities by identity or id; compare values by their attributes.

  • MistakeUsing floats for money.

    InsteadUse Decimal, or integer cents, inside a Money value object.

Summary and key terms

  • Start with functions; add a class when data and behaviour belong together, invariants must hold, state changes or implementations vary.
  • A class with one method is usually a function in disguise.
  • Value objects are defined by their attributes and should be immutable; entities have identity that persists through change.
  • Frozen dataclasses make simple, hashable value objects; Decimal keeps money exact.
  • Pure functions depend only on their inputs and have no side effects, which makes them easy to test.
  • Favour composition over inheritance to avoid class explosion; keep hierarchies shallow.
  • A functional core with an imperative shell keeps logic pure and input and output at the edges.
first-class function
A function that can be stored, passed and returned like any other value.
invariant
A rule about an object's data that must always hold.
value object
An immutable object defined only by its attributes.
entity
An object with a lasting identity whose attributes can change.
immutable
Unable to change after creation.
pure function
A function whose result depends only on its arguments and which has no side effects.
side effect
Any effect of a function besides returning a value, such as printing or changing data.
composition
Building behaviour by combining objects or functions rather than inheriting.
class explosion
The growth in subclasses needed to cover every combination of features.
functional core, imperative shell
A design with pure decision logic inside and input and output in a thin outer layer.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Ridgeline needs to convert green coffee weight to roasted weight. Function or class?

    Show answer

    A function: input to output, no state, no invariants.

  2. Why should a Money object be immutable?

    Show answer

    So that sharing it is safe and no code can change a price by accident.

  3. Is a function that reads date.today() pure?

    Show answer

    No: its result depends on the clock, not only on its arguments.

  4. What is class explosion, and how does composition avoid it?

    Show answer

    A new subclass for every combination of features; composition combines small parts, so combinations need no new classes.

  5. In a functional core and imperative shell, where does sending an email belong?

    Show answer

    In the shell.

  6. Give one good use of inheritance in Python.

    Show answer

    An exception hierarchy, or subclassing a framework class that is designed to be extended.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz