All Posts

Interface Segregation Principle: No Fat Interfaces

Clients shouldn't be forced to depend on methods they don't use. We explain the 'I' in SOLID with...

Abstract AlgorithmsAbstract Algorithms
ยทยท13 min read

AI-assisted content.

TLDR

TLDR: The Interface Segregation Principle (ISP) states that clients should not be forced to depend on methods they don't use. Split large "fat" interfaces into smaller, role-specific ones. A RoboticDuck should not be forced to implement fly() just because it's in a Bird interface.


๐Ÿ“– The Fat Remote Control Problem

Imagine a TV remote with 50 buttons. You only ever use 5: power, volume, channel up/down, and input select. ISP says: don't give every device the full remote. Give each device the buttons it actually needs.

In software, a fat interface forces every implementing class to provide methods it doesn't need, creating:

  • Stub implementations that throw UnsupportedOperationException.
  • Tight coupling โ€” changes to the fat interface force all implementers to update.
  • Fragile tests โ€” mocking 30 methods to test 2.
  • Runtime surprises โ€” calling a "supported" method that silently does nothing or crashes.

The ISP was introduced by Robert C. Martin as part of the SOLID principles. His original formulation: "Clients should not be forced to depend upon interfaces that they do not use." The word client is key โ€” it's not about the implementing class, it's about the caller. If a caller only needs print(), it should depend on an interface that only exposes print(). Anything else is noise that increases coupling and risk.


๐Ÿ” The Classic Violation: The Printer Interface

public interface Machine {
    void print(Document d);
    void scan(Document d);
    void fax(Document d);
    void copy(Document d);
    void staple(Document d);
}

public class SimplePrinter implements Machine {
    public void print(Document d) { /* works */ }
    public void scan(Document d)  { throw new UnsupportedOperationException(); }
    public void fax(Document d)   { throw new UnsupportedOperationException(); }
    public void copy(Document d)  { throw new UnsupportedOperationException(); }
    public void staple(Document d){ throw new UnsupportedOperationException(); }
}

SimplePrinter can only print โ€” but the Machine interface forces it to "implement" 4 other operations it can't do. Any code that calls simplePrinter.fax() fails at runtime rather than at compile time.

๐Ÿ“Š Fat Interface Violation: SimplePrinter Forced to Stub Four Methods

classDiagram
    class Machine {
        <>
        +print(d)
        +scan(d)
        +fax(d)
        +copy(d)
        +staple(d)
    }
    class SimplePrinter {
        +print(d)
        +scan(d) throws
        +fax(d) throws
        +copy(d) throws
        +staple(d) throws
    }
    class OfficePrinter {
        +print(d)
        +scan(d)
        +fax(d)
        +copy(d)
        +staple(d)
    }
    Machine <|.. SimplePrinter
    Machine <|.. OfficePrinter

Both SimplePrinter and OfficePrinter implement the fat Machine interface, but the similarity ends there. OfficePrinter can legitimately fulfil all five methods, while SimplePrinter is forced to throw UnsupportedOperationException in scan(), fax(), copy(), and staple() โ€” four methods it cannot meaningfully support. This is the ISP violation in its most visible form: a caller holding a Machine reference cannot tell at compile time which methods are real and which will blow up at runtime.


โš™๏ธ Applying ISP: Split by Role

// Segregated interfaces โ€” each is a single capability
public interface Printable  { void print(Document d); }
public interface Scannable  { void scan(Document d); }
public interface Faxable    { void fax(Document d); }
public interface Copyable   { void copy(Document d); }
public interface Stapleable { void staple(Document d); }

// Simple printer: only what it can do
public class SimplePrinter implements Printable {
    public void print(Document d) { /* full implementation */ }
}

// All-in-one office printer: combines all
public class OfficePrinter implements Printable, Scannable, Faxable, Copyable, Stapleable {
    public void print(Document d)  { /* ... */ }
    public void scan(Document d)   { /* ... */ }
    public void fax(Document d)    { /* ... */ }
    public void copy(Document d)   { /* ... */ }
    public void staple(Document d) { /* ... */ }
}
classDiagram
    class Printable {+print(d)}
    class Scannable {+scan(d)}
    class Faxable   {+fax(d)}

    SimplePrinter ..|> Printable
    OfficePrinter ..|> Printable
    OfficePrinter ..|> Scannable
    OfficePrinter ..|> Faxable

