I โ Interface Segregation Principle
"No client should be forced to depend on methods it does not use." โ Robert C. Martin
๐ง What Does It Mean?โ
Keep your interfaces small and focused. Don't create a "fat" interface that bundles unrelated methods together, forcing classes to implement things they don't need.
Real-world analogy: Imagine a job contract that says "You must be able to code, cook, drive a truck, and perform surgery." That's unreasonable! Each role should have its own specific contract.
In code terms: if a class has to implement a method just to throw UnsupportedOperationException or leave it empty โ your interface is too fat.
๐ฏ Why Should I Care?โ
Consider this scenario: A company has a shared UserService interface used by both the web dashboard team and the mobile API team:
public interface UserService {
UserProfile getProfile(Long userId);
void updateProfile(Long userId, UserProfile profile);
List<ActivityLog> getActivityLog(Long userId); // only web needs this
DashboardStats getDashboardStats(Long userId); // only web needs this
MobileSettings getMobileSettings(Long userId); // only mobile needs this
void registerPushToken(Long userId, String token); // only mobile needs this
}
Now every time the web team adds a dashboard feature, the method signature changes, and the mobile team's build breaks โ even though they don't use that method. The mobile team must recompile, redeploy, and re-test... for a change that doesn't affect them at all.
This is the cost of fat interfaces:
- ๐ Unnecessary coupling โ teams that share a fat interface are coupled to each other's changes
- ๐๏ธ Forced recompilation โ adding a method forces ALL implementors to update, even if irrelevant
- ๐ง Zombie code โ empty implementations and
UnsupportedOperationExceptionlitter the codebase - ๐งช Test pollution โ mocking a fat interface requires stubbing methods you don't care about
- ๐ Deployment coupling โ independent teams must coordinate releases because of a shared interface
ISP says: split that interface so each client depends only on what it actually uses.
โ Bad Example โ Violating ISPโ
// One giant interface for ALL types of workers
public interface Worker {
void work();
void eat();
void sleep();
void attendMeeting();
void writeReport();
}
// A human employee โ fine, they do all of this
public class HumanEmployee implements Worker {
@Override public void work() { System.out.println("Working..."); }
@Override public void eat() { System.out.println("Having lunch..."); }
@Override public void sleep() { System.out.println("Going home to sleep..."); }
@Override public void attendMeeting() { System.out.println("In a meeting..."); }
@Override public void writeReport() { System.out.println("Writing report..."); }
}
// A robot worker โ it doesn't eat, sleep, or attend meetings!
public class RobotWorker implements Worker {
@Override public void work() { System.out.println("Working 24/7..."); }
@Override public void eat() { /* Robots don't eat โ but forced to implement this! */ }
@Override public void sleep() { /* Robots don't sleep โ but forced to implement this! */ }
@Override public void attendMeeting() { /* N/A โ forced anyway! */ }
@Override public void writeReport() { System.out.println("Generating report..."); }
}
RobotWorker is forced to implement 3 methods it has no use for. This is interface pollution โ a sign that Worker is trying to do too much.
โ Good Example โ Applying ISPโ
Split the fat interface into small, focused ones:
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public interface Meetable {
void attendMeeting();
}
public interface Reportable {
void writeReport();
}
Now each class only implements what it actually does:
// Human implements everything relevant to them
public class HumanEmployee implements Workable, Eatable, Sleepable, Meetable, Reportable {
@Override public void work() { System.out.println("Working..."); }
@Override public void eat() { System.out.println("Having lunch..."); }
@Override public void sleep() { System.out.println("Going home to sleep..."); }
@Override public void attendMeeting() { System.out.println("In a meeting..."); }
@Override public void writeReport() { System.out.println("Writing report..."); }
}
// Robot only implements what's relevant
public class RobotWorker implements Workable, Reportable {
@Override public void work() { System.out.println("Working 24/7..."); }
@Override public void writeReport() { System.out.println("Generating report..."); }
}
No empty methods. No forced implementations. โ
๐ How to Spot Violationsโ
| Smell | What It Means |
|---|---|
| Empty method bodies | The class doesn't need this method but is forced to implement it |
throw new UnsupportedOperationException() | The contract demands something the class can't do |
// not applicable comments | A telltale sign of a forced implementation |
| Mocking unused methods in tests | Your test doubles stub methods that aren't relevant to the test |
| One interface, many disparate methods | Methods that serve different clients bundled together |
| Changes to one method affect unrelated implementors | Adding a method forces ALL implementations to change |
| Different clients use different subsets | Some callers use methods A/B, others use C/D โ same interface |
The Client-Usage Testโ
Look at your interface from each client's perspective:
Interface: UserService (10 methods)
โโโ WebController uses: getProfile, updateProfile, getDashboardStats, getActivityLog
โโโ MobileController uses: getProfile, getMobileSettings, registerPushToken
โโโ AdminController uses: getProfile, updateProfile, deleteUser, exportData
โ Three clients, three different subsets โ split into 3+ interfaces!
๐ฑ In a Spring Boot Applicationโ
A very common real-world example: repository interfaces.
// โ One fat repository interface
public interface UserRepository {
User findById(Long id);
List<User> findAll();
void save(User user);
void delete(Long id);
List<User> generateReport(); // belongs here? ๐ค
void sendWelcomeEmail(User user); // definitely not a repository concern! โ
}
// โ
Segregated interfaces โ each does one thing
public interface UserReadRepository {
User findById(Long id);
List<User> findAll();
}
public interface UserWriteRepository {
void save(User user);
void delete(Long id);
}
public interface UserReportRepository {
List<User> generateReport();
}
// A read-only service only depends on what it needs
@Service
public class UserQueryService {
private final UserReadRepository readRepository;
public UserQueryService(UserReadRepository readRepository) {
this.readRepository = readRepository;
}
public User getUser(Long id) {
return readRepository.findById(id);
}
}
๐ฑ Another Spring Example: Service Interfacesโ
// โ Fat interface โ forces SMS service to also "send email"
public interface MessageService {
void sendEmail(String to, String subject, String body);
void sendSms(String to, String message);
void sendPushNotification(String deviceToken, String message);
}
// โ
Segregated
public interface EmailSender {
void sendEmail(String to, String subject, String body);
}
public interface SmsSender {
void sendSms(String to, String message);
}
public interface PushSender {
void sendPushNotification(String deviceToken, String message);
}
@Component
public class SmsService implements SmsSender {
@Override
public void sendSms(String to, String message) {
System.out.println("SMS to " + to + ": " + message);
}
}
Now SmsService only knows about SMS. It won't be affected if email logic changes.
๐ข Real-World Use Casesโ
1. CQRS (Command Query Responsibility Segregation)โ
CQRS is ISP applied at the architectural level โ separating read and write operations:
// โ One interface for everything
public interface OrderRepository {
Order findById(Long id);
List<Order> findByCustomer(Long customerId);
List<OrderSummary> getAnalyticsSummary(DateRange range); // read-heavy
void save(Order order); // write
void updateStatus(Long id, OrderStatus status); // write
}
// โ
ISP applied: separate read and write ports
public interface OrderQueryPort {
Order findById(Long id);
List<Order> findByCustomer(Long customerId);
}
public interface OrderAnalyticsPort {
List<OrderSummary> getAnalyticsSummary(DateRange range);
}
public interface OrderCommandPort {
void save(Order order);
void updateStatus(Long id, OrderStatus status);
}
Benefits: The read side can be optimized independently (caching, read replicas). The write side can focus on consistency. Analytics can use a completely different data store.
2. Multi-Channel Notification Systemโ
A SaaS platform sends notifications via email, SMS, push, Slack, and in-app messages. Different notification types need different channels:
// โ
ISP: Each channel is its own capability
public interface EmailCapable {
void sendEmail(String to, String subject, String body);
}
public interface SmsCapable {
void sendSms(String phoneNumber, String message);
}
public interface SlackCapable {
void sendSlackMessage(String channel, String message);
}
public interface PushCapable {
void sendPush(String deviceToken, String title, String body);
}
// Different notification types compose different capabilities
@Service
public class OrderNotifier implements EmailCapable, PushCapable {
// Orders send email + push, but NOT Slack or SMS
}
@Service
public class AlertNotifier implements EmailCapable, SlackCapable, SmsCapable {
// Alerts go to email + Slack + SMS for urgency
}
3. Role-Based API Permissionsโ
A REST API serves different user roles with different capabilities:
// โ
ISP aligns with role boundaries
public interface ViewerApi {
List<Report> listReports();
Report getReport(Long id);
}
public interface EditorApi extends ViewerApi {
Report createReport(ReportRequest request);
Report updateReport(Long id, ReportRequest request);
}
public interface AdminApi extends EditorApi {
void deleteReport(Long id);
void manageUsers();
AuditLog getAuditLog();
}
Each controller implements only the API surface for its role. A ViewerController never sees deleteReport().
๐๏ธ Architecture-Level Deep Diveโ
ISP and API Gateway Designโ
In microservice architectures, fat APIs at the gateway level create the same problems as fat interfaces:
โ Fat Gateway API
/api/users โ ALL user operations (profile, settings, admin, analytics)
Every client gets endpoints they don't need
Mobile app downloads OpenAPI spec with 200 irrelevant endpoints
โ
ISP-Applied Gateway (Backend for Frontend pattern)
/api/web/users โ Web-specific user operations
/api/mobile/users โ Mobile-specific user operations
/api/admin/users โ Admin-specific user operations
This pattern is called Backend for Frontend (BFF) โ it's ISP at the API level.
ISP and GraphQL vs RESTโ
GraphQL naturally applies ISP by letting clients request only the fields they need:
# Mobile client โ only needs name and avatar
query {
user(id: 123) {
name
avatarUrl
}
}
# Web dashboard โ needs full profile with activity
query {
user(id: 123) {
name
email
role
activityLog {
action
timestamp
}
dashboardStats {
loginCount
lastActive
}
}
}
With REST, this would require either:
- A fat
/users/:idendpoint returning everything (violates ISP) - Multiple specialized endpoints (applies ISP manually)
Role Interfaces vs Header Interfacesโ
There are two ways to design interfaces:
| Type | Description | Example |
|---|---|---|
| Header Interface | Mirrors the full public API of a class | public interface UserService with ALL methods |
| Role Interface | Describes a specific capability/role | Readable, Writable, Cacheable |
ISP favors Role Interfaces. Design interfaces based on what clients need, not what implementations offer.
// Header Interface (anti-ISP) โ mirrors the implementation
public interface UserService {
User findById(Long id);
List<User> findAll();
void save(User user);
void delete(Long id);
UserStats getStats(Long id);
void sendNotification(Long id, String message);
}
// Role Interfaces (pro-ISP) โ designed for specific clients
public interface UserFinder { // used by: read-only services
User findById(Long id);
List<User> findAll();
}
public interface UserPersistence { // used by: write services
void save(User user);
void delete(Long id);
}
public interface UserNotifier { // used by: notification service
void sendNotification(Long id, String message);
}
Consumer-Driven Contractsโ
In microservice architectures, ISP maps to Consumer-Driven Contract Testing (popularized by Pact):
Service A (Provider) exposes: /users/{id}
Consumer 1 (Mobile) expects: { name, avatar }
Consumer 2 (Web) expects: { name, email, role, lastLogin }
Consumer 3 (Admin) expects: { name, email, role, permissions, auditLog }
Each consumer defines its OWN contract (ISP!) โ the provider must satisfy ALL contracts
but each consumer only cares about its subset.
ISP and Java Module System (JPMS)โ
Java 9+ modules enforce ISP at the package level:
// module-info.java for the domain module
module com.app.domain {
exports com.app.domain.read; // read-only port
exports com.app.domain.write; // write port
// internal implementation packages are NOT exported
}
// module-info.java for the query service
module com.app.query {
requires com.app.domain;
// can only access read port โ write port methods not visible
}
โ๏ธ Trade-offs & When NOT to Applyโ
Interface Explosionโ
The biggest risk of over-applying ISP is interface explosion โ too many tiny interfaces that make navigation and comprehension harder:
// Over-segregated โ these are always used together
public interface Nameable { String getName(); }
public interface Ageable { int getAge(); }
public interface Emailable { String getEmail(); }
public interface Addressable { Address getAddress(); }
public interface Phoneable { String getPhone(); }
// This is ridiculous โ just use a single interface
public interface Person {
String getName();
int getAge();
String getEmail();
Address getAddress();
String getPhone();
}
When to Split vs. When to Keep Unifiedโ
| Split When | Keep Unified When |
|---|---|
| Different clients use different method subsets | All clients use all methods |
| Methods change at different rates | Methods change together |
| Different teams own different implementations | One team owns everything |
| You see empty/no-op implementations | All implementations are complete |
| You're designing a public API or library | Internal implementation detail |
| The interface has 10+ methods serving different concerns | The interface has 3โ5 cohesive methods |
The "Same Client" Ruleโ
If the same client always uses all methods of an interface, don't split it. ISP is about client-driven design, not "make everything tiny."
// These 4 methods are ALWAYS used together in every client โ don't split
public interface Connection {
void open();
void close();
boolean isOpen();
void send(byte[] data);
}
Refactoring Strategy: Gradual Splitโ
Don't try to split a fat interface all at once in a large codebase. Use a phased approach:
// Phase 1: Introduce new focused interface, extend old one for compatibility
public interface UserReader {
User findById(Long id);
List<User> findAll();
}
// Old fat interface now extends the new one โ backward compatible
public interface UserRepository extends UserReader {
void save(User user);
void delete(Long id);
}
// Phase 2: Migrate clients one-by-one to use UserReader instead of UserRepository
// Phase 3: Once all read-only clients migrated, remove unused methods from UserRepository
๐งช Testing Implicationsโ
Focused Test Doublesโ
With ISP, your test doubles (mocks, stubs, fakes) are minimal and focused:
// โ Without ISP: Mock requires stubbing 10 methods, you only use 2
@Mock UserRepository repository; // 10 methods to potentially stub
// when(repository.findById(1L)).thenReturn(user);
// The other 8 methods? Ignored, but still pollute your mock setup.
// โ
With ISP: Mock is focused โ only the methods you need
@Mock UserReader userReader; // only 2 methods: findById, findAll
when(userReader.findById(1L)).thenReturn(user);
// Clean, minimal, clearly communicates what the test cares about
Verifying Interface Segregation in Testsโ
If you find yourself doing this in tests, it's a sign to split:
// ๐ฉ Red flag โ stubbing methods you don't care about to satisfy the interface
@Mock UserService userService;
@BeforeEach
void setup() {
// Required by interface but NOT by this test
when(userService.findAll()).thenReturn(Collections.emptyList());
when(userService.getStats(anyLong())).thenReturn(new UserStats());
when(userService.getDashboardData()).thenReturn(null);
// This is the ONLY method this test actually uses
when(userService.findById(1L)).thenReturn(testUser);
}
Testing Strategy Per Interfaceโ
// Each segregated interface gets its own test class
class UserReaderTest {
// Only tests read operations
@Test void shouldFindUserById() { /* ... */ }
@Test void shouldReturnAllUsers() { /* ... */ }
}
class UserWriterTest {
// Only tests write operations
@Test void shouldSaveUser() { /* ... */ }
@Test void shouldDeleteUser() { /* ... */ }
}
class UserReporterTest {
// Only tests reporting operations
@Test void shouldGenerateUserReport() { /* ... */ }
}
๐ Relationship to Other SOLID Principlesโ
| Principle | How It Connects to ISP |
|---|---|
| Single Responsibility (SRP) | SRP at the class level, ISP at the interface level โ both promote focus and cohesion |
| Open/Closed (OCP) | Narrow interfaces are easier to extend with new implementations โ fewer methods to implement |
| Liskov Substitution (LSP) | ISP prevents LSP violations โ if a class only implements interfaces it can fully support, no empty/throwing overrides |
| Dependency Inversion (DIP) | DIP says "depend on abstractions" โ ISP says "make those abstractions small and meaningful" |
ISP and LSP are best friends. Most LSP violations happen because a fat interface forces a class to implement methods it can't support. Split the interface (ISP), and the LSP violation disappears:
// Fat interface โ LSP violation
class GiftCard extends PaymentProcessor {
void refund() { throw new UnsupportedOperationException(); } // โ LSP violation
}
// Split interface (ISP) โ LSP violation gone
class GiftCard implements Payable {
// Only implements what it can do โ
โ no refund method to violate
}
๐ก Quick Rule of Thumbโ
If you see // not applicable comments or empty implementations in a class, your interface is likely too fat โ time to split it up.
๐ Summaryโ
| Bad | Good | |
|---|---|---|
| Interface size | One big interface with everything | Many small, focused interfaces |
| Implementation | Classes forced to implement irrelevant methods | Each class only implements what it needs |
| Impact of change | Changing one method can ripple everywhere | Changes are isolated |
Next up: Dependency Inversion Principle โ
Interview Questionsโ
Q: How does ISP reduce blast radius in large systems?โ
A: Smaller interfaces isolate change impact. Updating one capability contract does not force unrelated consumers to recompile or adapt.
Q: What are indicators of a fat interface in backend services?โ
A: Many methods unused by each consumer, repeated no-op implementations, and frequent breaking changes across unrelated teams.
Q: How does ISP help with microservice API design?โ
A: It encourages consumer-oriented contracts, reducing over-fetching/under-fetching and making service boundaries clearer.
Q: Should you split every interface aggressively?โ
A: No. Split by client usage patterns. Too many tiny interfaces with no distinct clients can make navigation harder.
Q: How does ISP improve test quality?โ
A: Test doubles only implement needed methods, which keeps tests focused and reduces mock maintenance overhead.
Q: What is a practical ISP pattern with Spring repositories?โ
A: Define separate read and write ports, then inject only the required port into each use case.
Q: How do ISP and DIP reinforce each other?โ
A: DIP depends on abstractions, and ISP makes those abstractions minimal and meaningful for each client.
Q: What migration path do you use when splitting a fat interface?โ
A: Introduce small interfaces, adapt existing implementation to support both temporarily, move consumers incrementally, then remove the old interface.