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.
- 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.
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.
| Principle | The question | The smell |
|---|---|---|
| Single responsibility | How many reasons to change? | Tax, layout, storage in one class |
| Open/closed | Can I add without editing? | An if/else ladder on a type |
| Liskov substitution | Can a subtype stand in? | A subclass that throws |
| Interface segregation | Does a client need methods it never calls? | Stubs that throw |
| Dependency inversion | Does 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.
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 3Score 2 out of 3 or better to mark this page complete.
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.