Back to the path
Level 2 intermediate 25 min #solid#isp#interface-segregation#oop#lld#coupling#interfaces#lsp

Interface Segregation Principle: Role Interfaces over Fat Contracts

The ISP says no client should be forced to depend on methods it does not use. This lesson dissects fat interfaces using the classic Worker/Robot example, refactors to role interfaces in three languages, and shows how ISP reduces coupling, protects LSP, and signals senior design judgment in interviews — including the language-dependent nuance that static enforcement differs across TypeScript, C++, and Python.

The Principle, Stated Precisely

Interface Segregation Principle (ISP): No client should be forced to depend on methods it does not use. Many client-specific interfaces are better than one general-purpose interface.

Robert Martin coined ISP at Xerox: a fat Job class served every print-station feature, so a change to the staple subsystem forced every client to recompile and redeploy, even clients that only ever printed. The fix was to give each client its own narrow interface tailored to what that client actually calls.

The “I” in SOLID is the easiest principle to misread. It is not “keep interfaces small” as a style rule. It is a statement about dependencies: an interface is a contract between a client (the caller) and an implementer. ISP says the client should not be coupled to operations it never invokes. The damage from violating it is not aesthetic — it is forced change, fake implementations, and broken substitutability.

There are two distinct flavors of “depend on a method you don’t use,” and a senior answer names both:

  • Implementer pain: a class is forced to implement methods that make no sense for it (Robot.eat()), usually by throwing or no-op’ing. This silently violates the Liskov Substitution Principle.
  • Client pain: a caller takes a wide parameter type and is now transitively recompiled/redeployed when an unrelated part of that interface changes, even though it only calls one method.
Key idea

ISP is about clients, not interface size. A 12-method interface where every client calls all 12 methods does not violate ISP. A 2-method interface where one client only ever uses one of them does. Always reason from “who calls this, and what do they actually need?”

The Violation: A Fat Interface

Here is the canonical smell. A factory models everyone who shows up to the plant as a Worker that can both work() and eat() (lunch break). Then robots arrive on the line.

// SMELL: fat interface — clients are forced to depend on eat()
interface Worker {
work(): void;
eat(): void; // lunch break
}

class HumanWorker implements Worker {
work(): void { /* assemble parts */ }
eat(): void { /* takes a lunch break */ }
}

class RobotWorker implements Worker {
work(): void { /* assemble parts 24/7 */ }
eat(): void {
  // Robots don't eat. Now we are forced to lie.
  throw new Error("Robots do not eat");
}
}

// A scheduler that only ever schedules WORK is still
// coupled to eat() through the Worker type.
class WorkScheduler {
run(workers: Worker[]): void {
  for (const w of workers) w.work();
}
}

Walk the failures explicitly — this is exactly the analysis an interviewer wants to hear:

  1. RobotWorker.eat() is a lie. It exists only to satisfy the compiler (or, in Python, to fill out the abstract contract). The interface promised every Worker can eat(), and the robot breaks that promise at runtime. That is a textbook LSP violation — you cannot substitute a RobotWorker everywhere a Worker is expected.
  2. WorkScheduler is over-coupled. It only calls work(), yet it depends on the whole Worker type. If you later add takeBreak() or change eat()’s signature, the scheduler is dragged into the recompile/retest blast radius for no reason.
  3. The “throw” pushes errors to runtime. A NoOp or exception in eat() converts a compile-time modeling error into a production incident the first time some cafeteria service iterates all workers and calls eat().

The “no-op or throw to satisfy an interface” reflex is the single loudest ISP smell. When you find yourself writing throw new UnsupportedOperationException() to make a class compile, the interface is fat.

The Refactor: Role Interfaces

Split the fat interface along the lines of who calls what. Each capability becomes a small role interface. A class composes exactly the roles it can honor.

// Role interfaces — each captures one capability
interface Workable { work(): void; }
interface Eatable  { eat(): void; }

class HumanWorker implements Workable, Eatable {
work(): void { /* assemble parts */ }
eat(): void  { /* lunch break */ }
}

// Robot only claims the role it can honor. No lies.
class RobotWorker implements Workable {
work(): void { /* 24/7 */ }
}

// Each client depends ONLY on the role it uses.
class WorkScheduler {
run(workers: Workable[]): void {
  for (const w of workers) w.work();
}
}

class CafeteriaService {
serveLunch(eaters: Eatable[]): void {
  for (const e of eaters) e.eat();
}
}

// Robots can be scheduled, but are not even *typeable*
// as something the cafeteria serves. The model is honest.
const scheduler = new WorkScheduler();
scheduler.run([new HumanWorker(), new RobotWorker()]);

const cafeteria = new CafeteriaService();
cafeteria.serveLunch([new HumanWorker()]);
// cafeteria.serveLunch([new RobotWorker()]);
//   -> tsc --strict error TS2741: property 'eat' is missing.
//   TS uses STRUCTURAL typing: RobotWorker is rejected purely
//   because its shape lacks an eat() method — no 'implements
//   Eatable' declaration is required or consulted.
Watch out

The “caught at compile time” benefit is language-dependent — surface this in an interview. In TypeScript and C++, passing a RobotWorker where an Eatable is required is a genuine compile error (tsc --strict → TS2741; C++ → no Workable* → Eatable* conversion). In Python, the same mistake compiles and runs; it only fails at runtime with AttributeError when eat() is actually called — unless you run an external static checker like mypy or pyright. CPython itself ignores type hints. The ISP design is identical across all three, but its enforcement guarantee is not. A strict SSE interviewer wants you to name this nuance rather than smooth it over.

Two typing models are worth distinguishing here, because the mechanism of rejection differs:

  • TypeScript (structural): RobotWorker is rejected by the cafeteria because its shape lacks an eat() method. It never declared implements Eatable, and TS does not care — it checks the structure. typing.Protocol in Python gives the same structural flavor (checked statically).
  • C++ / Java (nominal): rejection is by declared identity. RobotWorker is not an Eatable because it does not inherit from Eatable; having a matching method signature would not be enough.

What the refactor bought you

        BEFORE (fat)                      AFTER (role interfaces)

   +----------------+                 +-----------+   +----------+
   |    Worker      |                 | Workable  |   | Eatable  |
   | + work()       |                 | + work()  |   | + eat()  |
   | + eat()        |                 +-----------+   +----------+
   +----------------+                    ^   ^             ^
        ^      ^                         |   |             |
        |      |                  Human--+   |             |
   Human-+     +--Robot           Robot------+      Human--+
   (real) (eat() throws!)        (honest)        (Robot: not a member)

   WorkScheduler -> Worker         WorkScheduler -> Workable
   (coupled to eat too)            CafeteriaService -> Eatable
                                   (each client minimal)
  • RobotWorker no longer lies. It simply does not implement Eatable. The impossibility is now expressed in the type system — caught at compile time in TS/C++ (and by mypy/pyright in Python), instead of at 2am in production.
  • Clients are minimally coupled. WorkScheduler depends on Workable only; CafeteriaService on Eatable only. Changing eat() cannot ripple into the scheduler.
  • Composition stays open. A future CyborgWorker that both works and eats just implements both roles. ISP composes — fat interfaces force you into rigid hierarchies.
Tip

In statically-typed languages, role interfaces also make testing trivial. A unit test for WorkScheduler mocks a 1-method Workable, not a 7-method Worker. Smaller interfaces mean smaller, less brittle test doubles — every extra method on an interface is an extra method every mock must stub.

Why It Matters: Coupling, Testability, Change

ISP’s payoff is controlling the blast radius of change. Think of an interface as a recompilation/redeploy boundary. Every client that depends on interface I is, by definition, willing to be rebuilt when I changes. If a client only uses one of I’s ten methods, you have signed it up for nine methods’ worth of churn it never needed.

Symptom of an ISP violationConcrete cost
throw UnsupportedOperationException in an implLSP break; runtime failure instead of compile error
Mock objects with many empty-stubbed methodsBrittle tests; noise hides intent
One interface change recompiles unrelated modulesSlow builds, large PR review surface, accidental coupling
”Does this object support X?” instanceof/hasattr checksLost polymorphism; the model isn’t honest
Watch out

ISP is not a license to make every interface a single method. Over-segregation produces a swarm of tiny interfaces and bloated implements A, B, C, D, E lists that obscure the domain. The litmus test is real, observed client usage: split when distinct clients use disjoint subsets, not preemptively. Cohesion still matters — methods that are always used together by the same clients belong together.

How ISP Relates to the Rest of SOLID and to Patterns

ISP rarely acts alone — name these connections in an interview to show you see SOLID as a system, not five disconnected rules:

  • ISP protects LSP. Fat interfaces are the most common cause of Liskov violations: the moment a subtype can’t honor part of the contract, it degenerates (throws, no-ops). Segregating roles means every implementer can fully satisfy every interface it claims — so substitution is always safe. ISP is, in effect, “the dependency-side discipline that keeps LSP achievable.”
  • ISP serves the Dependency Inversion Principle. DIP says depend on abstractions. ISP says: make those abstractions client-specific. The best DIP abstractions are narrow role interfaces owned by the client (the consumer), not wide ones owned by the implementer. (This is the “role interface” idea — declare the interface where it’s used, not where it’s implemented.)
  • ISP supports SRP at the contract level. A fat interface usually means the type has multiple reasons to change (multiple client groups). Splitting roles aligns the interface with single responsibilities.
  • Patterns that embody ISP: the Adapter pattern often exists precisely to expose a narrow role from a wide legacy class. The Role/Capability idiom (small Closeable, Comparable, Iterable-style interfaces in standard libraries) is ISP in the wild. The Facade is the inverse-but-related move: it gives a client one cohesive interface over a subsystem so the client doesn’t depend on dozens of subsystem classes.

Role interfaces in the wild (tri-language)

Standard libraries are a masterclass in ISP — capabilities are sliced into tiny role interfaces a type mixes in only if it can honor them:

  • Java: Closeable, Flushable, Readable, Appendable, AutoCloseable — single-purpose roles, instead of one giant Stream interface everyone must fully implement.
  • C++: the standard library expresses the same idea as named requirements / conceptsstd::ranges::range, std::ranges::sized_range, std::copyable, std::equality_comparable. A type satisfies exactly the concepts its capabilities support; algorithms constrain on the narrowest concept they need.
  • Python: collections.abc provides Iterable, Sized, Hashable, Container, Callable — each a one-method role mixin. A class is Sized if it implements __len__, independently of whether it is Iterable.

Notice the symmetry: three very different type systems all converge on the same answer — many small, capability-shaped contracts, not one fat one. That convergence is the strongest possible evidence that ISP is a real design force, not a language quirk.

The Interview Signal

When you can articulate ISP at the dependency level rather than the size level, you signal seniority. In a machine-coding round, demonstrate it actively:

  1. Catch the smell live. When designing, the instant you write a method that some implementer would have to stub or throw on, say out loud: “This is an ISP smell — Robot can’t eat, so I’ll split Workable and Eatable instead of forcing a no-op.”
  2. Justify with blast radius, not aesthetics. Say “I’m segregating so the scheduler doesn’t recompile when the cafeteria contract changes,” not “smaller is cleaner.”
  3. Tie it to LSP. “Keeping interfaces role-sized means every implementer fully honors its contract, so substitution stays safe.” This shows you see the principles interacting.
  4. Name the language nuance. “In TS/C++ the bad call won’t compile; in Python it’s only caught by mypy, not the runtime.” Surfacing that the guarantee is language-dependent is a strong senior tell.
  5. Know the limit. Volunteer the counter-balance: “I won’t over-split — I segregate when distinct clients use disjoint subsets, and keep cohesive operations together.” Naming the failure mode of the principle is another strong senior tell.
Common pitfall

A frequent interview mistake: “fixing” the fat interface by keeping it and having RobotWorker.eat() do nothing instead of throwing. That is worse — a silent no-op hides the modeling error entirely and still violates LSP (a caller expecting eat() to actually feed the worker is now silently wrong). Segregate the interface; don’t paper over it.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. Which statement most precisely captures the Interface Segregation Principle?

2. A team has a 10-method `Repository` interface. Every service that uses it calls all 10 methods. Does this violate ISP?

3. Which of the following are direct consequences of violating ISP with a fat interface? (Select all that apply) (select all)

4. After segregating `Worker` into `Workable` and `Eatable`, you call `cafeteria.serveLunch([robotWorker])` where `RobotWorker` implements only `Workable`. In which languages is this caught BEFORE runtime, as written in the lesson?

Design problem 5

You are designing a document-management service. A `Document` abstraction currently exposes a single fat interface: `render()`, `print()`, `share()`, `encrypt()`, and `getVersionHistory()`. In practice: a `Viewer` UI only renders; a `PrintQueue` only prints; a `Collaboration` module only shares and reads version history; an `ArchiveStore` only encrypts. New document types are appearing — a `ScannedImage` can render and print but cannot be shared or version-tracked, and a `LegalContract` supports everything. Redesign the contracts to satisfy ISP. Show the role interfaces, which clients depend on which, how `ScannedImage` and `LegalContract` compose them, and explain how your design protects LSP and what enforcement guarantee each of TS/C++/Python gives. Also state when you would NOT split further.