SOLIDLANGUAGE-SPECIFICCONTESTED

Interface Segregation, Critically

A consumer should depend on the smallest contract that serves it. That is a statement about coupling, not a rule that interfaces must be small.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

How wide should an interface be, when narrowing it means more types and widening it means more consumers affected by every change?

The requirement

UserStore has fourteen methods: reads, writes, bulk import, GDPR export, admin search, session lookup. Eleven classes depend on it and most use two methods each. Adding a bulkAnonymise method last sprint forced four test doubles to be updated and broke a build in an unrelated service.

The obvious build

One interface per concept. UserStore covers everything you can do with stored users, which is easy to find, easy to name, and means a reader has one place to look for the available operations. That discoverability is real, and for a small system it beats a scattering of role interfaces.

Why it breaks

Every consumer depends on all fourteen methods whether it uses them or not, so a change to any of them is a change to all eleven consumers' compilation (Fan-in and Fan-out).

How it breaks as requirements change
  • Every consumer depends on all fourteen methods whether it uses them or not, so a change to any of them is a change to all eleven consumers' compilation (Fan-in and Fan-out).
  • Every implementation must implement all fourteen, including test doubles, so adding a method is a change to four files that have nothing to do with the feature.
  • A read-only consumer holds a reference that can delete users. Nothing in the type stops it, and code review is the only defence.
  • The interface stops describing anything: fourteen methods spanning admin search, GDPR export and session lookup is not one concept, it is a namespace, and its name has become a lie (Cohesion).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Eleven consumers, four implementations including two test doubles, and a shared build that recompiles all of them.
  • One consumer is in another team's module and is the one whose build broke — a cross-team cost, not just an inconvenience.
  • The team has previously been burned by an interface-per-method codebase and will resist "make interfaces smaller" as a slogan.
  • Java-style nominal typing, so an implementation must name every interface it satisfies — which makes many small interfaces more expensive here than in Go or TypeScript.
Invariants
  • A consumer that only reads users must not be able to delete one. The narrowing is a capability boundary, not only a coupling one (Least Privilege as a Design Decision).
  • However the contracts are split, there is exactly one implementation of the storage behaviour — segregating interfaces must not fork the implementation.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Each contract owns one consumer role: reading users, writing users, bulk operations, compliance export.
  • A single implementation owns satisfying all of them, because storage really is one implementation and splitting it would be a different and worse decision.
  • The composition root owns handing each consumer the narrowest contract it needs — which is where the capability boundary is actually enforced (Wiring and the Composition Root).
  • Nobody owns a UserStore interface that exists only because there is a UserStore class.
Boundaries
  • The seams fall on consumer roles, not on method count. Four contracts averaging three methods is the right answer here because there are four kinds of consumer, not because three is a good number.
  • The strongest form of this idea is that the *consumer* should declare the contract, sized to its own need — which is Go's idiom and is available in any structurally-typed language.
  • The boundary is also a permission boundary: what a consumer cannot name, it cannot call, and that is a cheaper enforcement mechanism than a review checklist (Capability Passing).

What a fat interface actually costs

The cost is not aesthetic. Every consumer of a fourteen-method interface is coupled to all fourteen, so a change to any one of them propagates to every consumer's compilation and to every implementation including the doubles. The consumer that only reads users pays for a change to bulk anonymisation.

Pricing one method addition makes the shape of it visible — and makes the cost of segregation visible too, which is the part the principle usually leaves out.

Add `bulkAnonymise` for a GDPR requirement
The change

Compliance need a bulk anonymisation operation over users. It is used by exactly one consumer, a nightly job.

One fourteen-method `UserStore` interface
UserStorePgUserStoreInMemoryUserStoreFakeUserStore (tests)StubUserStore (other team)
testsevery test file that constructs a double — nine of them
5 modules · 1 test file

Four implementations must gain a method, two of which exist only for tests and one of which belongs to another team. Eleven consumers recompile. The other team's build breaks on a change they were not told about, which converts an hour of work into a cross-team incident.

Four role contracts — `UserReader`, `UserWriter`, `BulkUserOps`, `ComplianceExporter` — one implementation
BulkUserOpsPgUserStoreNightlyAnonymiseJob
testsbulk_ops_testanonymise_job_test
3 modules · 2 test files

The method lands on the contract that has one consumer. Test doubles for reading and writing are unaffected because they do not implement the bulk contract. The other team compiles against UserReader and never sees the change.

what it cost Four interface files instead of one, and a newcomer asking "what can I do with users?" now has four places to look — a real and recurring navigation cost. In a nominally-typed language PgUserStore must name all four contracts, and keeping the four in coherent shape as they grow is ongoing work. There is also a new way to be wrong: a consumer given UserWriter when it needed UserReader and UserWriter produces a wiring error rather than a compile-clean one-liner.

Split by role, not by size

