Skip to content

Interface Segregation Principle (ISP)

SOLID Series — This article is Part 4 of 5. See also: SRP · OCP · LSP · DIP

Introduction

The Interface Segregation Principle, introduced by Robert C. Martin, states that clients should not be forced to depend on interfaces they do not use. A "fat" interface that bundles unrelated methods forces every implementing class to provide stub or no-op implementations for methods it does not need, polluting the codebase with empty or misleading implementations. ISP solves this by splitting large interfaces into smaller, role-specific ones so that each client depends only on the methods it actually uses.

Core Concept

ISP is, in essence, the Single Responsibility Principle applied at the interface level. Just as a class should have only one reason to change, an interface should represent only one role or capability. When an interface grows to cover multiple distinct concerns, it becomes a burden to every implementor and every caller. The fix is to split the interface along its natural role boundaries and let each class implement only the roles relevant to it.

A useful heuristic: if an implementing class has any method that throws UnsupportedOperationException or returns a stub value like null, 0, or "" just to satisfy an interface contract it cannot fulfill, that interface almost certainly violates ISP.

Anti-Pattern — The Fat Worker Interface

Imagine a workforce management system that defines a single Worker interface with every method a worker might need.

java
// ANTI-PATTERN: Fat Worker interface — bundles unrelated concerns
public interface Worker {
    void work();
    void eat();
    void sleep();
    void attendMeeting();
    void generateReport();
    void takeVacation();
}

Now consider a Robot that also needs to implement this interface because it participates in the workflow:

java
// ANTI-PATTERN: Robot forced to implement methods that do not apply to it
public class Robot implements Worker {

    @Override
    public void work() {
        System.out.println("Robot is executing assigned task...");
    }

    @Override
    public void eat() {
        // Robots don't eat — forced to provide a meaningless stub
        throw new UnsupportedOperationException("Robots do not eat");
    }

    @Override
    public void sleep() {
        // Robots don't sleep — this is a lie about Robot's capabilities
        throw new UnsupportedOperationException("Robots do not sleep");
    }

    @Override
    public void attendMeeting() {
        // Robots don't attend meetings — or do they? Ambiguous
        throw new UnsupportedOperationException("Robots do not attend meetings");
    }

    @Override
    public void generateReport() {
        // Maybe a Robot can generate reports? But it wasn't designed to...
        throw new UnsupportedOperationException("Not implemented");
    }

    @Override
    public void takeVacation() {
        // Definitely not applicable to a robot
        throw new UnsupportedOperationException("Robots do not take vacations");
    }
}

Problems:

  • Robot must implement six methods but only truly supports one.
  • Any code that calls worker.eat() on a Robot will throw at runtime — an LSP violation enabled by the fat interface.
  • Adding a new method to Worker (say attendTraining()) forces changes to Robot, HumanWorker, ContractWorker, and every other implementor, even those that don't care about training.

Correct Implementation — Segregated Role Interfaces

Split the fat Worker interface into focused, role-based interfaces. Each interface represents one capability.

java
// CORRECT: Role-specific interfaces — each has a single concern
public interface Workable {
    void work();
}

public interface Eatable {
    void eat();
}

public interface Sleepable {
    void sleep();
}

public interface MeetingAttendable {
    void attendMeeting();
}

public interface Reportable {
    void generateReport();
}

public interface VacationEligible {
    void takeVacation();
}
java
// CORRECT: HumanWorker implements all relevant interfaces
public class HumanWorker implements Workable, Eatable, Sleepable,
                                    MeetingAttendable, Reportable, VacationEligible {

    private final String name;

    public HumanWorker(String name) {
        this.name = name;
    }

    @Override
    public void work() {
        System.out.println(name + " is working on assigned tasks");
    }

    @Override
    public void eat() {
        System.out.println(name + " is having lunch");
    }

    @Override
    public void sleep() {
        System.out.println(name + " is resting");
    }

    @Override
    public void attendMeeting() {
        System.out.println(name + " is in the weekly standup");
    }

    @Override
    public void generateReport() {
        System.out.println(name + " is generating the weekly status report");
    }

    @Override
    public void takeVacation() {
        System.out.println(name + " is on vacation");
    }
}

// CORRECT: Robot implements only what applies — no stubs, no UnsupportedOperationException
public class Robot implements Workable, Reportable {

    private final String robotId;

    public Robot(String robotId) {
        this.robotId = robotId;
    }

    @Override
    public void work() {
        System.out.println("Robot " + robotId + " is executing automated task");
    }

    @Override
    public void generateReport() {
        System.out.println("Robot " + robotId + " generated performance telemetry report");
    }
    // No eat(), no sleep(), no attendMeeting() — these methods simply do not exist
    // on Robot's type. Callers that depend on Workable will never accidentally call eat().
}
java
// CORRECT: Each client depends only on the interface it needs
public class WorkScheduler {
    // Only cares that workers can work
    public void scheduleWork(List<Workable> workers) {
        workers.forEach(Workable::work);
    }
}

public class MealBreakManager {
    // Only humans (and Eatable implementors) can take meal breaks
    public void scheduleMealBreaks(List<Eatable> eaters) {
        eaters.forEach(Eatable::eat);
    }
}

