SOLID Principles¶
SOLID is a set of five object-oriented design principles — guidelines for shaping classes and their dependencies so a system stays easy to change. The five principles were collected and named by Robert C. Martin ("Uncle Bob") in his early-2000s writing on agile design; the SOLID acronym itself was coined later by Michael Feathers.
Where the Gang of Four catalog gives you patterns (concrete recipes), SOLID gives you the principles the recipes serve. Most GoF patterns are direct embodiments of one or two SOLID principles — Strategy and Decorator both operationalize Open/Closed; Abstract Factory is one way to satisfy Dependency Inversion; Adapter is the rescue when Interface Segregation has already been violated upstream. Read both sides and the same idea keeps appearing under different names.
Analogy
A well-run restaurant kitchen. Each station has exactly one job — the grill cook doesn't also do payroll (S). The menu can grow without re-plumbing the kitchen — adding a new dessert doesn't require rewiring the line (O). Any certified sous-chef can stand in at the grill station without breaking service (L). The dishwasher isn't handed the recipe book — they only get the tools they need (I). The head chef calls for "a sauce," not "Heinz bottle #4720" — the line cooks supply whichever bottle is in the rotation today (D).
The Five Principles¶
| Letter | Principle | One-line essence |
|---|---|---|
| S | Single Responsibility | A class should have one reason to change |
| O | Open/Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be usable wherever their base type is |
| I | Interface Segregation | No client should depend on methods it doesn't use |
| D | Dependency Inversion | Depend on abstractions, not concretions |
Gang of Four Patterns
The 23 classic OO design patterns — concrete recipes that, in many cases, exist because of these principles.
Single Responsibility Principle (SRP)¶
A class should have one — and only one — reason to change. "Reason to change" means a stakeholder or concern: persistence, business rules, formatting, transport, etc. When a class mixes concerns, a change driven by one concern can break the others.
Analogy
A waiter takes orders. They don't also cook the food, run payroll, and audit the books. If accounting changes the tax rate, the waiter doesn't need retraining — because the waiter never knew about taxes. Mix all those jobs into one role and every regulation change forces a kitchen rewrite.
When to Use¶
- A class is described with the word "and" — e.g., "the
Userclass persists itself and sends welcome emails and generates invoices" - Different stakeholders ask for changes in the same file — e.g., DBAs touching the same class as the marketing team
- Tests need elaborate setup because a single class touches the database, the mail server, and the rendering layer
Trade-offs¶
- ✅ Changes stay local — modifying email templates doesn't risk breaking persistence
- ✅ Smaller classes are easier to test, mock, and reason about in isolation
- ⚠️ More files and more wiring. Acceptable when responsibilities genuinely change at different rates; over-applied, it produces a fog of three-line classes (a real anti-pattern often called "SRP-itis").
- ⚠️ "Responsibility" is fuzzy. Use "axis of change" — who asks for the change? — as the working definition, not "does the class do more than one thing."
Fitness Test
"If I describe what this class does, do I need the word 'and'?"
- Yes → ❌ Likely violates SRP — split along the "and"
- No, it's one cohesive responsibility → ✅ SRP holds
Cross-reference: GoF
Facade is SRP applied at a subsystem boundary — one object whose single job is "present a simplified interface to this complex subsystem." Mediator is SRP applied to coordination — one object whose single job is "manage how these other objects talk." Both patterns exist precisely so the rest of your code can stay single-purpose.
flowchart LR
subgraph Before["Before — one class, three reasons to change"]
U1[User] --> DB1[(database)]
U1 --> M1[mail server]
U1 --> R1[PDF renderer]
end
subgraph After["After — three classes, one reason each"]
Repo[UserRepository] --> DB2[(database)]
Notif[UserNotifier] --> M2[mail server]
Inv[InvoiceRenderer] --> R2[PDF renderer]
end
class User {
constructor(public email: string, public name: string) {}
save(): void {
// talks to the database
db.query("INSERT INTO users ...", [this.email, this.name]);
}
sendWelcomeEmail(): void {
// talks to the mail server
mailer.send(this.email, "Welcome", `Hi ${this.name}`);
}
renderInvoicePdf(): Buffer {
// talks to a PDF library
return pdf.render(`Invoice for ${this.name}`);
}
}
class User {
constructor(public email: string, public name: string) {}
}
class UserRepository {
save(user: User): void {
db.query("INSERT INTO users ...", [user.email, user.name]);
}
}
class UserNotifier {
sendWelcome(user: User): void {
mailer.send(user.email, "Welcome", `Hi ${user.name}`);
}
}
class InvoiceRenderer {
forUser(user: User): Buffer {
return pdf.render(`Invoice for ${user.name}`);
}
}
Open/Closed Principle (OCP)¶
Software entities should be open for extension but closed for modification. You should be able to add new behavior by writing new code, not by editing tested code. The mechanism is almost always polymorphism via an abstraction — interface, abstract class, or strategy object.
Analogy
A power strip. You add new devices by plugging them in — you don't open up the wall and rewire the circuit each time. The strip exposes a stable contract (the socket); the devices vary.
When to Use¶
- You see a growing
if/switchchain on a type tag — e.g.,if (payment.type === "stripe") {...} else if (...) {...} - A new feature consistently requires editing the same shared file — e.g., every new report type requires a
caseinReportRenderer - You're integrating providers that come and go — e.g., payment gateways, storage drivers, OAuth providers
Trade-offs¶
- ✅ New providers ship without re-touching (or re-testing) the core
- ✅ Each variant is isolated — a bug in one Stripe handler can't break PayPal
- ⚠️ Premature OCP creates ceremony. Apply when you have, or imminently expect, a third variant — two cases is often still cheaper as an
if. - ⚠️ The abstraction can ossify in the wrong shape. The first two implementations rarely agree on what the interface should be; let them diverge before extracting it.
Fitness Test
"To add the next variant, do I have to edit existing tested code, or can I add a new file?"
- Edit existing → ❌ OCP is being violated
- Add new file → ✅ OCP holds
Cross-reference: GoF
Three classic GoF patterns operationalize OCP via different mechanisms — and choosing between them is mostly a question of what varies:
- Strategy — swap an entire algorithm at runtime (e.g., the payment gateway example below). Variation by choice.
- Decorator — wrap an object to add behavior without subclassing (e.g., logging, caching, compression layers). Variation by stacking.
- Template Method — fix the skeleton in a base class, let subclasses override the steps. Variation by hook.
All three answer OCP. Picking one is a question of whether the variation is what to do (Strategy), what to add around it (Decorator), or which step to override (Template Method).
flowchart LR
Client --> PG{PaymentGateway}
PG -.implements.-> Stripe[StripeGateway]
PG -.implements.-> Paypal[PayPalGateway]
PG -.implements.-> NewProvider[FuturePayGateway]
style NewProvider stroke-dasharray: 4 4
interface PaymentGateway {
charge(amount: number): void;
}
class StripeGateway implements PaymentGateway {
charge(amount: number): void { /* Stripe SDK */ }
}
class PayPalGateway implements PaymentGateway {
charge(amount: number): void { /* PayPal SDK */ }
}
class PaymentProcessor {
constructor(private gateway: PaymentGateway) {}
charge(amount: number): void {
this.gateway.charge(amount);
}
}
// adding FuturePayGateway is a new file — PaymentProcessor never reopens
Liskov Substitution Principle (LSP)¶
Objects of a subtype must be usable anywhere objects of the base type are expected, without altering the correctness of the program. A subclass can extend behavior, but it must not weaken contracts (preconditions), break invariants, or surprise callers with new exceptions.
Analogy
Any certified electrician can swap in for another on a job. The contract is "wires up to code, lights turn on, no fires." If your "electrician" subtype refuses to touch certain wires, or sometimes burns the house down, they don't actually satisfy the role — even if their business card says Electrician.
When to Use¶
- A subclass overrides a method to throw
NotSupportedException/UnsupportedOperationError— e.g.,ReadOnlyList.add()throwing instead of adding - An override silently weakens behavior — e.g.,
Square.setWidth()also setting height, breaking callers that assumed independent dimensions - You feel the urge to write
if (x instanceof SpecialSubtype)in client code — that's LSP failing in disguise
Trade-offs¶
- ✅ Polymorphism is actually safe — you can swap implementations without auditing every call site
- ✅ Forces you to model "is-a" honestly: a
Squareis not substitutable for a mutableRectangle, even though geometry says it is - ⚠️ Sometimes the right answer is "don't subclass" — composition or a separate interface is cleaner than a strained
extends - ⚠️ Contracts (preconditions/postconditions/invariants) are usually undocumented. LSP only bites at the boundary you can see — write the contract down before judging the subtype.
Fitness Test
"Can I replace every new Base() with new Subtype() and have all existing tests still pass?"
- Yes → ✅ LSP holds
- No → ❌ The "subtype" isn't really a subtype
Cross-reference: GoF
Template Method is the classic LSP trap: the base class defines the skeleton and calls hooks; if a subclass's hook violates the base's contract (skips a step, throws unexpectedly), the whole skeleton breaks for every caller. Composite depends on LSP holding for both leaves and composites — a tree-walking client treats them identically, and any subtype that refuses certain operations silently corrupts the traversal.
flowchart TB
R[Rectangle: w, h independent] --> Caller{Client expects w·h area}
S[Square: setWidth also sets h] -.violates.-> Caller
style S stroke:#c00
class Rectangle {
constructor(protected w: number, protected h: number) {}
setWidth(w: number): void { this.w = w; }
setHeight(h: number): void { this.h = h; }
area(): number { return this.w * this.h; }
}
class Square extends Rectangle {
setWidth(w: number): void { this.w = w; this.h = w; } // surprise!
setHeight(h: number): void { this.w = h; this.h = h; }
}
function stretch(r: Rectangle): void {
r.setWidth(5);
r.setHeight(4);
console.assert(r.area() === 20); // fails when r is a Square
}
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(private w: number, private h: number) {}
area(): number { return this.w * this.h; }
}
class Square implements Shape {
constructor(private side: number) {}
area(): number { return this.side * this.side; }
}
// No inheritance pretending to be substitutable.
// Both are Shapes; neither lies about the other's contract.
Interface Segregation Principle (ISP)¶
Clients should not be forced to depend on methods they do not use. Many small, focused interfaces beat one fat one — a class that needs only two methods shouldn't have to know about the other twelve.
Analogy
A library card lets you borrow books. It does not entitle you to use the staff printer or open the cash drawer. If the library issued a single "all-access pass" to every patron, the rules would balloon and patrons would constantly bump into permissions they neither want nor understand.
When to Use¶
- Implementations leave methods empty or throw
NotImplemented— e.g., aRobotWorkerforced to implementeat()because the interface bundles working and eating - A test double has to stub out a dozen unused methods — e.g., mocking the entire
IRepositoryto test a class that only callsfindById - Different consumers need different slices of the same class — e.g., a read-only reporting screen and a full-CRUD admin panel sharing one fat interface
Trade-offs¶
- ✅ Mocks/fakes shrink to what's actually used — tests get faster and intent gets clearer
- ✅ Adding a method to one role doesn't ripple through unrelated implementations
- ⚠️ More interface files. Acceptable when role boundaries are real; if every interface has exactly one implementer and one consumer, you've just renamed the class.
- ⚠️ Role-based slicing is a design judgment, not a mechanical rule. Group methods by who calls them together, not by what feels "tidy."
Fitness Test
"Does every implementer of this interface meaningfully implement every method?"
- Yes → ✅ Interface is appropriately sized
- No, some implementers throw or no-op → ❌ Split it along the seam where implementers diverge
Cross-reference: GoF
Adapter is the rescue when you can't avoid a fat interface you don't own (e.g., a third-party SDK). The Adapter implements the bloated foreign interface and exposes the narrow one your code actually needs — quarantining the ISP violation at the boundary instead of letting it leak inward.
flowchart LR
subgraph Before["Before — fat IWorker"]
Human1[HumanWorker] --> IW[IWorker: work eat sleep]
Robot1[RobotWorker] -- throws on eat --> IW
end
subgraph After["After — split roles"]
Human2[HumanWorker] --> Workable[Workable]
Human2 --> Feedable[Feedable]
Robot2[RobotWorker] --> Workable
end
interface IWorker {
work(): void;
eat(): void;
sleep(): void;
}
class HumanWorker implements IWorker {
work(): void { /* ... */ }
eat(): void { /* ... */ }
sleep(): void { /* ... */ }
}
class RobotWorker implements IWorker {
work(): void { /* ... */ }
eat(): void { throw new Error("robots don't eat"); } // ISP violated
sleep(): void { throw new Error("robots don't sleep"); }
}
interface Workable { work(): void; }
interface Feedable { eat(): void; }
interface Restable { sleep(): void; }
class HumanWorker implements Workable, Feedable, Restable {
work(): void { /* ... */ }
eat(): void { /* ... */ }
sleep(): void { /* ... */ }
}
class RobotWorker implements Workable {
work(): void { /* ... */ }
// doesn't pretend to eat or sleep
}
Dependency Inversion Principle (DIP)¶
High-level modules should not depend on low-level modules — both should depend on abstractions. And abstractions should not depend on details — details should depend on abstractions. In practice: your business logic should know about an OrderRepository interface, not about MySQL.
Analogy
The head chef calls for "a sauce" — they don't care if the line cook reaches for Heinz, Maille, or whatever's in the rotation today. The chef (high-level policy) depends on the idea of a sauce (abstraction); the bottle (detail) depends on satisfying that idea. Swap suppliers tomorrow and the menu doesn't change.
When to Use¶
- Business logic imports a database driver, a mail SDK, or a third-party API directly — those imports are leaks
- You can't unit-test the high-level class without spinning up the real database or hitting the network
- You want to swap an implementation per environment — e.g., MySQL in production, in-memory in tests, MongoDB for a one-off script
Trade-offs¶
- ✅ Business logic becomes testable in isolation — pass a fake repository, no infrastructure required
- ✅ Infrastructure choices become reversible — swap MySQL for Postgres without touching the domain layer
- ⚠️ Indirection has a cost. Justified at module/architectural seams (domain ↔ infrastructure); harmful inside a single cohesive module where you'd just be inventing interfaces nobody else implements.
- ⚠️ The abstraction must be owned by the high-level module, not the low-level one — otherwise you've inverted nothing.
Fitness Test
"Can I instantiate this high-level class in a unit test with no infrastructure running?"
- Yes → ✅ DIP holds
- No, it news up a database/mailer/HTTP client → ❌ DIP is being violated
Cross-reference: GoF and friends
DIP is the principle most riddled with overlapping vocabulary — and the place where critical thinking matters most. The next section ("Same Idea, Different Names") unpacks why DIP, DI, IoC, Service Locator, Abstract Factory, and Strategy all show up around the same code. For now, the short version:
- Abstract Factory (GoF Creational) is one way to satisfy DIP — the high-level module asks the factory for a family of products via an interface.
- Strategy (GoF Behavioral) satisfies DIP for algorithms — the high-level module receives a strategy through its constructor.
- Dependency Injection is the technique that lets the high-level module receive its abstraction (via constructor, setter, or container).
flowchart TB
subgraph Before["Before — domain depends on infra"]
OS1[OrderService] --> MyDB[(MySQLOrderRepository)]
end
subgraph After["After — both depend on the abstraction"]
OS2[OrderService] --> IRepo{OrderRepository}
IRepo -.implemented by.-> MyDB2[(MySQLOrderRepository)]
IRepo -.implemented by.-> InMem[(InMemoryOrderRepository)]
end
interface OrderRepository {
save(order: Order): void;
}
class OrderService {
constructor(private repo: OrderRepository) {} // injected
place(order: Order): void {
this.repo.save(order);
}
}
// production wiring
const service = new OrderService(new MySQLOrderRepository());
// test wiring
const service = new OrderService(new InMemoryOrderRepository());
interface OrderRepository {
public function save(Order $order): void;
}
class OrderService {
public function __construct(private OrderRepository $repo) {}
public function place(Order $order): void {
$this->repo->save($order);
}
}
// app/Providers/AppServiceProvider.php — the IoC container does the wiring
$this->app->bind(OrderRepository::class, MySQLOrderRepository::class);
// OrderService never mentions MySQL; the container resolves it on demand
Same Idea, Different Names — A Vocabulary Bridge¶
Around DIP the vocabulary gets crowded, and writers (and interview questions) often use these terms interchangeably even though they sit at different altitudes. Memorizing definitions doesn't help — seeing the same code wear all the labels at once does.
| Term | What it actually is | Lives at |
|---|---|---|
| Dependency Inversion (DIP) | A principle: high-level depends on abstractions, not concretions | SOLID — design rule |
| Inversion of Control (IoC) | A pattern: the framework calls your code, not the reverse ("Hollywood Principle") | Architectural style |
| Dependency Injection (DI) | A technique: pass dependencies in via constructor, setter, or interface | Implementation mechanic |
| IoC Container / DI Container | A tool: a registry that wires up DI for you (Laravel's app(), Spring, etc.) |
Framework feature |
| Service Locator | An alternative to DI: code asks a registry for its dependencies (often considered an anti-pattern because it hides the dependency) | Implementation mechanic |
| Abstract Factory | A GoF pattern: hands you a family of related objects via an interface | GoF Creational |
| Strategy | A GoF pattern: swap an algorithm at runtime via an interface | GoF Behavioral |
One example, four labels¶
Look at the Laravel snippet from the DIP section above and notice how the same eight lines of code satisfy all four ideas at once:
interface OrderRepository { /* ... */ } // (1) the abstraction — DIP
class OrderService {
public function __construct(private OrderRepository $repo) {} // (2) DI via constructor
// ...
}
$this->app->bind(
OrderRepository::class, // (3) IoC container does the wiring
MySQLOrderRepository::class // (4) one of many "Strategy" implementations
);
- DIP —
OrderServicedepends on theOrderRepositoryinterface, not on MySQL. That's the principle. - DI — the dependency arrives through the constructor instead of being
new-ed inside. That's the technique. - IoC — Laravel's container decides when and with what to instantiate
OrderService;OrderServicenever asks. That's the inversion. - Strategy / Abstract Factory —
MySQLOrderRepositoryis one interchangeable implementation; anInMemoryOrderRepositorycould swap in tomorrow. That's the GoF realization.
These aren't competing choices to pick between. They're four labels for four different altitudes of the same arrangement: the principle (DIP), the architectural style (IoC), the implementation technique (DI), and the catalog name for that shape (Strategy / Abstract Factory). Next time someone says "we use IoC here" or "this is a Strategy," check which altitude they mean — usually the disagreement isn't substantive, it's that two people are pointing at the same code from different floors.
Where Each Principle Shows Up in GoF¶
A compact map back to the Gang of Four catalog. Use it in reverse too: when you study a GoF pattern, ask which SOLID principle it serves.
| Principle | Most directly embodied by |
|---|---|
| SRP | Facade (single subsystem entry), Mediator (single coordination role) |
| OCP | Strategy, Decorator, Template Method — three different mechanisms (swap / wrap / hook) |
| LSP | Template Method (the contract trap), Composite (leaves and trees must be substitutable) |
| ISP | Adapter (narrowing a fat foreign interface) |
| DIP | Abstract Factory, Strategy, Bridge (separating abstraction from implementation) |