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.
- Judge a design by its coupling and cohesion, and apply SOLID in an idiomatic Python way
- Use the patterns that matter in Python (strategy, factory, adapter, observer, decorator, command, repository) with functions and Protocols, not ceremony
- Structure an application as layers or ports and adapters, with dependency injection and a composition root
- Model a domain with dataclasses and enums that make illegal states unrepresentable
- Choose an error-handling and configuration strategy for a whole application
- Refactor safely with tests, recognize code smells, and record decisions in ADRs
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.
- Coupling is how much one module depends on the details of another. If changing the database schema forces edits in the report formatter, those two are tightly coupled. Low coupling means a change stays local.
- Cohesion is how closely the contents of one module belong together. A module called
utils.pyholding date parsing, email sending, and a retry helper has low cohesion. High cohesion means each module has one clear purpose, so you know where code goes and where to find it.
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:
- Testability: the core runs in tests with in-memory adapters, in milliseconds, with no database or network.
- Replaceability: moving from SQLite to PostgreSQL, or adding a web API next to the CLI, adds an adapter and leaves the core untouched.
- Clarity: the most valuable code, the business rules, is not tangled with framework code that goes out of date.
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:
- Value objects are defined entirely by their values: two
Money(500, "EUR")are interchangeable. Make them immutable:@dataclass(frozen=True). - Entities have an identity that persists while their attributes change: a customer is the same
customer after changing address. They have an
id.
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:
- 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. - Fail fast on invalid input at the boundary (parse, don't validate: turn raw input into typed domain objects immediately).
- Translate at the edges. Adapters convert technology errors into domain errors
(
sqlite3.IntegrityErrorbecomesDuplicateError, usingraise ... from excto keep the cause). The driving adapter converts domain errors into its own language: an exit code for a CLI, a status code for HTTP. - Never swallow errors.
except Exception: passhides bugs forever. Catch what you can handle; let the rest propagate to a top-level handler that logs it with structlog and fails visibly. - 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.
| Smell | What it looks like | Typical refactoring |
|---|---|---|
| Long function | Scrolling to read one function; comments separating "sections" | Extract function |
| Long parameter list | Five or more positional parameters; calls full of bare numbers | Introduce parameter object; keyword-only args |
| Primitive obsession | Money as float, emails and ids as bare str, status as magic strings | Value objects, enums, NewType |
| Duplicated code | The same logic pasted in several places, slowly diverging | Extract function; pull up to a shared module |
| Repeated switch | The same if kind == ... chain in many functions | Replace conditional with polymorphism or a registry |
| Feature envy | A function that mostly manipulates another object's data | Move the function onto that object |
| Shotgun surgery | One change requires edits in many files | Move related code together (raise cohesion) |
| God object | A Manager or Utils class that does everything | Split by responsibility |
| Flag arguments | save(data, True, False) | Separate functions, or an enum parameter |
| Hidden dependencies | datetime.now(), globals, or os.environ deep inside logic | Inject 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:
- Functional core, imperative shell: put decisions in pure functions (the core) and keep I/O in a thin outer layer (the shell). The core gets fast, exhaustive unit tests; the shell gets a few integration tests.
- Seams: a seam is a place where you can substitute behavior without editing code. Constructor
parameters, function parameters, and Protocols are seams. A hard-coded
datetime.now()is not. - Prefer fakes to mocks. A fake is a small working implementation (an
InMemoryRepository, aFixedClock). Mocks that assert "method X was called with Y" test implementation details and break on every refactor. Assert on outcomes instead. - Contract tests: when several adapters implement one port, run the same test suite against each (a parametrized pytest fixture) to prove they are interchangeable.
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
Quiz
Recap
- Aim for high cohesion (one job per module) and low coupling (changes stay local).
- SOLID in Python: single responsibility, registries for open/closed, honest subtypes (LSP), small Protocols (ISP), and depending on abstractions you own (DIP). Do not over-apply them.
- Patterns are vocabulary. In Python a strategy is a function, a factory is a dict or classmethod, an observer is a list of callables, and DI is constructor parameters plus a composition root.
- Hexagonal architecture: a pure core defines ports; adapters on the outside implement them; dependencies point inward.
- Model the domain with frozen dataclasses and enums that enforce invariants.
- Design error handling and configuration once: domain exceptions translated at the edges; validated, immutable settings from the environment.
- Refactor in small, tested steps; notice smells; record big decisions in ADRs; design seams for testability.
Going further
- Architecture Patterns with Python (Percival and Gregory, free online): repositories, unit of work, and hexagonal design in Python
- Martin Fowler's refactoring catalog
- Hexagonal architecture (Alistair Cockburn)
- Documenting architecture decisions (Michael Nygard) and adr.github.io
- Python Design Patterns (Brandon Rhodes)
- Protocols in the typing specification and dataclasses
- The Twelve-Factor App: config and pydantic-settings
- Now apply it all: Project 6 · Habit Tracker Service