Part 6 · Professional Python · Module 46

Software Design & Architecture

Code that works is the beginning, not the end. Professional code must keep working while a team changes it for years. This module is about the decisions that make change cheap: how pieces depend on each other, which patterns earn their keep in Python, how to layer an application around its domain, and how to improve messy code safely.

⏱ ~3 hoursPrerequisites: M12, M13, M16, M17

Why design? Coupling and cohesion

Software design is the art of arranging code so that the changes you will need later are easy and the mistakes you will make are caught early. Nearly all design advice reduces to two measurable ideas.

The goal is high cohesion, low coupling. Here is a function with both problems: it mixes reading, business rules, and output (low cohesion), and it hard-codes where data comes from and goes to (high coupling to the file system and the console).

def monthly_report(path):
    total = 0
    for line in open(path):                      # coupled to a file format and the disk
        name, amount, kind = line.strip().split(",")
        if kind == "refund":
            total -= float(amount)
        else:
            total += float(amount) * 1.2 if kind == "vat" else float(amount)
    print(f"Total: {total:.2f}")                 # coupled to the console; returns nothing
from dataclasses import dataclass

@dataclass(frozen=True)
class Entry:
    name: str
    amount_cents: int
    kind: str

def parse_entries(lines):                        # input format, and nothing else
    for line in lines:
        name, amount, kind = line.strip().split(",")
        yield Entry(name, round(float(amount) * 100), kind)

def signed_amount(entry):                        # one business rule, testable alone
    if entry.kind == "refund":
        return -entry.amount_cents
    return entry.amount_cents * 6 // 5 if entry.kind == "vat" else entry.amount_cents

def total_cents(entries):
    return sum(signed_amount(e) for e in entries)

def format_total(cents):
    return f"Total: {cents / 100:.2f}"           # presentation; the caller decides where it goes

The separated version is longer, but every piece can be understood, tested, and changed alone. Reading from an API instead of a file touches only parse_entries's caller; a new tax rule touches only signed_amount.

SOLID, the Pythonic way

SOLID is five principles collected by Robert C. Martin for object-oriented design. They were written with Java and C++ in mind; in Python, first-class functions, duck typing, and typing.Protocol often satisfy them with much less code.

S: Single responsibility

A module or class should have one reason to change. The "Separated" tab above is SRP in action: parsing, rules, and formatting change for different reasons (a new file format, a new tax, a new report layout), so they live apart.

O: Open/closed

Code should be open for extension but closed for modification: adding a new case should not require editing a growing if/elif chain. In Python, a registry dict is the lightest way to do it.

L: Liskov substitution

A subclass must be usable anywhere its base class is expected, without surprising the caller. The classic violation: a Square that inherits from Rectangle but forces width and height to stay equal. Code written for rectangles breaks.

The fix is not cleverer inheritance; it is to stop pretending a mutable square is a mutable rectangle. Make shapes immutable values, or give them a common Shape protocol with just area().

I: Interface segregation

Clients should not depend on methods they do not use. With typing.Protocol (structural typing from M16), each function declares the smallest interface it needs, and any object with those methods qualifies, no inheritance required.

from typing import Protocol

class Readable(Protocol):
    def read(self, key: str) -> bytes: ...

class Writable(Protocol):
    def write(self, key: str, data: bytes) -> None: ...

def checksum(store: Readable, key: str) -> int:      # needs only read(), so asks only for read()
    return sum(store.read(key)) % 256

D: Dependency inversion

High-level policy should not depend on low-level details; both should depend on abstractions. Concretely: your business logic should not import sqlite3 or import httpx. It should depend on a Protocol it owns (OrderRepository, PaymentGateway), and the low-level adapters implement that Protocol. This single principle is the foundation of the architecture section below.

Design patterns, the Python way

A design pattern is a named, reusable solution to a recurring design problem. The famous 1994 "Gang of Four" book catalogued 23 of them for C++ and Smalltalk. Many exist mainly to work around missing language features; in Python, several collapse into a function or a dict. Knowing the names still matters: they are the vocabulary of design discussions.

Strategy: pass a function

