# SOLID Principles in Detail

SOLID is five object-oriented design principles that share one goal: let you change a system by *adding* code rather than editing code that already works. Introduced by Robert C. Martin (the principles) and later given the [SOLID mnemonic](https://www.digitalocean.com/community/conceptual-articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design) by Michael Feathers, they are the backbone of every low-level design and machine-coding round - and they are far more useful understood by *the refactor each one names* than by the one-line definition. This post takes each principle in depth: the precise statement, a concrete violation, the fix, and the nuance people get wrong. Examples are in TypeScript, because its explicit interfaces and types put the design on the page; the ideas carry to any object-oriented language.

This is part of the System Design Guide series, on the LLD track. It is the deep-dive companion to the low-level design concepts overview - if you want the wider set of principles and patterns, read that; if you want SOLID done properly, this is it.

## S - Single Responsibility Principle

**The statement people memorize:** a class should have one reason to change. **The statement that is actually useful:** [a module should be responsible to one, and only one, actor](https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html) - where an *actor* is the person or group who would request a change. This reframing matters, because "does one thing" is the wrong reading and leads people to shatter their code into a hundred trivial classes.

The canonical violation is a class whose methods answer to *different* stakeholders:

```typescript
class Employee {
  calculatePay(): number { return 0; }   // requested by Accounting (the CFO's org)
  reportHours(): number { return 0; }     // requested by HR (the COO's org)
  save(): void { /* persistence */ }       // requested by the DBA (the CTO's org)
}
```

Three actors share one class. The danger is not aesthetic: suppose `calculatePay` and `reportHours` both call a shared private `regularHours()` helper. Accounting asks for a change to how overtime is computed, a developer edits `regularHours()`, and HR's hour report silently breaks - because two actors were coupled through one method. A change requested by one stakeholder damaged a feature owned by another. That is the specific failure SRP prevents.

The fix is to separate along the actor boundaries and keep the shared data in a plain structure:

```typescript
class Employee { /* just the data */ }

class PayCalculator      { calculate(e: Employee): number { return 0; } }
class HourReporter       { report(e: Employee): number { return 0; } }
class EmployeeRepository { save(e: Employee): void {} }
```

**The nuance to hold onto:** SRP is really about [cohesion and coupling](https://raimund-kraemer.dev/solid-principles-common-misconceptions) - group together the things that change for the same reason, and separate the things that change for different reasons. It is not a license to make every class a single method. Martin himself has said he regrets how easily "responsibility" is misread; the honest test is "who asks for changes to this, and is it more than one group?", not "does this do more than one thing?"

## O - Open/Closed Principle

**The statement:** 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 existing, tested code. The reason is risk: every edit to working code is a chance to break it and forces you to re-test it. New code that plugs into a stable interface carries far less risk.

The tell of a violation is a conditional on a type that grows every time you add a case:

```typescript
function area(shape: any): number {
  if (shape.kind === "circle") return Math.PI * shape.r ** 2;
  if (shape.kind === "square") return shape.s ** 2;
  // ...add "triangle" here, and here, and in every other switch
  return 0;
}
```

Add a triangle and you are editing (and re-testing) `area`, plus every other function that switches on the shape type. The refactor pushes the varying behavior behind an interface so each shape owns its own area, and the calculator never changes again:

```typescript
interface Shape { area(): number; }

class Circle implements Shape {
  constructor(private r: number) {}
  area(): number { return Math.PI * this.r ** 2; }
}
class Square implements Shape {
  constructor(private s: number) {}
  area(): number { return this.s ** 2; }
}

function totalArea(shapes: Shape[]): number {
  return shapes.reduce((sum, s) => sum + s.area(), 0); // never edited for a new shape
}
```

**The nuance - strategic closure:** you cannot make code closed against *every* possible change, and trying to is over-engineering. You choose the axis you expect to vary and close against *that* one. Here we bet that "new shapes get added" is likely, so we made shapes extensible. If instead the operations varied (area today, perimeter tomorrow, rendering next) while shapes stayed fixed, a different structure would be right. The [deliberate, strategic choice of what to make extensible](https://raimund-kraemer.dev/solid-principles-common-misconceptions) - rather than speculatively making everything pluggable - is the actual skill. Plugin architectures are OCP at its best, precisely because the people writing plugins are not the people who own the core.

## L - Liskov Substitution Principle

**The statement:** if you have code that works with a base type, it must keep working when handed any subtype, without knowing the difference. Named for Barbara Liskov's work on behavioral subtyping, it is really a contract: a subtype may not strengthen preconditions, weaken postconditions, break invariants the base promised, or throw new exceptions the caller does not expect. Inheritance that merely reuses code while violating the base's *behavior* is the trap.

The textbook violation is the one that looks most innocent - a `Square` that "is a" `Rectangle`:

```typescript
class Rectangle {
  constructor(protected w: number, protected h: number) {}
  setWidth(w: number)  { this.w = w; }
  setHeight(h: number) { this.h = h; }
  area(): number { return this.w * this.h; }
}

class Square extends Rectangle {
  setWidth(w: number)  { this.w = w; this.h = w; }   // must keep sides equal
  setHeight(h: number) { this.w = h; this.h = h; }
}

function grow(r: Rectangle) {
  r.setWidth(5);
  r.setHeight(4);
  console.assert(r.area() === 20);  // FAILS for Square: area === 16
}
```

Mathematically a square is a rectangle; behaviorally it is not substitutable, because setting width and height independently is part of the rectangle's contract and the square breaks it. Other everyday violations: a `Bird` base with a `fly()` method that a `Penguin` subtype cannot honor, or a `ReadOnlyList` that inherits `add()` and throws at runtime (a "refused bequest").

The fix is not a clever override - it is to stop forcing the false `is-a`. Model what is actually shared behind an honest abstraction (`Shape` with `area()`), and let `Square` and `Rectangle` be siblings rather than parent and child, or use composition. LSP's practical lesson: **inheritance is a promise about behavior, not a code-reuse shortcut.** When a subtype has to lie about the base's contract, the hierarchy is wrong.

## I - Interface Segregation Principle

**The statement:** clients should not be forced to depend on methods they do not use. Prefer several small, role-focused interfaces over one fat, do-everything interface. A bloated interface couples every implementer to capabilities it may not have, and couples every client to methods it never calls.

The violation - one interface that assumes every device does everything:

```typescript
interface Machine {
  print(doc: string): void;
  scan(doc: string): void;
  fax(doc: string): void;
}

class SimplePrinter implements Machine {
  print(doc: string) { /* ok */ }
  scan(doc: string) { throw new Error("not supported"); } // smell
  fax(doc: string)  { throw new Error("not supported"); } // smell
}
```

Those runtime errors are the interface telling you it is too big. Split it into roles, and let each device implement exactly what it does:

```typescript
interface Printer { print(doc: string): void; }
interface Scanner { scan(doc: string): void; }
interface Fax     { fax(doc: string): void; }

class SimplePrinter implements Printer {
  print(doc: string) { /* only what it can actually do */ }
}
class OfficeMachine implements Printer, Scanner, Fax {
  print(d: string) {} scan(d: string) {} fax(d: string) {}
}
```

**The nuance:** this is why experienced designers favor tiny interfaces. The Go community states it most sharply - "[the bigger the interface, the weaker the abstraction](https://dave.cheney.net/2016/08/20/solid-go-design)" - and builds from one-method interfaces up. The same instinct shows up in [idiomatic Go](/the-idiomatic-golang): small interfaces are more reusable, easier to implement, and easier to test. ISP is the principle that keeps the abstractions used by the next principle small and honest.

## D - Dependency Inversion Principle

**The statement:** 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 plain terms: depend on an interface, not on a concrete class.

The violation is a high-level policy class that reaches down and hard-wires a concrete dependency:

```typescript
class EmailSender {
  send(msg: string) { /* SMTP... */ }
}

class NotificationService {
  private sender = new EmailSender();   // can't swap, can't test in isolation
  notify(msg: string) { this.sender.send(msg); }
}
```

`NotificationService` now *is* email. You cannot send an SMS without editing it, and you cannot unit-test it without actually sending email. Invert the dependency: define the abstraction the high-level code needs, and inject a concrete implementation from outside:

```typescript
interface Notifier { send(msg: string): void; }

class EmailNotifier implements Notifier { send(msg: string) { /* SMTP */ } }
class SmsNotifier   implements Notifier { send(msg: string) { /* SMS  */ } }

class NotificationService {
  constructor(private notifier: Notifier) {}   // depends on the abstraction
  notify(msg: string) { this.notifier.send(msg); }
}

const service = new NotificationService(new EmailNotifier());  // wiring at the edge
```

Both the high-level `NotificationService` and the low-level `EmailNotifier` now depend on the `Notifier` interface, and the concrete choice is made at the composition edge. In a test you pass a fake `Notifier` and assert on it.

**The distinction to get right:** DIP is the *principle* (depend on abstractions). Passing the dependency in through the constructor is [dependency injection](https://www.freecodecamp.org/news/solid-principles-explained-in-plain-english/), a *technique* that helps you satisfy DIP; "inversion of control" is the broader idea of who is in charge of wiring. DIP is the one interviewers push on hardest, because it is what makes a design testable and swappable - when a class feels rigid or impossible to unit-test, an injected abstraction is almost always the cure.

## The five are one idea

SOLID is not five unrelated rules; they interlock. Open/Closed is almost always achieved through polymorphism - and polymorphism is only safe when Liskov holds, so subtypes really are substitutable. The abstractions you extend under OCP are the ones Dependency Inversion tells you to depend on, and Interface Segregation keeps those abstractions small enough to implement without lying. Underneath it all, Single Responsibility keeps each class cohesive enough that clean abstractions are even possible. Read together, SOLID is a single stance: **depend on small, honest abstractions so that new behavior arrives as new code.**

## Do not cargo-cult it

SOLID applied without judgment produces its own failure mode: a fog of one-method classes, interfaces with a single implementer, and factories building factories - complexity with no payoff, which is exactly what KISS and YAGNI warn against. The principles earn their keep where change is *likely*; applying them speculatively everywhere is [over-engineering](https://raimund-kraemer.dev/solid-principles-common-misconceptions), not craftsmanship. Add the abstraction when a second implementation appears or is clearly coming, not on the off chance.

In an interview, this means you *apply* SOLID and *name* it when you justify a move - "I'll put pricing behind a strategy interface so we can add rules without touching checkout, that's open/closed" - rather than reciting the acronym unprompted. Reviewers are listening for whether you can spot the specific violation and make the specific fix. Here is the quick mapping from smell to principle:

| Smell in the code | Principle it violates |
|---|---|
| One class edited by several different teams / for unrelated reasons | Single Responsibility |
| A growing `if/switch` on a type; editing tested code to add a case | Open/Closed |
| A subtype that overrides to throw, or breaks the base's expectations | Liskov Substitution |
| Implementers forced to stub methods they cannot support | Interface Segregation |
| A class that constructs its own concrete dependencies | Dependency Inversion |

## You now have SOLID as a working tool

Each principle names a concrete refactor: separate classes by actor (S), extend through polymorphism instead of editing (O), keep inheritance honest about behavior (L), split fat interfaces into roles (I), and depend on injected abstractions (D). Learn to recognize the five smells above on sight, apply the fix, and name it - that is what a machine-coding reviewer is grading. For the Go-flavored take on the same principles, [SOLID Go Design](https://dave.cheney.net/2016/08/20/solid-go-design) is worth reading; and for the design patterns that put OCP and DIP to work, the next stop is a pattern catalog like [Refactoring Guru](https://refactoring.guru/design-patterns).

