cheat sheet
SOLID
A modern walk-through of the SOLID principles — single responsibility, open-closed, Liskov, interface segregation, dependency inversion — with anti-examples and refactors in TypeScript and Python.
SOLID — Five Principles for Maintainable OO Design
What it is
SOLID is a five-letter mnemonic — Single-Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, Dependency Inversion — coined by Robert C. Martin in the early 2000s and now the most-cited heuristic set for object-oriented design. The acronym is shorthand for properties that, when respected, make code easier to extend without breaking existing callers and easier to test in isolation. Each letter addresses one common failure mode: too many reasons to change a class (SRP), modifying old code to add new behaviour (OCP), subclasses that surprise callers (LSP), fat interfaces (ISP), and high-level code coupled to low-level details (DIP).
In 2026, the practical interpretation has softened from the early-2000s rules-lawyer reading. SRP is not "one method per class"; it is "one reason to change". OCP does not require abstract factories; it just rules out editing stable modules to add a variant. LSP is mostly absorbed by sound type design (variance, exceptions in signatures). ISP is satisfied by small interfaces and structural typing. DIP is the bones of dependency injection. Used as principles — not laws — SOLID is still the cleanest shared vocabulary for "why is this code hard to change?"
When SOLID earns its keep
| Symptom | Principle in play |
|---|---|
| Class has many "and" in its description | SRP |
| Adding a new variant means editing 5 files | OCP |
Subclass throws NotImplementedError or no-ops | LSP |
| Mock for tests has 20 methods, you use 3 | ISP |
| Domain layer imports the database driver | DIP |
| Tests need a real database | DIP |
| One change ripples through unrelated modules | SRP + DIP |
Single Responsibility (SRP)
A module should have one, and only one, reason to change.
A class with two unrelated responsibilities will be modified by two different stakeholders (or two different reasons). When they collide, every change risks breaking the other. SRP is not "one method per class" — it is cohesion. The methods of a class should change together; if a slice of the class evolves on a different rhythm, it belongs elsewhere.
Anti-example. A User class that authenticates, formats reports, and writes itself to the database has three unrelated reasons to change — auth policy, report layout, persistence schema.
# DON'T
class User:
def __init__(self, id: int, email: str, password_hash: str) -> None:
self.id, self.email, self.password_hash = id, email, password_hash
def check_password(self, raw: str) -> bool:
import bcrypt
return bcrypt.checkpw(raw.encode(), self.password_hash.encode())
def to_csv_row(self) -> str:
return f"{self.id},{self.email}"
def save(self, conn) -> None:
conn.execute("INSERT INTO users(id,email) VALUES(?,?)", (self.id, self.email))
Refactor. Split by reason-to-change. The User is just data. Auth, formatting, and persistence each get their own home.
# DO
from dataclasses import dataclass
@dataclass
class User:
id: int
email: str
password_hash: str
class PasswordChecker:
def check(self, user: User, raw: str) -> bool:
import bcrypt
return bcrypt.checkpw(raw.encode(), user.password_hash.encode())
class UserCsvFormatter:
def row(self, u: User) -> str: return f"{u.id},{u.email}"
class UserRepository:
def __init__(self, conn): self.conn = conn
def save(self, u: User) -> None:
self.conn.execute("INSERT INTO users(id,email) VALUES(?,?)", (u.id, u.email))
python user.py
Output: (none — exits 0 on success)
The smell to listen for is the word "and" in a class description. "User class — saves itself and formats CSV and authenticates" is three reasons.
Open-Closed (OCP)
Modules should be open for extension, closed for modification.
Adding a new variant of behaviour should not require editing already-working, tested code. In practice this means: when a feature could grow a new "case", make the existing module dispatch through an interface (or function-typed parameter), and add new cases as new classes/functions — not as new branches in a switch.
Anti-example. Every new shape forces an edit to area. Adding a triangle risks breaking circle and square.
// DON'T
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r * s.r;
case "square": return s.side * s.side;
}
}
Refactor. Define a contract; each shape implements it. Adding Triangle does not require editing area.
// DO
interface Shape { area(): number }
class Circle implements Shape {
constructor(private r: number) {}
area() { return Math.PI * this.r * this.r; }
}
class Square implements Shape {
constructor(private side: number) {}
area() { return this.side * this.side; }
}
// New variant — no edit to existing code
class Triangle implements Shape {
constructor(private base: number, private h: number) {}
area() { return 0.5 * this.base * this.h; }
}
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.area(), 0);
}
console.log(totalArea([new Circle(2), new Square(3), new Triangle(4, 5)]));
node ocp.js
Output:
31.566370614359172
The trade-off is real: OCP costs an extra abstraction up front. Apply it where you have evidence of growth — a third variant has appeared, or the variant set is product-defined (payment providers, pricing tiers). Resist it where the set is stable (HTTP status code categories, two date formats) — there a discriminated union with a switch is clearer.
OCP and the Strategy pattern are the same idea seen from two angles. See design patterns.
Liskov Substitution (LSP)
Subtypes must be substitutable for their base types without surprising the caller.
A subclass that throws on a method its parent supports, returns a different error type, requires stricter preconditions, or weakens postconditions — all violate LSP. The classic counter-example is Square extends Rectangle: setting width and height independently is part of Rectangle's contract, but Square cannot honour it without breaking the geometric invariant.
Anti-example. A read-only collection that inherits from a mutable one and throws on every mutator violates Liskov. Code that assumed list.append(x) works now crashes when handed the "subtype".
# DON'T
class ReadOnlyList(list):
def append(self, item): raise RuntimeError("read-only")
def extend(self, items): raise RuntimeError("read-only")
def pop(self, *a, **kw): raise RuntimeError("read-only")
Refactor. Make ReadOnlyList not a list. Define a smaller Sequence-like interface it can actually honour; let MutableList extend that.
# DO
from typing import Iterable, Iterator, Protocol, TypeVar
T = TypeVar("T", covariant=True)
class Readable(Protocol[T]):
def __iter__(self) -> Iterator[T]: ...
def __len__(self) -> int: ...
def __getitem__(self, i: int) -> T: ...
class ReadOnlyList(Readable[T]):
def __init__(self, items: Iterable[T]) -> None: self._items = tuple(items)
def __iter__(self): return iter(self._items)
def __len__(self): return len(self._items)
def __getitem__(self, i): return self._items[i]
ReadOnlyList is no longer a list, so no caller can be surprised by a missing append. This is the LSP cure: shrink the parent contract until the subtype can honour it.
The strongest LSP-friendly rule, due to Barbara Liskov and Jeannette Wing:
| Aspect | Subtype must … |
|---|---|
| Preconditions | be no stricter than the parent's |
| Postconditions | be no weaker than the parent's |
| Invariants | preserve every invariant of the parent |
| Exceptions | throw only exceptions the parent declared |
In statically-typed languages, LSP shows up as variance rules. Covariance, contravariance, and invariance encode "where substitution is safe". TS, Kotlin, and Scala compile-check this; Python and JS leave it to the programmer.
Interface Segregation (ISP)
Clients should not be forced to depend on methods they do not use.
A fat interface — Worker with eat(), sleep(), code(), report_to_manager() — forces every implementer to handle every method and every mock to stub every method. Split interfaces by what callers actually use.
Anti-example. A multifunction printer interface forces every implementer to support print, scan, and fax — including a basic printer that has no scanner.
// DON'T
interface MultiFunctionDevice {
print(doc: Document): void;
scan(): Document;
fax(number: string, doc: Document): void;
}
class BasicPrinter implements MultiFunctionDevice {
print(doc: Document) { /* ok */ }
scan(): Document { throw new Error("no scanner"); } // LSP violation too
fax(n: string, d: Document) { throw new Error("no fax"); }
}
Refactor. Three small interfaces; each device implements only what it supports.
// DO
interface Printer { print(doc: Document): void }
interface Scanner { scan(): Document }
interface Fax { fax(n: string, d: Document): void }
class BasicPrinter implements Printer {
print(doc: Document) { /* ... */ }
}
class OfficeAllInOne implements Printer, Scanner, Fax {
print(doc: Document) { /* ... */ }
scan(): Document { return { } as Document }
fax(n: string, d: Document) { /* ... */ }
}
// Code that only prints depends only on Printer
function printAll(p: Printer, docs: Document[]) { for (const d of docs) p.print(d); }
In structurally-typed languages (TS, Go) ISP costs almost nothing — clients import only the method-set they need and the compiler does the rest. In nominally-typed languages (Java, C#) you write more interface files, which is the historical reason ISP came with friction.
A signal you have an ISP problem: your test mocks define many
notImplementedError-style stubs. Split the interface and the mocks shrink to the methods the test actually exercises.
Dependency Inversion (DIP)
Depend on abstractions, not on concretions. High-level modules should not depend on low-level modules. Both should depend on abstractions.
A PaymentService that imports StripeClient directly is welded to Stripe. Tests need a Stripe account; switching providers means editing the service. DIP flips the direction: the service depends on an interface (PaymentGateway), and Stripe is one implementation passed in at runtime.
Anti-example. High-level domain code reaches down into a concrete low-level driver.
// DON'T — direct dependency on a concrete client
import { StripeClient } from "stripe-sdk";
class CheckoutService {
private stripe = new StripeClient(process.env.STRIPE_KEY!);
async charge(amount: number, card: string) {
return this.stripe.charges.create({ amount, source: card });
}
}
Refactor. Define PaymentGateway in the high-level layer; the Stripe-specific code is an adapter in the low-level layer.
// DO — depend on an abstraction
interface PaymentGateway {
charge(amount: number, card: string): Promise<{ id: string }>;
}
class CheckoutService {
constructor(private gateway: PaymentGateway) {}
async charge(amount: number, card: string) {
return this.gateway.charge(amount, card);
}
}
// Low-level adapter
class StripePaymentGateway implements PaymentGateway {
constructor(private stripe: StripeClient) {}
async charge(amount: number, card: string) {
const charge = await this.stripe.charges.create({ amount, source: card });
return { id: charge.id };
}
}
// Wiring (composition root)
const stripe = new StripeClient(process.env.STRIPE_KEY!);
const gateway: PaymentGateway = new StripePaymentGateway(stripe);
const checkout = new CheckoutService(gateway);
Tests pass a FakePaymentGateway. A migration from Stripe to a competitor changes one file. The "high-level" module is now insulated from infrastructure decisions.
// test
class FakePaymentGateway implements PaymentGateway {
charges: { amount: number; card: string }[] = [];
async charge(amount: number, card: string) {
this.charges.push({ amount, card });
return { id: "fake_1" };
}
}
const fake = new FakePaymentGateway();
const svc = new CheckoutService(fake);
await svc.charge(2500, "tok_visa");
console.log(fake.charges);
npx vitest run checkout.test.ts
Output:
[ { amount: 2500, card: 'tok_visa' } ]
✓ checkout charges via the gateway
DIP and dependency injection are different things. DIP is the principle (depend on abstractions). DI is one technique to implement it (pass dependencies in via constructor or function args, instead of importing them).
Modern interpretations (and where SOLID overreaches)
| Principle | 2000s reading | 2026 reading |
|---|---|---|
| SRP | "One reason to change", "atomic classes" | Cohesion — methods that change together stay together |
| OCP | Abstract base classes for every variant | Strategy / discriminated union when growth is real |
| LSP | Behavioural subtyping rules, design-by-contract | Sound types + don't lie about what your subclass supports |
| ISP | Many small interfaces | Structural typing or capability-shaped types |
| DIP | Inversion of Control containers everywhere | Constructor injection + a tiny composition root |
The original SOLID papers leaned heavily on Java-class inheritance idioms. In languages with first-class functions (Python, JS, Kotlin), structural types (Go, TS), traits (Rust), and ADTs (Rust, Swift), several SOLID ideas dissolve into the type system or become one-liners. The principles still matter; the literal templates do not.
Anti-examples — recognizing SOLID violations
SRP — the God service
class OrderService:
def create_order(self, ...): ... # domain
def calculate_tax(self, ...): ... # tax engine
def email_invoice(self, ...): ... # comms
def export_to_quickbooks(self, ...): ... # accounting integration
def archive_old_orders(self, ...): ... # scheduled job
Five reasons to change in one class — five teams stepping on each other's commits. Split by reason: OrderRepository, TaxEngine, InvoiceMailer, QuickbooksExporter, OrderArchiveJob.
OCP — the growing switch
function discount(plan: string, total: number): number {
if (plan === "free") return 0;
if (plan === "basic") return total * 0.05;
if (plan === "pro") return total * 0.10;
if (plan === "enterprise") return total * 0.15;
if (plan === "education") return total * 0.20;
return 0;
}
Every new plan touches the same function. Replace with a map or table:
const RATES: Record<string, number> = {
free: 0, basic: 0.05, pro: 0.10, enterprise: 0.15, education: 0.20,
};
const discount = (plan: string, total: number) => total * (RATES[plan] ?? 0);
Adding a plan is one line, not a function edit.
LSP — the throwing subclass
class Bird:
def fly(self): ...
class Penguin(Bird):
def fly(self): raise RuntimeError("penguins can't fly")
Any code holding a Bird and calling fly() will crash for penguins. The fix is to model the hierarchy by capabilities, not by biological taxonomy:
class Bird: ...
class FlyingBird(Bird):
def fly(self): ...
class Penguin(Bird): ...
Callers that need flying take a FlyingBird. Penguins never enter that codepath.
ISP — the fat repository
class UserRepository:
def find_by_id(self, id: int): ...
def save(self, u): ...
def delete(self, id: int): ...
def export_to_csv(self): ...
def send_password_reset_email(self, id: int): ...
def generate_quarterly_report(self): ...
Half the codebase only reads; nothing else should drag email and reports along. Split:
class UserReader: ... # find_by_id
class UserWriter: ... # save, delete
class UserCsvExporter: ... # export_to_csv
class PasswordResetMailer: ...
class UserReportBuilder: ...
DIP — the domain that imports the database
# DON'T
from psycopg2 import connect
class CheckoutService:
def __init__(self):
self.conn = connect("postgres://...")
def checkout(self, cart):
self.conn.execute("INSERT INTO orders ...")
The service cannot be tested without Postgres and cannot be reused for another store. Fix:
# DO
from typing import Protocol
class OrderStore(Protocol):
def save_order(self, cart) -> int: ...
class CheckoutService:
def __init__(self, store: OrderStore): self.store = store
def checkout(self, cart): return self.store.save_order(cart)
class PostgresOrderStore: # in the infrastructure layer
def __init__(self, dsn: str): self.conn = connect(dsn)
def save_order(self, cart): self.conn.execute("INSERT INTO orders ...")
class InMemoryOrderStore: # for tests
def __init__(self): self.orders: list = []
def save_order(self, cart): self.orders.append(cart); return len(self.orders)
Composition over inheritance
SOLID's emphasis on interfaces and substitutability nudges design toward composition: assemble small parts that each do one thing well rather than build a tall inheritance tree.
// Composition: each capability is a small object
class Engine { start() { console.log("vroom"); } }
class GPS { route(to: string) { console.log(`routing to ${to}`); } }
class Stereo { play(track: string) { console.log(`playing ${track}`); } }
class Car {
constructor(private engine: Engine, private gps: GPS, private stereo: Stereo) {}
drive(to: string, track: string) {
this.engine.start();
this.gps.route(to);
this.stereo.play(track);
}
}
const car = new Car(new Engine(), new GPS(), new Stereo());
car.drive("Sao Paulo", "Tropicalia");
node car.js
Output:
vroom
routing to Sao Paulo
playing Tropicalia
Two implications follow directly from SOLID: each capability can change independently (SRP), and Car can accept a SilentEngine or FakeGPS in tests (DIP).
Common pitfalls
- SRP misread as "one method per class" — SRP is about cohesion (changing together), not method count. A class with twelve cohesive methods is fine; a class with two unrelated ones is not.
- OCP applied speculatively — wrapping every two-branch switch in a Strategy adds layers nobody needed. Apply OCP when growth is actually expected.
- LSP confused with
instanceofchecks — usinginstanceofto special-case subclasses is itself a LSP smell. The whole point is that the caller should not care which subtype it has. - ISP turned into interface explosion — fifteen one-method interfaces is no better than one fifteen-method interface. Group by what callers actually need together.
- DIP without a composition root — interfaces and adapters but no central wiring leaves the dependency cobweb diffuse. Define one
main/bootstrapfunction where the graph is constructed. - SOLID as an end in itself — passing every SOLID checklist does not guarantee good code; the principles are heuristics. Working software with low change cost is the goal.
- Inheritance-as-reuse — extending a class to "borrow" methods is the most common LSP and SRP violation. Prefer composition unless you genuinely model an
is-arelationship. - Hidden globals masquerading as singletons — a "service locator" or singleton accessed inside a class is DIP-violating disguised. Pass it in.
Real-world recipes
Refactor a fat controller (SRP)
Express/FastAPI controllers tend to absorb business logic. Split:
// before — controller knows everything
app.post("/orders", async (req, res) => {
const cart = req.body;
if (!cart.items?.length) return res.status(400).json({ error: "empty cart" });
const total = cart.items.reduce((s: number, i: any) => s + i.price * i.qty, 0);
const tax = total * 0.07;
await db.query("INSERT INTO orders(total, tax) VALUES($1, $2)", [total, tax]);
await sendEmail(req.user.email, `Total: ${total + tax}`);
res.status(201).json({ total, tax });
});
// after — controller orchestrates; logic lives in services
app.post("/orders", async (req, res) => {
const result = await checkoutService.create(req.user, req.body);
if (!result.ok) return res.status(400).json(result.error);
await invoiceMailer.send(req.user, result.order);
res.status(201).json(result.order);
});
The controller's reason to change is now "HTTP wiring"; pricing rules live in checkoutService and templates in invoiceMailer.
Plugin architecture via OCP
# Open for extension — add new exporters as classes
from typing import Protocol
class Exporter(Protocol):
name: str
def export(self, rows: list[dict]) -> bytes: ...
EXPORTERS: dict[str, Exporter] = {}
def register(e: Exporter) -> None: EXPORTERS[e.name] = e
class CsvExporter:
name = "csv"
def export(self, rows): return ("\n".join(",".join(map(str, r.values())) for r in rows)).encode()
class JsonExporter:
name = "json"
def export(self, rows):
import json; return json.dumps(rows).encode()
register(CsvExporter()); register(JsonExporter())
def run(format: str, rows: list[dict]) -> bytes:
return EXPORTERS[format].export(rows)
print(run("csv", [{"id": 1, "name": "Alice Dev"}, {"id": 2, "name": "Bob"}]).decode())
python exporter.py
Output:
1,Alice Dev
2,Bob
LSP-safe variance with generics
// Covariant read, contravariant write — TS enforces this with `readonly`
function printAll(items: ReadonlyArray<string>) {
for (const i of items) console.log(i);
}
// Works with subtypes safely; the function cannot mutate the array
const literals: ["alice@example.com", "bob@example.com"] = ["alice@example.com", "bob@example.com"];
printAll(literals);
npx tsx variance.ts
Output:
alice@example.com
bob@example.com
ISP — capability-shaped types
// Each function declares only what it needs
type HasEmail = { email: string };
type HasName = { name: string };
function greet(u: HasName) { return `hi ${u.name}`; }
function notify(u: HasEmail) { return `mail ${u.email}`; }
const user = { id: 1, email: "alice@example.com", name: "Alice Dev", roles: ["admin"] };
console.log(greet(user));
console.log(notify(user));
npx tsx isp.ts
Output:
hi Alice Dev
mail alice@example.com
Structural typing makes greet accept any object with a name. The function does not depend on User; it depends on HasName. That is ISP in 8 lines.
DIP — full vertical slice
// domain/user-service.ts
export interface UserRepo { findById(id: number): Promise<User | null> }
export class UserService {
constructor(private repo: UserRepo) {}
async greet(id: number) {
const u = await this.repo.findById(id);
return u ? `hello ${u.name}` : "stranger";
}
}
// infra/prisma-user-repo.ts
import { PrismaClient } from "@prisma/client";
import type { UserRepo } from "../domain/user-service";
export class PrismaUserRepo implements UserRepo {
constructor(private prisma: PrismaClient) {}
findById(id: number) { return this.prisma.user.findUnique({ where: { id } }); }
}
// bootstrap.ts — the composition root
import { PrismaClient } from "@prisma/client";
import { UserService } from "./domain/user-service";
import { PrismaUserRepo } from "./infra/prisma-user-repo";
const prisma = new PrismaClient();
export const userService = new UserService(new PrismaUserRepo(prisma));
The domain folder never imports @prisma/client — that import lives in infra and bootstrap. The dependency graph points down, but the source dependency between layers is inverted: domain defines UserRepo, infra implements it.
Refactor a Liskov-violating subclass
# before
class Rectangle:
def __init__(self, w: float, h: float): 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) -> float: return self.w * self.h
class Square(Rectangle):
def __init__(self, side: float): super().__init__(side, side)
def set_width(self, w): self.w = self.h = w # surprise
def set_height(self, h): self.w = self.h = h # surprise
def grow(r: Rectangle):
r.set_width(10); r.set_height(5)
return r.area()
assert grow(Rectangle(2, 2)) == 50
assert grow(Square(3)) == 25 # WRONG — caller expected 50
# after — drop the inheritance; rectangle and square are siblings
class Shape:
def area(self) -> float: ...
class Rectangle(Shape):
def __init__(self, w: float, h: float): self.w, self.h = w, h
def area(self): return self.w * self.h
class Square(Shape):
def __init__(self, side: float): self.side = side
def area(self): return self.side ** 2
Tips
When in doubt, test. The hardest tests reveal SOLID violations. If a unit test needs the database, network, or a clock — DIP is probably missing. If a mock has 20 stubbed methods — ISP is probably missing. If changing one feature requires editing five files — SRP and OCP are both missing.
Cross-link: see Design Patterns for the structural patterns that implement SOLID in practice, and Code Review for spotting violations during review.
"SOLID" is a memorable acronym, but the principles do not stand alone. They overlap heavily — fixing an SRP violation often surfaces a DIP fix; OCP and Strategy are the same idea. Treat the five letters as five lenses on the same question: will this code still be changeable in six months?