Foundation 2 of 6 · 30 min

SOLID, with Code That Breaks

Five principles that explain why a design survives the next requirement. Each one is shown as code that hurts, then code that doesn't.

✓ Recognise each SOLID violation in real code✓ Fix it with a small interface or a split class✓ Use the principles as a vocabulary in the interview
Say it in 30 seconds
  • 5 principlesFive questions asked of a design
  • 1 reasonReasons to change per class: single responsibility
  • 3 branchesAn if ladder this long cues extraction
Show what to say

“SOLID is five questions I ask a design when a requirement changes: how many reasons does a class have to change, can I add instead of edit, can a subtype stand in, is the contract too wide, and does policy depend on detail. I use them as one concrete sentence that names the change and where it lands.”

Trade-offs

  • Interface now vs when pain arrives: extract at a second variant or a test fake; wrapping every class costs readability, not change.
  • Wide vs small interfaces: one wide contract makes Cash stub refund and throw; role interfaces let a one-method fake test checkout.
  • Injection vs inversion: injection passes a collaborator in; inversion makes the high-level code own the interface the detail implements.

Bring it up before they ask

Do not wrap every class in an interface, a factory and a manager: pay for an abstraction only where you can name the requirement that varies.

0 of 0 sections ticked
30-second answer

SOLID is five questions to ask a design when a requirement changes. Each one below is code that hurts, then code that does not.

What the five letters ask

Look at a class and say which change would hurt it.

PrincipleThe questionThe smell
Single responsibilityHow many reasons to change?Tax, layout, storage in one class
Open/closedCan I add without editing?An if/else ladder on a type
Liskov substitutionCan a subtype stand in?A subclass that throws
Interface segregationDoes a client need methods it never calls?Stubs that throw
Dependency inversionDoes policy depend on detail?new SmtpMailer() in business code

Single responsibility: one reason to change

A swiss army knife is handy until one broken tool ruins the lot. In srp-before.js an Order computes tax, formats the receipt, and saves itself. Three teams edit one file.

In srp-after.js each concern has a home: Order holds items, withTax is a rule, receiptText is layout, and OrderStore is storage.

Rule of thumb: a responsibility is a reason to change, not "one method per class".

// Three teams edit this class: finance (tax), marketing (receipt text), platform (storage).
export class Order {
  constructor(db) { this.db = db; this.items = []; }
  add(name, cents, qty) { this.items.push({ name, cents, qty }); }

  total() {
    const subtotal = this.items.reduce((sum, i) => sum + i.cents * i.qty, 0);
    return Math.floor((subtotal * 118 + 50) / 100); // 18% tax, rounded to the cent
  }

  receipt() {
    const lines = this.items.map((i) => `${i.qty} x ${i.name}`);
    return [...lines, `Total: ${(this.total() / 100).toFixed(2)}`].join('\n');
  }

  save() { this.db.insert('orders', { items: this.items, total: this.total() }); }
}

Open/closed: add, don't edit

Think of a power strip: plug in a new device, never rewire the wall. In ocp-before.js every customer kind is a branch in price.

In ocp-after.js a Pricing object holds registered rules. A student rule is one register call. Old code is closed to edits and open to additions.

A function in a map is the smallest version of this. It grows into Strategy and Decorator in the next lesson.

// Every new customer kind reopens this function.
export function price(kind, cents) {
  if (kind === 'regular') return cents;
  if (kind === 'gold') return cents - Math.floor(cents / 10);
  if (kind === 'employee') return cents - Math.floor((cents * 3) / 10);
  return cents; // 'student' is silently charged in full until someone edits this
}

Liskov substitution: keep the parent's promise

A subclass must work everywhere its parent does. lsp-before.js makes Square a Rectangle. But areaAt4By5 sets width 4 and height 5 and expects 20. A Square returns 25, because setting one side moves the other.

Three signs of a violation: the subclass throws where the parent did not, demands more of its inputs, or returns less than promised. In lsp-after.js the shapes stop pretending: each offers area().

Rule of thumb: if a caller needs an instanceof check to stay safe, the hierarchy is wrong. See the first lesson.

