Writing code that works is the easy part. Writing code that stays changeable as it grows, that a team of people who didn't write it can read and modify safely, is the craft. Almost every principle in this chapter is an answer to one question: when requirements change, how much of the code has to change with them? Good design keeps that number small and predictable.
Coupling and Cohesion
Two words sit under every other principle here.
- Cohesion is how closely the things inside one module belong together. A
PasswordHasher that only hashes and verifies passwords is highly cohesive. A utils.py with date formatting, retry logic and a CSV parser is not.
- Coupling is how much one module depends on the internals of another. If changing the database schema forces edits in the HTTP handlers, those two are tightly coupled.
The goal is high cohesion, low coupling: modules that each do one thing completely and talk to each other through small, stable interfaces. It is the same idea as layering in networking and the system-call boundary in the operating system, applied to your own code.
Analogy: A well-run kitchen. The pastry station has everything for pastry and nothing else (cohesion). The grill station doesn't reach into the pastry fridge; it asks for what it needs through the pass (low coupling). You can replace the pastry chef without retraining the grill cook.
SOLID
Five guidelines named by Robert C. Martin in the early 2000s. They are phrased for object-oriented code, but each is really about isolating change, and each has a functional or modular equivalent.
S: Single Responsibility
A module should have one reason to change. "Reason to change" means a person or team who might ask for a change.
# Bad: three different stakeholders edit this class
class User:
def authenticate(self, password): ... # security team
def save(self): ... # database team
def monthly_report(self): ... # finance team
# Good: each class has one owner and one reason to change
class Authenticator:
def verify(self, user, password): ...
class UserRepository:
def save(self, user): ...
def by_email(self, email): ...
class UserReport:
def monthly(self, users): ...
When finance asks for a new report column, you edit UserReport and nothing that touches passwords can break.
Analogy: A chef shouldn't also be the waiter and the accountant. When the menu changes, only the chef's job changes.
O: Open/Closed
Open for extension, closed for modification. Add behavior by adding code, not by editing code that already works.
# Bad: every new shape means editing (and re-testing) this function
def area(shape):
if shape.kind == "circle":
return math.pi * shape.r ** 2
elif shape.kind == "rectangle":
return shape.w * shape.h
# ...and another elif for triangles, and another for polygons
# Good: new shapes extend the system without touching existing ones
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return math.pi * self.r ** 2
class Rectangle(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
class Triangle(Shape): # added later; nothing above changed
def __init__(self, b, h): self.b, self.h = b, h
def area(self): return 0.5 * self.b * self.h
Plugin systems, middleware chains and strategy objects are all open/closed in practice: the core stays fixed and new behavior plugs in.
L: Liskov Substitution
A subtype must work anywhere its base type is expected, without surprising the caller. Barbara Liskov stated it in 1987; it is a rule about behavior, not just method signatures.
class Rectangle:
def set_width(self, w): self.w = w
def set_height(self, h): self.h = h
def area(self): return self.w * self.h
class Square(Rectangle): # "a square is a rectangle"... mathematically
def set_width(self, w): self.w = self.h = w
def set_height(self, h): self.w = self.h = h
def stretch(rect: Rectangle):
rect.set_width(5)
rect.set_height(4)
assert rect.area() == 20 # fails for Square: area is 16
The type checker is happy, and the program is wrong. The fix is to stop pretending: Square and Rectangle should both be Shapes, or the shapes should be immutable (a "resize" returns a new object), which removes the setter that caused the conflict.
Analogy: If I promise to deliver "a vehicle" and you're planning to drive it on the motorway, sending a boat breaks the deal, even though a boat is a vehicle.
I: Interface Segregation
Don't force clients to depend on methods they don't use.
# Bad: one fat interface
class Worker(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...
@abstractmethod
def sleep(self): ...
class Robot(Worker):
def work(self): ...
def eat(self): raise NotImplementedError # a lie in the type system
def sleep(self): raise NotImplementedError
# Good: small role interfaces; implement only what fits
from typing import Protocol
class Workable(Protocol):
def work(self) -> None: ...
class Feedable(Protocol):
def eat(self) -> None: ...
class Human:
def work(self): ...
def eat(self): ...
class Robot:
def work(self): ...
def run_shift(workers: list[Workable]): ... # accepts both
Python's Protocol and Go's interfaces make this natural: a type satisfies an interface just by having the methods, so interfaces can be tiny and defined by the code that uses them.
D: Dependency Inversion
High-level policy should not depend on low-level details; both should depend on abstractions.
# Bad: business logic welded to one database and impossible to unit test
class OrderService:
def __init__(self):
self.db = PostgresDatabase("prod-host:5432") # hidden, hard-coded dependency
def place(self, order):
self.db.insert("orders", order)
# Good: depend on an interface; inject the implementation
class OrderStore(Protocol):
def add(self, order) -> None: ...
class OrderService:
def __init__(self, store: OrderStore):
self.store = store
def place(self, order):
if order.total <= 0:
raise ValueError("empty order")
self.store.add(order)
# production
service = OrderService(PostgresOrderStore(pool))
# tests: an in-memory fake, no database needed
class FakeStore:
def __init__(self): self.orders = []
def add(self, order): self.orders.append(order)
def test_rejects_empty_order():
svc = OrderService(FakeStore())
with pytest.raises(ValueError):
svc.place(Order(total=0))
Passing dependencies in through the constructor is called dependency injection. It needs no framework; a constructor argument is enough.
Analogy: A lamp shouldn't be hard-wired to one power station. It has a standard plug (the interface), and any socket with the right voltage (any implementation) will do.
Beyond SOLID: The Everyday Rules
| Principle |
Meaning |
Where it goes wrong |
| DRY (Don't Repeat Yourself) |
Every piece of knowledge has one authoritative place |
Merging code that merely looks alike, coupling things that change for different reasons |
| KISS (Keep It Simple) |
Prefer the simplest design that works |
Mistaking "fewest lines" for simple; clever one-liners are not simple |
| YAGNI (You Aren't Gonna Need It) |
Don't build for imagined future requirements |
Skipping cheap seams (an interface, a config value) that are expensive to add later |
| Composition over inheritance |
Build behavior by combining objects, not by deep class trees |
Inheritance hierarchies more than two levels deep, "fragile base class" bugs |
| Law of Demeter |
Talk to your friends, not your friends' friends |
order.customer.address.city.name chains that couple you to four classes |
| Fail fast |
Detect bad state at the boundary and stop loudly |
Swallowing exceptions, returning None for errors |
Pitfall: "Duplication is far cheaper than the wrong abstraction" (Sandi Metz). Two functions that look the same today but serve different business rules will diverge tomorrow. If you merge them, every future change needs a flag. Wait for the third occurrence and for the reason to be the same, then extract.
Design Patterns
Patterns, catalogued in the 1994 "Gang of Four" book, are named solutions to problems that recur in object-oriented code. Their main value is vocabulary: "put a decorator around the client" says in five words what would otherwise take a paragraph.
| Family |
Pattern |
What it does |
You've used it as |
| Creational |
Singleton |
One shared instance |
A connection pool, a logger |
| Creational |
Factory |
Create objects without naming the concrete class |
open() returning a text or binary file |
| Creational |
Builder |
Assemble a complex object step by step |
Query builders, HTTP request builders |
| Structural |
Adapter |
Make one interface look like another |
A wrapper giving a legacy API a modern signature |
| Structural |
Proxy |
Stand in front of an object to control access |
Lazy loading, caching clients, ORMs |
| Structural |
Decorator |
Add behavior by wrapping, without modifying |
Python's @retry, @lru_cache |
| Behavioral |
Observer |
Notify subscribers when something changes |
Event listeners, pub/sub, React state |
| Behavioral |
Strategy |
Swap interchangeable algorithms at runtime |
sorted(key=...), pluggable pricing rules |
| Behavioral |
Iterator |
Traverse without exposing the structure |
Every for loop over a collection |
Four of them, in code:
# Builder: readable construction of something with many optional parts
query = (QueryBuilder()
.select("name", "age")
.from_table("users")
.where("age > 25")
.order_by("name")
.limit(10)
.build())
# Strategy: the algorithm is a parameter
def price_with(strategy, cart):
return strategy(cart)
regular = lambda cart: sum(i.price for i in cart)
black_fri = lambda cart: 0.7 * regular(cart)
loyalty = lambda cart: regular(cart) - min(20, 0.1 * regular(cart))
price_with(black_fri, cart)
# Decorator: add retries to any function without editing it
import functools, time
def retry(times=3, delay=0.2):
def wrap(fn):
@functools.wraps(fn)
def inner(*args, **kwargs):
for attempt in range(times):
try:
return fn(*args, **kwargs)
except ConnectionError:
if attempt == times - 1:
raise
time.sleep(delay * 2 ** attempt) # exponential backoff
return inner
return wrap
@retry(times=5)
def fetch_rates(): ...
# Observer: publishers don't know who is listening
class EventBus:
def __init__(self): self.subs = {}
def subscribe(self, event, fn): self.subs.setdefault(event, []).append(fn)
def publish(self, event, payload):
for fn in self.subs.get(event, []):
fn(payload)
bus = EventBus()
bus.subscribe("order_placed", send_confirmation_email)
bus.subscribe("order_placed", update_inventory)
bus.publish("order_placed", order)
Notice that in a language with first-class functions, Strategy is "pass a function" and Decorator is "wrap a function." Many classic patterns are workarounds for languages that lacked closures, which is why they look heavier in Java than in Python or Go.
Key idea: Patterns are vocabulary, not goals. Reaching for a pattern where a plain function would do adds indirection, not quality. Use them when the problem they solve is actually in front of you.
The Testing Pyramid
Tests come in layers. A healthy suite has many fast tests at the bottom and few slow ones at the top:
| Layer |
Tests |
Speed |
Catches |
| Unit |
One function or class, collaborators replaced |
Milliseconds |
Logic errors, edge cases |
| Integration |
Real components together: code + database, service + queue |
Seconds |
Wrong SQL, serialization mismatches, config errors |
| End-to-end |
The whole system through its real interface |
Minutes |
Broken user journeys, deployment problems |
# Unit: one function in isolation
def test_discount_never_negative():
assert apply_discount(price=10, pct=150) == 0
# Integration: the API and a real (test) database together
def test_create_user(client, db):
r = client.post("/users", json={"name": "Alice"})
assert r.status_code == 201
assert db.users.find_one(name="Alice") is not None
# End-to-end: a real browser against a deployed environment
def test_checkout(page):
page.goto("/login"); page.fill("#email", "alice@example.com"); page.click("text=Sign in")
page.click("text=Add to cart"); page.click("text=Checkout")
assert page.is_visible("text=Order confirmed")
Analogy: Unit tests check each LEGO brick is the right shape. Integration tests check the bricks snap together. End-to-end tests check the finished castle looks like the picture on the box. A suite that is mostly end-to-end tests (an "ice-cream cone") is slow, flaky and tells you that something broke but not where.
Test Doubles
To test a unit in isolation you replace its collaborators with stand-ins. They have distinct jobs:
| Double |
What it does |
Use when |
| Stub |
Returns canned answers |
You need the collaborator's output ("user 123 is Alice") |
| Mock |
Records calls so you can assert on them |
The interaction is the behavior ("an email was sent") |
| Fake |
A simplified working implementation |
You want realistic behavior cheaply (an in-memory store) |
| Spy |
Wraps the real object and records calls |
You want the real behavior and to check how it was used |
from unittest.mock import Mock
def test_signup_sends_welcome_email():
mailer = Mock() # mock: we assert on the call
users = FakeUserStore() # fake: works, in memory
svc = SignupService(users, mailer)
svc.signup("alice@example.com")
mailer.send.assert_called_once_with(
to="alice@example.com", template="welcome")
assert users.exists("alice@example.com")
Pitfall: Over-mocking. A test that mocks every collaborator and asserts on every call verifies the implementation, not the behavior, and breaks on every refactor while catching no real bugs. Mock at the boundaries you don't own (network, email, payment provider) and use real objects or fakes everywhere else.
Test-driven development, property tests and mutation testing
Test-driven development (TDD) writes the test first: red (a failing test that states the requirement), green (the simplest code that passes), refactor (clean up with the test as a safety net). Its main benefit is design pressure: code that is hard to test first is usually too coupled.
Property-based testing generates hundreds of random inputs and checks invariants instead of hand-picked examples:
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_properties(xs):
out = my_sort(xs)
assert len(out) == len(xs) # nothing lost
assert all(a <= b for a, b in zip(out, out[1:])) # ordered
assert sorted(xs) == out # agrees with a reference
When it finds a failure it shrinks the input to the smallest case that still fails, often an empty list or a single negative number.
Mutation testing measures the tests themselves: a tool makes small changes to your code (flip < to <=, delete a line) and checks that some test fails. A mutant that survives is a bug your suite would not catch. Line coverage tells you code ran; mutation score tells you it was checked.
Code Review and Readability
Code is read far more often than it is written, mostly by people who were not there when it was written. A few habits do most of the work:
- Names carry intent.
elapsed_ms beats t; is_eligible_for_refund() beats check().
- Functions do one thing at one level of abstraction. If you need "and" to describe it, split it.
- Comments explain why, not what. The code says what. The comment says why the obvious approach was wrong.
- Small changes. Reviews of 200 lines find real problems; reviews of 2,000 lines get approved.
- Make illegal states unrepresentable. A type with
status: Literal["pending", "paid", "refunded"] can't hold "payed". Types are documentation the compiler checks.
Key idea: Every principle in this chapter trades a little effort now for less effort later. Apply them in proportion to how long the code will live and how many people will change it. A throwaway script needs none of them; a payments service needs all of them.