After the split, SimplePrinter implements only Printable โ€” the single capability it genuinely supports โ€” while OfficePrinter implements Printable, Scannable, and Faxable to reflect its real hardware capabilities. Any code holding a Printable reference can safely accept either class, and no class is ever forced to provide a method it cannot meaningfully execute.

Now:

  • SimplePrinter compiles and works with zero stubs.
  • OfficePrinter implements everything it genuinely supports.
  • Code that only needs Printable accepts both โ€” no forced coupling.

๐Ÿ“Š ISP Correct: Each Class Implements Only Its Own Capabilities

classDiagram
    class Printable {
        <>
        +print(d)
    }
    class Scannable {
        <>
        +scan(d)
    }
    class Faxable {
        <>
        +fax(d)
    }
    class SimplePrinter {
        +print(d)
    }
    class OfficePrinter {
        +print(d)
        +scan(d)
        +fax(d)
    }
    Printable <|.. SimplePrinter
    Printable <|.. OfficePrinter
    Scannable <|.. OfficePrinter
    Faxable <|.. OfficePrinter

With <<interface>> markers, the diagram makes the type-system boundaries explicit: Printable, Scannable, and Faxable are three distinct contracts rather than three grouped methods inside one fat type. SimplePrinter has exactly one dependency arrow โ€” to Printable โ€” meaning the compiler rejects any attempt to call simplePrinter.scan() or simplePrinter.fax() before the code ever runs. OfficePrinter earns its three arrows by genuinely implementing each capability.


๐Ÿง  Deep Dive: Python Protocol-Based ISP

Python uses structural subtyping via Protocol (PEP 544) โ€” no explicit implements required:

from typing import Protocol

class Printable(Protocol):
    def print(self, doc: str) -> None: ...

class Scannable(Protocol):
    def scan(self) -> str: ...

class SimplePrinter:
    def print(self, doc: str) -> None:
        print(f"Printing: {doc}")

class OfficePrinter:
    def print(self, doc: str) -> None:
        print(f"Printing: {doc}")
    def scan(self) -> str:
        return "scanned content"

def send_to_printer(device: Printable, doc: str) -> None:
    device.print(doc)

def run_scanner(device: Scannable) -> str:
    return device.scan()

send_to_printer(SimplePrinter(), "Invoice.pdf")   # OK โ€” only print needed
send_to_printer(OfficePrinter(), "Report.pdf")    # OK โ€” also satisfies Printable
run_scanner(OfficePrinter())                      # OK โ€” satisfies Scannable
# run_scanner(SimplePrinter())                   # Type error โ€” SimplePrinter has no scan()

SimplePrinter never declared it implements Printable. The type checker confirms structural compatibility at analysis time without runtime overhead. Notice that OfficePrinter satisfies both Printable and Scannable โ€” Python's Protocol lets you express the same ISP split as Java's explicit interfaces, but with zero inheritance boilerplate.


โš–๏ธ Trade-offs & Failure Modes: ISP vs Other SOLID Principles

ISP is closely related to:

PrincipleFocusRelationship to ISP
SRP (Single Responsibility)Classes have one reason to changeISP = SRP applied to interfaces
DIP (Dependency Inversion)Depend on abstractions, not concretionsISP keeps those abstractions narrow
OCP (Open/Closed)Extend without modifyingNarrow interfaces are easier to extend without breaking others

Rule of thumb: If you ask "when would a class mock or stub this method just to satisfy the interface?", that method doesn't belong in that interface.

When Fat Interfaces Are Acceptable

SituationVerdict
All implementations genuinely use all methodsFat interface is fine โ€” it's cohesive
Interface will never be implemented outside your teamAcceptable technical debt
Splitting creates so many tiny interfaces it hurts readabilityConsolidate to 2โ€“3 cohesive groups
Library API that external code depends onApply ISP strictly โ€” breaking changes are costly

๐Ÿ“Š ISP Decision Flow: When to Split an Interface

flowchart TD
    A[Design Interface] --> B{Do all clients use all methods?}
    B -- Yes --> C[Keep Single Interface]
    B -- No --> D[Split into Focused Interfaces]
    D --> E[Interface A: subset methods]
    D --> F[Interface B: subset methods]
    E --> G[Only relevant clients implement A]
    F --> H[Only relevant clients implement B]

The flowchart captures the single question that drives every ISP refactoring decision: do all clients use all the methods? A "Yes" branch means the interface is already cohesive and no split is warranted. A "No" branch sends the design down the split path, where each focused interface (Interface A, Interface B) attracts only the clients that genuinely need it โ€” eliminating stub implementations and shrinking compile-time dependency surfaces.


๐Ÿ“Š ISP Interface Segregation Flow

The transformation from a fat interface to segregated role-based interfaces โ€” and how implementations choose only what they need.

flowchart LR
    A[Machine fat interface] --> B{Does class support all?}
    B -- YES --> C[OfficePrinter implements all 5]
    B -- NO --> D[Split into role interfaces]
    D --> E[Printable]
    D --> F[Scannable]
    D --> G[Faxable]
    E --> H[SimplePrinter implements Printable only]
    C --> E
    C --> F
    C --> G

The left-to-right flow traces the refactoring decision for the Machine interface. OfficePrinter answers "YES" to supporting all capabilities and takes the direct path โ€” it implements Printable, Scannable, and Faxable because it genuinely provides all three. SimplePrinter answers "NO" and routes through the split, arriving at Printable alone, with no stubs and no forced coupling to operations its hardware cannot perform.

SimplePrinter follows the right path: it only implements Printable. OfficePrinter implements all capabilities it genuinely supports. Neither is forced to implement what it cannot do.


๐ŸŒ Real-World Application: ISP in Production Systems

ISP applies everywhere interfaces define contracts between components.

DomainFat Interface ViolationISP-Compliant Design
User repositoryIUserRepo with 15 methods (all callers use 2โ€“3)Split into IUserReader, IUserWriter, IUserSearcher
Payment gatewayIPaymentGateway with refund, charge, subscription (mobile only charges)IPaymentChargeable, IRefundable, ISubscribable
Media playerIMediaPlayer with play, pause, record (playback-only clients get record)IPlayable, IRecordable
Notification serviceINotifier with email, SMS, push, webhookIEmailNotifier, ISMSNotifier, IPushNotifier
Storage adapterIStorage with read, write, delete, list, archive, compressGroup into IReadableStorage, IWritableStorage, IArchivableStorage

Kubernetes analogy: A Pod spec in Kubernetes follows ISP โ€” you only declare the capabilities you need (volumes, network policies, security contexts). Each concern is a separate, composable specification. You don't get a monolithic "configure everything" spec that forces you to set all 200 fields.

REST API design: Small, focused endpoint groups follow ISP. A service that only reads data consumes a read-only client interface; a service that only writes uses a write interface. Fat client SDKs that bundle all API methods force every consumer to depend on operations they'll never call.


๐Ÿงช Hands-On: Segregate a Fat Interface

Here is a fat IUserService interface. Split it.

// ๐Ÿ›‘ Fat interface โ€” forces all implementers to support everything
public interface IUserService {
    User findById(String id);
    List<User> searchByName(String name);
    List<User> listAll();
    void createUser(User user);
    void updateUser(User user);
    void deleteUser(String id);
    void resetPassword(String userId, String newPassword);
    void sendWelcomeEmail(String userId);
    List<String> getAuditLog(String userId);
}

Problem: A read-only admin dashboard must implement createUser, deleteUser, sendWelcomeEmail. A background job that only resets passwords must implement findById, listAll, sendWelcomeEmail, and getAuditLog.

ISP-compliant split:

public interface IUserReader {
    User findById(String id);
    List<User> searchByName(String name);
    List<User> listAll();
}

public interface IUserWriter {
    void createUser(User user);
    void updateUser(User user);
    void deleteUser(String id);
}

public interface IUserPasswordManager {
    void resetPassword(String userId, String newPassword);
}

public interface IUserNotifier {
    void sendWelcomeEmail(String userId);
}

public interface IUserAuditor {
    List<String> getAuditLog(String userId);
}

// Admin dashboard only needs reads
public class AdminDashboardService implements IUserReader { ... }

// Full user management service implements all
public class UserManagementService implements IUserReader, IUserWriter,
    IUserPasswordManager, IUserNotifier, IUserAuditor { ... }

Key benefit: AdminDashboardService cannot accidentally call deleteUser โ€” the interface doesn't include it. ISP enforces permission boundaries at compile time.


Decision Guide

SituationRecommended Action
Implementation throws UnsupportedOperationExceptionSplit the interface โ€” ISP violation
All implementers genuinely use every methodInterface is cohesive โ€” no split needed
External library clients depend on your interfaceApply ISP strictly to avoid costly breaking changes
Only internal code uses the interfaceAcceptable to defer; refactor when pain appears
Interface has 10+ methods with diverse implementersStrong signal to segregate by role

