SOLID is an acronym for five object-oriented design principles introduced by Robert Martin in the early 2000s. Each principle addresses a specific failure mode of object-oriented design — symptoms you recognise in real codebases: classes that are impossible to extend, modules that break in unexpected places when changed, code that cannot be tested in isolation. SOLID principles are not rules to apply mechanically — they are lenses for identifying design problems and guiding refactoring. This lesson covers each principle precisely, shows the violation before the fix, and explains the architectural implications.
Martin's formulation: 'A class should have only one reason to change.' The word 'reason' is key — it refers to an actor or stakeholder whose requirements might drive a change, not a syntactic 'one method' rule. A class that formats reports AND saves them to the database has two reasons to change: the reporting team (format changes) and the ops team (database migration). When those changes happen simultaneously, they conflict. SRP says these concerns belong in separate classes.
// ── VIOLATION: two reasons to change in one class ───────────────
class OrderProcessor { void process(Order order) {// business logic
order.setStatus(CONFIRMED);
// persistence — reason #2 to change
db.save(order);
// notification — reason #3 to change
emailClient.send(order.getEmail(), buildTemplate(order));
}
}
// ── FIX: each class has one reason to change ─────────────────────
class OrderConfirmationService { OrderConfirmationService(OrderRepository repo, OrderNotificationService notifier) { ... } void confirm(Order order) {order.confirm(); // domain logic only
repo.save(order); // delegates persistence
notifier.sendConfirmation(order); // delegates notification
}
}
class OrderRepository { void save(Order o) { ... } } // DB onlyclass OrderNotificationService { void sendConfirmation(Order o) { ... } } // email onlyBertrand Meyer's formulation (1988), reframed by Martin: 'Software entities should be open for extension, but closed for modification.' A class is closed for modification when its tested, deployed code does not need to change to accommodate new behavior. It is open for extension when new behavior can be added by writing new code — a new subclass, a new strategy implementation, a new decorator — without touching the existing class. The most common mechanism: depend on abstractions (interfaces) and inject concrete implementations.
// ── VIOLATION: every new payment method requires modifying this class
class PaymentProcessor { void process(Payment p) {if (p.type() == CREDIT_CARD) chargeCard(p);
else if (p.type() == PAYPAL) chargePayPal(p);
else if (p.type() == CRYPTO) chargeCrypto(p); // added later, modified existing class
}
}
// ── FIX: closed for modification, open for extension ────────────
interface PaymentGateway { void charge(Payment p); }class CreditCardGateway implements PaymentGateway { ... }class PayPalGateway implements PaymentGateway { ... }class CryptoGateway implements PaymentGateway { ... } // new method = new class onlyclass PaymentProcessor {private final Map<PaymentType, PaymentGateway> gateways;
void process(Payment p) {gateways.get(p.type()).charge(p); // never changes
}
}
Barbara Liskov's formulation (1987): 'If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.' In plain terms: a subclass must honor the contract of its superclass — not just its method signatures, but its behavioral expectations (preconditions, postconditions, invariants). The classic LSP violation is the Square-Rectangle problem: Square extends Rectangle, but overriding setWidth to also set height violates the rectangle's contract that width and height are independent.
// ── VIOLATION: Square breaks Rectangle's behavioral contract ────
class Rectangle {protected int width, height;
void setWidth(int w) { this.width = w; } void setHeight(int h) { this.height = h; } int area() { return width * height; }}
class Square extends Rectangle { @Override void setWidth(int w) { width = height = w; } // breaks rectangle contract @Override void setHeight(int h) { width = height = h; } // breaks rectangle contract}
// Client code breaks when Square is substituted for Rectangle:
// rect.setWidth(5); rect.setHeight(4); assert rect.area() == 20; // FAILS for Square
// ── FIX: model the actual relationship — no inheritance ─────────
interface Shape { int area(); }record Rectangle(int width, int height) implements Shape { public int area() { return width * height; }}
record Square(int side) implements Shape { public int area() { return side * side; }}
Martin's formulation: 'Clients should not be forced to depend on interfaces they do not use.' Fat interfaces — interfaces with many unrelated methods — force implementors to provide stub implementations of methods they do not need, and force clients to import a type that carries capabilities they do not use. ISP says: prefer many small, client-specific interfaces over one large general-purpose interface. The design pressure from ISP naturally produces Role Interfaces — interfaces that describe a role an object plays (Printable, Saveable, Auditable) rather than the full menu of everything an object can do.
// ── VIOLATION: fat interface forces irrelevant method stubs ─────
interface Worker {void work();
void takeBreak(); // irrelevant for Robot
void receivePaycheck(); // irrelevant for Robot
}
class Robot implements Worker { public void work() { ... } public void takeBreak() { /* Robot does not rest — forced stub */ } public void receivePaycheck() { /* Robot is not paid — forced stub */ }}
// ── FIX: small role interfaces ───────────────────────────────────
interface Workable { void work(); }interface Breakable { void takeBreak(); }interface Payable { void receivePaycheck(); }class Robot implements Workable { ... } // only what it needsclass HumanWorker implements Workable, Breakable, Payable { ... } // full set// Scheduler only needs Workable — does not know about breaks or pay:
class ProductionScheduler { void schedule(List<Workable> workers) { ... } }Martin's formulation: '(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.' DIP is the principle that underlies Onion Architecture, Hexagonal Architecture, and Clean Architecture — all the architectures covered in the previous chapter. It is also the principle that makes dependency injection meaningful: DI is the mechanism; DIP is the reason.
// ── VIOLATION: high-level module depends on low-level detail ────
class OrderService {private final MySqlOrderRepository repo = new MySqlOrderRepository();
void confirm(Long id) {Order order = repo.findById(id);
order.confirm();
repo.save(order);
}
}
// ── FIX: both depend on the abstraction ──────────────────────────
interface OrderRepository { Order findById(Long id); void save(Order o); }class OrderService {private final OrderRepository repo; // depends on abstraction
OrderService(OrderRepository repo) { this.repo = repo; } // DI via constructor void confirm(Long id) { ... } // unchanged logic}
// Low-level detail depends on abstraction (not vice versa):
class MySqlOrderRepository implements OrderRepository { ... } // productionclass InMemoryOrderRepository implements OrderRepository { ... } // tests| Principle | Symptom it fixes | Mechanism | Architectural impact |
|---|---|---|---|
| SRP | Classes change for multiple unrelated reasons; merge conflicts; scattered tests | Split by actor/reason to change | Drives modular decomposition; enables independent deployment |
| OCP | Adding features requires modifying tested code; growing if/else chains | Depend on abstractions; extend by adding new implementations | Drives plugin architectures, Strategy pattern, extension points |
| LSP | Subclasses that break calling code; UnsupportedOperationException in overrides | Honor the behavioral contract, not just the signature | Ensures substitutability in polymorphic hierarchies; drives composition over inheritance |
| ISP | Implementing irrelevant methods; fat interfaces that change too often | Small, role-specific interfaces per client need | Drives role interfaces, reduces coupling between modules |
| DIP | High-level code depends on DB/framework; untestable without infrastructure | Define abstractions in high-level package; inject details | Foundation of Onion/Hexagonal/Clean Architecture; enables test doubles |