public class ReportingDashboard {
    // Both robots and humans can generate reports
    public void collectAllReports(List<Reportable> reporters) {
        reporters.forEach(Reportable::generateReport);
    }
}

// Main — demonstrating narrowly typed client dependencies
public class Main {
    public static void main(String[] args) {
        HumanWorker alice = new HumanWorker("Alice");
        Robot r2d2 = new Robot("R2-D2");

        WorkScheduler scheduler = new WorkScheduler();
        scheduler.scheduleWork(List.of(alice, r2d2)); // Both work

        MealBreakManager mealManager = new MealBreakManager();
        mealManager.scheduleMealBreaks(List.of(alice)); // Only Alice — R2-D2 cannot be passed here

        ReportingDashboard dashboard = new ReportingDashboard();
        dashboard.collectAllReports(List.of(alice, r2d2)); // Both report
    }
}

Repository Interface Segregation

ISP applies powerfully to data access layers. A single CrudRepository<T> interface forces read-only consumers (like reporting services) to depend on write and delete operations they should never call.

java
// ANTI-PATTERN: Fat repository interface forces all consumers to depend on all operations
public interface CrudRepository<T, ID> {
    T save(T entity);
    Optional<T> findById(ID id);
    List<T> findAll();
    void deleteById(ID id);
    boolean existsById(ID id);
    long count();
    void saveAll(List<T> entities);
    void deleteAll();
}
java
// CORRECT: Segregated repository interfaces
public interface ReadableRepository<T, ID> {
    Optional<T> findById(ID id);
    List<T> findAll();
    boolean existsById(ID id);
    long count();
}

public interface WritableRepository<T, ID> {
    T save(T entity);
    void saveAll(List<T> entities);
}

public interface DeletableRepository<T, ID> {
    void deleteById(ID id);
    void deleteAll();
}

// Full CRUD: compose all three roles
public interface FullRepository<T, ID>
        extends ReadableRepository<T, ID>,
                WritableRepository<T, ID>,
                DeletableRepository<T, ID> {
}

// Concrete JPA implementation
@Repository
public class JpaProductRepository implements FullRepository<Product, Long> {

    @PersistenceContext
    private EntityManager em;

    @Override public Optional<Product> findById(Long id) {
        return Optional.ofNullable(em.find(Product.class, id));
    }
    @Override public List<Product> findAll() {
        return em.createQuery("SELECT p FROM Product p", Product.class).getResultList();
    }
    @Override public boolean existsById(Long id) { return findById(id).isPresent(); }
    @Override public long count() {
        return em.createQuery("SELECT COUNT(p) FROM Product p", Long.class).getSingleResult();
    }
    @Override @Transactional public Product save(Product p) { em.persist(p); return p; }
    @Override @Transactional public void saveAll(List<Product> list) { list.forEach(em::persist); }
    @Override @Transactional public void deleteById(Long id) {
        findById(id).ifPresent(em::remove);
    }
    @Override @Transactional public void deleteAll() {
        em.createQuery("DELETE FROM Product").executeUpdate();
    }
}

// Reporting service only depends on reads — cannot accidentally call deleteAll()
@Service
public class ProductReportService {

    private final ReadableRepository<Product, Long> productRepo;

    // Constructor injection with the narrow interface
    public ProductReportService(ReadableRepository<Product, Long> productRepo) {
        this.productRepo = productRepo;
    }

    public List<Product> getProductCatalog() {
        return productRepo.findAll();
    }

    public long getProductCount() {
        return productRepo.count();
    }
    // productRepo.deleteAll() is not even accessible here — compile-time safety
}

Diagrams

Segregated Interfaces Overview

Client Dependency Diagram — Before and After

Role-Based Interface Design

Repository Segregation Class Diagram

ISP Violation Detection

Best Practices

  1. Interface per role — Name interfaces after the capability they represent, not the class that implements them. Workable, Reportable, Auditable are role names. IWorker or WorkerInterface are anti-patterns.
  2. Prefer many small interfaces over few large ones — Implementing five small interfaces is almost always better than implementing one large interface with unused methods. Java's multiple-interface inheritance makes this cost-free.
  3. Let clients define interfaces — The best interfaces emerge from what clients actually need, not from what implementors happen to provide. Work backwards from callers to define the minimal interface.
  4. Avoid marker interfaces for behavior — A marker interface (one with no methods) used solely to tag a class as having certain behavior is a code smell. Use annotations for tagging and proper role interfaces for behavior.
  5. Watch for UnsupportedOperationException — Any occurrence of this exception in an interface implementation is a nearly certain ISP violation. It signals a class being forced to pretend it supports something it does not.
  6. Compose at the class level — Like HumanWorker implementing Workable, Eatable, Sleepable, and FullRepository extending three smaller interfaces, composition at the type level keeps each interface honest while allowing rich implementations.
  7. Apply ISP to generic parameters too — A method that accepts Map<String, Object> when it only reads keys accepts more than it needs. Prefer narrower types or custom interfaces that express only the required capabilities.

Other SOLID Principles:

Further Reading:

  • Role interfaces vs header interfaces (Martin Fowler).
  • Java's Comparable<T>, Iterable<T>, AutoCloseable as well-designed role interfaces.
  • java.util.function package: Supplier, Consumer, Predicate — standard library ISP in action.
  • REST API Design: applying ISP to API surface design and consumer-driven contracts.