A shared component often starts with a small interface. As more callers arrive, new methods are added until every caller sees operations it never uses. The interface still works, but it now connects unrelated needs through one contract.

That matters because an interface is a dependency. When a client depends on a broad contract, changes to unrelated parts of that contract can affect its compilation, tests, mocks, generated code, or understanding of the component even when the client needs only one capability.

A useful design principle is to shape interfaces around what clients actually need. This article develops that mental model, shows how to split an oversized contract without duplicating implementations, and explains when a larger interface is still the simpler choice.

Treat an interface as a promise to a client

An interface is not just a list of methods implemented by a class or module. From the client’s perspective, it is the set of operations the client is allowed to rely on.

Suppose an order application exposes this contract:

OrderStore
  find(orderId)
  save(order)
  delete(orderId)
  exportAll()
  rebuildIndex()

A checkout service may use only find and save. An administration tool may need delete and exportAll. A maintenance job may call only rebuildIndex.

If all three depend on OrderStore, each client receives a contract shaped by the combined needs of every client.

The key question is therefore not:

How many methods can this implementation provide?

It is:

Which operations does this client need to perform its job?

That shift keeps the dependency focused on the client’s responsibility rather than the provider’s full capability.

Start with the smallest useful split

The oversized OrderStore can be divided by client role:

OrderReader
  find(orderId)

OrderWriter
  save(order)

OrderAdministration
  delete(orderId)
  exportAll()

OrderIndexMaintenance
  rebuildIndex()

The checkout service can depend on OrderReader and OrderWriter. The maintenance job depends only on OrderIndexMaintenance.

This does not require four separate storage implementations. One concrete component can implement several interfaces:

DatabaseOrderStore implements
  OrderReader,
  OrderWriter,
  OrderAdministration,
  OrderIndexMaintenance

The implementation may still have a broad responsibility because it owns persistence for orders. The important change is that each client sees only the contract relevant to its work.

This is the practical form of the Interface Segregation Principle: clients should not be forced to depend on operations they do not need. The principle is about dependency shape, not about making every interface contain exactly one method.

Why broad interfaces create coupling

Consider a reporting service that only reads orders:

class SalesReport:
    constructor(store: OrderStore)

    generate(orderId):
        order = store.find(orderId)
        return buildReport(order)

The code calls only find, but its declared dependency says that it accepts an object capable of deleting orders, exporting them, rebuilding indexes, and saving changes.

That broader contract has several consequences.

First, tests may need a fake or mock that satisfies operations the reporting service never calls. Some languages and tools make this burden more visible than others, but the conceptual dependency exists either way.

Second, changes to administrative operations can force changes to implementations or test doubles used by read-only clients. A new parameter on rebuildIndex, for example, is irrelevant to reporting but still changes the shared contract.

Third, the dependency communicates more authority than the client needs. A reader of SalesReport cannot tell from its constructor that the component is intended to be read-only.

With a narrower dependency:

class SalesReport:
    constructor(orders: OrderReader)

its required capability is explicit. The reporting service needs order lookup and nothing more.

The benefit is not fewer lines of code. It is a smaller surface across which unrelated changes can propagate.

Design from usage, not from the implementation

A common way to create a large interface is to begin with a concrete component and copy all of its public methods into a contract:

DatabaseOrderStore
  find
  save
  delete
  exportAll
  rebuildIndex

becomes:

OrderStore
  find
  save
  delete
  exportAll
  rebuildIndex

The abstraction has changed the type name but preserved the implementation’s shape.

Instead, examine a client and describe the capability it needs:

Checkout needs: load an order and persist its updated state
Reporting needs: load an order
Administration needs: remove and export orders
Maintenance needs: rebuild the index

Then define contracts around those roles.

This approach can produce overlapping interfaces. OrderReader may be used by several clients, while OrderWriter is needed by only a few. That is not duplication of behaviour. The interfaces describe different dependency relationships; the concrete implementation can remain shared.

Narrow interfaces are useful, but splitting mechanically can make a design harder to understand.

Imagine a payment component with these operations:

authorize(payment)
capture(authorization)
void(authorization)

If the same clients normally perform the payment workflow and the operations evolve together, separating them into three one-method interfaces may add names and wiring without isolating meaningful change.

A better boundary might be:

PaymentProcessor
  authorize(payment)
  capture(authorization)
  void(authorization)

The goal is not the minimum possible method count. The goal is a contract whose operations belong to one client-facing role and tend to be needed together.