// A Square "is a" Rectangle, so code written for rectangles should keep working.
export class Rectangle {
  constructor(w, h) { this.w = w; this.h = h; }
  setWidth(w) { this.w = w; }
  setHeight(h) { this.h = h; }
  area() { return this.w * this.h; }
}

export class Square extends Rectangle {
  setWidth(w) { this.w = this.h = w; } // stays square by breaking the parent's promise
  setHeight(h) { this.w = this.h = h; }
}

// Relies on the promise: width and height change independently.
export function areaAt4By5(shape) {
  shape.setWidth(4);
  shape.setHeight(5);
  return shape.area();
}

Interface segregation: small contracts

isp-before.js has one wide contract for paying: charge, refund and save a card. Cash can only charge, so it stubs the rest with throws. Then refundAll meets one cash entry and the whole batch crashes.

In isp-after.js the contracts are roles named by what a client needs. checkout needs something that can charge, so a one-method fake is enough to test it.

Rule of thumb: write the interface from the caller's side. If an implementer would throw "not supported", it is too wide.

// One wide contract for "a way to pay". Cash has to answer for card features it can never have.
export class Cash {
  charge(cents) { return `took ${cents} in cash`; }
  refund() { throw new Error('cash is refunded at the desk'); }
  saveCard() { throw new Error('cash has no card to save'); }
}

export class Card {
  charge(cents) { return `charged ${cents} to the card`; }
  refund(cents) { return `sent ${cents} back to the card`; }
  saveCard(token) { return `saved ${token}`; }
}

// Needs refunds only, but receives the whole wide contract.
export const refundAll = (methods, cents) => methods.map((m) => m.refund(cents));

Dependency inversion: policy should not know the detail

A lamp does not care which power station feeds the socket. In dip-before.js, OrderService creates its own SmtpMailer, so every test needs a mail server.

Step through: the service names a Mailer, and SMTP or a fake plugs into it. The inversion: the arrow now points from the detail to the interface the policy owns.

In dip-after.js the service takes a Mailer, so a test needs only a FakeMailer.

Injection is passing it in. Inversion is who owns the interface: the high-level side.

Stage 1 of 4

The policy builds its detail

OrderService creates an SmtpMailer inside place(). The business rule points straight at a network detail, so every test needs a mail server and a new channel means editing the service.

Dependency inversion, in four stagesOrderService first builds its own SmtpMailer. Then it names what it needs as a Mailer interface, SmtpMailer implements that interface, and a FakeMailer implements it for tests. Every arrow ends at the interface.OrderServicemailer: Mailerplace(order)

OrderService first builds its own SmtpMailer. Then it names what it needs as a Mailer interface, SmtpMailer implements that interface, and a FakeMailer implements it for tests. Every arrow ends at the interface.

  • ◆ composition (owns)
  • ◇ aggregation
  • △ inheritance / implements (dashed)
  • → association · ⇢ dependency
// The high-level rule (confirm an order) is welded to a low-level detail (SMTP).
export class SmtpMailer {
  send(to, text) {
    throw new Error('no SMTP server reachable'); // stands in for opening a real connection
  }
}

export class OrderService {
  place(order) {
    new SmtpMailer().send(order.email, `Order ${order.id} confirmed`);
    return order.id;
  }
}

SOLID as interview vocabulary, and when to stop

Say it as one concrete sentence: "I keep tax in its own rule so Order has one reason to change."

Stop when the pain is not real. Extract an interface when a second variant appears or a test needs a fake. An if with two branches is fine. Three is the cue.

Practise on the parking lot exercise.

Follow-up questions

Which SOLID principle matters most in an LLD round?

Open/closed and dependency inversion, because every question asks "how do I add a new X?" and "how do I test this?".

What is the difference between dependency injection and dependency inversion?

Injection passes a collaborator in. Inversion means the high-level code defines the interface the detail implements.

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. areaAt4By5 sets width 4 and height 5 and expects an area of 20. It returns 25 for a Square that extends Rectangle. Which principle is broken?

Code it yourself

Write the class in the browser editor, in JavaScript or Python. Each exercise runs against hidden tests, and the step-by-step solution unlocks after a wrong submission.

Next: The cheapest way to satisfy open/closed is to let behaviour be swapped. That is Strategy and Decorator.