What concrete symptoms in a codebase tell you an interface violates the Interface Segregation Principle, and what is the step-by-step refactor to fix it?
answer
- throw UnsupportedOperation = loudest smell
- caller uses 1 of 12 methods
- mock full of unused stubs
- supports(...) probe = type info lost
- extract roles, then narrow the call sites
basics
~20 sLook for implementations with empty bodies, return null, or "not supported" exceptions; callers that use one or two methods of a large type; and mocks/fakes full of unused stubs. Fix by extracting one small interface per role and letting classes implement several.
solid answer
~50 sThe strongest signal is a **forced no-op**: an implementer that leaves methods empty, returns null/default, or throws `UnsupportedOperationException` because the operation is meaningless for it. Others: callers that touch only a slice of a wide type; test doubles dominated by stubs the test never exercises; a name ending in `-Manager`/`-Service`/`-Helper` with unrelated verb clusters; a change to one method forcing rebuilds or re-releases across unrelated components; capability leakage, where a read-only caller receives a handle that can also delete. The refactor: map every caller to the exact members it invokes, cluster callers by that set — the clusters are the roles — extract one cohesive interface per role named for the capability, keep the existing concrete class implementing all of them, narrow the parameter and field types at each call site, then delete or reduce the fat interface to a composed alias. Runtime wiring is unchanged, so this is normally a safe, mechanical, incremental refactor.
code
typescript · 24 lines// SMELL: forced no-ops + capability probing at the call site
interface Storage {
read(k: string): Blob;
write(k: string, v: Blob): void;
delete(k: string): void;
supportsWrite(): boolean;
}
class ReadOnlyArchive implements Storage {
read(k: string) { /* real */ return blob; }
write() { throw new Error("read-only"); } // lie
delete() { throw new Error("read-only"); } // lie
supportsWrite() { return false; } // type info leaked to runtime
}
// AFTER: roles carry the truth; probes disappear
interface ReadStore { read(k: string): Blob; }
interface WriteStore { write(k: string, v: Blob): void; delete(k: string): void; }
class ReadOnlyArchive2 implements ReadStore { read(k: string) { return blob; } }
class S3Store implements ReadStore, WriteStore { /* both, honestly */ }
// Caller states its real requirement; least privilege enforced at compile time
function renderPage(store: ReadStore, key: string) { return store.read(key); }go deeper
Name the throwing/empty implementation smell and say the fix is smaller interfaces per role.
List several smells (thin-slice callers, stub-heavy mocks, capability probes) and walk the extract-role-interfaces refactor including narrowing the call sites.
Explain why the refactor is runtime-neutral, why default methods only mask it, how a forced no-op is also an LSP break, and when segregating is not worth the churn.
Frame it as least-privilege at the type boundary and as decoupling deployment/rebuild units; set team policy on when to split (change-driven, not checklist-driven) and how to keep composed aliases from re-fattening.
## What you are hunting for ISP says *clients should not be forced to depend on methods they do not use*. Violations leave physical fingerprints in code. Here is the checklist, with why each one implies the violation. ### 1. Forced no-op implementations (the loudest signal) An implementer supplies a body that is a placeholder rather than behavior: - empty method body - `return null` / `return 0` / `return emptyList()` as a stand-in for "n/a" - `throw new UnsupportedOperationException(...)` / `NotImplementedError` / `raise NotImplementedError` - a comment like `// not applicable for this type` Why it implies ISP: the interface promised a capability this type does not have. The type system now says something false. Note the knock-on: any caller that legitimately calls that method through the interface can now blow up at runtime — that is also a **Liskov Substitution** violation, which is how an ISP problem escalates from "awkward" to "broken". ### 2. Callers using a thin slice Grep the call sites. If `ReportPrinter` only ever calls `print()` on a twelve-member `Machine`, it is paying for eleven members in coupling, compile dependencies, and mock setup. ### 3. Test doubles bloated with stubs When a hand-written fake needs twenty method bodies so a test can exercise one, or when a strict mocking framework forces you to configure unused members, the interface is too wide for the collaboration under test. Test pain is design feedback, not a tooling problem. ### 4. Unrelated verb clusters in one type `UserService` with `authenticate`, `resetPassword`, `exportToCsv`, `sendMarketingEmail`, `recalculateBillingTier`. Different callers want different clusters; nobody wants all of them. ### 5. Change ripple A signature change that only one consumer cares about triggers rebuilds, re-reviews, or coordinated redeploys across modules that never call it. This is the original Xerox symptom that produced the principle. ### 6. Capability leakage (the security-flavored smell) A component that only needs to read is handed an interface that can also delete or mutate. Segregating (`AuditReader` vs `AuditWriter`) makes the least-privilege boundary a compile-time fact rather than a code-review convention. ### 7. Boolean/enum mode parameters and `supports(...)` probes `if (device.supportsFax()) device.fax(...)` — the caller is doing runtime capability negotiation because the type system lost the information. That check belongs in the type: either you hold a `Fax` or you don't. ## The refactor, step by step 1. **Inventory usage.** For each caller of the fat interface, list the members it invokes. IDE "find usages" or a call-graph tool does this mechanically. 2. **Cluster.** Group callers by their used-member set. Overlapping clusters are fine; the clusters are your candidate **roles**. 3. **Name roles by capability, not by object.** `Printer`, `Stapler`, `PasswordHasher`, `OrderReader` — named for what the caller needs. This is Martin Fowler's **role interface** as opposed to a **header interface** (one that merely mirrors a class's full public surface). 4. **Extract interfaces.** Create one interface per role. Move signatures; write no new logic. 5. **Keep the concrete class broad.** The existing implementation declares all the new interfaces. Multiple interface implementation exists in essentially every mainstream OO language, so the object graph and dependency-injection wiring do not change. 6. **Narrow the call sites.** Change parameter types, constructor arguments, and fields from the fat type to the specific role. This is where the coupling actually drops — steps 1-5 alone change nothing. 7. **Retire the fat interface**, or keep it only as a composition (`interface Machine : Printer, Stapler, Fax`) for the rare caller that genuinely needs everything. A composed alias is legitimate; a composed alias that everyone still depends on by default is the violation wearing a disguise. 8. **Delete the stubs.** If an implementer no longer declares a role, its throwing method disappears — that deletion is the proof the refactor worked. ## Language variations - **Java 8+ / C# default interface methods** let you give a default body so implementers need not write stubs. Beware: this *hides* the smell rather than removing it — the client still depends on the member, and a default that throws or silently does nothing is the same lie with better ergonomics. Defaults are appropriate for genuine backward compatibility on an interface you cannot break, not as an ISP workaround. - **Go** conventionally declares tiny interfaces (`io.Reader`, `io.Writer`) **in the consuming package**, which makes ISP the default rather than a refactor. `io.ReadWriter` shows the composition pattern. - **Dynamic languages** have no compile-time break, so the smell shows up as `NotImplementedError`, `hasattr`/`respond_to?` probes, and fakes that must mimic a huge surface. - **Structural typing** (TypeScript, Go) lets a caller declare exactly the shape it needs without the implementer knowing, which is ISP for free. ## When to stop Stop when no client depends on a member it does not use. Do **not** keep going to one interface per method: you get a directory of anemic types, callers that must accept three parameters or an intersection type to do one job, and a reader who cannot reconstruct the domain concepts. If two operations are always used together by every client, they belong together. ## Sequencing advice for a real codebase Do it incrementally, driven by pain: split the interface the next time you must change it, or when a new caller would only need a slice. A big-bang segregation of a stable, working interface with no new callers is churn — ISP earns its keep where change and reuse actually happen.
- Java lets me give the interface a default method body so implementers don't need stubs. Doesn't that solve it?It removes the boilerplate, not the coupling. Clients still depend on the member, and a default that throws or no-ops is the same false promise. Default methods are for evolving an interface you cannot break; use them for compatibility, not as a substitute for splitting roles.
- How do you handle a caller that genuinely needs two roles at once?Take two parameters, or declare a composed interface (`interface ReadWriteStore extends ReadStore, WriteStore`), or use an intersection type where the language supports one. Composition is the intended escape hatch; the point is that the composition is opt-in per caller.
- Should I split every fat interface I find during a feature ticket?No — split opportunistically where you are already changing code or adding a caller that needs only a slice. Segregating a stable interface with no pressure on it is churn with real review and merge cost.
- Isn't `supportsFax()`-style capability probing sometimes necessary?At a plugin or dynamic-discovery boundary, yes — you may not know the capability set until runtime. Then the honest design is to *query for the role* and get back a typed handle (or null), not to call a method that might throw.
A job application form that demands your pilot's licence number, your commercial-fishing permit, and your forklift certification. Most applicants write "N/A" in three boxes — those N/As are the forced no-ops. The fix is role-specific forms, not a longer universal one with better instructions.
saying these in an interview costs you the question
- Adding a default/empty body and declaring the ISP problem solved
- Splitting the interface but leaving every call site typed against the fat one — coupling is unchanged
- Assuming the refactor is risky; it is normally runtime-neutral since one class implements all roles
- Calling any long interface a violation without checking whether clients use all of it
- Replacing throwing stubs with silent no-ops, turning a loud failure into a quiet bug
- Big-bang segregation of a stable interface just to satisfy a linter or checklist