How does the Interface Segregation Principle apply at a remote/service API boundary rather than inside one codebase, and what are the failure modes of over-segregating there?
answer
- same principle, different currency: release-time not compile-time
- BFF, consumer-driven contracts, field masks, scoped credentials
- CQRS = ISP at service scale
- over-segregation → chatty N+1, sagas, nano-services
- segregate types aggressively, deployables conservatively
basics
~20 sThe same idea scales up: don't force every consumer onto one giant shared API. Give each consumer group a contract shaped to its needs. But splitting too far gives chatty calls, many contracts to version, and heavy operational overhead — the fix costs more than the fat interface at some point.
solid answer
~50 sAcross a network the client's dependency is not compile-time but **release-time and runtime**: a fat shared contract means a change wanted by one consumer forces re-verification and often coordinated redeploys of all of them, and every consumer receives payload fields and capabilities it does not need. ISP-shaped answers include a backend-for-frontend per client type, consumer-driven contracts (each consumer publishes the subset it depends on and the provider tests against them), field-selection protocols such as GraphQL, and splitting one service interface into role-scoped ones with separately scoped credentials. The pull the other way is real: each extra remote boundary adds a round trip, a version to maintain, retries/timeouts/idempotency, observability, and a deploy pipeline. Over-segregation produces chatty N+1 remote calls, distributed sagas where a single call once sufficed, and contract sprawl no one can reason about. The judgment is that remote segregation should follow **consumer groups and change cadence**, not method counts — and that in-process role interfaces are nearly free while remote ones are not.
code
protobuf · 23 lines// Fat, shared contract: every consumer is coupled to all of it,
// and one credential grants read + write + admin.
service Catalog {
rpc GetProduct(GetProductReq) returns (Product);
rpc SearchProducts(SearchReq) returns (SearchResp);
rpc UpsertProduct(UpsertReq) returns (Product);
rpc ReindexAll(ReindexReq) returns (ReindexResp); // ops-only
rpc BulkImport(ImportReq) returns (ImportResp); // ops-only
}
// Segregated by consumer ROLE, not by method count.
// Same process/deployable is fine; scopes and change cadence now differ.
service CatalogRead { // scope: catalog.read (storefront, mobile)
rpc GetProduct(GetProductReq) returns (Product);
rpc SearchProducts(SearchReq) returns (SearchResp);
}
service CatalogWrite { // scope: catalog.write (merchandising tools)
rpc UpsertProduct(UpsertReq) returns (Product);
}
service CatalogAdmin { // scope: catalog.admin (ops only)
rpc ReindexAll(ReindexReq) returns (ReindexResp);
rpc BulkImport(ImportReq) returns (ImportResp);
}go deeper
Say the idea generalizes — don't make every consumer depend on one huge API — and that too many tiny APIs means lots of network calls.
Name concrete mechanisms (per-client BFF, field selection, read/write split) and the main cost, chattiness plus more contracts to version.
Contrast the cost model in-process vs remote, bring in consumer-driven contracts, scoped credentials, CQRS, and give a criterion for when to split.
Own the trade-off explicitly: segregate types aggressively and deployables conservatively; tie boundaries to consumer cadence, trust, scaling, and team ownership; account for sagas, tail latency, versioning policy, and operational budget; treat consumer-driven contracts and field projection as ways to get ISP's benefit without a new boundary.
## Restating ISP one level up ISP: *clients should not be forced to depend on methods they do not use.* Nothing in that sentence mentions classes. At a service boundary the same defect appears with different currency: | | In-process | Across a network | |---|---|---| | The client's "dependency" | compile-time type reference | published contract + deployed version | | Cost of an unused member | recompile, mock, review | consumer re-verification, coordinated release, over-broad permissions, wasted payload | | Cost of splitting | one more type (≈ free) | one more endpoint/service: round trip, version, SLO, pipeline, on-call | The asymmetry in the last row is the whole answer: **in-process segregation is nearly free; remote segregation is not.** Applying ISP identically at both levels is the classic senior-to-principal mistake. ## Symptoms of a fat contract at a service boundary 1. **Release lockstep.** A field added for one consumer triggers regression runs, sign-off, or redeploy across all consumers of the shared contract. 2. **Over-fetching.** Mobile clients download desktop-sized payloads; bandwidth, battery, and parse cost paid for unused fields. 3. **Over-authorization.** One API key or token scope grants read *and* write *and* admin because there is one interface. Least privilege is impossible to express. 4. **Optional-field swamp.** The response schema is mostly nullable because different consumers need different subsets — the payload equivalent of `UnsupportedOperationException`. 5. **Mode parameters.** `?view=mobile|desktop|partner` branching inside one endpoint: capability negotiation leaking to runtime, exactly like a `supports()` probe. 6. **Unclear ownership.** Nobody can change the contract because nobody knows the full consumer set. ## ISP-shaped remedies (named, with what each actually buys) - **Backend for Frontend (BFF)** — one tailored aggregation API per client type (web, iOS, partner). Each client depends only on its own contract, which can change at that client's cadence. Cost: duplicated aggregation logic and one more deployable per client type. - **Consumer-driven contracts (CDC)** — each consumer publishes the subset of the provider's behavior it relies on; the provider runs those as tests. This makes the *actual* dependency explicit and lets the provider change anything no consumer asserts. It is ISP as an executable artifact, without splitting the deployment. - **Field selection / projection** — GraphQL, sparse fieldsets (`?fields=`), gRPC `FieldMask`, OData `$select`. Consumers request exactly what they use, so payload-level ISP is achieved with one endpoint. Cost: query complexity, caching difficulty, N+1 resolver risk, and harder authorization/rate-limiting. - **Role-scoped interfaces + scoped credentials** — split a service's surface into read/write/admin contracts with distinct OAuth scopes or API keys. Least privilege becomes enforceable, not just documented. - **Command/query split (CQRS)** — a strong, well-known ISP-at-scale case: read models and write models have genuinely different consumers, shapes, scaling profiles, and change cadences. - **Versioning strategy** — additive-only evolution plus tolerant readers (ignore unknown fields) reduces the pressure to split at all; often the cheaper answer. ## Failure modes of over-segregation (the part interviewers listen for) 1. **Chattiness / N+1 over the network.** Five one-purpose calls replacing one aggregate call turns a 20 ms operation into 5 round trips with tail latency compounding. Latency is the resource in-process interfaces don't spend. 2. **Distributed transactions.** Splitting a coherent operation across boundaries turns an in-process transaction into a saga with compensation logic, idempotency keys, and partial-failure states. 3. **Contract sprawl.** Twelve narrow contracts each need versioning, docs, deprecation policy, client SDKs, and monitoring. The cognitive and ops budget dwarfs the fat interface you fled. 4. **Nano-services / operational overhead.** Each deployable adds pipeline, alerts, dashboards, on-call, capacity, and security patching. Team size and topology (Conway's law) should bound the number of boundaries. 5. **Duplicated aggregation.** Multiple BFFs re-implementing the same joins, drifting apart. 6. **Consistency ambiguity.** Narrow reads served by different services can disagree; the caller must now reason about staleness that a single call never exposed. ## The judgment rule Segregate a **remote** boundary when consumers differ in **who they are, how fast they change, what they are allowed to do, or how they scale** — not because a contract has many operations. Concretely: - Different **change cadence** (mobile ships monthly, internal service ships daily) → split. - Different **trust/authz level** (partner vs internal) → split, with scopes. - Different **shape/latency needs** (mobile vs analytics export) → split, or use projection. - Same consumers, same cadence, only "the interface is big" → **do not split**; use projection, tolerant readers, and CDC instead. Also: keep ISP *inside* the service regardless. Narrow in-process role interfaces cost nothing and preserve the option to extract a boundary later. The mature move is to segregate types aggressively and deployables conservatively. ## How to phrase it in an interview "ISP generalizes — the client shouldn't be coupled to capabilities it doesn't use — but the unit of cost changes. In-process the cost of a split is one more type; across a network it's a round trip, a version, an SLO, and a pager. So I use consumer-driven contracts and field projection to get ISP's benefit without a new boundary, and I only split the boundary when consumers differ in cadence, trust, or scaling. Over-segregating gets you chatty calls, sagas replacing transactions, and contract sprawl."
- Doesn't GraphQL make service-level ISP unnecessary, since every client selects exactly the fields it wants?It solves payload-level over-fetching elegantly, but not the rest: authorization and rate limiting get harder because the query shape is dynamic, resolver N+1 must be batched, caching is weaker than with fixed endpoints, and the schema is still one shared contract with one change cadence and one blast radius. It is a strong tool for field-level segregation, not a substitute for boundary judgment.
- How do consumer-driven contracts implement ISP without splitting the service?Each consumer publishes an executable expectation covering only the operations and fields it actually uses; the provider runs all of them in CI. The provider then knows precisely what it may change freely — the true dependency set is documented and enforced instead of assumed — which is the ISP benefit (no coupling to unused parts) with zero new deployables.
- When would you deliberately keep a fat shared API?When all consumers are internal, ship on the same cadence, share a trust level, and the operation set is genuinely cohesive; when latency budgets punish extra round trips; or when the team cannot operate more deployables. Add tolerant readers and additive-only versioning and revisit only when a real consumer divergence appears.
- How does this interact with team structure?Conway's law: the boundaries you can maintain are bounded by the teams who own them. A contract split that no team owns end-to-end degrades into a shared-everything API with extra hops. Align remote segregation with ownership, then let in-process role interfaces carry the finer-grained separation.
A restaurant with one 200-item menu forces every guest to wade through everything and makes the kitchen re-print for all guests whenever one dish changes. Separate lunch, kids', and allergen menus fix that. But giving every dish its own laminated card means guests flag the waiter ten times per meal — the fix has its own cost, paid in round trips.
saying these in an interview costs you the question
- Mechanically applying "one interface per role" to remote services and calling nano-services good design
- Ignoring latency and partial failure — the costs that make remote splitting fundamentally different from in-process splitting
- Claiming microservices are ISP applied to architecture, with no mention of round trips, sagas, or operational cost
- Assuming GraphQL fully solves it, without acknowledging authz, caching, and N+1 resolver problems
- Splitting boundaries by method count rather than by consumer cadence, trust, or scaling profile
- Never mentioning versioning, tolerant readers, or consumer-driven contracts as cheaper alternatives to splitting