skip to content

How are the Interface Segregation Principle and the Liskov Substitution Principle related, and how can violating interface segregation push a design into a substitutability violation?

level: seniorimportance: should knowfreq 38%

answer

  1. ISP = contract width; LSP = implementer behavior
  2. forced no-op bridges the two
  3. smell (stub exists) → bug (client calls it)
  4. unmodifiableList.add() throws; Kotlin List/MutableList fixes it
  5. neither principle implies the other

basics

~20 s

ISP is about not exposing callers to operations they don't use; LSP is about subtypes being usable anywhere the supertype is. A too-wide interface forces implementers to stub or throw on operations they can't support — and a caller that invokes one then breaks, which is an LSP failure.

solid answer

~60 s

They attack the same underlying defect — an abstraction that promises more than some of its members can deliver — from opposite ends. **ISP** constrains the *width* of the contract a client sees; **LSP** constrains the *behavior* of every implementer of whatever contract exists. The causal chain is: a fat interface forces an implementer that plays only part of the role to supply a fake body — empty, null-returning, or `UnsupportedOperationException`. As long as no client calls that member on that instance, you have only an ISP smell. The moment a client legitimately calls it through the base type, the substitution fails and you have an LSP violation, which is a genuine correctness bug. The classic case is a read-only collection implementing a mutable `List`: `add()` throws, so any code written against `List` can break. Segregating the interface (`Collection` vs `MutableCollection`) removes the temptation to lie, so honoring ISP eliminates a whole class of LSP violations by construction. The converse does not hold — you can honor LSP and still hand a client a bloated interface.

code

typescript · 16 lines
typescript
// ISP violation: one role fuses read + mutate
interface List<T> { get(i: number): T; add(x: T): void; }

class Immutable<T> implements List<T> {
  get(i: number): T { return this.items[i]; }
  add(_: T): void { throw new Error("unsupported"); } // ISP smell...
}

function append<T>(l: List<T>, x: T) { l.add(x); }    // ...becomes an LSP bug here

// ISP fix removes the possibility of the LSP bug
interface ReadList<T>    { get(i: number): T; }
interface MutableList<T> extends ReadList<T> { add(x: T): void; }

class Immutable2<T> implements ReadList<T> { get(i: number): T { return this.items[i]; } }
function append2<T>(l: MutableList<T>, x: T) { l.add(x); } // Immutable2 can't even be passed

go deeper

for a junior

Say ISP = don't force callers to depend on unused methods; LSP = a subtype must work wherever the parent works. Give the throwing-add() example as the link.

for a middle

Trace the chain explicitly: fat interface → forced stub (ISP smell) → client calls it → runtime failure (LSP bug), and show the read/mutable split as the fix.

for a senior

State LSP in contract terms (weaken preconditions, strengthen postconditions, preserve invariants), explain that neither principle implies the other, and discuss adapters/capability queries for legacy APIs you cannot split.

for a principal

Position ISP/LSP/DIP as one system for designing ports; note that documented weak contracts (JDK optional operations) are a deliberate compatibility trade-off, and mandate contract tests since segregation alone cannot enforce semantics.

## The two principles, stated precisely - **ISP (Interface Segregation Principle)** — *clients should not be forced to depend on methods they do not use.* It governs the **width** of an interface as seen from the caller. - **LSP (Liskov Substitution Principle)** — Barbara Liskov's behavioral subtyping rule: if `S` is a subtype of `T`, then objects of type `T` may be replaced with objects of type `S` **without altering the correctness of the program**. Formally, a subtype may **weaken preconditions** (accept more), must **strengthen or preserve postconditions** (promise at least as much), and must preserve the supertype's **invariants** and **history constraints**. It governs the **behavior** of every implementer of whatever contract exists. ## Where they meet Both are symptoms of one root problem: **an abstraction that claims more than all of its members can honor.** ### The causal chain, in order 1. Someone designs a wide interface covering several roles (`Bird` with `fly()`; `List` with `add()`; `Machine` with `fax()`). 2. A type arrives that plays only part of the role (a penguin; an immutable list; a printer without a fax modem). 3. The language forces that type to supply *every* member. It writes a **forced no-op**: empty body, `return null`, or `throw UnsupportedOperationException`. **This is the ISP violation** — the client is depending on a method that this implementation does not really have. 4. A client holding the base type calls that member. The call throws, returns nonsense, or silently does nothing. **This is the LSP violation** — substituting the subtype changed program correctness. So: **ISP violation is the design defect; LSP violation is its runtime manifestation.** Step 3 is a smell; step 4 is a bug. Segregating at step 1 makes step 3 impossible, which makes step 4 impossible. That is why ISP is described as a *preventive* principle for LSP. ### Concrete canonical example Java's `Collections.unmodifiableList(x)` returns something typed `List`, but `add()` throws `UnsupportedOperationException`. Every method written against `List` that adds an element can now fail depending on which instance it receives — a textbook LSP break, caused by a `List` interface that fuses reading and mutation into one role. Kotlin's split into `List` (read) and `MutableList` (read + mutate) is the ISP fix: the read-only view simply never declares `add`, so nothing can call it, and substitutability is restored by construction. Note the JDK's constraint was backward compatibility, not ignorance — the lesson is that the cost of a fat interface is paid for decades. ### The relationship is asymmetric - **ISP-compliant does not imply LSP-compliant.** A narrow `Printer { print(doc) }` can still be implemented by a class that prints the pages in reverse order, corrupts state, or ignores the argument. Narrowing the interface does not force anyone to implement it faithfully; only the contract's semantics (documented pre/postconditions, contract tests) do. - **LSP-compliant does not imply ISP-compliant.** Every implementer of a twelve-method interface might honor all twelve perfectly, and every client is still coupled to eleven it never calls. Correct but bloated. ### Diagnostic question that separates them > *Can a caller be surprised at runtime?* - If the pain is "I have to write stubs, my mock is huge, unrelated changes rebuild my module, this caller sees powers it shouldn't" → **ISP**. - If the pain is "this worked with implementation A and threw/misbehaved with implementation B through the same declared type" → **LSP**. ## Interaction with the rest of SOLID - **SRP** (a module should have one reason to change) explains *why* fat interfaces form: low-cohesion classes get low-cohesion interfaces. - **DIP** (depend on abstractions; high-level policy should not depend on low-level detail) says *which direction* the dependency points. ISP says *how wide* the abstraction should be, and the two combine into consumer-owned role interfaces (ports). - **OCP** (open for extension, closed for modification) benefits: narrow, honest interfaces are easier to add new implementations for without touching clients. ## Escape hatches when you cannot split (legacy / published API) 1. **Adapter** — keep the fat published interface for compatibility, but have internal code depend on narrow role interfaces adapted onto it. Clients migrate over time. 2. **Capability query returning a typed handle** — `asFax(): Fax?` instead of `fax()` that might throw. The absence of the capability becomes a value you can check, not an exception you can trip over. 3. **Deprecate + parallel narrow API**, migrate callers, then remove — the only path that actually pays off the debt. 4. **Widen the contract instead** — sometimes the honest fix is to make "may throw for unsupported operations" part of the *documented* supertype contract (this is what the JDK did). It preserves LSP formally, but pushes the burden onto every caller and is a poor default for new code. ## Bottom line for an interview Say: they are different principles at different levels — ISP is structural/static (what the client sees), LSP is behavioral/dynamic (what implementations do) — and the practical link is that fat interfaces manufacture forced no-ops, and forced no-ops are LSP violations waiting for a caller. Fixing ISP removes the incentive to lie.

  • Is `Collections.unmodifiableList` actually an LSP violation, given the JDK documents that optional operations may throw?
    Formally, no — by documenting `add` as an optional operation that may throw `UnsupportedOperationException`, the JDK made throwing part of the supertype contract, so substitution preserves the (weak) contract. Practically it is the failure mode LSP exists to prevent: callers reason about `List` as addable and get runtime surprises. Best cited as "a weakened contract adopted to avoid an ISP split, and the reason Kotlin and others split instead."
  • If I split every interface aggressively, do I still need to worry about LSP?
    Yes. Segregation removes the *forced* lies, but an implementer of a one-method interface can still violate the documented semantics — wrong ordering, unexpected side effects, stricter preconditions than the contract allows. Contract tests run against every implementation are the defense.
  • Which principle do you invoke when a subclass overrides a method to throw for arguments the parent accepted?
    That is LSP — the subtype strengthened a precondition. ISP is not implicated because no unused method is involved; the interface width is fine, the behavior is not.
  • How does Dependency Inversion fit alongside these two?
    DIP sets the direction (high-level policy owns the abstraction, details implement it); ISP sets the width (that abstraction only carries what the policy uses); LSP sets the behavioral guarantee (all details honor it). Together they produce small, consumer-owned, faithfully implemented ports.

ISP is not giving a bicycle courier the keys to the truck fleet. LSP is guaranteeing that whichever vehicle you hand them actually drives when they turn the key. If you insist every courier carry a truck key (fat interface), sooner or later one of them turns it on a vehicle with no engine — and that is the moment the design defect becomes an outage.

saying these in an interview costs you the question

  • "ISP and LSP are the same thing" — one is about interface width, the other about implementation behavior
  • Claiming ISP compliance guarantees substitutability
  • Assuming every throwing method is automatically an LSP violation without asking whether throwing is part of the documented contract
  • Fixing a throwing stub by making it a silent no-op — that turns a visible LSP break into a hidden data bug
  • Describing LSP as merely "a subclass must have the same methods" (that is just type-checking, not behavioral subtyping)
  • Never mentioning pre/postconditions or invariants when explaining LSP

context