Skip to content
Unverified — AI-generated content. Help verify this page

Interface Segregation Principle

The Principle

No client should be forced to depend on methods it does not use. — Robert C. Martin, The Interface Segregation Principle (1996)

ISP emerged from a real consulting engagement. In the mid-1990s, Martin was working on a printing system at Xerox. The system had a single Job class used by everything — stapling, printing, faxing, scanning. When the stapling subsystem needed a change, the entire system had to be recompiled and redeployed because every component depended on the monolithic Job interface.

The fix was simple: split Job into focused interfaces — Printable, Stapleable, Faxable — so that each client only depended on the methods it actually called. This reduced the blast radius of changes and decoupled the build.

Why ISP Matters

Fat interfaces create three concrete problems:

1. Forced Implementation

When you implement a fat interface, you must provide bodies for methods you do not need. This leads to empty methods, throw new NotImplementedError(), or dangerous no-ops that silently swallow calls.

2. Unnecessary Recompilation

In compiled languages, when a fat interface changes, every implementor must be recompiled — even if the change is irrelevant to most of them.

3. Cognitive Overhead

A consumer looking at a 20-method interface cannot quickly determine which methods are relevant. Lean interfaces document their purpose through their shape.

Fat Interface Anti-Pattern

The Monolithic Repository

One of the most common ISP violations in enterprise applications is the "God Repository":

typescript
// FAT INTERFACE: every consumer depends on all 15 methods
interface UserRepository {
  // Read operations
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findAll(page: number, size: number): Promise<PaginatedResult<User>>;
  search(query: string): Promise<User[]>;
  count(): Promise<number>;

  // Write operations
  save(user: User): Promise<void>;
  update(id: string, data: Partial<User>): Promise<void>;
  delete(id: string): Promise<void>;
  bulkInsert(users: User[]): Promise<void>;

  // Specialized queries
  findByRole(role: Role): Promise<User[]>;
  findInactive(since: Date): Promise<User[]>;
  findWithExpiredPasswords(): Promise<User[]>;

  // Admin operations
  lockAccount(id: string): Promise<void>;
  resetPassword(id: string, hash: string): Promise<void>;
  exportAll(format: ExportFormat): Promise<Buffer>;
}

Problems

  • The login handler needs findByEmail but must depend on exportAll
  • The admin dashboard needs findAll but must depend on resetPassword
  • A read-only analytics service depends on write methods it must never call
  • Adding findByDepartment forces recompilation of everything

The Fix: Role-Based Interfaces

typescript
// Segregated by client need

interface UserReader {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
}

interface UserSearcher {
  findAll(page: number, size: number): Promise<PaginatedResult<User>>;
  search(query: string): Promise<User[]>;
  count(): Promise<number>;
}

interface UserWriter {
  save(user: User): Promise<void>;
  update(id: string, data: Partial<User>): Promise<void>;
  delete(id: string): Promise<void>;
}

interface UserBulkOperations {
  bulkInsert(users: User[]): Promise<void>;
  exportAll(format: ExportFormat): Promise<Buffer>;
}

interface UserAdminOperations {
  lockAccount(id: string): Promise<void>;
  resetPassword(id: string, hash: string): Promise<void>;
}

// The implementation can satisfy all interfaces
class PostgresUserRepository implements
  UserReader, UserSearcher, UserWriter, UserBulkOperations, UserAdminOperations {
  // ... all methods implemented
}

// But consumers only depend on what they need
class LoginService {
  constructor(private users: UserReader) {} // Only 2 methods
}

class AdminDashboard {
  constructor(
    private users: UserSearcher,
    private admin: UserAdminOperations,
  ) {} // Only 7 methods
}

class AnalyticsService {
  constructor(private users: UserSearcher) {} // Read-only, 3 methods
}

Implementation Strategies

Strategy 1: Multiple Small Interfaces (TypeScript)

TypeScript's structural type system makes ISP effortless — you do not even need explicit implements:

typescript
// Define focused interfaces
interface Readable {
  read(path: string): Promise<Buffer>;
}

interface Writable {
  write(path: string, data: Buffer): Promise<void>;
}

interface Deletable {
  delete(path: string): Promise<void>;
}

// Full implementation
class FileStorage implements Readable, Writable, Deletable {
  async read(path: string): Promise<Buffer> { /* ... */ }
  async write(path: string, data: Buffer): Promise<void> { /* ... */ }
  async delete(path: string): Promise<void> { /* ... */ }
}

// Read-only consumer — doesn't know about write or delete
class ReportGenerator {
  constructor(private storage: Readable) {}

  async generate(reportId: string): Promise<Buffer> {
    const template = await this.storage.read(`templates/${reportId}.html`);
    return this.render(template);
  }
}

// Cleanup service — doesn't know about read
class TempFileCleaner {
  constructor(private storage: Deletable) {}

  async cleanup(files: string[]): Promise<void> {
    await Promise.all(files.map(f => this.storage.delete(f)));
  }
}

Strategy 2: Go's Natural ISP

Go's implicit interface satisfaction and cultural convention of tiny interfaces make ISP the default:

go
// Go standard library: io package has single-method interfaces
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

