SOLID as five refactors
Read each SOLID letter as a smell you can point at and the one refactor that removes it, and learn when applying all five makes the code worse.
SOLID is usually taught as five definitions, which is why most people can recite it and almost nobody uses it. A definition tells you what a principle says, not when to act on it.
Every letter is easier to hold as a refactor: a smell you can point at in a file, and the one change that removes it. The five also conflict, and a small program is worse for having all of them.
S · the class that changes for two reasons
"A class does one thing" is unusable: every class does many things. The workable
version is one reason to change — one group of people who ask for it to be
different. An Invoice with these methods has three:
total(),apply_discount()— finance changes the discount capto_pdf()— the design team changes the layoutsave(),email_to_customer()— ops moves delivery from SMTP to a queue
Eight edits a quarter from three teams all land in one file, so each is a merge risk reviewed by someone who does not care about it.
Split along the reason, not the noun: Invoice keeps money and the discount
rule, InvoiceRenderer the layout, InvoiceRepository storage, InvoiceMailer
delivery. The test is mechanical — read each method name and say who would ask
for it to change. Two answers, two classes. One answer
twice is not a violation, whatever the method count.
O · the switch that grows with every case
def price(item, kind):
if kind == "book": return item.base * 0.95
elif kind == "food": return item.base
elif kind == "electronic": return item.base * 1.18
raise ValueError(kind)
The smell is not the switch. It is that the same switch appears again in
label() and in tax_report(), so adding "clothing" means three edits in three
places, and nothing tells you when you have found only two.
Dispatch moves each case into an object answering all three:
class Electronic:
def price(self, item): return item.base * 1.18
def label(self): return "Electronics"
def tax_code(self): return "STD"
interface Kind {
double price(Item item);
String label();
String taxCode();
}
final class Electronic implements Kind {
public double price(Item item) { return item.base() * 1.18; }
public String label() { return "Electronics"; }
public String taxCode() { return "STD"; }
}
A new kind is one new file and one line where kinds are constructed — one site instead of three. But notice which axis got cheap. Adding an operation — a new question every kind must answer — now edits every kind class, where before it was one new switch. Dispatch is the right trade when kinds churn and operations are stable, and the wrong one reversed.
L · the subtype that throws
class ReadOnlyAccount(Account):
def withdraw(self, amount):
raise NotImplementedError("read only")
final class ReadOnlyAccount extends Account {
@Override public void withdraw(Money m) {
throw new UnsupportedOperationException();
}
}
A subtype may accept more than its base and promise more than its base, never
less. Throwing where the base returned is the largest cut to what is promised,
and the cost lands on callers: code holding an Account must ask what it really
is before calling withdraw. With 12 call sites that is 12 checks, and the one
you forget fails in production.
Composition removes the lie: a view is not an account:
class AccountView:
def __init__(self, account): self._a = account
def balance(self): return self._a.balance()
def history(self): return self._a.history()
Nothing can call withdraw on an AccountView, so nothing needs to check.
I · the interface nobody can implement cheaply
A Repository with nine methods — save, find_by_id, find_all, delete, count,
exists, update, search, stream — forces every implementer to supply nine. The
reconciliation job that depends on it calls two.
Count the cost in the test suite. Four test doubles, nine methods each, is 36
method bodies, of which 28 exist only to raise or return null. Split by the
roles callers use — a reader with two methods, a writer with three, a search
with two, and count and exists dropped because nothing called them — and the
four doubles, all read-only, implement the reader alone: eight bodies instead
of 36.
Designing to an interface works the width rule through: an interface is as wide as its narrowest caller needs, not as wide as its implementation.
D · the policy that imports the driver
import psycopg2
class PayrollRun:
def execute(self, month):
conn = psycopg2.connect(DSN)
cur = conn.cursor()
cur.execute(ACTIVE_SQL, [month])
rows = cur.fetchall()
...
Payroll is the policy: what the business means. Postgres is a detail, and a policy that imports a detail cannot run without it.
Invert the arrow: the policy declares what it needs in its own vocabulary, and the driver is written to fit:
from typing import Protocol
class EmployeeStore(Protocol):
def active_for(self, month) -> list[Employee]: ...
class PayrollRun:
def __init__(self, store: EmployeeStore):
self.store = store
interface EmployeeStore {
List<Employee> activeFor(YearMonth month);
}
final class PayrollRun {
private final EmployeeStore store;
PayrollRun(EmployeeStore store) { this.store = store; }
}
The arithmetic makes it worth doing. With the concrete import, a suite of 300 tests issuing 8 queries each is 2,400 datacenter round trips at 0.5 ms ≈ 1.2 s of waiting per run, before schema setup, and none of it runs offline. Against an in-memory list the same 2,400 calls are main-memory references at roughly 100 ns ≈ 0.24 ms, about 5,000 times less, bought with one constructor parameter.
Name the interface for the need, not the technology. EmployeeStore is
inversion; PostgresGateway is the same dependency with a longer name.
The five conflict, and small code loses
They are heuristics, and they pull against each other:
- S splits, and splitting costs: a change touching an amount and how it prints now edits two files plus whatever coordinates them.
- O buys cheap kinds with expensive operations.
- I and S both push toward more, smaller units, each another name to learn.
- D relocates the concrete choice rather than removing it. Something near
mainstill says Postgres, and the reader has to find it.
Work the small case. A 200-line script with three payment kinds, all five applied: three kind classes, two split interfaces, a store interface with a real implementation and a fake, an injection point, and a factory to wire it — about nine files, roughly 22 lines each, and following one payment from the entry point opens four of them. The switch-and-a-query version is 200 lines in one file and reads in two minutes. It is the better code, and saying so out loud is what separates use from recitation.
Apply a letter when its smell is visible: a file two teams edit for unrelated reasons, a switch you have edited three times, a subtype that throws, a fake full of empty methods, a policy you cannot test without a server. No smell, no refactor.
In an interview
SOLID gets used as a vocabulary check ("what is the L?") and as a judgment check ("what would you change about this class?"). Answer the second even when asked the first: attach the principle to a smell and a refactor: "the switch over payment kinds is the open/closed smell, it gets edited in three places every time a kind is added, so I would put each kind behind a small interface."
Then volunteer the cost, the part most candidates skip: "that makes new kinds free and new operations expensive, so it is the right trade only if kinds change more often than operations."
The mistake that loses points is applying all five to a forty-line exercise. An interviewer watching you invent a factory and two interfaces for three enum values learns that you cannot tell when a principle is worth its indirection. The same instinct produces the dozen classes nobody needs in the design interview.
Check yourself
The same switch over item kinds appears in three methods. Kinds change once a year; you add a new operation over them most sprints. Refactor to dispatch?
No. Dispatch makes new kinds cheap and new operations expensive: an operation added to a hierarchy edits every kind class, where the switch version edits one file. With stable kinds and churning operations the switch is the cheaper side of the trade.
ImmutableList extends List and throws on add. Which letter is that,
what is the fix, and what does the throw cost callers?
Liskov. The subtype promises less than its base, so every caller holding a
Listmust check what it actually has before callingadd— 12 call sites, 12 checks, and the missed one fails in production. Fix by composition: a class that holds a list and exposes only reads, soaddis not reachable.
A colleague says a class with save() and to_pdf() violates SRP. What do
you need to know before agreeing?
Who asks for each to change. If one team owns storage and layout and both change together, splitting buys two files and a coordinator for no reduction in churn. SRP counts sources of change, not responsibilities.