Engineering Atlas › Low-Level Design

Interface segregation principle (ISP)

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem: one interface, many unrelated clients

Picture a payments backend with this interface, written early and extended for two years:

class AccountStore(Protocol):
    def get(self, account_id: str) -> Account: ...
    def save(self, account: Account) -> None: ...
    def list_by_owner(self, owner_id: str) -> list[Account]: ...
    def freeze(self, account_id: str, reason: str) -> None: ...
    def export_csv(self, since: datetime) -> bytes: ...
    def reindex_search(self) -> None: ...
    def audit_trail(self, account_id: str) -> list[AuditEvent]: ...

Four parts of the system use it. The balance API calls get. The fraud worker calls get and freeze. The finance export job calls export_csv. An admin tool calls audit_trail and reindex_search. Every one of them takes an AccountStore.

Now the finance team needs export_csv to stream rows instead of returning bytes. The balance API never touched exports, but its test fake no longer satisfies the interface, and neither does the fraud worker's. A one-line change for one team becomes a pull request touching five test files owned by three teams, and every service rebuilds. That is the cost the principle names: callers coupled to each other through methods they never call.

The fix: role interfaces

Split the interface by what callers need, not by what the implementation can do:

class AccountReader(Protocol):
    def get(self, account_id: str) -> Account: ...

class AccountFreezer(Protocol):
    def freeze(self, account_id: str, reason: str) -> None: ...

class AccountExporter(Protocol):
    def export_rows(self, since: datetime) -> Iterator[Row]: ...

def handle_balance(reader: AccountReader, account_id: str) -> int: ...
def review_txn(reader: AccountReader, freezer: AccountFreezer, txn: Txn) -> None: ...

The Postgres-backed SqlAccountStore still implements all of these; the database code is unchanged. What changed is that each function states its real needs in its signature. The fraud worker's fake is ten lines. The export change touches only the export job and the store. The signature also documents risk: a function that takes an AccountReader cannot freeze an account, which is least privilege enforced by the type checker.

Who owns the interface

A useful rule from Go applies everywhere: define the interface where it is consumed, not where it is implemented. The fraud package declares "I need something that can get and freeze accounts"; the storage package exports a concrete type. Each consumer writes down only what it calls, so interfaces stay small by construction. When the producer defines the interface, it lists everything it can do, and you are back to the fat interface.

Splitting too far

The principle has a failure mode that shows up just as often. A codebase with AccountGetter, AccountGetterByOwner, AccountSaver, AccountSaverWithAudit and forty more single-method interfaces, each used by one function, has traded coupling for indirection. Readers cannot tell which concrete type is behind a parameter. Renames ripple through dozens of declarations. Mocks multiply.

Signs you have gone too far:

A good middle ground is to split along real seams: read versus write, user-facing versus administrative, hot path versus batch. Those boundaries tend to match teams and deploy units, which is where coupling hurts.

The same shape elsewhere

The problem shape is fan-out of change: one edit reaches many dependents. GraphQL and sparse fieldsets are interface segregation for APIs. Scoped OAuth tokens are role interfaces for permissions. An AI agent given run_shell when it only needed read_file has a fat interface too, and the risk is larger than a recompile.

Down the stack

Layer 1The contract

An interface promises a set of method signatures and their behaviour. A client that names an interface is promised it can call those methods on any value it receives, and it is exposed to every change in that set. The testable claim of the principle: the number of clients that must change when one method changes should equal the number of clients that call that method.

Layer 2The mechanism

Each language checks the contract differently, which affects how cheap small interfaces are. Go uses structural typing at compile time: any type with the right method set satisfies an interface without declaring it, so splitting an interface costs nothing on the implementation side. An interface value is two words: a pointer to an itab (holding the interface type, the concrete type, and an array of function pointers) and a pointer to the data. The runtime builds an itab the first time a concrete type is converted to an interface type and caches it in a global hash table, so a narrower interface means a smaller function array, not more work per call.

