Library
9 min read

The SOLID principles that look alike

Published

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 answer

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#

LetterThe question it asksWhat it looks at
S, single responsibilityWho will ask for this code to change?The people behind a class
O, open closedTo add a new kind of thing, do I have to edit code that already works?What has to be reopened
L, Liskov substitutionCan a child be used wherever the parent is, without surprising anyone?Promises between parent and child
I, interface segregationIs a caller forced to depend on methods it never uses?What a caller has to know
D, dependency inversionDoes 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:

Which principle is broken?

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
}
Read the story, then pick the one principle it breaks. Each answer also tells you which principle it is usually confused with, and why that one is wrong.

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 OrderService is 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:

ts
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: preparing

The 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 Staff does 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:

ts
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())) // -20

This 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:

ts
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)) // 250

A 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.
ts
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 confirmed

Inverting 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 OrderService without 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 seeAskThen it is
A big classDo 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 misbehavesDoes it break a promise of the parent?L. No interface change will help.
An if/else chain that grows each sprintDoes each new case edit tested code?O.
A new SomeVendor() inside business codeCan you test or swap it without editing this file?D. O usually follows.
Applying all five everywhere

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".

ChoiceWhat you gainWhat you payPick it when
S, single responsibilityEach 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 segregationCallers 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 inversionBusiness 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.
TypeScript you just picked up
class A implements X, Y
One class can satisfy several small interfaces. That is how ISP looks in code.
override apply(total: number)
Says "this replaces the parent's method". With noImplicitOverride on, leaving it out is an error.
constructor(private notifier: Notifier) {}
A parameter property: declares a private field and assigns it from the argument, in one line.
const sms: Notifier = { send: (p, t) => ... }
An object literal typed by an interface. p and t get their types from Notifier.

In the interview#

Interviewer
Isn't interface segregation just single responsibility for interfaces?
A deliberate push, to see if you know the difference or only the definitions.
You
They are related but look from opposite sides. Single responsibility is about the implementation: a class should have one group of people who ask it to change. Interface segregation is about the caller: no caller should depend on methods it does not use. They are independent. One class can implement three small interfaces, so every caller is happy, and still mix code from three teams, which breaks SRP. And a class with one owner can be exposed through a huge interface, which breaks only ISP.
Implementation versus caller, then an example of each breaking alone.
Interviewer
Where does Liskov fit then?
You
Liskov is about behaviour, not shape. A subclass can match the type perfectly and still break a promise, like a discount returning a negative total. The compiler cannot catch that, a test run against every subclass can. ISP violations often lead to Liskov ones, because a class forced to implement a method it cannot support ends up throwing.

Checkpoint#

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?

Worth memorising
  • 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.
Say this in 60 seconds

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.