The previous post tidied one function. But most of the cost of a large piece of software isn't inside individual functions; it's in the joints between them: one class knows too much about another, changing one module breaks three others, testing a small rule means setting up a whole database.
SOLID is five principles about those joints, gathered by Robert C. Martin in the early 2000s (the acronym was coined by Michael Feathers). They were born in the world of Java and C++, so many articles apply them to Python word for word and end up with a forest of unnecessary interfaces. This post goes through each principle with one question: what kind of change does it protect you from, and what does it look like in Python?
The route: S (one reason to change), O (add without modifying), L (subclasses mustn't surprise callers, with two runnable traps), I (small interfaces, with Protocol), D (depend on abstractions, measured through test speed), then when SOLID becomes a burden.
Every number in this post was measured on a real machine, with Python 3.12, single-threaded. A few examples come from this website's own code.
S: one reason to change
Single Responsibility Principle: a module should have one reason to change. This is often read as "a class should do one thing", then pushed until every class has one method. Martin later made it more precise: a "reason to change" is a group of people asking for changes. Accounting wants to change how pay is calculated, operations wants a different report format, the DBA wants a different schema: three groups, three reasons.
A class that breaks SRP looks like this:
class UserService:
def register(self, email, name): ... # business rules: duplicate email, valid name
def save_to_postgres(self, user): ... # storage details
def send_welcome_email(self, user): ... # email wording, SMTP
def render_profile_html(self, user): ... # presentation
Marketing wants to change the welcome email, and they have to touch the same file as the registration rules. Same with changing the database. Every change risks breaking everything else in the file, and everyone steps on each other's toes in the same place.
At the module level, this website's code is split along the same lines: each domain (auth, blog, mail, games) is a folder, in which router.py handles HTTP, service.py holds the rules, and schemas.py defines the shape of the data. Changing how a page looks doesn't mean opening the file with the sign-in rules.
O: add without modifying
Open/Closed Principle: a module should be open for extension, closed for modification. Adding new behaviour should mean adding new code, not editing old code that already works.
The familiar violation is an if/elif chain on a type:
def login_url(provider, ...):
if provider == "google":
... # 15 lines specific to Google
elif provider == "github":
... # 15 lines specific to GitHub
Adding a third provider means opening this function, squeezing in another branch, and hoping you didn't break the other two. This site's sign-in with outside accounts does the opposite: the OAuth flow is written once in the OAuthProvider class, and each provider is just a record of data holding what differs (URLs, scope, a function that reads the user's details):
"""SSO providers. Adding one = an OAuthProvider entry in `get_providers` plus its client id/secret in config."""
@dataclass(frozen=True)
class OAuthProvider:
name: str
authorize_url: str
token_url: str
userinfo_url: str
scope: str
parse_user: Callable[[dict[str, Any]], ProviderUser] # the only part each provider writes itself
...
Adding a provider is adding a record; the code handling the OAuth flow isn't touched. Note that "closed" isn't absolute: get_providers still gets a new entry. OCP doesn't demand that nothing is ever edited; it demands that the fragile, proven part (here, the OAuth flow with PKCE) doesn't get reopened every time a new requirement comes in.
L: subclasses mustn't surprise callers
Liskov Substitution Principle (Barbara Liskov, 1987): anywhere a type is used, you should be able to substitute a subtype and the program stays correct. Not just "same method names", but the same promises: a subtype mustn't demand more, mustn't return less, mustn't break what callers are allowed to assume.
The classic example: mathematically, a square is a rectangle. So let Square inherit from Rectangle:
class Rectangle:
def __init__(self, w, h): self.w, self.h = w, h
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):
def __init__(self, side): super().__init__(side, side)
def set_width(self, w): self.w = self.h = w # a square's sides must stay equal
def set_height(self, h): self.w = self.h = h
def stretch(shape):
shape.set_width(5)
shape.set_height(4)
return shape.area()
print(stretch(Rectangle(1, 1)), stretch(Square(1))) # 20 16
stretch is written with an assumption that's reasonable for any rectangle: once the width is set, setting the height doesn't change the width. Square breaks that assumption, so the same function returns 20 for a rectangle and 16 for a square. No error is raised, just a wrong number. An "is a" relationship in real life doesn't guarantee substitutability in code, especially when objects are mutable. If shapes were immutable (Rectangle(5, 4) makes a new shape instead of changing one), the problem would disappear.
The second trap is right in Python's standard library. Write a case-insensitive dict by inheriting from dict and overriding __getitem__:
class Lower(dict):
def __getitem__(self, key):
return super().__getitem__(key.lower())
d = Lower(a=1)
print(d["A"]) # 1
print(d.get("A")) # None
d["A"] works, d.get("A") doesn't: dict.get is written in C and reads the hash table directly, without going through your __getitem__. Same object, two ways of asking, two answers. This is why the standard library has collections.UserDict (every method goes through __getitem__) and collections.abc.Mapping (you write a few basic methods and the rest are derived consistently from them). Inheriting from a class whose calls to itself you don't control is the easiest way to break LSP.
I: small interfaces, exactly what the caller needs
Interface Segregation Principle: don't make anyone depend on methods they don't use. A Storage interface with 12 methods means every class that wants to act as storage, test fakes included, has to implement all 12, even if the caller only needs get.
Python has a lightweight way to express this: typing.Protocol. A function declares exactly what it needs, and any object with those methods works, no inheritance required:
from typing import Protocol
class UserLookup(Protocol):
def get(self, email: str) -> str | None: ...
def is_taken(users: UserLookup, email: str) -> bool:
return users.get(email) is not None
is_taken accepts a real user store, a dict, or a two-line fake, as long as it has get. Type checkers (mypy, pyright) still catch you passing something without get. It's duck typing, written down.
D: depend on abstractions, not on details
Dependency Inversion Principle: business rules (high level) shouldn't depend directly on infrastructure details (low level: databases, SMTP, HTTP). Both should depend on an abstraction in between, and the details are passed in from outside instead of created inside.
def register(users, email, name): # users: anything with get/add
if users.get(email):
raise ValueError("taken")
users.add(email, name.strip())
This function doesn't know whether users is SQLite, Postgres or a dict. In production, pass in a real store. In tests, pass in a fake:
class FakeUsers:
def __init__(self): self.rows = {}
def add(self, email, name): self.rows[email] = name
def get(self, email): return self.rows.get(email)
The most visible benefit is test speed. The same test (register someone, check the name is trimmed, check a duplicate registration fails), run 200 times against a SQLite store on a file and 20,000 times against the fake:
| User store | Per test |
|---|---|
| SQLite on a file (create table, write, read, delete file) | ~520–560 µs |
| In-memory fake | 0.29 µs |
About 1,800–1,900 times faster. For a suite of a few thousand tests, that's the difference between seconds and minutes. The bigger benefit is isolation: the test checks the registration rule, not the disk or the database configuration, so when it goes red, you know straight away where the problem is.
This website does exactly that for sending email. Routes don't create a Mailer directly; they receive one through Depends(get_mailer), and the test suite replaces it with a fake that just records emails in a list:
class FakeMailer:
"""Collects emails instead of sending them."""
def __init__(self) -> None:
self.sent = []
async def send(self, email) -> None:
self.sent.append(email)
app.dependency_overrides[get_mailer] = lambda: mailer
The forgotten-password test can check "exactly one email was sent, to the right address, containing a /forgot-password link" without any SMTP server. Keep fakes simple: a fake with complicated logic needs tests of its own.
One caveat: fakes test rules, not infrastructure. A SQL query with a syntax error will never show up when testing against a dict. So you still need a few tests against a real database, just not every test.
When SOLID becomes a burden
- Interfaces for things with one implementation. An
IUserRepositorywith exactly oneUserRepositoryclass, never a second one, no fake, just adds a layer to jump through. In Python, start with a concrete function or class; when you really need a second implementation, adding aProtocollater costs nothing. - SRP down to crumbs. One method per class, one class per file, a simple flow spread across 15 files. A reason to change is a group of people, not a line of code.
- OCP for changes that never come. Building a plugin system for "in case there's a fifth provider someday" when there's only one is paying a certain cost up front for a benefit that may never arrive. A two-branch
ifis fine; at the third or fourth, change it.
All five principles share one purpose: containing change. Whenever you have to decide, ask: which change is most likely to come, and which structure makes it touch only one place?
Summary
| Principle | The real question | In Python |
|---|---|---|
| S: Single responsibility | Who will ask for this module to change? | A folder per domain; router, service, schemas apart |
| O: Open/closed | Does adding a new kind mean opening old code? | Data tables, OAuthProvider records, instead of if chains |
| L: Liskov substitution | Does the subtype keep the parent's promises? | Square gives 16 instead of 20; dict.get skips __getitem__ |
| I: Interface segregation | What does the caller really need? | Small Protocols declaring only what's used |
| D: Dependency inversion | Do the rules know about the database? | Pass dependencies in; fakes ~1,900 times faster |
SOLID is about the shape of the joints. Some shapes recur so often that people gave them names: Strategy, Factory, Adapter, Observer... Next post: design patterns, which ones Python already has built into the language, which are still worth learning, and how many nanoseconds each kind of "indirection" costs per call.