TypeScript checks shapes at compile time and erases them; at runtime there is only a JavaScript object, and V8 finds methods through the object's hidden class (its "shape") and the prototype chain, with inline caches at each call site. Python's typing.Protocol is checked only by tools such as mypy; at runtime, a method call is an attribute lookup through the type's method resolution order, sped up by a per-type cache. Java is nominal (implements), and the JIT turns invokeinterface calls into inline caches once a call site has seen one or two types.

Layer 3The machine

A call through an interface is an indirect call: load the function pointer, jump to it. With a warm branch target predictor it costs about 1 to 3 ns. The real costs are elsewhere. The compiler usually cannot inline through an interface, which blocks follow-on optimisations in hot loops. In Go, storing a non-pointer value in an interface often forces a heap allocation (about 20 to 50 ns plus later garbage collection work), which escape analysis reports with go build -gcflags=-m. In V8, a call site that sees more than four object shapes goes megamorphic and falls back to a slower generic lookup. None of this depends on interface size; a fat interface and a thin one dispatch at the same speed. What costs is the number of distinct types flowing through a hot call site.

Why it's built this way

  1. Code is changed more often than it is written.
  2. A change to a type reaches everything that names the type, whether or not it uses the changed part.
  3. In the 1990s, when Robert Martin described the principle, that meant hours of C++ recompilation; today it means CI time, fake updates and cross-team reviews.
  4. Narrow interfaces shrink the set of things each client names, so each change reaches fewer clients.
  5. Modern structural typing makes narrow interfaces nearly free to declare, so the remaining cost is cognitive, which is why over-splitting is the failure to watch for now.

Diagram

flowchart LR
  API["Balance API"] --> R["AccountReader"]
  Fraud["Fraud worker"] --> R
  Fraud --> F["AccountFreezer"]
  Export["Finance export"] --> E["AccountExporter"]
  R --> SQL["SqlAccountStore"]
  F --> SQL
  E --> SQL

Worked example

A Go service has a Storage interface with 14 methods used by 9 packages. Over a quarter, the team records 31 changes to that interface. For each change they count the packages that had to be edited (usually just to update a fake) versus the packages that call the changed method. The average change touched 7.2 packages but only 1.6 actually called the changed method. The difference, about 5.6 packages per change, is pure segregation cost; at roughly 15 minutes of editing and review each, that is about 43 engineer-hours a quarter.

They split Storage into four consumer-owned interfaces: BlobReader (2 methods), BlobWriter (3), BlobLister (2) and Admin (7, used only by the admin CLI). The concrete s3Store is untouched. In the next quarter, changes touched an average of 1.9 packages. Test fakes shrank from about 200 lines to between 10 and 40 each.

They also tried, and reverted, one interface per method. Call sites then asked for three or four interfaces at a time, each constructor took four parameters that were the same object, and the team merged them back into the four roles that matched the four kinds of callers.

Trade-offs

OptionStrengthsWeaknessesChoose when
One broad interfaceEasy to find, one fakeCouples all callers, large fakesFew callers that all use most methods
Role interfaces per kind of callerChanges stay local, small fakes, clear privilegeMore names to learnSeveral callers with distinct needs
One interface per methodMaximum decouplingInterface explosion, constructors take many paramsRarely; a genuine single-method role like io.Reader
No interface, concrete typeSimplest, easy to navigateHard to fake, couples to implementationOne implementation, no test seam needed

Pitfalls in production

Leadership lens

Check yourself

  1. A test fake for AccountStore breaks when a method the code under test never calls changes signature. Which principle is being violated, and what is the fix?

    Show answer

    Interface segregation: the code under test depends on a wider interface than it uses. Change its parameter type to a role interface containing only the methods it calls.

  2. Why does it make sense for Go code to define interfaces in the consuming package?

    Show answer

    Because Go satisfies interfaces structurally, the consumer can declare only the methods it calls and the producer needs no changes, which keeps each interface as small as its real use.

  3. Does splitting a 12-method interface into three 4-method interfaces make calls faster?

    Show answer

    No. Dispatch cost is an indirect call either way; the benefit is in change propagation, test fakes and readability, not runtime speed.

  4. Name two signs that interfaces have been split too far.

    Show answer

    Interfaces with one implementation and one caller that is not a test seam, and pairs of interfaces that are always requested together.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →