SOLID principles
SRP, OCP, LSP, ISP, DIP — the backbone of clean code.
SOLID is five rules of thumb for keeping object-oriented code changeable. Applied mechanically they over-engineer; applied when you feel the pain they describe, they pay back tenfold. The trick is recognising the pain.
The big idea
Each letter describes one thing that makes code easy to change:
- S — Single Responsibility
- A class should have one reason to change.
- O — Open/Closed
- Open to extension, closed to modification.
- L — Liskov Substitution
- Subtypes must be drop-in replacements for their base type.
- I — Interface Segregation
- Many small interfaces beat one fat one.
- D — Dependency Inversion
- Depend on abstractions, not concretions.
S — Single Responsibility
A class that parses, formats, validates, persists, and emails a report has five reasons to change. Each split into its own class can change independently.
class ReportParser { parse(raw: string): Report { /* … */ } }
class ReportPersister { save(r: Report): Promise<void> { /* … */ } }
class ReportEmailer { email(r: Report): Promise<void> { /* … */ } }class ReportParser:
def parse(self, raw: str) -> Report: ...
class ReportPersister:
async def save(self, r: Report) -> None: ...
class ReportEmailer:
async def email(self, r: Report) -> None: ...One reason to change each.
class ReportService {
parse() { /* … */ }
validate() { /* … */ }
saveToDb() { /* … */ }
formatForPdf() { /* … */ }
sendEmail() { /* … */ }
}class ReportService:
def parse(self): ...
def validate(self): ...
def save_to_db(self): ...
def format_for_pdf(self): ...
def send_email(self): ...Five reasons to change. Five places to break a test.
O — Open/Closed
Adding a new payment method shouldn't require editing the existing pay() function. Make
it accept a strategy or a new subclass instead.
interface Payment { charge(cents: number): Promise<void>; }
class StripeCard implements Payment { /* … */ }
class AchBank implements Payment { /* … */ }
// Adding `Bitcoin` means *adding* a new class — not editing existing ones.
class Bitcoin implements Payment { /* … */ }from typing import Protocol
class Payment(Protocol):
async def charge(self, cents: int) -> None: ...
class StripeCard: ...
class AchBank: ...
# Adding `Bitcoin` means *adding* a new class — not editing existing ones.
class Bitcoin: ...Strategy and Decorator (see Behavioral patterns) are the mechanical ways to satisfy O/C.
L — Liskov Substitution
If Square extends Rectangle, anywhere you accept a Rectangle you must accept a
Square. The classic violation: setting width on a Square also changes its height,
breaking callers who set width and height independently. Subtypes must honour the
contract of the base, not just the type signature.
I — Interface Segregation
A class shouldn't be forced to implement methods it doesn't need. One fat interface that mixes concerns becomes a forced lie for anyone implementing it.
interface Readable<T> { read(id: number): Promise<T | null>; }
interface Writable<T> { write(value: T): Promise<void>; }
class ReadOnlyCache<T> implements Readable<T> { /* no write */ }from typing import Protocol, Generic, TypeVar
T = TypeVar("T")
class Readable(Protocol, Generic[T]):
async def read(self, id: int) -> T | None: ...
class Writable(Protocol, Generic[T]):
async def write(self, value: T) -> None: ...
class ReadOnlyCache(Generic[T]):
async def read(self, id: int) -> T | None: ... # no writeinterface Repository<T> {
read(id: number): Promise<T | null>;
write(value: T): Promise<void>;
delete(id: number): Promise<void>;
search(q: string): Promise<T[]>;
bulkLoad(items: T[]): Promise<void>;
}
// every implementation either lies or throws on half the methodsfrom typing import Protocol, Generic, TypeVar
T = TypeVar("T")
class Repository(Protocol, Generic[T]):
async def read(self, id: int) -> T | None: ...
async def write(self, value: T) -> None: ...
async def delete(self, id: int) -> None: ...
async def search(self, q: str) -> list[T]: ...
async def bulk_load(self, items: list[T]) -> None: ...
# every implementation either lies or raises on half the methodsD — Dependency Inversion
High-level modules shouldn't depend on low-level modules; both should depend on abstractions. In practice: depend on an interface, inject the implementation.
// good: handler depends on an interface
class OrderHandler {
constructor(private readonly repo: OrderRepository) {}
async place(items: Item[]) {
return this.repo.save({ items, status: 'new' });
}
}
// caller decides what 'repo' is — Pg, Fake, in-memory — at the edge of the app
new OrderHandler(new PgOrderRepository(pool));# good: handler depends on an interface
class OrderHandler:
def __init__(self, repo: OrderRepository) -> None:
self._repo = repo
async def place(self, items: list[Item]):
return await self._repo.save({"items": items, "status": "new"})
# caller decides what 'repo' is — Pg, Fake, in-memory — at the edge of the app
OrderHandler(PgOrderRepository(pool))DI containers automate this — but the principle applies even without one. Pass collaborators in; don't construct them inside.
In practice
You'll feel SOLID violations as friction:
- Tests are hard to write
- You can't mock the dependency → DI is missing.
- One bug fix breaks three other features
- SRP violated; multiple reasons to change.
- Adding a feature edits a long switch
- O/C violated; replace with polymorphism.
- Subclass throws `NotImplementedError`
- LSP or ISP violated.
The principles are five different ways of saying "make change cheap." When change is cheap, everything else gets easier.
Key takeaways
- SOLID is about making change cheap — apply it when you feel the pain.
- SRP: one class, one reason to change.
- OCP: extend by adding, not editing.
- LSP: subclasses must honour the base contract, not just its type.
- ISP: split fat interfaces; let implementations opt into capabilities.
- DIP: depend on interfaces and inject implementations.
Checkpoint questions
Use these to test whether the lesson is clear enough to explain without rereading.
- 1What is one concrete sign that a class has more than one responsibility?
- 2How can you extend behavior without editing existing code?
- 3What kind of subclass breaks Liskov Substitution?
- 4How does depending on interfaces make code easier to test?
References
External resources for going deeper after the lesson above.