Skip to main content

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 UnsupportedOperationException litter 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โ€‹

SmellWhat It Means
Empty method bodiesThe 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 commentsA telltale sign of a forced implementation
Mocking unused methods in testsYour test doubles stub methods that aren't relevant to the test
One interface, many disparate methodsMethods that serve different clients bundled together
Changes to one method affect unrelated implementorsAdding a method forces ALL implementations to change
Different clients use different subsetsSome 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/:id endpoint returning everything (violates ISP)
  • Multiple specialized endpoints (applies ISP manually)

Role Interfaces vs Header Interfacesโ€‹

There are two ways to design interfaces:

TypeDescriptionExample
Header InterfaceMirrors the full public API of a classpublic interface UserService with ALL methods
Role InterfaceDescribes a specific capability/roleReadable, 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 WhenKeep Unified When
Different clients use different method subsetsAll clients use all methods
Methods change at different ratesMethods change together
Different teams own different implementationsOne team owns everything
You see empty/no-op implementationsAll implementations are complete
You're designing a public API or libraryInternal implementation detail
The interface has 10+ methods serving different concernsThe 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โ€‹

PrincipleHow 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โ€‹

BadGood
Interface sizeOne big interface with everythingMany small, focused interfaces
ImplementationClasses forced to implement irrelevant methodsEach class only implements what it needs
Impact of changeChanging one method can ripple everywhereChanges 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.