Interface Segregation Principle: Stop Forcing Clients to Carry Dead Weight

SOLID Principles Series #4

Robert C. Martin’s definition is short: “Clients should not be forced to depend on methods they do not use.” A client here is any class that implements or depends on an interface. If that class has to implement methods it has no use for, the interface is too wide.

It’s bundling responsibilities that don’t belong together, and every client pays the price.
ISP says: split the fat interface into focused ones. Each client depends only on what it actually needs.


What Is the Interface Segregation Principle?

ISP is the fourth of the five SOLID principles. It targets a specific problem: interfaces that grow over time, accumulating methods from different concerns until every implementor is forced to carry weight that isn’t theirs.

The cost is not just aesthetic. A class that implements a fat interface is coupled to everything in it. A change to any method signature in the interface, even one the class doesn’t use, can force a recompile, a re-test, or a redeployment. In large codebases, that cascade gets expensive fast.

Two signals that an interface violates ISP:

  • Implementing classes throw UnsupportedOperationException or leave methods with empty bodies because they have nothing useful to put there.
  • You describe the interface with “and” more than once. It handles order lifecycle and generates reports and sends notifications and calculates tax.

The Problem in Code

An e-commerce system grows over time. Every new feature lands in the same OrderService interface because it’s “order-related,” so it feels like the obvious place:

// OrderService.java - the fat interface
public interface OrderService {
    void placeOrder(Order order);
    void cancelOrder(long orderId);

    List<String> generateMonthlyReport(int month, int year);
    List<String> generateCustomerReport(long customerId);

    void sendOrderConfirmation(Order order);
    void sendShippingUpdate(Order order, String trackingCode);

    double calculateTax(Order order);
    double calculateVat(Order order, String countryCode);
}

Eight methods covering four completely different concerns: lifecycle, reporting, notifications, and tax. Now look at what happens when you build a class that only sends emails:

// NotificationServiceImpl.java - forced to implement everything
public class NotificationServiceImpl implements OrderService {

    private final EmailClient emailClient;

    public NotificationServiceImpl(EmailClient emailClient) {
        this.emailClient = emailClient;
    }

    @Override
    public void sendOrderConfirmation(Order order) {
        emailClient.send(order.getEmail(), "Your order is confirmed.");
    }

    @Override
    public void sendShippingUpdate(Order order, String trackingCode) {
        emailClient.send(order.getEmail(), "Your order shipped: " + trackingCode);
    }

    // Everything below: not our job, but the interface forces it.
    @Override public void placeOrder(Order order) {
        throw new UnsupportedOperationException();
    }
    @Override public void cancelOrder(long orderId) {
        throw new UnsupportedOperationException();
    }
    @Override public List<String> generateMonthlyReport(int month, int year) {
        throw new UnsupportedOperationException();
    }
    @Override public List<String> generateCustomerReport(long customerId) {
        throw new UnsupportedOperationException();
    }
    @Override public double calculateTax(Order order) {
        throw new UnsupportedOperationException();
    }
    @Override public double calculateVat(Order order, String countryCode) {
        throw new UnsupportedOperationException();
    }
}

Six out of eight methods throw exceptions. This class only cares about sending emails, but the interface forces it to pretend it can also place orders, generate reports, and calculate tax. That’s the violation. The problem is not NotificationServiceImpl, it’s OrderService being too wide.


The Solution, Step by Step

Split OrderService into focused interfaces, one per concern. Each client then depends only on what it needs.

1. One interface per concern

// OrderLifecycle.java
public interface OrderLifecycle {
    void placeOrder(Order order);
    void cancelOrder(long orderId);
}
// OrderReporting.java
public interface OrderReporting {
    List<String> generateMonthlyReport(int month, int year);
    List<String> generateCustomerReport(long customerId);
}
// OrderNotification.java
public interface OrderNotification {
    void sendOrderConfirmation(Order order);
    void sendShippingUpdate(Order order, String trackingCode);
}
// TaxCalculation.java
public interface TaxCalculation {
    double calculateTax(Order order);
    double calculateVat(Order order, String countryCode);
}

Each interface has two methods, all from the same concern. No mixing.

2. Each implementor takes only what it needs

No more forced implementations. No more exceptions. Each class is honest about what it does.

// NotificationServiceImpl.java - clean
public class NotificationServiceImpl implements OrderNotification {

    private final EmailClient emailClient;

    public NotificationServiceImpl(EmailClient emailClient) {
        this.emailClient = emailClient;
    }

    @Override
    public void sendOrderConfirmation(Order order) {
        emailClient.send(order.getEmail(), "Your order is confirmed.");
    }

    @Override
    public void sendShippingUpdate(Order order, String trackingCode) {
        emailClient.send(order.getEmail(), "Your order shipped: " + trackingCode);
    }
}
// TaxCalculationImpl.java - clean
public class TaxCalculationImpl implements TaxCalculation {

