Lesson 35 of 54

SOLID Principles in Practice

Interface Segregation Principle (ISP)

The Interface Segregation Principle states:

"Clients should not be forced to depend on interfaces they do not use."

Fat interfaces—interfaces with many methods—force implementers to provide methods they don't need. Clients end up depending on methods they never call.

ISP says: split fat interfaces into smaller, focused ones.

The Problem

Consider this interface:

Typescript
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  attendMeeting(): void;
  writeCode(): void;
  reviewCode(): void;
  manageTeam(): void;
  conductInterview(): void;
}

Now implement it for different types of workers:

Typescript
class JuniorDeveloper implements Worker {
  work() { this.writeCode(); }
  eat() { /* lunch */ }
  sleep() { /* rest */ }
  attendMeeting() { /* stand-up */ }
  writeCode() { /* coding */ }
  reviewCode() { throw new Error("Juniors don't review"); }  // Unused
  manageTeam() { throw new Error("Juniors don't manage"); }  // Unused
  conductInterview() { throw new Error("Juniors don't interview"); }  // Unused
}

class Robot implements Worker {
  work() { /* robot work */ }
  eat() { throw new Error("Robots don't eat"); }  // Unused
  sleep() { throw new Error("Robots don't sleep"); }  // Unused
  attendMeeting() { throw new Error("Robots don't attend meetings"); }  // Unused
  writeCode() { /* generates code */ }
  reviewCode() { throw new Error("Robots don't review"); }  // Unused
  manageTeam() { throw new Error("Robots don't manage"); }  // Unused
  conductInterview() { throw new Error("Robots don't interview"); }  // Unused
}

This is a mess. Implementers are forced to provide methods they can't actually support.

The Solution: Role Interfaces

Split the fat interface into focused, role-based interfaces:

Typescript
interface Workable {
  work(): void;
}

interface Feedable {
  eat(): void;
}

interface Restable {
  sleep(): void;
}

interface MeetingAttendee {
  attendMeeting(): void;
}

interface Developer {
  writeCode(): void;
}

interface CodeReviewer {
  reviewCode(): void;
}

interface Manager {
  manageTeam(): void;
}

interface Interviewer {
  conductInterview(): void;
}

Now classes implement only what they support:

Typescript
class JuniorDeveloper implements Workable, Feedable, Restable, MeetingAttendee, Developer {
  work() { this.writeCode(); }
  eat() { /* lunch */ }
  sleep() { /* rest */ }
  attendMeeting() { /* stand-up */ }
  writeCode() { /* coding */ }
}

class Robot implements Workable, Developer {
  work() { /* robot work */ }
  writeCode() { /* generates code */ }
}

class SeniorDeveloper implements Workable, Feedable, Restable, MeetingAttendee, Developer, CodeReviewer, Interviewer {
  // ... all relevant methods
}

Clean. Each class declares exactly what it can do.

Real-World Example: CRUD Operations

Before: Fat Interface

Typescript
interface Repository<T> {
  create(entity: T): Promise<T>;
  read(id: string): Promise<T>;
  update(id: string, entity: T): Promise<T>;
  delete(id: string): Promise<void>;
  list(): Promise<T[]>;
  search(query: string): Promise<T[]>;
  bulkCreate(entities: T[]): Promise<T[]>;
  bulkDelete(ids: string[]): Promise<void>;
  archive(id: string): Promise<void>;
  restore(id: string): Promise<void>;
}

A read-only view only needs read, list, search. Why force it to depend on delete, bulkDelete, archive?

After: Segregated Interfaces

Typescript
interface Readable<T> {
  read(id: string): Promise<T>;
}

interface Listable<T> {
  list(): Promise<T[]>;
}

interface Searchable<T> {
  search(query: string): Promise<T[]>;
}

interface Writable<T> {
  create(entity: T): Promise<T>;
  update(id: string, entity: T): Promise<T>;
}

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

interface BulkOperations<T> {
  bulkCreate(entities: T[]): Promise<T[]>;
  bulkDelete(ids: string[]): Promise<void>;
}

interface Archivable {
  archive(id: string): Promise<void>;
  restore(id: string): Promise<void>;
}

// Full repository composes all
interface FullRepository<T> extends 
  Readable<T>, Listable<T>, Searchable<T>,
  Writable<T>, Deletable, BulkOperations<T>, Archivable {}

// Read-only client only needs these
type ReadOnlyRepository<T> = Readable<T> & Listable<T> & Searchable<T>;

Now clients declare minimal dependencies.

Benefits of ISP

Clearer Dependencies

Typescript
// This function clearly only needs reading capability
function displayUser(repo: Readable<User>, id: string) {
  const user = await repo.read(id);
  render(user);
}

// This function needs full write access
function importUsers(repo: Writable<User> & BulkOperations<User>, data: User[]) {
  await repo.bulkCreate(data);
}

Easier Testing

Typescript
// Only mock what's needed
const mockReadable: Readable<User> = {
  read: jest.fn().mockResolvedValue(testUser),
};

await displayUser(mockReadable, '123');

No need to mock delete, archive, bulkCreate, etc.

Flexible Implementation

Typescript
// Read-only cache implementation
class CachedUserReader implements Readable<User>, Listable<User> {
  read(id: string) { return this.cache.get(id); }
  list() { return this.cache.values(); }
}

// Doesn't need to implement write operations it can't support

Identifying ISP Violations

Signs of a Fat Interface

  1. Methods that throw "not implemented"
  2. Methods that return null/undefined "because this type doesn't support it"
  3. Clients that only use a subset of methods
  4. Implementers that leave methods empty

The Interface Dependency Test

For each client of an interface, ask: "Which methods does this client actually call?"

If the answer is "a subset," the interface might be too fat.

Practical Application

Start Coarse, Refine When Needed

You don't need to preemptively split every interface. Start with reasonable groupings:

Typescript
interface UserService {
  getUser(id: string): User;
  createUser(data: CreateUserInput): User;
  updateUser(id: string, data: UpdateUserInput): User;
}

When you find yourself needing just getUser, split:

Typescript
interface UserReader {
  getUser(id: string): User;
}

interface UserWriter {
  createUser(data: CreateUserInput): User;
  updateUser(id: string, data: UpdateUserInput): User;
}

interface UserService extends UserReader, UserWriter {}

Composition Over Inflation

Instead of adding methods to an interface, compose interfaces:

Typescript
// Instead of growing this
interface Service {
  methodA();
  methodB();
  methodC();  // New!
  methodD();  // New!
}

// Compose
interface ServiceA { methodA(); methodB(); }
interface ServiceC { methodC(); }
interface ServiceD { methodD(); }

type FullService = ServiceA & ServiceC & ServiceD;

Key insight: Fat interfaces force clients to depend on things they don't need. Split interfaces by client usage. Each interface should represent a cohesive role. Clients declare minimal dependencies, implementations provide exactly what they support.