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.
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:
RobotWorker.eat()is a lie. It exists only to satisfy the compiler (or, in Python, to fill out the abstract contract). The interface promised everyWorkercaneat(), and the robot breaks that promise at runtime. That is a textbook LSP violation — you cannot substitute aRobotWorkereverywhere aWorkeris expected.WorkScheduleris over-coupled. It only callswork(), yet it depends on the wholeWorkertype. If you later addtakeBreak()or changeeat()’s signature, the scheduler is dragged into the recompile/retest blast radius for no reason.- 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 callseat().
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.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):
RobotWorkeris rejected by the cafeteria because its shape lacks aneat()method. It never declaredimplements Eatable, and TS does not care — it checks the structure.typing.Protocolin Python gives the same structural flavor (checked statically). - C++ / Java (nominal): rejection is by declared identity.
RobotWorkeris not anEatablebecause it does not inherit fromEatable; 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)
RobotWorkerno longer lies. It simply does not implementEatable. 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.
WorkSchedulerdepends onWorkableonly;CafeteriaServiceonEatableonly. Changingeat()cannot ripple into the scheduler. - Composition stays open. A future
CyborgWorkerthat both works and eats just implements both roles. ISP composes — fat interfaces force you into rigid hierarchies.
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 violation | Concrete cost |
|---|---|
throw UnsupportedOperationException in an impl | LSP break; runtime failure instead of compile error |
| Mock objects with many empty-stubbed methods | Brittle tests; noise hides intent |
| One interface change recompiles unrelated modules | Slow builds, large PR review surface, accidental coupling |
”Does this object support X?” instanceof/hasattr checks | Lost polymorphism; the model isn’t honest |
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 giantStreaminterface everyone must fully implement. - C++: the standard library expresses the same idea as named requirements / concepts —
std::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.abcprovidesIterable,Sized,Hashable,Container,Callable— each a one-method role mixin. A class isSizedif it implements__len__, independently of whether it isIterable.
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:
- 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 —
Robotcan’teat, so I’ll splitWorkableandEatableinstead of forcing a no-op.” - 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.”
- 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.
- 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.
- 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.
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.