10 KiB
SOLID — the five principles
Source: Robert C. Martin. The five principles were collected on Uncle Bob's "Principles of OOD" page on objectmentor.com (mirrored at butunclebob.com) and updated on blog.cleancoder.com. Original papers from C++ Report circa 1995–1996.
Contents
- S: Single Responsibility Principle
- O: Open/Closed Principle
- L: Liskov Substitution Principle
- I: Interface Segregation Principle
- D: Dependency Inversion Principle
- How AI-generated code typically breaks SOLID
- Self-check for SOLID
S — Single Responsibility Principle
Definition (Martin 2014, hardened from the original). "A module should be responsible to one, and only one, actor."
Older form: "A class should have only one reason to change."
Source: blog.cleancoder.com — SRP, 2014.
Why
The axis is people. Different stakeholders (Accounting, HR, DBA) want different things from the same class. When their needs change, they edit the same file, conflict, and break each other.
Smells to flag
- One class contains methods touching unrelated subsystems (persistence + presentation + business rules).
- Methods on the class serve disjoint stakeholder groups.
- Git history shows two distinct clusters of co-changing methods inside one class.
Common misinterpretation
"A class should do one thing." No. SRP is about cohesion around an actor, not method count. A 12-method InvoiceRepository answerable only to the data-access layer satisfies SRP. A 3-method class with one HTTP call, one Jinja render, and one DB write does not.
Bad
EmployeeReport
calculatePay() // Accounting
reportHours() // HR
save() // Data storage owner
Good
PayCalculator // Accounting
HoursReporter // HR
EmployeeRepository // Data storage owner
O — Open/Closed Principle
Definition. "Software entities (classes, modules, functions) should be open for extension, but closed for modification."
Originally Bertrand Meyer (1988, Object-Oriented Software Construction); Martin refocused it on polymorphic abstraction rather than implementation inheritance.
Source: blog.cleancoder.com — OCP, 2014; Martin's 1996 paper PDF (Duke mirror).
Why
Protect stable high-level policy from churn in low-level variants. New behavior should arrive as new code, not edits to working code.
Smells to flag
- Branch dispatching on a type tag or runtime type check — every new type requires editing the same function.
- Adding a feature requires modifying N existing files instead of adding one.
match/enumswitches that cross module boundaries (policy reaching into details).
Common misinterpretation
"Never modify code." The principle is that modules containing high-level policy should not be modified to accommodate new variants. Leaf code changes freely.
Bad
export(record, kind):
if kind == "pdf": return toPdf(record)
if kind == "csv": return toCsv(record)
if kind == "json": return toJson(record)
// adding "xml" requires editing this function
Good
exporters = {
"pdf": toPdf,
"csv": toCsv,
"json": toJson,
}
export(record, kind):
return exporters[kind](record)
// adding "xml" is one line in the table
L — Liskov Substitution Principle
Definition (Liskov & Wing, 1994). "If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T."
Source: Martin's LSP paper PDF (LaBRI mirror).
Why
Substitutability. Callers written against a base type must continue to work when handed a subtype — otherwise polymorphism leaks abstraction.
Smells to flag
- A subclass overrides a method to signal "not implemented" or "unsupported operation."
- A subclass strengthens preconditions (rejects inputs the parent accepts).
- A subclass weakens postconditions (returns something the parent guarantees against).
- Callers perform runtime subtype checks to decide whether to call a method.
Common misinterpretation
"Subclasses must have the same methods." That's signature compatibility, which is just type-checking. LSP is behavioral:
- Preconditions can only weaken in the subtype.
- Postconditions and invariants can only strengthen.
- Parameter types are contravariant; return types covariant.
The Rectangle/Square classic
Rectangle
setWidth(width)
setHeight(height)
area()
Square extends Rectangle
setWidth(width):
this.width = width
this.height = width // invariant: width == height
setHeight(height):
this.width = height // invariant: width == height
this.height = height
A caller holding a Rectangle reference does r.set_width(5); r.set_height(4); assert r.area() == 20. With a Square, the assertion fails — LSP violated.
The fix is not to fix the methods. The fix is that Square is-not-a Rectangle in the behavioral sense. Compose, don't inherit.
I — Interface Segregation Principle
Definition. "Clients should not be forced to depend on methods they do not use." Equivalently: many client-specific interfaces beat one general-purpose interface.
Source: Martin's 1996 ISP paper, catalogued at butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod.
Why
Fat interfaces create transitive coupling. Clients are dragged into recompiles and test-fixtures for methods they never call.
Smells to flag
- A
Service/Manager/Repositoryinterface with 10+ methods, where any given caller uses one or two. - Implementations that stub half the methods with no-op bodies, null/empty placeholders, or unimplemented failures (usually co-occurs with an LSP violation).
- One mock object reconfigured differently across tests because the interface is too broad.
Common misinterpretation
"Make interfaces small." As a count rule, no. ISP is client-centric — segregation is driven by the set of methods a particular client uses, not by an arbitrary method-count ceiling. Two clients with identical method needs can share one interface even if it has 20 methods.
Bad
UserService
create(...)
read(...)
update(...)
delete(...)
email(...)
notify(...)
audit(...)
export(...)
The audit logger only needs audit. It now depends transitively on the email and export subsystems.
Good
UserAuditor
audit(...)
UserNotifier
notify(...)
Implementations can satisfy multiple protocols. Callers depend only on what they use.
D — Dependency Inversion Principle
Definition (verbatim, two clauses). (a) High-level modules should not depend on low-level modules. Both should depend on abstractions. (b) Abstractions should not depend on details. Details should depend on abstractions.
Source: Martin's 1996 C++ Report paper, archived at Wayback / objectmentor.com.
Why
Control the direction of the import graph. Policy must not transitively import mechanism, or policy becomes un-reusable and untestable.
Smells to flag
- A high-level module imports a concrete low-level client inside business logic.
- A constructor that
new/instantiates concrete collaborators instead of accepting them as parameters. - Abstractions defined in the low-level package (the interface lives next to its database or service implementation) — ownership reversed. The interface should live in the client's package.
- Function signatures typed against concrete classes instead of interfaces, protocols, or abstract contracts.
Common misinterpretation
"DIP means use a DI container." No. DIP is about the direction of source-code dependencies. You can satisfy DIP with plain constructor injection and no framework; you can violate DIP while using Spring.
Bad
// billing/charge — high-level policy
import SqlUserRepository // concrete import
chargeUser(userId, amount):
repository = new SqlUserRepository() // concrete instantiation
user = repository.get(userId)
...
Good
// billing/user-repository — abstraction lives WITH the client (billing)
UserRepository
get(userId) -> User
// billing/charge
chargeUser(userId, amount, repository: UserRepository):
user = repository.get(userId)
...
// sql/user-repository — detail depends on the abstraction
SqlUserRepository satisfies UserRepository
get(userId) -> User
The import arrows go: sql → billing (detail → abstraction). They do not go billing → sql.
How AI-generated code typically breaks SOLID
Mapped to the principle each breaks:
- God-module from "do everything in one file" prompts — SRP + DIP + usually OCP.
- Type-tag dispatch chains (
if kind == "pdf": ...) — OCP. - Unsupported-operation stubs in subclasses when asked to "implement only the methods we need" — LSP + ISP.
- Concrete SDK/client imports at module load time — DIP, hard to test without patching the runtime.
- Mega-
Serviceinterfaces with create/read/update/delete/email/notify/audit/export — ISP, usually SRP too. - Silent precondition strengthening on override — defensive-looking, breaks LSP because callers holding the base type now crash on previously-valid inputs.
- Invariant-breaking "convenience" subclasses (e.g.,
ReadOnlyList(list)overridingappendto no-op) — LSP. - Inverted ownership of abstractions — putting the interface/protocol/abstract contract in the same file as the concrete implementation. Cosmetic DIP fix, real dependency graph unchanged.
Self-check for SOLID
Before you ship code:
- (SRP) Does any class in the diff answer to more than one stakeholder group?
- (OCP) Does any change require a type-tag branch added to an existing function? Could it be data-driven (registry/strategy) instead?
- (LSP) Does any new subclass signal "not implemented", tighten preconditions, or weaken postconditions?
- (ISP) Does any interface have a method your concrete client doesn't use?
- (DIP) Does the high-level package import the low-level concrete? Where do new abstractions live — with the client or with the implementation?