Interface segregation principle (ISP)
Open in the interactive app: practice, quiz, mock interview →In one minute
- No client should be forced to depend on methods it does not use. Give each kind of caller a small interface that describes only what it needs.
- A fat interface couples unrelated callers: a change made for one of them forces the others to recompile, retest, update their fakes and redeploy.
- The fix is usually role interfaces:
Reader,Writer,Listerinstead of oneStorageServicewith twenty methods. One concrete class can still implement all of them. - Overdo it and you get interface explosion: one-method interfaces nobody reuses, names that hide the real type, and readers hopping through five files to find the code.
- In review, agent-written code tends to produce both failure modes: a single "service" interface that grows with every task, or a fresh interface for every class "for testability". Ask who the callers are.
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:
- An interface has exactly one implementation and one caller, and neither is a test boundary.
- Two interfaces are always requested together. Merge them; they form one role.
- The interface mirrors a single class method for method, with no consumer that uses a subset.
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
- Code is changed more often than it is written.
- A change to a type reaches everything that names the type, whether or not it uses the changed part.
- 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.
- Narrow interfaces shrink the set of things each client names, so each change reaches fewer clients.
- 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
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One broad interface | Easy to find, one fake | Couples all callers, large fakes | Few callers that all use most methods |
| Role interfaces per kind of caller | Changes stay local, small fakes, clear privilege | More names to learn | Several callers with distinct needs |
| One interface per method | Maximum decoupling | Interface explosion, constructors take many params | Rarely; a genuine single-method role like io.Reader |
| No interface, concrete type | Simplest, easy to navigate | Hard to fake, couples to implementation | One implementation, no test seam needed |
Pitfalls in production
- The "god interface" that every new feature extends. Fix: when adding a method, check which callers need it; if it is only one, put it in a separate role.
- Mocks that implement 20 methods with
panic("not implemented"). Fix: these are a signal the consumer depends on too much; narrow the parameter type. - Over-split interfaces that are always passed together. Fix: merge into the role they really represent, or embed them (
ReadWriter). - A narrow interface that hides a capability check, such as a reader that can also write through a type assertion. Fix: do not downcast an interface to recover the wide type; that reintroduces the coupling invisibly.
Leadership lens
- In design review, ask for the list of callers and the methods each one calls. If that table is a dense block, one interface is fine; if it is sparse, split along the clusters.
- Agent-written code needs this question asked explicitly. Agents tend to extend the nearest existing interface to finish a task, and they also produce speculative one-implementation interfaces. Both pass tests; neither shows up as a bug. Make "who consumes this interface?" a standard review comment.
- Treat interface size as an ownership question: a broad interface shared by several teams is a coordination tax on every change. Splitting it is often cheaper than the meetings it causes.
- Apply the same reasoning to tool and permission scopes for agents: the narrowest capability set that does the job.
Check yourself
A test fake for
AccountStorebreaks 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.
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.
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.
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
- Uncle Bob's SOLID Principles Made Easy, in Python (ArjanCodes): refactors one small program through all five principles, including interface segregation.
- Abstraction Can Make Your Code Worse (CodeAesthetic): the cost side of interfaces, and when not to add one.
Free resources
- Interface segregation principle (Wikipedia): definition and the Xerox origin story.
- Role Interface (Martin Fowler): role interfaces versus header interfaces, in a few paragraphs.
- Go Code Review Comments: Interfaces: why Go interfaces belong in the consuming package.
- Go Data Structures: Interfaces (Russ Cox): how Go lays out interface values and itabs.
- PEP 544: Protocols: structural interfaces in Python's type system.