๐Ÿ› ๏ธ Spring IoC: How Fine-Grained ISP Interfaces Make Dependency Injection Cleaner

Spring IoC (Inversion of Control) is the dependency injection container at the core of Spring Framework โ€” it creates beans, resolves their dependencies, and wires them together at runtime based on type or qualifier annotations.

How it solves the problem in this post: When you split a fat interface into narrow ISP-compliant interfaces (IUserReader, IUserWriter, IUserNotifier), Spring's @Autowired can inject exactly the interface a class needs โ€” not the full implementation. This means a read-only dashboard controller depends only on IUserReader, making it impossible (at compile time and at Spring wiring time) to accidentally call write or notify methods.

// ISP-segregated interfaces
public interface IUserReader {
    User findById(String id);
    List<User> searchByName(String name);
}

public interface IUserWriter {
    void createUser(User user);
    void updateUser(User user);
    void deleteUser(String id);
}

public interface IUserNotifier {
    void sendWelcomeEmail(String userId);
}

// Full implementation โ€” Spring bean that implements all three
@Service
public class UserServiceImpl implements IUserReader, IUserWriter, IUserNotifier {

    @Override
    public User findById(String id) { /* JPA lookup */ return null; }

    @Override
    public List<User> searchByName(String name) { /* JPA query */ return List.of(); }

    @Override
    public void createUser(User user) { /* persist */ }

    @Override
    public void updateUser(User user) { /* merge */ }

    @Override
    public void deleteUser(String id) { /* remove */ }

    @Override
    public void sendWelcomeEmail(String userId) { /* email service */ }
}

// Read-only admin dashboard โ€” Spring injects only IUserReader
// AdminDashboardController CANNOT call createUser or deleteUser at compile time
@RestController
@RequestMapping("/admin")
public class AdminDashboardController {

    private final IUserReader userReader; // narrow dependency โ€” ISP in action

    public AdminDashboardController(IUserReader userReader) {
        this.userReader = userReader;
    }

    @GetMapping("/users/{id}")
    public ResponseEntity<User> getUser(@PathVariable String id) {
        return ResponseEntity.ok(userReader.findById(id));
    }
}

// Registration flow โ€” only needs writer + notifier
@Service
public class RegistrationService {

    private final IUserWriter writer;
    private final IUserNotifier notifier;

    public RegistrationService(IUserWriter writer, IUserNotifier notifier) {
        this.writer = writer;
        this.notifier = notifier;
    }

    public void register(User user) {
        writer.createUser(user);
        notifier.sendWelcomeEmail(user.getId()); // compile-safe โ€” ISP guarantees this exists
    }
}

Spring resolves IUserReader, IUserWriter, and IUserNotifier to the same UserServiceImpl bean, since it implements all three. The key benefit: each consumer's constructor declares only the interface it truly needs โ€” ISP-enforced access control without any framework magic.

For a full deep-dive on Spring IoC and ISP-aligned service layer design, a dedicated follow-up post is planned.


๐Ÿ“š Lessons Learned From ISP in Practice

  • UnsupportedOperationException is the ISP alarm. Any time you write throw new UnsupportedOperationException() in an interface implementation, that method doesn't belong in that interface.
  • ISP = SRP for interfaces. Just as SRP says a class should have one reason to change, ISP says an interface should have one role. If two different clients use different subsets of your interface, it should be split.
  • Don't fragment to extremes. One method per interface is over-engineering. Group methods that always change together and are always used together. IUserReader grouping findById, searchByName, and listAll is cohesive โ€” they're all read operations.
  • Public API boundaries demand strict ISP. Library interfaces that external teams consume are expensive to break. Apply ISP strictly when designing external contracts.
  • ISP reduces mock complexity. Test code that needs only a Printable mock doesn't have to mock scan(), fax(), and staple(). Narrow interfaces = simpler, more focused tests.
  • Combine with Dependency Inversion. Inject narrow interfaces (ISP) as constructor parameters (DIP) and you get maximum decoupling: each consumer depends only on exactly the capabilities it needs.

๐Ÿ“Œ TLDR: Summary & Key Takeaways

  • Fat interfaces force stub implementations and tight coupling โ€” avoid UnsupportedOperationException as a design smell.
  • Split by role: Each interface represents one capability. Classes implement only what they support.
  • Java uses explicit interface implementation; Python achieves the same with Protocol and duck typing.
  • ISP = SRP for interfaces: One reason to change, one capability per interface.
  • Apply strictly when the interface is part of a public API or library boundary.


Share
Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms