Cruxly › Low-level design › Principles & Patterns
Interface Segregation
no client should depend on methods it doesn't use
Open the interactive lessonThe principle: many small, client-specific interfaces beat one large, general-purpose one.
If implementing an interface means writing empty or throwing methods for the parts you don't need, the interface was too big.
Martin's own wording: "Clients should not be forced to depend upon interfaces that they do not use." Note the subject is the client (the caller), not the implementer. Empty methods on an implementer are a symptom; the real cost is on the callers.
Where it came from. Martin formulated ISP while consulting for Xerox on a new printer system. Almost every task — printing, stapling, faxing — went through one Job class, so a stapling job could see and depend on every print-job method. Because every client depended on that one fat class, a change for one client forced the others to rebuild, and even a small change meant a redeploy cycle of about an hour. The fix was not to split Job itself, but to put a small interface in front of it for each kind of client — a StapleJob interface, a PrintJob interface — all implemented by the same Job class. Each client now depends only on the methods it actually calls.
The violation, concretely
interface Worker {
void work();
void eat();
void sleep();
}
class HumanWorker implements Worker {
public void work() { ... }
public void eat() { ... }
public void sleep() { ... }
}
class RobotWorker implements Worker {
public void work() { ... }
public void eat() { throw new UnsupportedOperationException(); } // robots don't eat
public void sleep() { throw new UnsupportedOperationException(); } // or sleep
}
RobotWorker is forced to implement two methods that make no sense for it. Any code holding a Worker and calling .eat() now has to know, out of band, which concrete type it actually has — which defeats the entire point of programming to an interface. The interface promised a capability the type cannot provide, and the throw is the compiler-visible scar of that broken promise.
The fix — split by capability
interface Workable { void work(); }
interface Feedable { void eat(); }
interface Restable { void sleep(); }
class HumanWorker implements Workable, Feedable, Restable {
public void work() { ... }
public void eat() { ... }
public void sleep() { ... }
}
class RobotWorker implements Workable { // only what it actually does
public void work() { ... }
}
Code that only needs to trigger work depends on Workable and never learns that RobotWorker lacks the other two capabilities — because it was never promised them in the first place. There is no throw, because there is no broken promise.
Who depends on what, before and after
The cost of a fat interface is not felt by the implementer that throws. It is felt by every caller, because they can no longer tell what is safe to call.
Splitting a fat interface in live code
You rarely get to design the interface fresh. You inherit one with nine methods and four implementers, two of which throw. Here is the sequence that does not break callers.
@@ 1 | Group methods by which implementers actually use them
Make the table before touching code — methods down the side, implementers across the top, a tick where it is genuinely implemented. The groupings fall out of the ticks; you are reading the split off real usage rather than guessing at categories.
@@ 2 | Define the narrow interfaces alongside the fat one
Do not delete anything yet. Workable, Feedable, Restable exist in parallel with Worker.
@@ 3 | Make the fat interface extend the narrow ones
interface Worker extends Workable, Feedable, Restable { }
Every existing implementer and every existing caller still compiles — a Worker is still a Worker. This step is what makes the whole migration incremental rather than a flag day.
@@ 4 | Narrow each call site to the smallest interface it needs
Scheduler changes its parameter from Worker to Workable. One call site at a time, in separate commits, each independently reviewable and revertible.
@@ 5 | Narrow the implementers, then delete the fat interface
Once nothing references Worker, RobotWorker drops to implements Workable and the throwing methods are deleted outright. The fat interface goes last, and its deletion is the proof the migration finished.
:::key Why step 3 is the load-bearing one Making the fat interface extend the narrow ones means the old and new worlds coexist with zero breakage. Skip it and you need to change every implementer and every caller in one commit — the flag-day refactor that gets reverted on Friday afternoon. :::
Where this shows up, and how ISP differs from SRP
The rate limiter's TierPolicy is deliberately narrow — ratePerSec() and capacity(), nothing else — rather than one bloated LimiterConfig interface carrying every configurable knob any limiter variant might ever want. A hypothetical SlidingWindowPolicy that doesn't use capacity() the same way would be forced to implement a method that means nothing to it, exactly the smell above.
The practical rule: when you're designing an interface, ask who will implement it and whether every implementer needs every method. If one implementer's answer to a method is "N/A" or "throw," the interface needs splitting before it needs implementing.
ISP and SRP are constantly confused, and the distinction is simple. SRP is about the implementation: how many reasons does this class have to change? ISP is about the contract: how many capabilities does this interface force on people who only wanted one?
A class can satisfy SRP and still expose an ISP-violating interface — one cohesive class whose public surface is so wide that callers are coupled to far more than they use. And a narrow interface can be implemented by a class that badly violates SRP. They are orthogonal, and saying which one you are applying is more precise than gesturing at "separation of concerns."
:::warn Over-splitting is a real failure here too One method per interface is not the goal. An interface should carry a coherent capability — Workable is one capability even if it grows a pause() and a resume() alongside work(). Splitting until every interface has exactly one method produces a codebase where implementing anything means a twelve-item implements clause, and that is worse to read than the fat interface was. :::
How to say it in the interview
"The tell is an implementation with a method that does nothing, or throws, or returns a placeholder — that's not a bug in the implementer, it's the interface demanding a capability this client never had."
Checklist
- Treated an empty or throwing method in an implementer as an interface design smell
- Framed the cost as callers being coupled to capabilities they never needed
- Split one broad interface into narrow, capability-specific ones
- Derived the split from a methods-by-implementers usage table, not from guessed categories
- Made the fat interface extend the narrow ones so old and new coexist
- Asked whether every implementer needs every method before finalising an interface
- Distinguished ISP (contract width) from SRP (reasons to change)
- Kept interfaces at the level of a coherent capability, not one method each