// Compose when needed
type ReadWriter interface {
    Reader
    Writer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

// Consumer asks for minimum capability
func countLines(r io.Reader) (int, error) {
    scanner := bufio.NewScanner(r)
    count := 0
    for scanner.Scan() {
        count++
    }
    return count, scanner.Err()
}
// Works with: *os.File, *bytes.Buffer, *http.Response.Body,
// net.Conn, *strings.Reader, *gzip.Reader, ...

Go proverb

"The bigger the interface, the weaker the abstraction." — Rob Pike

Go interfaces are typically 1-2 methods. The standard library's most powerful interfaces (io.Reader, io.Writer, fmt.Stringer, error) each have exactly one method.

Strategy 3: Java Interface Composition

java
// Segregated interfaces
public interface Identifiable<ID> {
    ID getId();
}

public interface Auditable {
    Instant getCreatedAt();
    Instant getUpdatedAt();
    String getCreatedBy();
}

public interface SoftDeletable {
    boolean isDeleted();
    void markDeleted();
    Instant getDeletedAt();
}

// Entity composes only what it needs
public class Product implements Identifiable<UUID>, Auditable {
    private UUID id;
    private Instant createdAt;
    private Instant updatedAt;
    private String createdBy;

    // ... implementations
}

public class User implements Identifiable<UUID>, Auditable, SoftDeletable {
    // Users can be soft-deleted; Products cannot
    // ... implementations
}

// Repository uses specific interfaces
public interface SoftDeleteRepository<T extends SoftDeletable & Identifiable<ID>, ID> {
    void softDelete(ID id);
    List<T> findAllActive();
}

Strategy 4: Python Protocols (Duck Typing ISP)

python
from typing import Protocol, runtime_checkable

@runtime_checkable
class Renderable(Protocol):
    def render(self) -> str: ...

@runtime_checkable
class Serializable(Protocol):
    def to_dict(self) -> dict: ...

@runtime_checkable
class Cacheable(Protocol):
    def cache_key(self) -> str: ...
    def ttl_seconds(self) -> int: ...

# Class satisfies only the protocols it needs
class UserProfile:
    def __init__(self, user_id: str, name: str):
        self.user_id = user_id
        self.name = name

    def render(self) -> str:
        return f"<div class='profile'>{self.name}</div>"

    def to_dict(self) -> dict:
        return {"user_id": self.user_id, "name": self.name}

    def cache_key(self) -> str:
        return f"profile:{self.user_id}"

    def ttl_seconds(self) -> int:
        return 300

# Consumer depends only on what it needs
def render_page(components: list[Renderable]) -> str:
    return "\n".join(c.render() for c in components)

def cache_objects(store: CacheStore, objects: list[Cacheable]) -> None:
    for obj in objects:
        store.set(obj.cache_key(), obj, ttl=obj.ttl_seconds())

ISP and Microservices

ISP extends beyond class design to API design. A microservice API is an interface consumed by other services:

BFF Pattern (Backend for Frontend)

The Backend for Frontend pattern is ISP applied to API consumers. Instead of one API serving web, mobile, and third-party clients, each client gets a tailored API surface:

ConsumerNeedsBFF Surface
Web appFull user profile, settings, history/web/users/{id} — rich JSON
Mobile appCompact profile, offline sync/mobile/users/{id} — minimal JSON
Partner APILimited user data per contract/partner/users/{id} — restricted fields

Common Mistakes

1. Interface per Method (Over-Segregation)

typescript
// TOO GRANULAR — cognitive overhead exceeds coupling reduction
interface HasId { id: string; }
interface HasName { name: string; }
interface HasEmail { email: string; }
interface HasCreatedAt { createdAt: Date; }
// ... 20 more single-property interfaces

WARNING

ISP is about role-based segregation, not method-level granularity. Group methods by the actor or use case that needs them, not by individual method.

2. Marker Interfaces with No Methods

java
// WRONG: empty marker interfaces don't provide behavioral contracts
public interface Cacheable {} // No methods — what does this even mean?

// BETTER: define the caching contract
public interface Cacheable {
    String cacheKey();
    Duration ttl();
}

3. Not Segregating When You Should

The signal to segregate is pain: when implementors throw NotImplementedError, when tests require mocking unused methods, or when a change to one method forces redeployment of unrelated consumers.

ISP Metrics

MetricWhat It MeasuresTarget
Interface methodsNumber of methods per interface1-5 methods
Implementation coveragePercentage of methods actually used by each consumer> 80%
Dead method ratioMethods implemented but never called by any consumer0%
Recompilation scopeNumber of modules affected by an interface changeMinimize

Relationship to Other Principles

  • SRP + ISP: SRP says a class should serve one actor; ISP says an interface should expose only what one role needs. They are the same idea at different abstraction levels.
  • LSP + ISP: Smaller interfaces make LSP compliance easier — fewer methods mean fewer contracts to satisfy correctly.
  • DIP + ISP: DIP says depend on abstractions; ISP says make those abstractions small and focused. Together they produce the lean, stable interfaces that high-level modules depend on.

Further Reading

"What I cannot create, I do not understand." — Richard Feynman