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 AlgorithmsAI-assisted content. This post may have been written or enhanced with AI tools. Please verify critical information independently.
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
RoboticDuckshould not be forced to implementfly()just because it's in aBirdinterface.
๐ 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:
SimplePrintercompiles and works with zero stubs.OfficePrinterimplements everything it genuinely supports.- Code that only needs
Printableaccepts 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:
| Principle | Focus | Relationship to ISP |
| SRP (Single Responsibility) | Classes have one reason to change | ISP = SRP applied to interfaces |
| DIP (Dependency Inversion) | Depend on abstractions, not concretions | ISP keeps those abstractions narrow |
| OCP (Open/Closed) | Extend without modifying | Narrow 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
| Situation | Verdict |
| All implementations genuinely use all methods | Fat interface is fine โ it's cohesive |
| Interface will never be implemented outside your team | Acceptable technical debt |
| Splitting creates so many tiny interfaces it hurts readability | Consolidate to 2โ3 cohesive groups |
| Library API that external code depends on | Apply 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.
| Domain | Fat Interface Violation | ISP-Compliant Design |
| User repository | IUserRepo with 15 methods (all callers use 2โ3) | Split into IUserReader, IUserWriter, IUserSearcher |
| Payment gateway | IPaymentGateway with refund, charge, subscription (mobile only charges) | IPaymentChargeable, IRefundable, ISubscribable |
| Media player | IMediaPlayer with play, pause, record (playback-only clients get record) | IPlayable, IRecordable |
| Notification service | INotifier with email, SMS, push, webhook | IEmailNotifier, ISMSNotifier, IPushNotifier |
| Storage adapter | IStorage with read, write, delete, list, archive, compress | Group 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
| Situation | Recommended Action |
Implementation throws UnsupportedOperationException | Split the interface โ ISP violation |
| All implementers genuinely use every method | Interface is cohesive โ no split needed |
| External library clients depend on your interface | Apply ISP strictly to avoid costly breaking changes |
| Only internal code uses the interface | Acceptable to defer; refactor when pain appears |
| Interface has 10+ methods with diverse implementers | Strong 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
UnsupportedOperationExceptionis the ISP alarm. Any time you writethrow 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.
IUserReadergroupingfindById,searchByName, andlistAllis 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
Printablemock doesn't have to mockscan(),fax(), andstaple(). 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
UnsupportedOperationExceptionas 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
Protocoland 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.
๐ Related Posts
- Single Responsibility Principle: One Class, One Job
- Open/Closed Principle: Extend Without Modifying
- Dependency Inversion Principle: Decoupling Your Code
- KISS, YAGNI, and DRY Principles Explained

Written by
Abstract Algorithms
@abstractalgorithms
More Posts
RAG vs Fine-Tuning: When to Use Each (and When to Combine Them)
TLDR: RAG gives LLMs access to current knowledge at inference time; fine-tuning changes how they reason and write. Use RAG when your data changes. Use fine-tuning when you need consistent style, tone, or domain reasoning. Use both for production assi...
Fine-Tuning LLMs with LoRA and QLoRA: A Practical Deep-Dive
TLDR: LoRA freezes the base model and trains two tiny matrices per layer โ 0.1 % of parameters, 70 % less GPU memory, near-identical quality. QLoRA adds 4-bit NF4 quantization of the frozen base, enabling 70B fine-tuning on 2ร A100 80 GB instead of 8...
Build vs Buy: Deploying Your Own LLM vs Using ChatGPT, Gemini, and Claude APIs
TLDR: Use the API until you hit $10K/month or a hard data privacy requirement. Then add a semantic cache. Then evaluate hybrid routing. Self-hosting full model serving is only cost-effective at > 50M tokens/day with a dedicated MLOps team. The build ...
Watermarking and Late Data Handling in Spark Structured Streaming
TLDR: A watermark tells Spark Structured Streaming: "I will accept events up to N minutes late, and then I am done waiting." Spark tracks the maximum event time seen per partition, takes the global minimum across all partitions, subtracts the thresho...