Strategy means "make an algorithm a swappable parameter". In Python, functions are objects, so a strategy is usually just a callable. You have used this pattern since sorted(key=...).

Factory: one place that decides what to build

A factory centralizes object creation so callers do not need to know concrete classes. Python's idioms are alternative constructors (@classmethod such as datetime.fromisoformat) and a mapping from names to classes, which is how the registry above works.

class Storage(Protocol):
    def save(self, key: str, data: bytes) -> None: ...

def make_storage(url: str) -> Storage:            # callers ask for "a Storage", not a class
    scheme = url.split("://", 1)[0]
    match scheme:
        case "file":
            return FileStorage(url.removeprefix("file://"))
        case "memory":
            return MemoryStorage()
        case _:
            raise ValueError(f"unsupported storage {scheme!r}")

Adapter: make an existing thing fit the interface you need

You have a third-party or legacy class with the wrong interface. Rather than spreading its quirks through your code, wrap it once in an adapter that speaks your interface. Every "adapter" in ports-and-adapters architecture is this pattern.

class LegacyMailer:                                # can't change it: it's vendored
    def send_mail_v2(self, to_addr, subj, body_text, html=False): ...

class Notifier(Protocol):
    def notify(self, user_email: str, message: str) -> None: ...

class LegacyMailerAdapter:                         # satisfies Notifier structurally
    def __init__(self, mailer: LegacyMailer) -> None:
        self._mailer = mailer

    def notify(self, user_email: str, message: str) -> None:
        self._mailer.send_mail_v2(user_email, "Notification", message)

Observer: tell interested parties that something happened

An observer (publish/subscribe) decouples the code where an event happens from the code that reacts to it. The order service publishes "order placed"; email, analytics, and inventory subscribe, and the order service knows none of them. Step through it:

Decorator pattern vs @decorator

The names collide, so be precise. The decorator pattern wraps an object in another object with the same interface to add behavior (caching, logging, retries) without changing the original. Python's @decorator syntax (M15) wraps a function at definition time. They share the idea of wrapping; the pattern works on any object at runtime, the syntax on functions and classes at definition time.

Command: actions as objects (and undo)

A command packages a request (what to do, with which arguments) as an object, so you can queue it, log it, send it elsewhere, or undo it. Editors, task queues, and database migrations are all built on commands.

Repository: hide storage behind a collection-like interface

A repository gives the domain a simple, collection-like interface (add, get, list) and hides SQL, files, or HTTP behind it. The domain and service layers depend on the Protocol; an in-memory implementation makes tests fast, and a SQLite or PostgreSQL implementation runs in production. You will build exactly this in Project 6.

Dependency injection, without a framework

Dependency injection (DI) just means: an object receives its collaborators instead of constructing or importing them. In Python you rarely need a DI container. Pass dependencies to __init__ (or as function parameters), and wire everything up in one place at startup, the composition root.

Layered and hexagonal architecture

Architecture is design at the scale of the whole application: which big parts exist and which way their dependencies point. The traditional layered architecture stacks presentation (CLI, web), application/service (use cases), domain (business rules), and infrastructure (database, network), with each layer calling only the one below.

Hexagonal architecture (Alistair Cockburn's "ports and adapters", closely related to "clean architecture") refines this with one rule: dependencies point inward, toward the domain. The core (domain + use cases) defines ports, interfaces in its own language, such as "a repository of habits" or "a clock". Adapters on the outside implement those ports for specific technologies. Driving adapters (a CLI, an HTTP API, tests) call into the core; driven adapters (SQLite, an SMTP mailer, the system clock) are called by it.

flowchart LR
  subgraph driving["Driving adapters"]
    CLI["CLI (argparse)"]
    WEB["HTTP API (FastAPI)"]
    TEST["Tests"]
  end
  subgraph core["Application core (no I/O imports)"]
    UC["Service layer / use cases"]
    DOM["Domain model
entities, value objects, rules"] P1(["Port: Repository"]) P2(["Port: Clock"]) P3(["Port: Notifier"]) end subgraph driven["Driven adapters"] SQL["SqliteRepository"] MEM["InMemoryRepository"] SYS["SystemClock / FixedClock"] MAIL["SmtpNotifier"] end CLI --> UC WEB --> UC TEST --> UC UC --> DOM UC --> P1 UC --> P2 UC --> P3 SQL -. implements .-> P1 MEM -. implements .-> P1 SYS -. implements .-> P2 MAIL -. implements .-> P3

What this buys you:

src/habits/
  domain.py        # entities, value objects, pure rules: imports only the stdlib
  ports.py         # Protocols the core needs: HabitRepository, Clock
  service.py       # use cases: imports domain + ports, never an adapter
  adapters/
    memory.py      # InMemoryRepository
    sqlite.py      # SqliteRepository (sqlite3 lives ONLY here)
  cli.py           # the composition root: parses args, builds adapters, calls the service

Domain modeling with dataclasses

The domain model is code that represents the concepts of the problem (habits, orders, invoices) and their rules. Two kinds of objects appear again and again:

A good model enforces its invariants (rules that must always hold) at construction, so an invalid object can never exist, and uses types to make illegal states unrepresentable: an enum instead of a free-form string, a separate type instead of a flag that is only sometimes meaningful.

An error-handling strategy

Error handling should be designed once for the whole application, not improvised in each function. A strategy that works well in layered Python apps:

  1. Define a domain exception hierarchy with one base class (class AppError(Exception)) and specific subclasses (NotFoundError, ValidationError, ConflictError). Callers can catch one family or one case.
  2. Fail fast on invalid input at the boundary (parse, don't validate: turn raw input into typed domain objects immediately).
  3. Translate at the edges. Adapters convert technology errors into domain errors (sqlite3.IntegrityError becomes DuplicateError, using raise ... from exc to keep the cause). The driving adapter converts domain errors into its own language: an exit code for a CLI, a status code for HTTP.
  4. Never swallow errors. except Exception: pass hides bugs forever. Catch what you can handle; let the rest propagate to a top-level handler that logs it with structlog and fails visibly.
  5. Distinguish expected outcomes from errors. "Not found" in a lookup may be a normal None; a corrupted file is an exception.

Configuration

Configuration is everything that varies between deployments (development, CI, production): database URLs, API keys, log levels, feature flags. The widely adopted "twelve-factor app" rule is to read configuration from environment variables, keep secrets out of the repository, and keep code identical across environments.

Read and validate configuration once, at startup, into an immutable, typed settings object, then inject it. Never sprinkle os.environ["..."] through the codebase: that couples everything to the process environment and makes tests depend on global state.

For larger apps, pydantic-settings does this declaratively, with type conversion, validation, .env file support, and nested settings:

Refactoring with confidence

Refactoring is changing the structure of code without changing its behavior, in small steps, with tests passing after each one (Martin Fowler's definition). The tests are what make it refactoring rather than rewriting. If the code has no tests, first write characterization tests: tests that pin down what the code does today, even the odd parts, so you will notice if you change it.

Extract function

def print_invoice(invoice):
    print(f"Invoice {invoice.number}")
    total = 0
    for line in invoice.lines:
        amount = line.qty * line.unit_cents
        if line.qty >= 10:
            amount = amount * 90 // 100
        total += amount
    print(f"Total: {total / 100:.2f}")
def line_amount(line):
    amount = line.qty * line.unit_cents
    return amount * 90 // 100 if line.qty >= 10 else amount   # bulk discount, now nameable and testable

def invoice_total(invoice):
    return sum(line_amount(line) for line in invoice.lines)

def render_invoice(invoice):
    return f"Invoice {invoice.number}\nTotal: {invoice_total(invoice) / 100:.2f}"

Introduce parameter object

def search(query, start_date, end_date, min_amount, max_amount, page, per_page):
    ...

search("coffee", d1, d2, 0, 5000, 1, 50)          # which number is which?
@dataclass(frozen=True)
class DateRange:
    start: date
    end: date
    def __post_init__(self):
        if self.end < self.start:
            raise ValueError("end before start")    # the invariant now lives in one place

@dataclass(frozen=True)
class Page:
    number: int = 1
    size: int = 50

def search(query: str, period: DateRange, amount: range, page: Page = Page()):
    ...

search("coffee", DateRange(d1, d2), range(0, 5001))

Replace conditional with polymorphism

def shipping_cost(order):
    if order.method == "standard":
        return 499
    elif order.method == "express":
        return 999 + 100 * order.weight_kg
    elif order.method == "pickup":
        return 0
    raise ValueError(order.method)   # and the same if/elif appears in eta() and label()...
class Shipping(Protocol):
    def cost(self, order: Order) -> int: ...
    def eta_days(self) -> int: ...

class Standard:
    def cost(self, order): return 499
    def eta_days(self): return 5

class Express:
    def cost(self, order): return 999 + 100 * order.weight_kg
    def eta_days(self): return 1

class Pickup:
    def cost(self, order): return 0
    def eta_days(self): return 0

SHIPPING: dict[str, Shipping] = {"standard": Standard(), "express": Express(), "pickup": Pickup()}

def shipping_cost(order):
    return SHIPPING[order.method].cost(order)        # a new method = a new class, no edits here

Polymorphism pays off when the same switch on a type code appears in several places. A single, stable match statement is often clearer than a class hierarchy; refactor when the duplication appears.

Code smells

A code smell is a surface symptom that often (not always) indicates a deeper design problem. Learn to notice them; each has standard refactorings.

SmellWhat it looks likeTypical refactoring
Long functionScrolling to read one function; comments separating "sections"Extract function
Long parameter listFive or more positional parameters; calls full of bare numbersIntroduce parameter object; keyword-only args
Primitive obsessionMoney as float, emails and ids as bare str, status as magic stringsValue objects, enums, NewType
Duplicated codeThe same logic pasted in several places, slowly divergingExtract function; pull up to a shared module
Repeated switchThe same if kind == ... chain in many functionsReplace conditional with polymorphism or a registry
Feature envyA function that mostly manipulates another object's dataMove the function onto that object
Shotgun surgeryOne change requires edits in many filesMove related code together (raise cohesion)
God objectA Manager or Utils class that does everythingSplit by responsibility
Flag argumentssave(data, True, False)Separate functions, or an enum parameter
Hidden dependenciesdatetime.now(), globals, or os.environ deep inside logicInject a clock, settings, or a port

Architecture decision records

Six months from now, someone (often you) will ask "why on earth did we use SQLite?" An architecture decision record (ADR) is a short document, kept in the repository (for example docs/adr/0003-use-sqlite.md), that captures one significant decision: the context, the decision, and its consequences. ADRs are numbered and never edited after acceptance; a later ADR supersedes an earlier one. The format, proposed by Michael Nygard, fits on one screen:

# 3. Use SQLite for habit storage

Date: 2026-03-14
Status: Accepted (supersedes ADR 1, "store habits in a JSON file")

## Context
The JSON file is rewritten on every check-in, and two CLI processes running at once lost data
(issue #41). We need atomic writes, simple queries for streak statistics, and zero setup for users.
We expect one user per database and fewer than 100k rows.

## Decision
Store data in SQLite via the stdlib sqlite3 module, behind the HabitRepository port.
Schema changes are versioned migrations recorded in a schema_version table.

## Consequences
+ Atomic transactions; no lost updates; no new dependency.
+ The port keeps a future PostgreSQL adapter possible without touching the domain.
- Only one writer at a time; acceptable for a single-user CLI, revisit for the web version.
- We must maintain migrations ourselves (see ADR 4 if we adopt Alembic).

Designing for testability

Hard-to-test code is almost always badly designed code; the test is just the first client to notice. Design choices that make testing easy:

Practice

Write three pricing strategies (each takes a total in cents and returns cents): no_discount, percent_off(pct) (a factory returning a strategy; round down with //), and bulk(threshold, off_cents) (a factory: subtract off_cents when the total is at least threshold, never going below 0). Then write best_price(total, strategies) returning the lowest price any strategy gives.

Hint

A factory defines an inner function that uses the outer arguments (a closure) and returns it. best_price is min(s(total) for s in strategies).

Show solution
def no_discount(cents):
    return cents

def percent_off(pct):
    def apply(cents):
        return cents * (100 - pct) // 100
    return apply

def bulk(threshold, off_cents):
    def apply(cents):
        return max(0, cents - off_cents) if cents >= threshold else cents
    return apply

def best_price(total, strategies):
    return min(strategy(total) for strategy in strategies)

Write a class EventBus with subscribe(event, handler), unsubscribe(event, handler) (a no-op if not subscribed), and publish(event, payload) which calls each handler for that event in subscription order with the payload and returns how many handlers ran. Subscribing the same handler twice to the same event registers it once.

Hint

Keep a dict[str, list]; check handler not in handlers before appending. Bound methods such as log.append compare equal when they wrap the same object and function.

Show solution
class EventBus:
    def __init__(self):
        self._handlers = {}

    def subscribe(self, event, handler):
        handlers = self._handlers.setdefault(event, [])
        if handler not in handlers:
            handlers.append(handler)

    def unsubscribe(self, event, handler):
        handlers = self._handlers.get(event, [])
        if handler in handlers:
            handlers.remove(handler)

    def publish(self, event, payload):
        handlers = list(self._handlers.get(event, []))
        for handler in handlers:
            handler(payload)
        return len(handlers)

A Document holds text. Write two command classes, each with execute(doc) and undo(doc): Append(text) and DeleteLast(n) (removes the last n characters; its undo must restore exactly what was removed, even if fewer than n characters existed). Then write Editor(doc) with run(command), undo(), and redo(), each of the last two returning False when there is nothing to undo or redo. Running a new command clears the redo stack.

Hint

DeleteLast must remember what it removed in execute (self.removed = doc.text[len(doc.text) - n:], careful with n larger than the text). The editor keeps two lists: done and undone.

Show solution
class Document:
    def __init__(self, text=""):
        self.text = text

class Append:
    def __init__(self, text):
        self.text = text

    def execute(self, doc):
        doc.text += self.text

    def undo(self, doc):
        doc.text = doc.text[: len(doc.text) - len(self.text)]

class DeleteLast:
    def __init__(self, n):
        self.n = n
        self.removed = ""

    def execute(self, doc):
        cut = max(0, len(doc.text) - self.n)
        self.removed = doc.text[cut:]
        doc.text = doc.text[:cut]

    def undo(self, doc):
        doc.text += self.removed

class Editor:
    def __init__(self, doc):
        self.doc = doc
        self.done = []
        self.undone = []

    def run(self, command):
        command.execute(self.doc)
        self.done.append(command)
        self.undone.clear()

    def undo(self):
        if not self.done:
            return False
        command = self.done.pop()
        command.undo(self.doc)
        self.undone.append(command)
        return True

    def redo(self):
        if not self.undone:
            return False
        command = self.undone.pop()
        command.execute(self.doc)
        self.done.append(command)
        return True

You cannot change LegacyThermometer (given), which reports Fahrenheit. Write CelsiusAdapter(thermometer) with a method celsius() returning the temperature rounded to one decimal. Then write HeatingController(sensor, target) whose decide() returns "heat" if the sensor's celsius() is more than 0.5 below target, "idle" otherwise. The controller must work with any object that has celsius() (the tests use a fake).

Hint

Celsius is (f - 32) * 5 / 9. Store the injected objects in __init__; never create a thermometer inside the controller.

Show solution
class LegacyThermometer:
    def __init__(self, fahrenheit):
        self._f = fahrenheit
    def read_fahrenheit(self):
        return self._f

class CelsiusAdapter:
    def __init__(self, thermometer):
        self._thermometer = thermometer

    def celsius(self):
        return round((self._thermometer.read_fahrenheit() - 32) * 5 / 9, 1)

class HeatingController:
    def __init__(self, sensor, target):
        self._sensor = sensor
        self._target = target

    def decide(self):
        return "heat" if self._sensor.celsius() < self._target - 0.5 else "idle"

Write a frozen dataclass Settings with fields db_path: str = "app.db", workers: int = 2, debug: bool = False, and a classmethod from_env(env) that reads APP_DB_PATH, APP_WORKERS, and APP_DEBUG from the mapping env. workers must be an integer from 1 to 32; debug accepts true/false/1/0/yes/no in any case. Anything invalid raises ValueError whose message names the variable.

Hint

Convert with int() inside try, and re-raise ValueError(f"APP_WORKERS must be ...") from None. Map debug strings through two sets of accepted words.

Show solution
from dataclasses import dataclass

TRUE = {"true", "1", "yes"}
FALSE = {"false", "0", "no"}

@dataclass(frozen=True)
class Settings:
    db_path: str = "app.db"
    workers: int = 2
    debug: bool = False

    @classmethod
    def from_env(cls, env):
        raw_workers = env.get("APP_WORKERS", str(cls.workers))
        try:
            workers = int(raw_workers)
        except ValueError:
            raise ValueError(f"APP_WORKERS must be an integer, got {raw_workers!r}") from None
        if not 1 <= workers <= 32:
            raise ValueError(f"APP_WORKERS must be between 1 and 32, got {workers}")
        raw_debug = env.get("APP_DEBUG", "false").lower()
        if raw_debug not in TRUE | FALSE:
            raise ValueError(f"APP_DEBUG must be true/false, got {raw_debug!r}")
        return cls(env.get("APP_DB_PATH", cls.db_path), workers, raw_debug in TRUE)

Mini-project: untangle a big ball of mud

Mini-projectFrom script to layered app

A "big ball of mud" is a system with no discernible structure. Below is a realistic one: a 40-line script that reads a CSV of library loans, computes late fees, emails reminders, and writes a report, all in one function with globals, datetime.now(), string status codes, and print(). Refactor it, step by step, into a layered application, keeping its behavior.

import csv, smtplib, datetime

RATE = 25
def main():
    rows = list(csv.reader(open("loans.csv")))
    total = 0
    for r in rows[1:]:
        days = (datetime.datetime.now().date() - datetime.date.fromisoformat(r[3])).days
        if r[4] == "R":                     # returned
            continue
        if days > 14:
            fee = (days - 14) * RATE
            if r[5] == "staff":
                fee = fee // 2
            total += fee
            s = smtplib.SMTP("localhost")
            s.sendmail("lib@x.org", r[2], f"You owe {fee/100}")
            print("emailed", r[2], fee)
    open("report.txt", "w").write(f"total fees {total/100}\n")
    print("done")
main()
  1. pixi init library-fees; pixi add structlog; pixi add --feature dev pytest ruff mypy.
  2. Write characterization tests first: run the script logic against a sample CSV with a fixed date and record the fees it computes.
  3. Model the domain: a Loan dataclass, a Borrower kind enum, a LoanStatus enum, and a pure late_fee(loan, today, policy).
  4. Define ports: LoanSource, Notifier, ReportSink, Clock. Write adapters: CsvLoanSource, SmtpNotifier plus a fake OutboxNotifier, FileReportSink, and clocks.
  5. Write a FeeService use case that takes the ports in its constructor.
  6. Write cli.py as the composition root, with settings from environment variables and structlog logging instead of print().
  7. Record one ADR explaining your layering, then run pixi run pytest and pixi run mypy --strict src.
  • Characterization tests pass before and after the refactor
  • The domain module imports nothing outside the standard library and does no I/O
  • The service depends only on Protocols; no smtplib, csv, or datetime.now() inside it
  • Tests use a fixed clock and an outbox notifier: no real email, no sleeping, no real date
  • Configuration (rate, SMTP host, paths) comes from a validated, frozen settings object
  • Logs are structlog events; there is no print() outside the CLI
  • An ADR in docs/adr/0001-layered-architecture.md explains the structure

Stretch goals: add a second driving adapter (a FastAPI endpoint, from M45) that reuses FeeService unchanged; add a SqliteLoanSource (M44) and run one contract test suite against both sources; enforce the layering with import-linter in CI.

Quiz

Recap

Going further