open atlas
↑ Back to track
Code patterns & craft CP · 06 · 01

Interface segregation

A fat interface is a coupling magnet — one client's change forces recompile and retest on every other consumer. Segregate into role interfaces named for how each client uses the type, not per method, or the cure becomes a swarm of one-method noise.

CP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You have an IUserRepository with eighteen methods — CRUD, search, bulk import, audit-trail export, GDPR erasure, cache warming. A reporting screen needs exactly two of them: findById and search. But the report’s test double has to stub all eighteen, the report module recompiles every time someone adds a bulk-import method it will never call, and a reviewer reading the report can’t tell which capabilities it actually depends on. The report is coupled to seventeen methods it never touches.

That coupling is invisible until it bites: a change demanded by the import pipeline forces a recompile, a retest, and a re-review on a reporting module that has nothing to do with imports. The interface is a single fat cable everyone is forced to plug into, so every consumer feels every twitch. The fix has a name.

Goal

After this lesson you can recognise a fat interface as a coupling magnet; explain why “depends on a method” means depends even when you never call it; split a fat contract into role interfaces named for how each client uses the type; and avoid the opposite failure — shredding a contract into a swarm of one-method interfaces that add noise without buying decoupling.

1

The Interface Segregation Principle: no client should be forced to depend on methods it does not use. “Depend on” is the load-bearing phrase. A client depends on every method in the interface it names — not just the ones it calls — because the compiler, the type checker, the mock framework, and the next reader all see the whole surface. A reporting module typed against an eighteen-method IUserRepository is, for every purpose that matters, bound to all eighteen.

// One fat contract every consumer is forced to name in full.
interface IUserRepository {
  findById(id: string): Promise<User | null>;
  search(q: Query): Promise<User[]>;
  create(u: NewUser): Promise<User>;
  update(id: string, patch: Partial<User>): Promise<User>;
  delete(id: string): Promise<void>;
  bulkImport(rows: CsvRow[]): Promise<ImportReport>;
  exportAuditTrail(id: string): Promise<AuditEvent[]>;
  eraseForGdpr(id: string): Promise<void>;
  warmCache(): Promise<void>;
  // …nine more
}

The report uses two of these. ISP says: it should not have to know the other sixteen exist.

2

A fat interface couples unrelated clients through the type, not through their behaviour. The reporting screen and the import pipeline share no logic, no data flow, no requirement. Yet because they name the same interface, a change one demands lands on the other. Add bulkImportStreaming(stream) for the importer and the report’s mock no longer satisfies the interface; its file recompiles; CI reruns its tests; a reviewer must re-approve it. Nothing the report does changed — only the shape it was forced to depend on.

// The importer's new requirement…
interface IUserRepository {
  // …
  bulkImportStreaming(stream: ReadableStream): Promise<ImportReport>; // NEW
}

// …breaks this, which never imports anything:
class FakeRepoForReportTests implements IUserRepository {
  // ❌ now missing bulkImportStreaming — report tests fail to compile
}

This is the coupling magnet: the wider the interface, the more unrelated clients get pulled together and the more often each one is disturbed by a change it had no stake in.

3

Split into role interfaces named for how each client uses the type. Don’t name interfaces after the implementation (IUserRepository); name them after the role a client needs — the slice of capability a given consumer plays against. The report needs to look users up, so it depends on a UserLookup. The importer needs to write them in bulk, so it depends on a UserImporter. The concrete repository still implements everything; clients just stop seeing past their own role.

interface UserLookup {
  findById(id: string): Promise<User | null>;
  search(q: Query): Promise<User[]>;
}

interface UserWriter {
  create(u: NewUser): Promise<User>;
  update(id: string, patch: Partial<User>): Promise<User>;
  delete(id: string): Promise<void>;
}

interface UserImporter {
  bulkImport(rows: CsvRow[]): Promise<ImportReport>;
}

// One class can satisfy several roles at once.
class UserRepository implements UserLookup, UserWriter, UserImporter { /* … */ }

Now the report depends on UserLookup — two methods, the exact slice it uses. The importer can grow UserImporter forever and the report neither recompiles nor retests.

4

