281 lines
10 KiB
Markdown
281 lines
10 KiB
Markdown
# 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
|
||
|
||
```text
|
||
EmployeeReport
|
||
calculatePay() // Accounting
|
||
reportHours() // HR
|
||
save() // Data storage owner
|
||
```
|
||
|
||
### Good
|
||
|
||
```text
|
||
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`/`enum` switches 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
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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` / `Repository` interface 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
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
// billing/charge — high-level policy
|
||
import SqlUserRepository // concrete import
|
||
|
||
chargeUser(userId, amount):
|
||
repository = new SqlUserRepository() // concrete instantiation
|
||
user = repository.get(userId)
|
||
...
|
||
```
|
||
|
||
### Good
|
||
|
||
```text
|
||
// 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:
|
||
|
||
1. **God-module** from "do everything in one file" prompts — SRP + DIP + usually OCP.
|
||
2. **Type-tag dispatch chains** (`if kind == "pdf": ...`) — OCP.
|
||
3. **Unsupported-operation stubs in subclasses** when asked to "implement only the methods we need" — LSP + ISP.
|
||
4. **Concrete SDK/client imports at module load time** — DIP, hard to test without patching the runtime.
|
||
5. **Mega-`Service` interfaces** with create/read/update/delete/email/notify/audit/export — ISP, usually SRP too.
|
||
6. **Silent precondition strengthening on override** — defensive-looking, breaks LSP because callers holding the base type now crash on previously-valid inputs.
|
||
7. **Invariant-breaking "convenience" subclasses** (e.g., `ReadOnlyList(list)` overriding `append` to no-op) — LSP.
|
||
8. **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:
|
||
|
||
1. (SRP) Does any class in the diff answer to more than one stakeholder group?
|
||
2. (OCP) Does any change require a type-tag branch added to an existing function? Could it be data-driven (registry/strategy) instead?
|
||
3. (LSP) Does any new subclass signal "not implemented", tighten preconditions, or weaken postconditions?
|
||
4. (ISP) Does any interface have a method your concrete client doesn't use?
|
||
5. (DIP) Does the high-level package import the low-level concrete? Where do new abstractions live — with the client or with the implementation?
|