The two splits below have the same method count and are not remotely the same design. One is grouped by what a consumer does, so each contract has a name a person would use and each consumer takes exactly one dependency. The other is grouped by nothing in particular, so the consumer takes four parameters and the reader is no better off.

The test for a good segregation is whether the contracts have names that predate the split. UserReader is a role; UserStorePart2 is a mechanical operation on a file.

Fourteen methods, two ways to divide them
Split by mechanical rule
interface UserFinder     { findById(id): User | null }
interface UserSearcher   { search(q): User[] }
interface UserCreator    { create(u): void }
interface UserUpdater    { update(u): void }
interface UserDeleter    { delete(id): void }
// ...nine more, one method each

class ProfilePage {
  constructor(
    private finder: UserFinder,
    private updater: UserUpdater,
    private searcher: UserSearcher,
    private deleter: UserDeleter,   // needed for "close account"
  ) {}
}
// Four parameters where there was one, and none of the
// four names describes anything a stakeholder talks about.
Split by consumer role
interface UserReader {              // the read-only consumers
  findById(id): User | null
  search(q): User[]
}

interface UserWriter {              // the profile and admin flows
  create(u): void
  update(u): void
  delete(id): void
}

interface BulkUserOps { /* import, anonymise */ }
interface ComplianceExporter { /* gdpr export */ }

class ProfilePage {
  constructor(private users: UserReader, private edit: UserWriter) {}
}
// One implementation satisfies all four.
// The reporting service takes only UserReader — and therefore
// cannot delete a user, which review no longer has to check.

The right-hand split tracks how consumers actually group operations, so each contract is nameable, each consumer takes one or two dependencies, and the narrowing does capability work: a reporting service holding a UserReader cannot delete anything, and that is enforced by the compiler rather than by a review comment (Least Privilege as a Design Decision). The left-hand split satisfies "small interfaces" completely and improves nothing — it moved coupling from one wide type into four parameters, and it lost the ability to say what a consumer is allowed to do.

The five-part reading

Stated in the same shape as the others. ISP has the largest gap in this module between the useful version and the popular version, because "keep interfaces small" is memorable and is not what it says.

The smell below is the residue of the popular version, and it is worth being fair about: there are contexts where a one-method interface is exactly right.

  • Problem it addresses — a consumer coupled to operations it never calls recompiles, breaks and must be re-reasoned-about whenever those operations change, and holds capabilities it should not have.
  • Useful example — the four role contracts above: the reporting service takes UserReader and cannot delete a user; a bulk method lands without touching nine test doubles.
  • Misuse — "interfaces should be small" as a size rule, producing one-method interfaces with no names anyone recognises and consumers with four parameters instead of one (Over-Decomposition).
  • Trade-off — more types and worse discoverability, and in a nominally-typed language an ongoing cost every time the implementation must name another contract.
  • Counterexample — a Connection, a Document, a rich Money type: wide surfaces where consumers genuinely use most of it and the concept is one thing. Segregating them produces ceremony and no benefit (What an Abstraction Actually Is).
smellOne-method interfaces everywhere

looks like A directory of interfaces each declaring a single method, typically named SomethingDoer or ISomethingHandler, each with exactly one implementation, and consumers whose constructors list four or five of them.

suggests ISP applied as a size rule rather than as a coupling argument. The coupling was not reduced — the same consumer still depends on the same operations — it was just spread across more type names, and the codebase lost the ability to describe what a consumer is for (How SOLID Gets Misused).

fix Regroup by role. Ask which methods a single consumer uses together and let those groupings define the contracts; the answer is usually two to five contracts, not fifteen. Where a one-method interface is really a function, make it a function type and delete the interface (Function Design).

when this is fine Genuinely right in several cases. A single-method interface used as a callback or strategy in a language without first-class functions is the language's idiom, not a smell — Java's functional interfaces exist for exactly this. A one-method contract declared by a consumer that truly needs one operation is the strongest form of ISP, not a violation of it. And a narrow contract used as a capability — a token whose whole purpose is that its holder can do exactly one thing — is doing security work that a wider type could not (Capability Passing).

How to build it

Most important first.

  • Group the methods by which consumers use them together. If the grouping is obvious, the segregation is obvious; if it is not, the interface may genuinely be one thing.
  • Name the resulting contracts after the role, not the shape: UserReader, UserWriter, ComplianceExporter. A name like UserStoreReadPart is a sign the split was mechanical.
  • Keep one implementation satisfying several contracts. Segregating interfaces should never mean forking implementations, and when it does something else has gone wrong.
  • Where the language allows it, declare the contract at the consumer and let the implementation satisfy it structurally — no coordination, no shared interface file (Dependency Inversion).
  • Stop when each contract corresponds to something a person would name. Splitting past that produces the one-method-interface codebase everyone remembers hating (Over-Decomposition).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: adding a method costs edits to four implementations including two test doubles, recompilation of eleven consumers, and a cross-team build break. Roughly a day, most of it unrelated to the feature.
  • After: adding a bulk method costs one edit to the bulk contract, one to the implementation, and touches only the two consumers that use bulk operations. Under an hour.
  • The cost that went up: there are now four interface files instead of one, and a new engineer asking "what can I do with users?" has to look in four places rather than one. Discoverability is a real thing to lose.
  • Also more expensive: a change that genuinely spans roles — adding a tenant id to every operation — now touches four contracts consistently instead of one.
