Cruxly

Cruxly › Low-level design › Principles & Patterns

Interface Segregation

no client should depend on methods it doesn't use

Open the interactive lesson

The 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.

one fat interface Scheduler needs work() only Worker work() eat() sleep() coupled to all three HumanWorker all 3 real RobotWorker 2 throw Scheduler cannot tell which calls are safe. three capability interfaces Scheduler Workable Feedable Restable HumanWorker all three RobotWorker Workable only Nothing is promised that cannot be delivered.
ISP is about what callers are coupled to. The throw in RobotWorker is a symptom; the disease is that Scheduler was made to depend on capabilities it never needed.

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

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons