A ticket asks for one thing: the reconciliation job should also fetch a settlement batch id. You add one method to PaymentOperations, and the build goes red in seven places — a gift-card gateway that cannot settle, two test fakes, a sandbox stub, and a NoopPaymentOperations that exists purely to satisfy the compiler. None of those seven care about settlement. All seven had to change.
That is Interface Segregation failing, and it fails in the build log before it fails in the design review.
No client should be forced to depend on methods it does not use. The definition is one sentence — the work is learning to see “client” as the thing that defines the interface’s shape, rather than the implementation.
This is Part 5 of the SOLID series. If you want the one-line definitions of the other four letters and the shared lab domain, start with SOLID Roadmap. Everything here uses that same Order / PaymentGateway / OrderProcessor code.
The interface that grew
Payments arrived as one abstraction, because “payments” felt like one thing. Two years and four providers later, it looks like this:
public interface PaymentOperations {
PaymentResult charge(Order order, BigDecimal amount);
RefundResult refund(String paymentId, BigDecimal amount);
void capture(String authorizationId);
void voidAuthorization(String authorizationId);
ReconciliationReport reconcile(LocalDate day);
byte[] exportReport(LocalDate from, LocalDate to);
List<Payout> listPayouts(LocalDate day);
void updateWebhookUrl(String url);
}
Eight methods, one type name. Nothing about it is obviously wrong — every method is a real payment operation, and each one has at least one caller somewhere in the codebase.
The problem is that no single caller needs more than two of them.
Who actually calls what
Map the callers against the methods before you touch any code. This table is the whole diagnosis:
| Client | Methods it calls | Methods it depends on |
|---|---|---|
OrderProcessor | charge | 8 |
RefundHandler | refund | 8 |
FinanceReportJob | exportReport | 8 |
ReconciliationJob | reconcile, listPayouts | 8 |
AuthCaptureWorker | capture, voidAuthorization | 8 |
The right column is the cost. OrderProcessor uses one method and is coupled to eight — including updateWebhookUrl, an admin concern it has no business knowing exists. Depending on a method is not the same as calling it: the dependency is in the type, so it survives recompiles, ripples into mocks, and shows up in every IDE autocomplete list in the file.
Note: “Client” here means the calling code, not the implementation. This is the part people get backwards. ISP asks you to shape interfaces around what consumers need, which is why the fix produces interfaces named after use cases rather than after providers.
Symptom 1: implementations full of no-ops
StripeGateway can do all eight. GiftCardGateway cannot — gift cards do not authorize, settle, or pay out. But the interface demands eight methods, so it gets eight:
public class GiftCardGateway implements PaymentOperations {
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
return balances.debit(order.id(), amount); // the only real method
}
@Override
public RefundResult refund(String paymentId, BigDecimal amount) {
return balances.credit(paymentId, amount); // also real
}
@Override
public void capture(String authorizationId) {
// gift cards charge immediately; nothing to capture
}
@Override
public void voidAuthorization(String authorizationId) {
// no authorizations exist
}
@Override
public ReconciliationReport reconcile(LocalDate day) {
throw new UnsupportedOperationException("gift cards do not reconcile");
}
@Override
public byte[] exportReport(LocalDate from, LocalDate to) {
return new byte[0];
}
@Override
public List<Payout> listPayouts(LocalDate day) {
return List.of();
}
@Override
public void updateWebhookUrl(String url) {
// no webhooks
}
}
Six of eight methods are fiction, and they lie in three different dialects: silent no-op, empty result, and exception at runtime. A caller holding a PaymentOperations reference cannot tell which one it will get.
That last dialect is the reason ISP and Liskov are usually broken together — a subtype that throws where the supertype promised a result is exactly the substitution failure covered in Liskov Substitution: Subtypes That Don’t Surprise Callers. The fat interface is what created the pressure to lie.
Symptom 2: the forced recompile
Back to the opening ticket. Reconciliation needs a settlement batch id, so the interface grows one method:
public interface PaymentOperations {
// ...existing eight...
String settlementBatchId(LocalDate day); // one line added here
}
Then count what the compiler makes you touch:
PaymentOperations.java + 1 method (the actual change)
StripeGateway.java + real implementation (also the actual change)
RazorpayGateway.java + real implementation (also the actual change)
GiftCardGateway.java + throw (noise)
SandboxGateway.java + return "TEST" (noise)
NoopPaymentOperations.java + return null (noise)
FakeGatewayForOrderTests.java + throw (noise)
FakeGatewayForRefundTests.java + throw (noise)
Two files needed the change. Five paid for it. And the two test fakes belong to OrderProcessor and RefundHandler — tests for charging broke because reconciliation grew a feature. That is the blast radius the series keeps measuring: files touched per change, and unrelated tests broken.
Symptom 3: test doubles nobody can read
The fat interface also decides what your tests look like. To unit-test the tax rule inside OrderProcessor, you need a PaymentOperations, which means eight methods of ceremony around one line that matters:
class OrderProcessorTest {
// 40 lines of double for a test about charging
private final PaymentOperations gateway = new PaymentOperations() {
@Override public PaymentResult charge(Order o, BigDecimal amt) {
return PaymentResult.success("pay_1", amt);
}
@Override public RefundResult refund(String id, BigDecimal amt) { throw new UnsupportedOperationException(); }
@Override public void capture(String id) { throw new UnsupportedOperationException(); }
@Override public void voidAuthorization(String id) { throw new UnsupportedOperationException(); }
@Override public ReconciliationReport reconcile(LocalDate d) { throw new UnsupportedOperationException(); }
@Override public byte[] exportReport(LocalDate f, LocalDate t) { throw new UnsupportedOperationException(); }
@Override public List<Payout> listPayouts(LocalDate d) { throw new UnsupportedOperationException(); }
@Override public void updateWebhookUrl(String url) { throw new UnsupportedOperationException(); }
};
}
Mocking frameworks hide this cost rather than removing it — mock(PaymentOperations.class) is one line, but the mock still answers eight methods, and a stray call to reconcile() now returns null instead of failing loudly. When a hand-written test double is painful to write, the interface is too wide. That pain is the most reliable ISP signal you get, and it arrives long before anyone opens a design document.
The fix: split by client need
Take the caller table and let it name the interfaces. Each one is the smallest contract that makes a single client’s job possible:
public interface PaymentGateway {
PaymentResult charge(Order order, BigDecimal amount);
}
public interface RefundService {
RefundResult refund(String paymentId, BigDecimal amount);
}
public interface PaymentAuthorization {
void capture(String authorizationId);
void voidAuthorization(String authorizationId);
}
public interface PaymentReporting {
ReconciliationReport reconcile(LocalDate day);
byte[] exportReport(LocalDate from, LocalDate to);
List<Payout> listPayouts(LocalDate day);
String settlementBatchId(LocalDate day);
}
Notice what the split is not. It is not one interface per method, and it is not one interface per provider. PaymentAuthorization keeps two methods because no caller ever captures without also needing to void, and PaymentReporting keeps four because the reconciliation and finance jobs move as a unit. Cohesion is the grouping rule; the client’s job is the boundary.
updateWebhookUrl disappeared entirely. It was never a payment operation — it is provider configuration, and it belongs to whatever admin code owns provider setup, not to an interface that business logic depends on.
Clients now ask for what they use
The declarations get shorter, and the constructor becomes documentation:
public class OrderProcessor {
private final PaymentGateway gateway; // one method, one reason to care
public OrderProcessor(PaymentGateway gateway) {
this.gateway = gateway;
}
public void process(Order order) {
BigDecimal payable = pricing.payableFor(order);
PaymentResult result = gateway.charge(order, payable);
// ...
}
}
public class FinanceReportJob {
private final PaymentReporting reporting;
public FinanceReportJob(PaymentReporting reporting) {
this.reporting = reporting;
}
public byte[] monthly(LocalDate from, LocalDate to) {
return reporting.exportReport(from, to);
}
}
Add settlementBatchId now and only PaymentReporting plus its real implementations change. OrderProcessor does not recompile, its tests do not break, and GiftCardGateway has nothing to lie about — it never claimed to report.
The test double shrinks to something you can read in one glance:
PaymentGateway gateway = (order, amount) -> PaymentResult.success("pay_1", amount);
A one-method interface is a functional interface, so the double is a lambda. That is not a trick; it is what “client-shaped” buys you.
Implementations compose the pieces
Splitting the interfaces does not force you to split the classes. StripeGateway genuinely does all four jobs, so it declares all four:
public class StripeGateway
implements PaymentGateway, RefundService, PaymentAuthorization, PaymentReporting {
// one class, one Stripe client, four contracts it can honestly keep
}
public class GiftCardGateway implements PaymentGateway, RefundService {
// two methods, zero no-ops, zero exceptions
}
This is the point people miss when they hear “many small interfaces” and picture an explosion of classes. Interfaces are per client; classes are per implementation. One well-factored class can implement six interfaces without gaining a single line.
And GiftCardGateway now says something true about the domain: gift cards charge and refund, and they do not settle. The type system carries that fact instead of a comment.
Note: Java lets you keep the umbrella if some caller really needs everything: interface FullPaymentProvider extends PaymentGateway, RefundService, PaymentAuthorization, PaymentReporting {}. Useful for a provider-conformance test suite. Do not let application code depend on it — the moment OrderProcessor takes a FullPaymentProvider, you are back to eight methods with extra steps.
Wiring stays at the edge
One object, several roles, resolved where the application is assembled:
public static void main(String[] args) {
StripeGateway stripe = new StripeGateway(System.getenv("STRIPE_KEY"));
OrderProcessor processor = new OrderProcessor(stripe); // as PaymentGateway
RefundHandler refunds = new RefundHandler(stripe); // as RefundService
FinanceReportJob finance = new FinanceReportJob(stripe); // as PaymentReporting
// gift cards can only take the charge/refund roles — enforced by the compiler
OrderProcessor giftCardFlow = new OrderProcessor(new GiftCardGateway(balances));
}
Each client sees the narrowest view of the same instance. Handing the details in rather than constructing them is the next principle in the series, and it is what makes this wiring possible at all.
How small is too small
ISP is easy to over-apply, and the failure mode is real: eleven single-method interfaces, each with one implementation, and a constructor that takes six of them to do one workflow. Now understanding the charge path means opening eleven files instead of one.
Use these to decide:
| Signal | Meaning |
|---|---|
| Two clients need different subsets | Split — that is the seam |
| Implementations stub methods out | Split — the interface is lying |
| Adding a method breaks unrelated tests | Split — wrong things are coupled |
| Every client calls all methods | Leave it — the interface is cohesive |
| Methods always change together | Leave it — one reason to change |
| Split would create a one-impl, one-client type | Leave it — indirection with no payoff |
The trigger is two clients with different needs, not a method count. A four-method interface where every caller uses all four is fine and should stay whole. Splitting it is the cargo-cult failure from Part 1: indirection with no second reason to change.
ISP is not SRP with interfaces
These two get conflated in review comments, and the distinction is what makes each one actionable:
- SRP looks at a class and asks how many audiences can force it to change. It is about who owns the code.
- ISP looks at an interface and asks how much of it each caller ignores. It is about what callers are coupled to.
StripeGateway implementing four interfaces is fine under ISP and might still be an SRP problem if the reporting code and the charging code change for different reasons on different schedules. Fixing the interfaces did not fix the class. They are separate questions asked from opposite ends of the dependency.
Cheat sheet
Principle: no client depends on methods it does not use
Unit: the interface, judged from the caller's side
Trigger: two clients need different subsets of one interface
Smells: no-op overrides, UnsupportedOperationException, 40-line test doubles,
unrelated modules recompiling when one method is added
Fix: name interfaces after the client's job, not the provider
group methods that always travel together
let one class implement several small interfaces
keep config/admin methods out of business-facing contracts
Stop when: every client uses every method, or the split yields
one interface with one impl and one caller
Scoreboard: methods a client depends on vs. methods it calls
Do:
- Write the client-to-method table before you split anything.
- Name interfaces after use cases —
PaymentGateway,RefundService,PaymentReporting. - Let a capable class implement several narrow interfaces.
- Prefer the narrowest type in constructor parameters and method signatures.
- Treat a painful hand-written test double as evidence, and shrink the interface.
- Move provider configuration out of interfaces that policy code depends on.
Don’t:
- Add a method to a shared interface because one implementation happens to support it.
- Ship overrides that no-op or throw to satisfy the compiler.
- Split until every interface has exactly one method.
- Depend on an umbrella
extends-everything interface from application code. - Assume a mocking framework removed the coupling — it only hid the stubs.
- Split a cohesive interface whose callers all use the whole thing.
Wrap-up
Interface Segregation is measured from the caller’s side. The question is never “how many methods does this interface have,” it is “how many of them does this client ignore.” OrderProcessor calling one method out of eight was the defect; the seven it ignored are what made a reconciliation ticket break the charging tests.
The mechanical fix is small: read the caller table, cut interfaces along client jobs, and let one class implement several of them. What you get back is honest types — GiftCardGateway no longer pretends to reconcile — and a build that only recompiles code the change actually concerns.
The main method above quietly assumed something: that OrderProcessor receives a gateway instead of building one. That assumption is the last letter, and without it none of these narrow interfaces reach the code that needs them.