What it says
No client should be forced to depend on methods it does not use.
The word client means the calling code, the code that uses the interface. It is not the class that implements the interface, and it is not an end user. ISP asks one question: what does each caller need to see?
Imagine three methods:
void printInvoice(Printer printer) { /* uses print only */ }
void scanContract(Scanner scanner) { /* uses scan only */ }
void archiveCopy(Copier copier) { /* uses copy only */ }Each parameter describes one role. A caller that only prints should not receive a type that also lets it scan, copy, fax, and run admin tasks.
The fix is to replace one broad interface with smaller role interfaces. Each one is named after what the caller needs, such as "something I can print to", and not after every feature of the device. One multifunction machine can still implement several roles.
A large interface is called a fat interface. It causes real problems:
- Implementers fake unsupported methods. A basic printer must supply
scan()andcopy(), so it throws or silently does nothing. - Callers depend on unrelated types. A customer app may only read a menu. If the interface also mentions
PayoutReportandBankAccount, the app is tied to finance types. When finance changes a method signature, the customer app has to be rebuilt too. - Tests carry unnecessary setup. A test that calls one method has to create or mock an interface with fifteen methods.
- The reference gives too much access. If customer code receives a type with
updateBankAccount(), it can call that method even though it never should.
Method count alone is not the test. A five-method interface is fine when every caller uses all five methods. The better question is whether different callers use different slices.
An everyday analogy
Think of a delivery partner app that shows the rider every screen the restaurant owner sees: menu editor, payout reports, GST settings. The rider only needs "pick up" and "delivered". The extra buttons confuse them, and one wrong tap can change a restaurant's menu.
Good apps give each role its own small set of screens. ISP does the same for interfaces.
How to spot a violation
- Implementations full of
throw new UnsupportedOperationException()or empty method bodies. (This also breaks LSP, the Liskov Substitution Principle. The two often show up together.) - Big interfaces with vague names, like
IService,UserManagerorCommonOperations, with 15+ methods. - Callers that only use a small slice. A class takes a
RestaurantServicebut only ever callsgetMenu(). - Mocks that stub methods the test never calls, just to make the mock compile.
- One method change forces edits in unrelated classes.
defaultmethods added just so existing implementers still compile, especially ones that throw by default.- Comments like "only for admin use" on interface methods. If only some callers may use a method, it belongs in a different interface.
Example 1: the printer in this repo
Before
BadCode/Machine.java describes an all-in-one machine:
public interface Machine {
void print(Document doc);
void scan(Document doc);
void copy(Document doc);
}BadCode/SimplePrinter.java can only print, but it has to implement everything:
public class SimplePrinter implements Machine {
@Override
public void print(Document doc) {}
@Override
public void scan(Document doc) {
throw new UnsupportedOperationException("Scan not supported");
}
@Override
public void copy(Document doc) {
throw new UnsupportedOperationException("Scan not supported");
}
}Look closely and you will spot two bugs in this class. print() is empty, so it does nothing, even though it is the one method this class should do. copy() throws "Scan not supported", which is a copy and paste mistake in the message. Nobody checks methods they were forced to write.
After
GoodCode splits by capability:
public interface Printer { void print(Document doc); }
public interface Scanner { void scan(Document doc); }
public interface Copier { void copy(Document doc); }
public class SimplerPrinter implements Printer {
@Override
public void print(Document doc) { System.out.println("Printing Document..."); }
}
public class MultiPurposeMachine implements Printer, Scanner, Copier {
// all three, for real
}The real payoff is on the caller side. GoodCode/Main.java does not show it, because it uses the concrete classes directly. In real code you write:
public void printInvoice(Printer printer, Document invoice) {
printer.print(invoice);
}Now printInvoice works with a SimplerPrinter or a MultiPurposeMachine, and it cannot call scan() by mistake. A scanning job asks for a Scanner, so the compiler stops anyone from passing in a basic printer.
If some code really needs all three, you can still say so:
public interface AllInOne extends Printer, Scanner, Copier { }Small gotcha: Java already has a class called java.util.Scanner. The repo's Scanner interface works inside its own package. But if a file there adds import java.util.Scanner; (say, to read console input), that import wins, and the file now means the JDK class. The errors that follow are confusing. A name like DocumentScanner avoids this.
Example 2: a food delivery restaurant API, split by role
Before
One interface for "everything about a restaurant":
public interface RestaurantService {
Menu getMenu(long restaurantId);
List<Review> getReviews(long restaurantId);
void acceptOrder(long orderId);
void markFoodReady(long orderId);
void updateMenuItem(long restaurantId, MenuItem item);
PayoutReport getPayoutReport(long restaurantId, YearMonth month);
void updateBankAccount(long restaurantId, BankAccount account);
}Three different callers use it, and each needs only a few methods:
| Caller | Methods it actually calls |
|---|---|
| Customer app backend | getMenu, getReviews |
| Kitchen tablet | acceptOrder, markFoodReady, updateMenuItem |
| Finance dashboard | getPayoutReport, updateBankAccount |
Problems you will actually hit:
- The customer app holds a reference that can change bank accounts. A security review will flag this, and rightly so.
- Finance adds a
currencyparameter togetPayoutReport. Every module that usesRestaurantServicemust be rebuilt and retested, including the customer app. Test fakes ofRestaurantServicein those modules stop compiling too. - A unit test for the menu page mocks a 7 method interface.
After
Split by role. Each caller gets an interface named after what it needs:
public interface MenuReader {
Menu getMenu(long restaurantId);
List<Review> getReviews(long restaurantId);
}
public interface KitchenOperations {
void acceptOrder(long orderId);
void markFoodReady(long orderId);
void updateMenuItem(long restaurantId, MenuItem item);
}
public interface PartnerFinance {
PayoutReport getPayoutReport(long restaurantId, YearMonth month);
void updateBankAccount(long restaurantId, BankAccount account);
}
public class RestaurantServiceImpl implements MenuReader, KitchenOperations, PartnerFinance {
// one class can still do it all
}public class MenuPageController {
private final MenuReader menus;
public MenuPageController(MenuReader menus) {
this.menus = menus;
}
// ...
}Notice that the implementation did not change at all. ISP is about what each caller sees. The menu test now mocks a 2 method interface. The customer app cannot call updateBankAccount, because its reference does not have that method.
Later, menus may move to their own service with a cache in front. Then only MenuReader needs a new implementation. The kitchen and finance code are not affected.
Example 3: notification channels that can't all do the same things
Before
public interface NotificationSender {
void sendText(String to, String text);
void sendRichCard(String to, String imageUrl, List<String> buttons);
DeliveryStatus getDeliveryStatus(String messageId);
}
public class SmsSender implements NotificationSender {
public void sendText(String to, String text) { /* ... */ }
public void sendRichCard(String to, String imageUrl, List<String> buttons) {
throw new UnsupportedOperationException("SMS can't show images");
}
public DeliveryStatus getDeliveryStatus(String messageId) { /* ... */ }
}
public class PushSender implements NotificationSender {
public void sendText(String to, String text) { /* ... */ }
public void sendRichCard(String to, String imageUrl, List<String> buttons) { /* ... */ }
public DeliveryStatus getDeliveryStatus(String messageId) {
return DeliveryStatus.UNKNOWN; // we never get a receipt
}
}The OTP service only needs sendText. The "your order is on the way" promo card needs sendRichCard. The retry job needs getDeliveryStatus. But every one of them depends on the whole interface, and both senders lie about something. SMS claims it can send rich cards. Push claims it can report delivery status, but it only returns UNKNOWN.
After
public interface TextSender {
void sendText(String to, String text);
}
public interface RichMessageSender {
void sendRichCard(String to, String imageUrl, List<String> buttons);
}
public interface DeliveryTracker {
DeliveryStatus getDeliveryStatus(String messageId);
}
public class SmsSender implements TextSender, DeliveryTracker { /* ... */ }
public class PushSender implements TextSender, RichMessageSender { /* ... */ }
public class WhatsAppSender implements TextSender, RichMessageSender, DeliveryTracker { /* ... */ }Callers ask for exactly what they use:
public class OtpService {
private final TextSender sender;
// ...
public void sendOtp(String phone, String otp) {
sender.sendText(phone, "Your OTP is " + otp + ". Valid for 5 minutes.");
}
}OtpService works with SMS, push or WhatsApp. The promo service cannot be given an SmsSender by mistake, because SmsSender is not a RichMessageSender. That mistake now fails when you compile, not in production.
Java's own examples
The JDK is full of small role interfaces: Runnable, Comparable, AutoCloseable, Iterable, and everything in java.util.function. Each one does a single job, so many unrelated classes can implement them.
The Collection interface is the opposite. It has "optional operations" like add and remove. An unmodifiable list throws an exception if you call them. That is the cost of a fat interface, and it stayed for historical reasons.
When not to apply it
- Don't go one method per interface by default. Ten interfaces with one method each, all implemented by the same class and used by the same caller, is noise.
- Split by caller, not by method. If every caller uses all five methods, the interface is already the right size, even if it looks big.
- Avoid "header interfaces". An interface that just copies every public method of one class (
UserServiceandUserServiceImpl, one implementation, one caller) doesn't segregate anything. - Don't break a stable public API for neatness. If other teams depend on an interface, splitting it is a migration project. Add the small interfaces, make the old one extend them, then move callers over time.
- Wait for the second kind of caller. One caller using everything is not an ISP problem.
| Split the interface when | Leave it alone when |
|---|---|
| Implementers throw or stub some methods | Every implementer supports every method |
| Different callers use different slices | All callers use roughly all of it |
| Some methods are sensitive (bank, admin) | It is internal and has one caller |
| Changes to one slice force rebuilds elsewhere | It rarely changes |
How it connects to the rest
- SRP: ISP is SRP for interfaces. A fat interface used by customers, kitchen staff and finance has three reasons to change. See SRP-Notes.md.
- LSP: a fat interface pushes implementers into throwing or stubbing methods, which breaks LSP. Splitting the interface is usually the fix. See LSP-Notes.md.
- DIP: a high level class should depend on an abstraction, not a concrete class. The small role interfaces are those abstractions, and ideally the caller defines them. See DIP-Notes.md.
- Basics: 01-Interfaces.md covers how one class implements several interfaces.
Patterns that help:
- Adapter: wrap a big third party SDK behind the small interface your code actually needs.
- Facade: give a simple narrow entry point to a big subsystem.
- Proxy: when access control is the concern, a proxy around a narrow interface is easier to reason about than one around a fat one.
Before you commit, ask
- Does any implementation throw
UnsupportedOperationExceptionor leave a method empty? - Which methods does each caller actually call? Are they different slices?
- Would changing one method force unrelated callers to rebuild or change?
- Can a caller reach methods it should never use (bank details, admin actions)?
- Am I splitting because callers really differ, or just because the interface looks long?
Interview questions
"What is the Interface Segregation Principle?"
No client should be forced to depend on methods it doesn't use. Instead of one big interface, give each kind of caller a small interface with only what it needs. One class can still implement several of them.
"Who is the 'client' in ISP, the implementer or the caller?"
The caller. ISP is mainly about the code that uses an interface and what it depends on. Implementers benefit too, since they stop faking methods, but the split should follow what callers need.
"How do you decide where to split a fat interface?"
List the callers and the methods each one actually calls. Methods that are used together by one kind of caller form a role interface, like
MenuReader,KitchenOperationsandPartnerFinance. Splitting by method count instead of by caller usually gives you useless tiny interfaces.
"How is ISP different from SRP?"
SRP is about classes having one reason to change. ISP applies the same idea to interfaces, from the caller's side. A class can implement several focused interfaces and still be fine, as long as each caller only sees the slice it needs.
"Does ISP mean every interface should have one method?"
No. Single method interfaces like
Runnableare great when one job is the whole role, but splitting everything that way creates noise. If all callers use all five methods, a five method interface is the right size.
"How does a fat interface lead to LSP violations?"
Implementers that can't support some methods end up throwing
UnsupportedOperationExceptionor doing nothing. Any caller using the interface as promised then breaks at runtime. Splitting the interface removes those fake methods, so the compiler catches the misuse instead.