Skip to main content

SOLID gets taught as five abstract rules and forgotten just as fast. Here they are with concrete, boring, realistic code — the kind you'd actually review in a pull request.

10 min readsolid, object-oriented-design, software-design, code-review

SOLID is usually taught as five capitalized definitions that are easy to recite and hard to apply — "a class should have a single responsibility" doesn't mean much until you've seen the pull request where it would have mattered. Here's each principle with realistic code instead of abstractions, the kind of thing that actually shows up in code review.

S — Single Responsibility Principle

A class should have one reason to change. Not "one method" — one axis of change. An Invoice class that calculates totals, formats output, and saves to a database has three unrelated reasons to change: pricing logic, formatting requirements, and storage details. Split them.

class Invoice {
  constructor(private items: { price: number; qty: number }[]) {}
 
  total(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.qty, 0)
  }
}
 
class InvoicePrinter {
  print(invoice: Invoice): void {
    console.log(`Total: $${invoice.total().toFixed(2)}`)
  }
}

Now pricing logic can change without touching formatting code, and vice versa. Neither class needs to know the other exists beyond the one method call.

O — Open/Closed Principle

A class should be open for extension, closed for modification. In practice: when a new case shows up, you should be able to add code, not edit code that already works and is already tested. A switch/if-else chain over a "type" field is the classic violation — every new type means reopening the same function.

interface Discount {
  apply(price: number): number
}
 
class PercentageDiscount implements Discount {
  constructor(private percent: number) {}
  apply(price: number): number {
    return price * (1 - this.percent / 100)
  }
}
 
class FlatDiscount implements Discount {
  constructor(private amount: number) {}
  apply(price: number): number {
    return Math.max(0, price - this.amount)
  }
}
 
// Adding a new discount type never touches this function again.
function checkout(price: number, discount: Discount): number {
  return discount.apply(price)
}

A new discount type is a new class implementing the same interface — checkout never changes, and there's no risk of a new else if breaking an existing case.

L — Liskov Substitution Principle

Any subtype should be usable anywhere its parent type is expected, without breaking the caller's assumptions. The textbook trap: a Square extends Rectangle that overrides setWidth to also change the height, silently breaking any code that expected setting a rectangle's width to leave its height alone.

// Violates LSP: callers that treat every Shape as an independent-width/height
// Rectangle get silently wrong behavior when the Shape happens to be a Square.
class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number) { this.width = w }
  setHeight(h: number) { this.height = h }
  area() { return this.width * this.height }
}
 
class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w } // surprise side effect
  setHeight(h: number) { this.width = h; this.height = h } // surprise side effect
}
 
// Fix: don't model Square as a Rectangle subtype at all. Model both as Shapes
// with their own construction, and drop the assumption that width/height are
// independently mutable — the real bug was the shared mutable interface, not
// which class extended which.
interface Shape {
  area(): number
}
 
class Rectangle2 implements Shape {
  constructor(private width: number, private height: number) {}
  area() { return this.width * this.height }
}
 
class Square2 implements Shape {
  constructor(private side: number) {}
  area() { return this.side * this.side }
}

The fix isn't a better override — it's recognizing that Square and Rectangle don't actually share a substitutable contract once mutation is involved, so inheritance was the wrong tool from the start.

I — Interface Segregation Principle

Don't force a class to implement methods it doesn't use. A fat Worker interface with work() and eat() forces a RobotWorker to implement eat() with an empty or throwing body — a sign the interface is really two interfaces wearing one name.

// Violates ISP: RobotWorker is forced to implement a method that makes no
// sense for it, which usually means throwing or silently doing nothing —
// both are worse than not having the method.
interface Worker {
  work(): void
  eat(): void
}
 
class RobotWorker implements Worker {
  work() { /* ... */ }
  eat() {
    throw new Error("Robots don't eat")
  }
}
 
// Fix: split the interface along the lines its implementers actually need.
interface Workable {
  work(): void
}
 
interface Eatable {
  eat(): void
}
 
class HumanWorker implements Workable, Eatable {
  work() { /* ... */ }
  eat() { /* ... */ }
}
 
class RobotWorker2 implements Workable {
  work() { /* ... */ }
}

RobotWorker2 now only implements what it can actually do — there's no method to throw from, because there's no method that doesn't apply.

D — Dependency Inversion Principle

High-level modules shouldn't depend on low-level modules directly — both should depend on an abstraction. In practice: a NotificationService that directly news up an EmailClient can never be tested without sending real email, and can never support SMS without a rewrite. Depend on an interface instead, and inject the concrete implementation from outside.

interface MessageSender {
  send(to: string, body: string): void
}
 
class EmailSender implements MessageSender {
  send(to: string, body: string): void {
    console.log(`Emailing ${to}: ${body}`)
  }
}
 
class NotificationService {
  constructor(private sender: MessageSender) {} // depends on the abstraction
 
  notify(userId: string, message: string): void {
    this.sender.send(userId, message)
  }
}

Testing NotificationService now means passing in a fake MessageSender — no real email gets sent, and no test framework gymnastics are required. Adding SMS later means writing an SmsSender that implements the same interface; NotificationService doesn't change at all.

Why bother

None of these are rules to apply reflexively to every class — a one-off script doesn't need an injected abstraction. They're a checklist for the moment a class starts resisting change: if adding a feature means editing a working method instead of adding a new one, that's O being violated. If a class needs three unrelated reasons to change, that's S. If a test needs real infrastructure to run, that's usually D. Treated as diagnostic questions instead of upfront ceremony, SOLID earns the reputation it has — as a description of what well-factored code tends to look like, not a template you fill in before writing the first line.