A useful test is to ask what would cause the interface to change. If one group of methods changes because reporting requirements evolve while another changes because administrative workflows evolve, they probably represent different roles. If the operations form one cohesive capability and are used together, keeping them together may be clearer.

Watch for interfaces that expose unrelated authority

Interface size is only one signal. An interface with four closely related methods may be well designed, while an interface with two unrelated methods may still mix responsibilities.

Pay attention when a client receives capabilities it should not normally exercise. Examples include:

  • a read-only component depending on mutation operations;
  • a request handler depending on maintenance controls;
  • a billing calculation depending on user-administration methods;
  • a background importer depending on interactive presentation operations.

These mismatches reveal that the contract is organized around the provider rather than the client’s role.

Narrowing the interface can also make architectural intent easier to enforce. A component that receives only OrderReader cannot accidentally call delete through that dependency because deletion is not part of the contract it was given.

This is a design constraint, not a security boundary. If untrusted code can reach the underlying implementation through another path, a narrow programming interface does not provide authorization by itself.

Do not hide unstable boundaries behind many tiny interfaces

Splitting a contract does not automatically make a design stable.

Suppose five interfaces all expose the same volatile third-party data structure:

CustomerReader
  find(id) -> VendorCustomerRecord

CustomerWriter
  save(VendorCustomerRecord)

The interfaces are small, but clients still depend on the vendor’s representation. A vendor schema change can spread through the application.

Interface segregation controls which operations a client depends on. It does not by itself control which data model crosses the boundary. If external types are volatile, translating them into application-owned types is a separate design decision.

Similarly, creating dozens of tiny interfaces around an unstable domain can increase navigation and wiring while the real source of coupling remains untouched.

Refactor a broad interface safely

When a large interface already has many callers, it is usually unnecessary to rewrite every client at once.

Start with one client whose needs are clear. Define a smaller interface containing only the operations that client already uses. Make the existing implementation satisfy that interface, then change the client to depend on the smaller contract.

For example:

before:
SalesReport -> OrderStore

step 1:
OrderReader
  find(orderId)

step 2:
DatabaseOrderStore implements OrderStore, OrderReader

step 3:
SalesReport -> OrderReader

The runtime behaviour has not changed. Only the declared dependency has become more precise.

Repeat this when another client has a distinct role. Once no callers need the original broad interface, it can be removed if it no longer serves a useful purpose.

This incremental approach is especially valuable when the broad interface is widely used. It lets each change remain small and reviewable while preserving existing behaviour.

Avoid common failure modes

One mistake is splitting an interface solely because it has reached an arbitrary number of methods. Method count can prompt investigation, but it does not tell you whether the operations form a coherent client role.

Another mistake is creating an interface for every class even when there is only one stable caller-provider relationship and no useful boundary to express. An extra abstraction has a maintenance cost: another name, another file in some languages, and another concept for readers to understand.

A third mistake is defining tiny interfaces in a central “interfaces” package far away from their clients. That can preserve provider-oriented design under different filenames. Where language and project conventions allow it, keeping a client-facing contract near the code that consumes it can make ownership and purpose clearer.

Finally, do not assume that narrower interfaces remove all coupling. Clients still depend on method semantics, input and output types, failure behaviour, and timing or ordering guarantees. A small contract with vague semantics can be harder to use than a slightly larger contract with clear guarantees.

When a broader interface is reasonable

A larger interface can be appropriate when its operations form one cohesive capability, its clients genuinely need most of them, and the operations tend to evolve together.

For a small internal component with one caller and one implementation, introducing several role interfaces may also provide little benefit. Direct use of the concrete component can be simpler until distinct client needs appear.

The pressure to split becomes stronger when:

  • different clients use clearly different subsets of operations;
  • unrelated changes repeatedly affect the same shared contract;
  • test doubles must implement irrelevant methods;
  • a dependency grants a client capabilities outside its responsibility;
  • the interface combines operations that evolve for different reasons.

Use those signals rather than treating interface segregation as a rule to apply everywhere.

Conclusion

An interface should describe what a client needs from a collaborator, not everything the collaborator knows how to do.

When unrelated clients depend on one broad contract, their needs become coupled. Splitting that contract into cohesive client-facing roles can contain changes, make required capabilities explicit, and reduce irrelevant work in tests and implementations. The same concrete component can still implement several roles, so better dependency boundaries do not require duplicating behaviour.

When reviewing an interface, ask a simple question: does each client need this whole contract? If the answer is no, look for a smaller coherent role. If the operations are genuinely used and changed together, keep the broader interface and avoid abstraction for its own sake.