Skip to content

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.

Read more →


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 User class 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/switch chain 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 case in ReportRenderer
  • 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
class PaymentProcessor {
    charge(type: string, amount: number): void {
        if (type === "stripe") {
            // call Stripe SDK
        } else if (type === "paypal") {
            // call PayPal SDK
        }
        // every new provider edits this file
    }
}
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/UnsupportedOperationErrore.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 Square is not substitutable for a mutable Rectangle, 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 NotImplementede.g., a RobotWorker forced to implement eat() because the interface bundles working and eating
  • A test double has to stub out a dozen unused methods — e.g., mocking the entire IRepository to test a class that only calls findById
  • 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
import { MySQLOrderRepository } from "./infra/mysql";

class OrderService {
    private repo = new MySQLOrderRepository();   // hard-wired

    place(order: Order): void {
        this.repo.save(order);
    }
}
// unit-testing OrderService now requires MySQL
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
);
  1. DIPOrderService depends on the OrderRepository interface, not on MySQL. That's the principle.
  2. DI — the dependency arrives through the constructor instead of being new-ed inside. That's the technique.
  3. IoC — Laravel's container decides when and with what to instantiate OrderService; OrderService never asks. That's the inversion.
  4. Strategy / Abstract FactoryMySQLOrderRepository is one interchangeable implementation; an InMemoryOrderRepository could 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)

Gang of Four →