Type the client against the narrow role, and the decoupling becomes real. Segregating the interface only pays off if consumers actually name the narrow type. A report that still accepts IUserRepository “for convenience” is back to depending on everything. The discipline is: each consumer’s signature advertises precisely the role it plays.

// Before: bound to the whole surface, coupled to 16 unused methods.
function buildReport(repo: IUserRepository) { /* uses 2 */ }

// After: the signature is the dependency declaration.
function buildReport(repo: UserLookup) {
  return repo.search({ active: true });
}

The payoff is concrete and senior: the test double for buildReport stubs two methods, not eighteen; the signature documents the dependency honestly; and a change to write/import/GDPR roles cannot reach this function, because the type system no longer connects them. Segregation lets each client depend only on the slice it needs.

Worked example

Watch one client shed sixteen dependencies. A notification service sends a “your account is ready” email. It needs to read one user. Typed against the fat repository:

// before
class WelcomeMailer {
  constructor(private repo: IUserRepository) {}
  async send(id: string) {
    const user = await this.repo.findById(id);
    if (user) await this.email(user);
  }
}

// the test double pays the fat-interface tax:
const fake: IUserRepository = {
  findById: async () => sampleUser,
  // ❌ TS forces you to stub search, create, update, delete, bulkImport,
  //    exportAuditTrail, eraseForGdpr, warmCache, …nine more — all unused
} as IUserRepository; // ← the `as` cast is the smell: lying to the compiler

The as IUserRepository cast is the tell — the test is pretending to satisfy a contract it doesn’t, because honestly satisfying it means stubbing sixteen irrelevant methods. Now segregate by role:

// after — depend on the role this client plays
interface UserLookup {
  findById(id: string): Promise<User | null>;
}

class WelcomeMailer {
  constructor(private users: UserLookup) {}
  async send(id: string) {
    const user = await this.users.findById(id);
    if (user) await this.email(user);
  }
}

// the test double now tells the truth, no cast:
const fake: UserLookup = { findById: async () => sampleUser };

Nothing about the mailer’s behaviour changed. But it no longer recompiles when an audit or import method is added, its test states its real dependency in one line, and a reviewer sees at a glance that the mailer only reads users. The fat interface was a coupling magnet; naming the role it plays cut the cable.

Why this works

Why does “depends on a method it never calls” actually cost anything? Three concrete bills. Compilation/CI: in many toolchains a module that names a type is invalidated when that type’s surface changes, so unrelated consumers recompile and their tests rerun. Test doubles: a fake must implement the whole interface (or lie with a cast), so the fat surface inflates every test that touches it. Cognition and review: a wide dependency hides the real one — a reader and a reviewer can’t tell two methods from eighteen, so the blast radius of any change to the type is “everyone who names it.” Segregation shrinks all three to the slice actually used.

Common mistake

The failure mode of ISP is over-correction: shredding one fat interface into a swarm of one-method interfaces — IFindById, ISearch, ICreate, IUpdate… — so every client now juggles five micro-types and the codebase is noise. That is its own coupling-and-clutter problem: nothing reads as a coherent role, and you’ve traded “too wide” for “too granular.” The principle is segregate by client role, not per method. A role is the cohesive set of operations one kind of client uses together — UserLookup (read), UserWriter (mutate), UserImporter (bulk). If two methods are always needed together by the same client, keeping them in one role interface is correct, not a violation. When in doubt, let the clients draw the lines: one role per distinct way the type is used.

Check yourself
Quiz

A read-only report is typed against a fat IUserRepository but calls only findById and search. The import team adds bulkImportStreaming to the interface. What is the ISP-correct reason this is a problem, and the fix?

Recap

The Interface Segregation Principle says no client should be forced to depend on methods it does not use — and “depend on” includes methods you never call, because the compiler, mock framework, and reviewer all bind you to the whole surface. A fat interface is a coupling magnet: it pulls unrelated clients together so a change one demands forces recompilation, retesting, and re-review on every other. Split it into role interfaces named for how each client uses the typeUserLookup, UserWriter, UserImporter — and type each consumer against the slice it needs; one class can satisfy several roles. The failure mode is over-segregation into a swarm of one-method interfaces, which trades width for granular noise. The rule: segregate by client role, not per method — let the clients draw the lines.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.