Restructuring the code to follow ISP
ISP works by giving each client a separate type to depend on, so that recompilation and the obligation to implement a method propagate only to the classes that actually use that method — a fat Printer interface couples every implementer to every capability, so adding one fax tweak forces BasicPrinter to recompile and re-stub a method it can never honestly run. The previous lesson left us with that fat interface; here we split it along roles and watch the dependency graph collapse.
The starting point: one fat interface, dishonest implementers
The pre-refactor design has one interface with four methods. A copier supports everything; a cheap office printer supports only printing. With a single interface the cheap printer is forced to declare all four — and the only way to satisfy the compiler for capabilities it lacks is to throw:
// BEFORE — the ISP violation
interface Printer {
void printDocument(String d);
void scanDocument(String d);
void faxDocument(String d);
void stapleDocument(String d);
}
class BasicPrinter implements Printer {
public void printDocument(String d) { System.out.println("Print: " + d); }
public void scanDocument(String d) { throw new UnsupportedOperationException(); }
public void faxDocument(String d) { throw new UnsupportedOperationException(); }
public void stapleDocument(String d){ throw new UnsupportedOperationException(); }
}Why the naive version is wrong: the throw stubs are not a style nit — they break the Liskov Substitution Principle. Any code holding a Printer reference is told, by the type, that scanDocument is callable; calling it on a BasicPrinter blows up at runtime. The type system promised something the object cannot keep. ISP is the fix that keeps that promise honest.
The refactor: split by role (one interface per client need)
Cut the fat interface into the smallest pieces a real caller would ever depend on. Each becomes a role — a contract a class opts into only when it can truly fulfill it. BasicPrinter implements just Printable; the office multifunction copier implements all four. No stubs, no throws.
// AFTER — four role interfaces
interface Printable { void printDocument(String d); }
interface Scannable { void scanDocument(String d); }
interface Faxable { void faxDocument(String d); }
interface Stapler { void stapleDocument(String d); }
class BasicPrinter implements Printable {
public void printDocument(String d) { System.out.println("Print: " + d); }
}
class OfficeCopier implements Printable, Scannable, Faxable, Stapler {
public void printDocument(String d) { System.out.println("Print: " + d); }
public void scanDocument(String d) { System.out.println("Scan: " + d); }
public void faxDocument(String d) { System.out.println("Fax: " + d); }
public void stapleDocument(String d) { System.out.println("Staple: " + d); }
}
public class Main {
// A client that only needs to print depends ONLY on Printable
static void runPrintJob(Printable p, String doc) { p.printDocument(doc); }
public static void main(String[] args) {
Printable basic = new BasicPrinter();
OfficeCopier copier = new OfficeCopier();
runPrintJob(basic, "Invoice #4471");
runPrintJob(copier, "Contract.pdf"); // copier IS-A Printable too
copier.scanDocument("Contract.pdf");
copier.faxDocument("Contract.pdf");
// basic.scanDocument(...) // WON'T COMPILE — and that is the win
}
}Traced run — what the compiler and the runtime each enforce
Running main with the inputs above:
| Call site | Static type seen | Resolves to | Output |
|---|---|---|---|
runPrintJob(basic, "Invoice #4471") | Printable | BasicPrinter.printDocument | Print: Invoice #4471 |
runPrintJob(copier, "Contract.pdf") | Printable | OfficeCopier.printDocument | Print: Contract.pdf |
copier.scanDocument("Contract.pdf") | OfficeCopier | OfficeCopier.scanDocument | Scan: Contract.pdf |
copier.faxDocument("Contract.pdf") | OfficeCopier | OfficeCopier.faxDocument | Fax: Contract.pdf |
basic.scanDocument(...) | Printable | — no such method — | compile error |
The last row is the payoff. In the fat-interface version that line compiled and threw at runtime; after the split it fails at compile time. ISP moves the error from production back to the build.
Pitfalls
- Interface explosion / one-method-per-interface fever. Splitting until every method has its own interface produces a class declaration like
implements Printable, Scannable, Faxable, Stapler, Collatable, Duplexable. ISP says segregate by client need, not by method count. If two methods are always used together by every caller (e.g.open/closeon a connection), keeping them in one interface is correct cohesion, not a violation. - Header interfaces vs role interfaces. An interface that mirrors the whole public surface of one class (a header interface, often auto-extracted by an IDE as
IFooServicewith 30 methods) defeats ISP — every client still drags in everything. Role interfaces are defined from the consumer side: the client declares the 1–2 methods it needs and the implementation happens to satisfy them. - Splitting an interface is a breaking change. Renaming
PrintertoPrintableor removing methods recompiles and breaks every implementer and caller. On a published API this is a major-version bump. Segregate before the interface is widely depended on, or add new narrow interfaces alongside the old one. - Default methods are not an ISP escape hatch. Adding
default void faxDocument(...) { throw ...; }to the fat interface silences the compiler but reintroduces the exact LSP lie ISP was meant to remove. The throw is now hidden in the interface instead of the class.
When to split — and when NOT to
Decision signals that point to ISP: implementers contain empty bodies or UnsupportedOperationException stubs; a change to one method's signature recompiles classes that never call it; different callers clearly use disjoint subsets of the methods; mocking the interface in a test forces you to stub five methods to exercise one.
Trade-offs vs the obvious alternatives:
| Approach | You gain | It costs |
|---|---|---|
| Split into role interfaces (ISP) | honest types, compile-time safety, narrow mocks, independent evolution | more type declarations; a class may list several implements; risk of over-fragmentation |
Fat interface + UnsupportedOperationException | one type, fewer files, trivial to add an implementer | runtime failures, LSP violation, callers can't trust the contract |
| Composition / capability objects (copier has-a Scanner field rather than is-a) | swap capabilities at runtime, avoids deep implements lists | extra indirection and delegation boilerplate; capability discovery becomes a runtime query, not a compile-time type |
Choose role interfaces when capabilities are stable per-class and you want the compiler to enforce who-can-do-what; prefer composition when a device's capabilities vary at runtime (a printer that gains a stapler add-on module) or when the implements list would grow unboundedly. Keep methods together when every client uses them as a unit — splitting cohesive operations is the opposite mistake.
Concrete call: for the office device above, the capability set is fixed at manufacture, callers like runPrintJob want one narrow contract, and we want basic.scanDocument(...) to fail to compile — so role interfaces win over both alternatives here.
Takeaways
- ISP's mechanism: depend on the narrowest role a client needs, so changes and implementation obligations don't leak to classes that don't use the capability.
- The real value isn't "cleaner" — it's moving a class of LSP-violating runtime errors to compile time, and shrinking the recompile/blast radius of a change.
- Segregate by client need (role interfaces), not by method count (over-split) or by class surface (header interfaces).
- Prefer composition over role interfaces when capabilities vary at runtime or the
implementslist would explode.
Re-authored and deepened for this guide. Draws on Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (the original ISP statement) and his Clean Architecture; Martin Fowler's "RoleInterface" vs "HeaderInterface" note (martinfowler.com); and the Java Language Specification on interface method resolution and default methods. The original lesson's mechanical split is preserved; added the before/after dependency graph, a compile-vs-runtime trace, the role-vs-header and interface-explosion pitfalls, and the composition trade-off.
Interview drills
Q1. What is the smell that says "apply ISP here"?
Implementers with empty bodies or UnsupportedOperationException stubs; a signature change recompiling classes that never call that method; different callers using disjoint subsets of the interface; a test that must stub five methods to exercise one.
Q2. When do you prefer composition over role interfaces?
When capabilities vary at runtime (a printer that gains a stapler add-on module) or when the implements list would grow unboundedly — attach capability objects (copier has-a Scanner) instead of an is-a type. The cost is delegation boilerplate and capability discovery becoming a runtime query rather than a compile-time type.
Q3. Why aren't Java default methods an ISP fix?
Adding default void faxDocument(...) { throw ...; } to the fat interface silences the compiler but reintroduces the exact LSP lie — the throw is now hidden in the interface instead of the class. The type still promises a capability the object cannot keep.
🤖 Don't fully get this? Learn it with Claude
Stuck on Restructuring the code to follow ISP? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Restructuring the code to follow ISP** (OO & Low-Level Design) and want to truly understand it. Explain Restructuring the code to follow ISP from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Restructuring the code to follow ISP** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Restructuring the code to follow ISP** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Restructuring the code to follow ISP** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.