What the recommended approach costs
  • More types, more names, more files, and a discoverability loss that is genuinely felt by newcomers.
  • In a nominally-typed language every additional contract must be named by the implementation, so the cost of segregation scales with the number of contracts in a way it does not in Go or TypeScript.
  • Taken as a size rule rather than a coupling argument it produces the worst codebase in this module, which means the principle has an unusually high chance of being applied harmfully.

What can go wrong

Failure modes
  • Segregation by mechanical rule: one method per interface, eleven interfaces, none of which means anything, and a consumer that now takes four constructor parameters where it took one (Long Parameter List).
  • The split forks the implementation, so there are now two classes that must agree about the same table and quietly do not.
  • Interfaces named after the split rather than the role, which leaves a reader unable to tell which one they need.
  • The mitigation fails when the narrow contracts all end up injected into the same consumer anyway, because the consumer really did need all fourteen methods — at which point the finding is about that consumer, not about the interface (God Object).
Dependencies, and their direction
  • Each consumer depends on one narrow contract, so the dependency graph gets more edges and much thinner ones.
  • The single implementation depends on all the contracts it satisfies, which in a nominal language means naming them all — a real cost paid once.
  • The composition root depends on knowing which consumer gets which view, which is a small amount of extra wiring knowledge in exchange for the capability boundary.
Misreads
  • "Interfaces should be small." The principle is about what a *consumer* depends on. A fourteen-method interface with exactly one consumer that uses all fourteen violates nothing (Designing a Module Interface).
  • "So every class needs its own interface." No. This is the forbidden version of the idea and it produces a codebase where every type has a shadow and none of the shadows mean anything (How SOLID Gets Misused).
  • "Segregate the interfaces, so segregate the implementations." Almost always wrong: one implementation satisfying four contracts is the intended outcome, and forking it creates a consistency problem where there was none.
  • "One-method interfaces are the ideal." A one-method interface is a function. In a language with first-class functions, say so and skip the type; in one without, a single-method interface is fine but is not a target to aim for (Function Design).
Smells this explains
  • long-parameter-list
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • Test doubles get much smaller, and that is the clearest signal that the segregation worked: a fake implementing two methods rather than fourteen (Test Doubles, Precisely).
  • One contract suite per contract, run against the single implementation, so the promises of each role are checked independently (Contract Tests).
  • An architecture test that read-only consumers do not import the writing contract, since the capability boundary is only real if something enforces it.
  • Do not write a test per interface for its own sake. The interfaces have no behaviour; the implementation does (What a Unit Is).
How this design ages
  • Contracts accrete methods over time, and periodically one of them has grown into a second role and should split again. The signal is the same as it was originally: consumers using disjoint subsets.
  • In a structurally-typed codebase the number of contracts grows without much cost, because consumers declare their own and there is no registration. In a nominal one the cost is real and argues for coarser splits.
  • The design stops fitting if the single implementation grows so large that satisfying four contracts is what is holding a god class together. Then the finding is about the implementation and the interfaces were a symptom (God Object).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • LANGUAGE-SPECIFICGo makes the strong form free: consumers declare interfaces, implementations satisfy them structurally without naming them, so a two-method contract costs two lines at the point of use and nothing anywhere else. TypeScript gets the same via structural typing. Java, C# and Kotlin require the implementation to name every interface it satisfies, so each additional contract has an ongoing cost — which is why the same advice produces elegant code in one language and ceremony in another. In a dynamically-typed language the interface is implicit and the principle degrades to "do not pass an object with more capability than the callee needs".
  • CONTESTEDThe strongest opposing case: a single well-named interface per concept is more discoverable, and discoverability is a daily cost while recompilation and double maintenance are occasional ones. Critics point out that ISP was formulated when C++ recompilation of a large system took hours and a fat header genuinely hurt — a constraint modern build systems have largely removed — and that what remains is a real but small coupling benefit paid for with a real and constant navigation cost. That argument is sound wherever build times are irrelevant and all consumers are in one team. It is weakest where the interface crosses a team or module boundary, or where the narrow contract is doing capability enforcement rather than merely reducing coupling.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — the same argument at service scale is why a service exposing one endpoint per consumer role couples less than one exposing a single wide RPC surface, and the trade-offs there include versioning and network round trips this lesson does not.