The SOLID principles that look alike
Published
Read firstSOLID
Read the five SOLID principles in a row and they blur together. Split this class. Split that interface. Depend on less. Depend on abstractions. It sounds like one rule ("make things smaller and less tangled") said five ways, and in an interview that blur shows up as "ISP is basically SRP for interfaces", which is the answer that gets a follow up question.
A doctor has the same problem. Fever, headache and tiredness turn up in dozens of illnesses. The symptom does not tell you the cause, so the doctor asks a different question for each one. The SOLID principles share symptoms (code that is painful to change) but each one names a different cause, and each one is a question you can ask about a piece of code.
Your OrderService has placeOrder, gstFor and invoicePdf. One teammate says it breaks the single responsibility principle. Another says it breaks interface segregation. Who is right, and what would you need to know to decide?
Five principles, five questions#
| Letter | The question it asks | What it looks at |
|---|---|---|
| S, single responsibility | Who will ask for this code to change? | The people behind a class |
| O, open closed | To add a new kind of thing, do I have to edit code that already works? | What has to be reopened |
| L, Liskov substitution | Can a child be used wherever the parent is, without surprising anyone? | Promises between parent and child |
| I, interface segregation | Is a caller forced to depend on methods it never uses? | What a caller has to know |
| D, dependency inversion | Does important code point at a concrete class or at an abstraction? | Which way the import arrow points |
Keep this table in your head and most of the confusion goes away. Try it on six cases from the same food delivery codebase:
Finance changes GST rules, the design team changes the invoice layout, and the orders team changes checkout. All three edit this one file every sprint, and their pull requests conflict.
class OrderService {
placeOrder(cart: Cart) { /* orders team */ }
gstFor(total: number) { /* finance */ }
invoicePdf(order: Order) { /* design */ }
}The dispatch screen only ever calls deliver(). When cleanTable() was added to Staff, the dispatch tests broke, because their fake rider now had to implement cleaning.
interface Staff {
cook(): void; serve(): void
deliver(): void; cleanTable(): void
}
function assignDelivery(rider: Staff) {
rider.deliver()
}A cart worth ₹30 got a FLAT50 coupon, and the bill showed a total of minus ₹20. The payment page passed -20 to the gateway, which rejected it.
class Discount {
// promise: never returns less than 0
apply(total: number) { return total }
}
class Flat50 extends Discount {
apply(total: number) { return total - 50 }
}Every new vehicle type means editing this function. Adding e-cycles last month changed the bike branch by mistake, and bike fees were wrong for two days.
function deliveryFee(vehicle: string, km: number) {
if (vehicle === 'bike') return 20 + 5 * km
else if (vehicle === 'cycle') return 10 + 4 * km
else if (vehicle === 'ecycle') return 12 + 4 * km
throw new Error('unknown vehicle')
}To unit test placeOrder you need a live SMS key, because OrderService builds the vendor client itself. The test suite sent 400 real SMS to a test number last quarter.
class OrderService {
private sms = new Msg91Client('key_live_8f2a')
placeOrder(phone: string) {
this.sms.send(phone, 'Order confirmed')
}
}A tablet app for the kitchen only reads and updates order status, yet it has to ship with the code for refunds and for exporting reports, because both live in the one interface it is given.
interface OrderApi {
status(id: string): string
setStatus(id: string, s: string): void
refund(id: string): void
exportCsv(month: string): string
}S vs I: who changes it, or who uses it#
Both say "split it". They split different things, for different people.
- Single responsibility is about the implementation, seen from the people who ask for
changes. A class has one responsibility when one group of people (one team, one part of the
business) is the reason it changes. Finance, design and the orders team all editing
OrderServiceis a single responsibility problem. - Interface segregation is about the contract, seen from the callers. A caller should be handed only the methods it uses, so a change to the others cannot touch it.
They are independent. Here is a class where ISP is fine and SRP is broken:
interface OrderStatus {
status(id: string): string
}
interface Refunds {
refund(id: string): string
}
// ISP is fine: each caller is handed only what it needs.
// SRP is not: the kitchen team and finance both edit this class.
class OrderDesk implements OrderStatus, Refunds {
status(id: string) {
return `${id}: preparing`
}
refund(id: string) {
return `${id}: refunded, 2% fee kept`
}
}
function kitchenScreen(orders: OrderStatus) {
return orders.status('A17')
}
console.log(kitchenScreen(new OrderDesk())) // A17: preparingThe kitchen screen asks for OrderStatus and knows nothing about refunds, so ISP is happy. But
when finance changes the refund fee, they edit the same file the kitchen team edits, and their
pull requests collide. Split the class by team (KitchenOrders, RefundService) to fix SRP.
The reverse also happens: one tidy class with a single owner, handed to callers through one
huge interface. That is an ISP problem with no SRP problem.
So back to the question at the top. OrderService with three teams' methods breaks S. It
only breaks I as well if some caller is handed the whole class and uses one method of it.
L vs I: a broken promise, or too big a promise#
These two get confused because the classic ISP example ends in a Liskov bug. A Staff
interface has cook, serve and deliver. A Rider has to implement all three, so cook()
throws "riders do not cook". Now any code that is handed a Staff and calls cook() can blow
up.
- The throw is the Liskov violation: something that claims to be a
Staffdoes not behave like one. - The reason it had to throw is the ISP violation: the interface demanded something a rider can never do.
Fix the interface and the throw has no reason to exist. But Liskov is wider than that, and it does not need an interface at all. It is about behaviour, and the compiler cannot check it:
class Discount {
// Promise to every caller: never returns less than 0.
apply(total: number) {
return total
}
}
class Flat50 extends Discount {
override apply(total: number) {
return total - 50
}
}
function bill(total: number, discount: Discount) {
return discount.apply(total)
}
console.log(bill(30, new Flat50())) // -20This compiles cleanly. The shapes match, the interface is tiny, and ISP has nothing to say. It still breaks every caller that trusted the parent's promise, and on a ₹30 cart it sent a negative amount to the payment gateway. The fix keeps the promise:
class Discount {
apply(total: number) {
return total
}
}
class Flat50 extends Discount {
override apply(total: number) {
return Math.max(0, total - 50)
}
}
console.log(new Flat50().apply(30)) // 0
console.log(new Flat50().apply(300)) // 250A quick test for Liskov: write a test against the parent, then run the same test against every child. If a child fails a test the parent passes, it is not a real substitute.
O vs D: the goal, or the direction of the arrow#
These two are confused because the fix for both is usually "add an interface".
- Open closed is a goal: you can add a new kind of thing (a new SMS vendor, a new vehicle type, a new discount) by adding code, without editing code that already works.
- Dependency inversion is a structure: high level code (business rules) does not import low level code (vendors, databases). Both depend on an abstraction, and the abstraction belongs to the high level side.
interface Notifier {
send(phone: string, text: string): string
}
class OrderService {
constructor(private notifier: Notifier) {}
placeOrder(phone: string) {
return this.notifier.send(phone, 'Order confirmed')
}
}
// D: OrderService names Notifier and nothing else.
// O: a new channel is a new object. OrderService is not edited.
const sms: Notifier = { send: (p, t) => `SMS ${p}: ${t}` }
const whatsapp: Notifier = { send: (p, t) => `WhatsApp ${p}: ${t}` }
const viaSms = new OrderService(sms).placeOrder('98100')
const viaWhatsapp = new OrderService(whatsapp).placeOrder('98100')
console.log(viaSms) // SMS 98100: Order confirmed
console.log(viaWhatsapp) // WhatsApp 98100: Order confirmedInverting the dependency is the most common way to get open closed, but they are not the same thing, and you can tell by asking why you want each one:
- You want D even if there will never be a second SMS vendor, because without it you cannot
unit test
OrderServicewithout sending real messages. D pays off on day one. - You want O only where new kinds really keep arriving. A tax rule that changes once a year does not need a plugin system.
The difference between dependency inversion and dependency injection (passing things into the constructor, as above) is its own common mix up. It has its own article: dependency inversion vs dependency injection.
When two seem to apply#
| You see | Ask | Then it is |
|---|---|---|
| A big class | Do different teams edit it? | S. If one team owns it but callers use a sliver, it is I. |
| A method that throws "not supported" | Was it forced on the class by an interface? | Cause I, symptom L. Fix the interface. |
| A subclass that compiles but misbehaves | Does it break a promise of the parent? | L. No interface change will help. |
| An if/else chain that grows each sprint | Does each new case edit tested code? | O. |
A new SomeVendor() inside business code | Can you test or swap it without editing this file? | D. O usually follows. |
The principles are for code that changes. An interface for every class, a one method class for
every verb, and a factory for every new is not good design. It is three files to read where
one would do. Apply a principle when you can name the change it protects you from: "finance
will change this", "a second vendor is signed for next quarter", "the tests send real SMS".
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| S, single responsibility | Each team edits its own files. Fewer conflicting pull requests and smaller blast radius. | More classes, and the code for one feature is spread over more files. | Different people ask for changes to different parts of one class. |
| I, interface segregation | Callers and their test fakes depend on two methods, not twelve. | More named interfaces to keep track of. | Callers use clearly different slices of a wide interface. |
| D, dependency inversion | Business code is testable without the network and does not care which vendor it uses. | Someone has to wire the real objects together at the edge of the app. | Business logic talks to anything slow, paid, external or likely to change. |
class A implements X, Yoverride apply(total: number)constructor(private notifier: Notifier) {}const sms: Notifier = { send: (p, t) => ... }In the interview#
Checkpoint#
1. Finance, design and the orders team all edit the same class every sprint. Which principle is that?
2. A subclass compiles and passes the type checker, but returns a negative total where the parent never did. What is broken?
3. Why would you invert a dependency on an SMS vendor even if you will never change vendors?
- S: who asks for it to change. I: what the caller is forced to know.
- L: a broken promise. I: too big a promise. A forced method that throws is cause I, symptom L.
- L is behaviour, the compiler cannot check it. A test run against every subclass can.
- O is the goal, D is the arrow. Inverting dependencies is the usual way to get open closed.
- Apply a principle when you can name the change it protects you from.
The SOLID principles feel alike because they share symptoms, code that is painful to change, but each names a different cause, and I tell them apart by the question each asks. Single responsibility asks who will request changes to a class; it is about the implementation and the people behind it. Interface segregation asks what a caller is forced to depend on; it is about the contract seen from outside. They are independent, since one class can implement several small interfaces and still mix three teams' code. Liskov asks whether a child keeps the parent's promises; it is about behaviour, which the compiler cannot check, and a forced method that throws is the usual place ISP turns into a Liskov bug. Open closed is the goal of adding new kinds without editing working code, and dependency inversion is the structure that usually gets you there: business code depends on an interface it owns, not on a vendor class. I apply them where I can name the change they protect against.