Concepts & Notes
SOLID Principles
The five object-oriented design principles behind code that stays easy to change — each stated plainly, with a smell it fixes and a before/after in Java.
SOLID is five design principles that, taken together, keep object-oriented code easy to change — the property that actually decays as a codebase grows. Each one targets a specific way a design goes rigid or fragile. This is a quick reference: the one-line statement, the smell it fixes, and a minimal before/after.
S — Single Responsibility Principle
A class should have one reason to change.
“Responsibility” is best read as a source of change. If a class is touched for two unrelated reasons — say, the way an invoice is calculated and the way it’s formatted for print — those concerns will fight over the same file and leak into each other.
// Before: two reasons to change live in one class
class Invoice {
BigDecimal computeTotal() { /* tax + line-item rules */ }
String toPdf() { /* layout, fonts, page breaks */ }
}
// After: each class changes for exactly one reason
class Invoice {
BigDecimal computeTotal() { /* tax + line-item rules */ }
}
class InvoicePdfRenderer {
String render(Invoice invoice) { /* layout, fonts, page breaks */ }
}
Now a change to PDF layout can’t break total calculation, and vice versa.
O — Open/Closed Principle
Open for extension, closed for modification.
You should be able to add new behavior without editing existing, tested code.
The tell is a switch/if-else on a type that grows a new branch every time a
variant is added — each edit risks the branches already working.
// Before: every new payment type reopens this method
BigDecimal fee(Payment p) {
if (p.type == CARD) return p.amount.multiply(0.029);
if (p.type == BANK) return BigDecimal.valueOf(0.25);
// add WALLET here, and here, and here...
}
// After: add a type by adding a class, not by editing this code
interface Payment { BigDecimal fee(); }
class CardPayment implements Payment {
public BigDecimal fee() { return amount.multiply(0.029); }
}
class BankPayment implements Payment {
public BigDecimal fee() { return BigDecimal.valueOf(0.25); }
}
// WALLET? new class. Nothing above changes.
Adding WalletPayment is now purely additive — the existing, tested classes are
never reopened.
L — Liskov Substitution Principle
A subtype must be usable anywhere its base type is expected — without surprises.
Inheritance claims an is-a relationship. LSP says that claim must hold
behaviorally, not just compile. The classic violation is the Square extends Rectangle trap: a square that overrides setWidth to also change height breaks
any code that expected width and height to move independently.
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // fails for Square — it forced width == height
The subtype technically satisfies the interface but violates the caller’s expectations. Fix it by not modeling the relationship as inheritance when the behavioral contract doesn’t actually hold.
I — Interface Segregation Principle
No client should be forced to depend on methods it doesn’t use.
Fat interfaces couple unrelated clients. If Machine declares print, scan,
and fax, a simple printer is forced to implement scan and fax it can’t do —
usually by throwing. Split the interface along how it’s actually consumed.
// Before: a fat interface every device must fully implement
interface Machine { void print(); void scan(); void fax(); }
// After: small, role-based interfaces
interface Printer { void print(); }
interface Scanner { void scan(); }
class SimplePrinter implements Printer { public void print() { /* ... */ } }
class AllInOne implements Printer, Scanner { /* implements both */ }
Each class depends only on what it genuinely provides.
D — Dependency Inversion Principle
Depend on abstractions, not concretions. High-level policy shouldn’t depend on low-level detail.
High-level code (business rules) should not be nailed to a concrete low-level implementation (a specific database, HTTP client, or vendor SDK). Both should depend on an interface the high-level module owns.
// Before: the service is welded to a concrete Postgres class
class OrderService {
private final PostgresOrderRepo repo = new PostgresOrderRepo(); // rigid
}
// After: the service depends on an abstraction it defines
interface OrderRepository { void save(Order o); }
class OrderService {
private final OrderRepository repo;
OrderService(OrderRepository repo) { this.repo = repo; } // injected
}
Now Postgres, an in-memory fake for tests, or a future DynamoDB implementation all
slot in without touching OrderService.
At a glance
| Principle | One-line rule | Smell it fixes |
|---|---|---|
| Single Responsibility | One reason to change per class | A class edited for unrelated reasons |
| Open/Closed | Extend without modifying | A growing switch on type |
| Liskov Substitution | Subtypes honor the base contract | Override that throws or surprises |
| Interface Segregation | No unused method dependencies | Fat interface with UnsupportedOperation |
| Dependency Inversion | Depend on abstractions | new ConcreteThing() inside policy code |
The goal was never to memorize five acronyms — it’s to notice, while writing a class, what would force this to change and put a seam there. SOLID is just the catalog of the seams that pay off most often.