    @Override
    public double calculateTax(Order order) {
        return order.getTotalAmount() * 0.23;
    }

    @Override
    public double calculateVat(Order order, String countryCode) {
        double rate = VatRates.getRate(countryCode);
        return order.getTotalAmount() * rate;
    }
}
// OrderReportingImpl.java - clean
public class OrderReportingImpl implements OrderReporting {

    private final OrderRepository orderRepository;

    public OrderReportingImpl(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public List<String> generateMonthlyReport(int month, int year) {
        List<Order> orders = orderRepository.findByMonth(month, year);
        return orders.stream()
            .map(o -> o.getId() + ": " + o.getTotalAmount())
            .collect(Collectors.toList());
    }

    @Override
    public List<String> generateCustomerReport(long customerId) {
        List<Order> orders = orderRepository.findByCustomer(customerId);
        return orders.stream()
            .map(o -> o.getId() + ": " + o.getStatus())
            .collect(Collectors.toList());
    }
}

Each class implements one interface. Each interface has one concern. No dead weight.

3. A class can implement multiple interfaces when it genuinely needs to

ISP doesn’t forbid a class from implementing more than one interface. It only forbids being forced to. An OrderProcessor that genuinely handles both lifecycle and tax can implement both, and that’s a deliberate design choice, not a fat interface problem.

// OrderProcessor.java - multiple interfaces by choice
public class OrderProcessor implements OrderLifecycle, TaxCalculation {

    private final OrderRepository orderRepository;

    public OrderProcessor(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public void placeOrder(Order order) {
        double tax = calculateTax(order);
        order.setTax(tax);
        orderRepository.save(order);
    }

    @Override
    public void cancelOrder(long orderId) {
        orderRepository.updateStatus(orderId, "CANCELLED");
    }

    @Override
    public double calculateTax(Order order) {
        return order.getTotalAmount() * 0.23;
    }

    @Override
    public double calculateVat(Order order, String countryCode) {
        double rate = VatRates.getRate(countryCode);
        return order.getTotalAmount() * rate;
    }
}

Every method here is used. Nothing is forced. That’s the difference.

4. Callers depend only on what they need

This is where ISP really pays off. The code that calls these classes only sees the interface it cares about:

// CheckoutController.java
public class CheckoutController {

    private final OrderLifecycle lifecycle;
    private final OrderNotification notifications;

    public CheckoutController(OrderLifecycle lifecycle, OrderNotification notifications) {
        this.lifecycle = lifecycle;
        this.notifications = notifications;
    }

    public void checkout(Order order) {
        lifecycle.placeOrder(order);
        notifications.sendOrderConfirmation(order);
    }
}

CheckoutController has no idea that tax calculation or reporting even exist. It depends on two focused interfaces, and that’s all it needs. Tomorrow when the tax formula changes, this class is not recompiled, not retested, not redeployed.


Why This Actually Works

Dependencies shrink. Before the split, NotificationServiceImpl depended on the entire OrderService interface, including tax and reporting. A change to calculateVat() could trigger a recompile of notification code that has nothing to do with VAT. After the split, NotificationServiceImpl depends only on OrderNotification. Tax changes are completely invisible to it.

Testing becomes focused. To test NotificationServiceImpl you mock OrderNotification, which has two methods. Before, you had to mock the entire OrderService with eight methods, most returning meaningless values just to satisfy the compiler. Smaller interfaces mean smaller mocks and cleaner tests.

The connection to SRP. ISP and SRP solve related problems from different angles. SRP asks: does this class have one reason to change? ISP asks: does this interface force clients to depend on things they don’t need? A fat interface is often a sign that the underlying classes also violate SRP. The two tend to appear together, and fixing one usually leads you to fix the other.


When to Split and When to Hold Off

Split the interface when:

  • Implementing classes throw exceptions or leave methods empty.
  • You describe the interface with “and” more than once.
  • Different clients use completely different subsets of the methods.

Hold off when:

  • Every implementing class genuinely uses every method. The interface is cohesive, not fat.
  • You only have one client today, so splitting prematurely just adds files without any benefit.
  • The methods are tightly related and would always change together anyway.

A large interface is not automatically a violation. An interface with 10 methods where every implementor uses all 10 is perfectly fine. The violation is forcing clients to carry methods they never call.


Wrapping Up

The fat OrderService interface wasn’t designed badly on purpose. It grew one method at a time, each addition feeling like the obvious place to put it. ISP is the principle that recognises this pattern and gives you the fix: when clients start carrying methods that aren’t theirs, the interface has outgrown its purpose and needs to be split.

Next up: the Dependency Inversion Principle, where we look at why high-level business logic should never depend on low-level infrastructure details, and how inverting that dependency makes your code genuinely testable.


Have you seen a fat interface in the wild? Drop a comment below. I’m curious how it got there, whether it was one team adding methods over time or a single design decision that seemed right at the start. And if you’ve split one, what did the refactor actually look like in practice?

Posted in solidTags:
Write a comment