Loading learning content...
When we scale from class-level design to system architecture, the stakes of interface design multiply exponentially. A fat interface within a single codebase causes inconvenience and technical debt. A fat interface between modules—between packages, libraries, or microservices—creates organizational dysfunction.
Module boundaries are the seams of your system. They define what can change independently, what can be deployed separately, what can scale autonomously, and what teams can own without constant coordination. Get these boundaries wrong, and your 'distributed system' becomes a distributed monolith—all the complexity of microservices with none of the benefits.
The cost at module boundaries compounds:
By the end of this page, you will understand how to apply ISP at package, library, and service boundaries. You'll learn techniques for designing module interfaces that enable true independence, strategies for versioning segregated interfaces, and patterns for preventing monolithic coupling in distributed systems.
A module boundary exists wherever there's a defined interface between independently-developable units. These boundaries occur at multiple scales:
Package/Namespace Level: Within a monorepo or single application, packages group related code with explicit public APIs. Other packages import from the public surface; internal details are hidden. Languages enforce this differently—Go uses package-level visibility, Java uses module-info.java, TypeScript uses package.json exports.
Library Level: Reusable libraries published as artifacts (npm packages, Maven JARs, NuGet packages) have strict API boundaries. Consumers only access exported types. This is the most common form of cross-organization interface.
Service Level: Microservices communicate through network interfaces—REST APIs, gRPC contracts, message schemas. The interface is literally a wire protocol. Changes require understanding of backward/forward compatibility.
Bounded Context Level: In Domain-Driven Design, bounded contexts represent semantic boundaries. Each context has its own ubiquitous language and data model. Interfaces between contexts must translate between models.
At every level, ISP applies: Do not expose more through the boundary than consumers need.
| Boundary Type | Coupling Mechanism | Change Propagation | ISP Violation Impact |
|---|---|---|---|
| Package | Import statements, public types | Recompilation required | Slower builds, broader test scope |
| Library | Package dependency + versioning | Upgrade required | Forced upgrades, dependency conflicts |
| Service | Network call, API contract | Contract breach | Runtime failures, service outages |
| Bounded Context | Translation layer | Model drift | Integration complexity, data inconsistency |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// MODULE BOUNDARY EXAMPLES AT EACH SCALE // ====== PACKAGE LEVEL ======// A package's public API is its boundary // packages/order-service/src/index.ts (public API)export { OrderService } from './services/order-service';export { IOrderReader, IOrderWriter } from './interfaces';export type { Order, OrderStatus } from './types'; // These are NOT exported (internal implementation)// - OrderRepository (internal persistence)// - OrderValidator (internal logic)// - OrderEventPublisher (internal eventing) // ====== LIBRARY LEVEL ======// Published library with segregated public types // @company/payments (npm package)// package.json exports field defines the public surface{ "exports": { "./processor": "./dist/processor/index.js", "./types": "./dist/types/index.js", "./testing": "./dist/testing/index.js" }} // Consumer imports only what they needimport { ChargeProcessor } from '@company/payments/processor';// Cannot import internal modules not in exports // ====== SERVICE LEVEL ======// Each endpoint is part of the module boundary // OpenAPI spec defines the service boundary// Segregated by resource and capability // Order reads: GET /api/orders/*// Order writes: POST/PUT /api/orders/* // Order admin: DELETE /api/orders/* // Each is a separate logical interface// Different clients invoke different subsets // ====== BOUNDED CONTEXT LEVEL ======// Anti-corruption layer translates between contexts // Order Context views customer differently than Customer Contextinterface OrderCustomer { id: string; name: string; shippingAddress: Address; // Only what Order Context needs} interface CustomerContextCustomer { id: string; profile: CustomerProfile; preferences: CustomerPreferences; paymentMethods: PaymentMethod[]; orderHistory: OrderReference[]; // Full customer model} // Translation layer at boundaryclass CustomerAntiCorruptionLayer { async getOrderCustomer(customerId: string): Promise<OrderCustomer> { const fullCustomer = await this.customerService.get(customerId); // Translate to Order Context's minimal view return { id: fullCustomer.id, name: fullCustomer.profile.displayName, shippingAddress: fullCustomer.profile.primaryAddress, }; }}A distributed monolith is a system that has the deployment topology of microservices but the coupling characteristics of a monolith. Every service change requires coordinated updates across multiple services. Deployments are synchronized 'trains' where everything releases together.
The root cause is almost always fat module interfaces.
When a service exposes a kitchen-sink API—every operation, every data shape, every capability in one undifferentiated surface—consumers end up depending on capabilities they don't use. Any change to the API, even to methods the consumer never calls, becomes a potential breaking change.
Symptoms of fat module interfaces:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// ❌ FAT SERVICE API: Creates distributed monolith // The Product Service exposes everything through one API// Every consumer, regardless of needs, depends on all of this // product-service/api.proto (gRPC definition)service ProductService { // Catalog browsing (used by Storefront) rpc GetProduct(GetProductRequest) returns (Product); rpc ListProducts(ListProductsRequest) returns (ListProductsResponse); rpc SearchProducts(SearchRequest) returns (SearchResponse); // Catalog management (used by CMS) rpc CreateProduct(CreateProductRequest) returns (Product); rpc UpdateProduct(UpdateProductRequest) returns (Product); rpc DeleteProduct(DeleteProductRequest) returns (Empty); rpc BulkImport(BulkImportRequest) returns (BulkImportResponse); // Inventory (used by Warehouse) rpc GetInventory(GetInventoryRequest) returns (InventoryLevel); rpc AdjustInventory(AdjustInventoryRequest) returns (InventoryLevel); rpc ReserveInventory(ReserveRequest) returns (Reservation); rpc ReleaseReservation(ReleaseRequest) returns (Empty); // Pricing (used by Checkout) rpc GetPrice(GetPriceRequest) returns (Price); rpc CalculateDiscount(DiscountRequest) returns (DiscountResult); rpc GetBundlePricing(BundleRequest) returns (BundlePrice); // Analytics (used by BI Tools) rpc GetProductAnalytics(AnalyticsRequest) returns (AnalyticsData); rpc GetPerformanceMetrics(MetricsRequest) returns (Metrics);} // PROBLEMS: // 1. Storefront only uses 3 endpoints but depends on entire gRPC package// Including types for Inventory, Pricing, Analytics it never uses // 2. When Pricing adds a new field to DiscountRequest:// - Storefront build breaks (it must recompile with new proto)// - Storefront must redeploy (even though it doesn't use Pricing)// - Integration tests fail until Storefront upgrades // 3. When Inventory is slow, ProductService is slow// - Storefront times out getting products// - Even though it never calls inventory endpoints! // 4. Version upgrade requires all 5 consumers to upgrade simultaneously// - Checkout, Warehouse, CMS, Storefront, BI Tools// - Must coordinate releases across 5 teams // Result: Five microservices that must deploy together = Distributed MonolithPutting an API Gateway in front of fat backend APIs doesn't fix ISP violations—it just moves them. The gateway becomes the fat interface. Consumers still depend on the entire gateway contract, and backend changes still propagate through to all consumers.
Service interfaces can be segregated just like class interfaces. Instead of one monolithic API, you expose multiple smaller APIs—each serving a specific consumer type or capability domain.
Strategies for service segregation:
1. Consumer-Specific APIs Create different API surfaces for different consumer types. A public API for external consumers, an internal API for sister services, an admin API for operations.
2. Capability-Based APIs Group endpoints by capability domain. ReadAPI for queries, WriteAPI for mutations, StreamAPI for real-time subscriptions.
3. Multiple Protocol Endpoints Expose different interfaces via different protocols. REST for simple consumers, gRPC for high-performance internal services, GraphQL for flexible frontend queries.
4. Backend-for-Frontend (BFF) Pattern Create dedicated backend services for each frontend type. Web BFF, Mobile BFF, Partner BFF—each exposes only what its frontend needs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// ✅ SEGREGATED SERVICE INTERFACES // Instead of one fat ProductService, create focused services/APIs // ====== STRATEGY 1: Consumer-Specific APIs ====== // For external consumers (public internet)// product-service/api/public/products.protoservice PublicProductCatalog { rpc GetProduct(ProductId) returns (PublicProduct); rpc ListProducts(ListRequest) returns (PublicProductList); rpc SearchProducts(SearchQuery) returns (SearchResults);}// - Rate limited// - Returns sanitized data (no internal IDs, no cost info)// - Stable contract, versioned carefully // For internal services// product-service/api/internal/products.proto service InternalProductService { rpc GetProductWithInternals(ProductId) returns (InternalProduct); rpc GetInventoryPosition(ProductId) returns (InventoryPosition); rpc ReserveStock(ReserveRequest) returns (Reservation);}// - No rate limits// - Includes internal data// - Can evolve faster within organization // For operations/admin// product-service/api/admin/products.protoservice ProductAdmin { rpc CreateProduct(CreateRequest) returns (Product); rpc BulkImport(stream ProductData) returns (ImportResult); rpc PurgeDeletedProducts() returns (PurgeResult);}// - Requires admin auth// - Potentially dangerous operations// - Protected from external exposure // ====== STRATEGY 2: Capability-Based APIs ====== // Read operations (high volume, cacheable)service ProductQueries { rpc GetProduct(ProductId) returns (Product); rpc ListByCategory(CategoryId) returns (ProductList); rpc SearchProducts(Query) returns (SearchResults);}// Deployed on read-optimized infrastructure// Horizontally scaled// Cache-friendly // Write operations (lower volume, transactional)service ProductCommands { rpc CreateProduct(CreateProductCommand) returns (ProductId); rpc UpdateProduct(UpdateProductCommand) returns (Empty); rpc ArchiveProduct(ProductId) returns (Empty);}// Deployed with write guarantees// Event sourcing enabled// Audit logging // Real-time updates (WebSocket/SSE)service ProductEvents { rpc SubscribeToUpdates(SubscriptionRequest) returns (stream ProductEvent);}// Deployed on event infrastructure// Long-lived connections// Different scaling characteristics // ====== STRATEGY 3: Backend-for-Frontend ====== // Web application needs rich product data// web-bff/services/product-display.tsclass WebProductDisplayService { async getProductPage(productId: string): Promise<ProductPageData> { // Aggregates from multiple backend services const [product, reviews, recommendations] = await Promise.all([ this.productCatalog.getProduct(productId), this.reviewService.getReviewSummary(productId), this.recommendationService.getSimilar(productId), ]); return this.composeProductPage(product, reviews, recommendations); }} // Mobile app needs minimal data (bandwidth constrained)// mobile-bff/services/product-card.tsclass MobileProductCardService { async getProductCard(productId: string): Promise<ProductCard> { const product = await this.productCatalog.getProduct(productId); // Returns only essential fields return { id: product.id, name: product.name, thumbnailUrl: product.images[0].thumbnailUrl, price: product.price.display, }; }} // Result: Each consumer depends only on their BFF's contract// Backend services can evolve without affecting consumers directly| Strategy | Best For | Trade-off | Example |
|---|---|---|---|
| Consumer-Specific | Different trust levels, different SLAs | Multiple APIs to maintain | Public vs Internal vs Admin APIs |
| Capability-Based | Different scaling needs, CQRS | More services to deploy | QueryAPI vs CommandAPI |
| BFF Pattern | Diverse frontend needs | Per-frontend backend | Web BFF, Mobile BFF, Partner BFF |
| Protocol-Based | Different integration patterns | Protocol expertise needed | REST + gRPC + GraphQL |
Within a codebase, packages (or modules, namespaces) provide internal boundaries. Applying ISP at this level prevents one package from becoming entangled with another, enabling independent development within the same repository.
Public API Surface:
Every package has a public API—the types and functions it exports. This is the interface other packages see. Just like class interfaces, package APIs should expose only what consumers need.
Package structure for ISP:
Single responsibility packages — Each package should have one clear purpose. @company/payment-processing does payment processing, not user management.
Explicit exports — Only export what's part of the public contract. Internal utilities, helpers, and implementation details stay internal.
Type-only exports when possible — If consumers only need types (for typesafe programming), export types separately from implementations. This reduces runtime coupling.
Separate client from provider — When a package provides both an interface and an implementation, consider splitting them. @company/payment-types for contracts, @company/payment-stripe for Stripe implementation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// PACKAGE-LEVEL ISP: Structuring packages for minimal coupling // ====== MONOREPO STRUCTURE ====== // packages/// ├── payment-types/ # Types only - no runtime dependencies// │ ├── src/// │ │ ├── index.ts # Re-exports all public types// │ │ ├── charge.ts # ChargeRequest, ChargeResult types// │ │ ├── refund.ts # RefundRequest, RefundResult types// │ │ └── common.ts # Money, PaymentMethod shared types// │ └── package.json # Zero dependencies!// │// ├── payment-contracts/ # Interfaces only - depends on types// │ ├── src/// │ │ ├── index.ts// │ │ ├── processor.ts # IPaymentProcessor interface// │ │ ├── gateway.ts # IPaymentGateway interface// │ │ └── events.ts # IPaymentEventHandler interface// │ └── package.json # depends on payment-types// │// ├── payment-stripe/ # Stripe implementation// │ ├── src/// │ │ ├── index.ts// │ │ └── stripe-processor.ts// │ └── package.json # depends on payment-contracts, stripe-sdk// │// └── payment-braintree/ # Braintree implementation// ├── src/// │ ├── index.ts// │ └── braintree-processor.ts// └── package.json # depends on payment-contracts, braintree-sdk // ====== PACKAGE: payment-types ======// Purpose: Type definitions only, no runtime code // packages/payment-types/src/index.tsexport type { ChargeRequest, ChargeResult } from './charge';export type { RefundRequest, RefundResult } from './refund';export type { Money, PaymentMethod, PaymentError } from './common'; // Note: No 'export' without 'type' - forces consumers to recognize these are types // ====== PACKAGE: payment-contracts ======// Purpose: Interface definitions, depends only on types // packages/payment-contracts/src/processor.tsimport type { ChargeRequest, ChargeResult, RefundRequest, RefundResult } from '@company/payment-types'; export interface IPaymentProcessor { charge(request: ChargeRequest): Promise<ChargeResult>; refund(request: RefundRequest): Promise<RefundResult>;} // packages/payment-contracts/src/index.tsexport type { IPaymentProcessor } from './processor';export type { IPaymentGateway } from './gateway';// Re-export types for convenienceexport type * from '@company/payment-types'; // ====== CONSUMER: checkout-service ======// Only depends on contracts package - no Stripe SDK! // services/checkout/src/checkout.tsimport type { IPaymentProcessor, ChargeRequest } from '@company/payment-contracts'; class CheckoutService { constructor(private payments: IPaymentProcessor) {} async processCheckout(order: Order): Promise<CheckoutResult> { const chargeRequest: ChargeRequest = { amount: order.total, paymentMethod: order.selectedPayment, }; const result = await this.payments.charge(chargeRequest); // ... }} // CheckoutService dependencies:// - @company/payment-contracts (interfaces + types)// // CheckoutService does NOT depend on:// - @company/payment-stripe (Stripe SDK)// - @company/payment-braintree (Braintree SDK)// - Any payment provider implementation // ====== DEPENDENCY INJECTION WIRES IT UP ====== // services/checkout/src/composition-root.tsimport { StripeProcessor } from '@company/payment-stripe';import { CheckoutService } from './checkout'; // Only the composition root knows about the implementationconst processor = new StripeProcessor(config.stripe);const checkout = new CheckoutService(processor);Separating types into their own package is a powerful ISP technique. The types package has zero runtime dependencies, so consuming it is essentially free. Consumers get type safety without pulling in implementation dependencies. This is especially valuable for shared domain types used across many packages.
One of the biggest benefits of interface segregation at module boundaries is independent versioning. Instead of one monolithic version that all consumers must upgrade to, each segregated interface can version independently.
Semantic Versioning for Segregated Interfaces:
Each interface follows its own semver lifecycle:
Independent Evolution:
If IOrderReader adds a new method, that's a minor version bump for IOrderReader. Consumers of IOrderWriter don't need to upgrade—they're on a different interface.
If IOrderWriter changes a method signature (breaking change), that's a major version bump for IOrderWriter. Consumers of IOrderReader are unaffected.
Deprecation and Migration:
With segregated interfaces, you can deprecate and migrate one interface at a time. No big-bang migrations—consumers of other interfaces continue working while you transition one group.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// VERSIONING SEGREGATED INTERFACES INDEPENDENTLY // ====== PACKAGE VERSIONS ====== // Each segregated interface is in its own package with its own version // @company/order-reader@2.1.0// - v2.0.0: Initial stable release// - v2.1.0: Added getOrderSummary() method (backward compatible)export interface IOrderReader { getOrder(id: string): Order; listOrders(filter: OrderFilter): Order[]; getOrderSummary(id: string): OrderSummary; // NEW in v2.1.0} // @company/order-writer@1.3.2// - v1.0.0: Initial stable release // - v1.3.0: Added updateStatus() method// - v1.3.2: Bug fix in validationexport interface IOrderWriter { createOrder(data: CreateOrderData): Order; updateOrder(id: string, data: UpdateOrderData): Order; updateStatus(id: string, status: OrderStatus): void; // Added v1.3.0} // @company/order-admin@3.0.0// - Major version because deleteOrder semantics changed// - v2.x: deleteOrder() was soft delete// - v3.0.0: deleteOrder() is hard delete, added archiveOrder() for soft deleteexport interface IOrderAdmin { deleteOrder(id: string): void; // BREAKING: Now hard delete archiveOrder(id: string): void; // NEW: For soft delete behavior purgeArchived(olderThan: Date): number;} // ====== CONSUMER DEPENDENCIES ====== // Checkout Service (only reads orders)// package.json{ "dependencies": { "@company/order-reader": "^2.0.0" // Any 2.x version works // Does NOT depend on order-writer or order-admin! }} // Order Management UI (reads and writes)// package.json{ "dependencies": { "@company/order-reader": "^2.1.0", // Needs getOrderSummary "@company/order-writer": "^1.3.0" // Needs updateStatus }} // Admin Tools (needs everything including dangerous operations)// package.json{ "dependencies": { "@company/order-reader": "^2.0.0", "@company/order-writer": "^1.0.0", "@company/order-admin": "^3.0.0" // Upgraded to new delete semantics }} // ====== API VERSIONING FOR SERVICES ====== // For HTTP APIs, version in the path or header // Version 1 - Original fat API (deprecated)// GET /api/v1/orders/:id// POST /api/v1/orders// DELETE /api/v1/orders/:id// GET /api/v1/orders/:id/analytics// ... 20 more endpoints // Version 2 - Segregated by concern// Order reading// GET /api/v2/order-queries/orders/:id// GET /api/v2/order-queries/orders // Order mutations// POST /api/v2/order-commands/orders// PUT /api/v2/order-commands/orders/:id // Admin operations (separate auth required)// DELETE /api/v2/order-admin/orders/:id// POST /api/v2/order-admin/purge // Analytics (separate service entirely)// GET /api/v2/order-analytics/orders/:id/metrics // ====== MIGRATION PATH ====== // Consumers can migrate one interface at a time // Phase 1: Migrate read-heavy services to v2 order-queries// - Checkout Service: v1 → v2 order-queries (2 weeks) // Phase 2: Migrate order creation flows to v2 order-commands// - Order UI: v1 → v2 order-commands (3 weeks) // Phase 3: Migrate admin tools to v2 order-admin// - Admin Dashboard: v1 → v2 order-admin (1 week) // Phase 4: Deprecate and remove v1// - After all consumers migrated// - v1 endpoints return 410 Gone| Scenario | Fat Interface | Segregated Interfaces |
|---|---|---|
| Breaking change in one capability | All consumers must upgrade | Only affected interface's consumers upgrade |
| Adding new capability | New version for entire API | New version only for relevant interface |
| Deprecation | Deprecate entire API | Deprecate only the obsolete interface |
| Version matrix complexity | One version, but coupled consumers | Multiple versions, but independent consumers |
| Upgrade coordination | Big-bang synchronized upgrade | Rolling independent upgrades |
When interfaces cross module boundaries, traditional unit tests aren't enough. You need contract tests that verify both sides of the interface agree on the contract—without requiring end-to-end integration testing.
Consumer-Driven Contract Testing:
Consumers define the contract they expect. Providers verify they satisfy all consumer contracts. This inverts the traditional model where providers dictate and consumers comply.
Benefits with ISP:
With segregated interfaces, each consumer only contracts for the interface they use. Contracts are smaller and more focused. Provider changes that don't affect any consumer's contract pass verification—true backward compatibility checking.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// CONTRACT TESTING FOR SEGREGATED INTERFACES // ====== CONSUMER SIDE: Define expected contract ====== // Checkout Service expects Order Reader interface// checkout-service/tests/contracts/order-reader.contract.ts import { PactV4, SpecificationVersion } from '@pact-foundation/pact'; const orderReaderContract = new PactV4({ consumer: 'CheckoutService', provider: 'OrderService', spec: SpecificationVersion.V4,}); describe('Order Reader Contract', () => { it('should return order by ID', async () => { await orderReaderContract .addInteraction() .given('order 123 exists') .uponReceiving('a request for order 123') .withRequest({ method: 'GET', path: '/api/v2/order-queries/orders/123', }) .willRespondWith({ status: 200, headers: { 'Content-Type': 'application/json' }, body: { id: '123', status: Matchers.string('pending'), items: Matchers.eachLike({ productId: Matchers.string(), quantity: Matchers.integer(), }), total: { amount: Matchers.decimal(), currency: Matchers.string('USD'), }, }, }) .executeTest(async (mockServer) => { const client = new OrderReaderClient(mockServer.url); const order = await client.getOrder('123'); expect(order.id).toBe('123'); }); });}); // This contract specifies ONLY what CheckoutService needs// It does NOT specify admin endpoints, analytics, etc. // ====== PROVIDER SIDE: Verify all consumer contracts ====== // order-service/tests/contract-verification/verify.ts import { Verifier } from '@pact-foundation/pact'; describe('Order Service Contract Verification', () => { it('should satisfy all consumer contracts', async () => { const verifier = new Verifier({ provider: 'OrderService', providerBaseUrl: 'http://localhost:3000', pactBrokerUrl: 'https://pact-broker.company.io', // State handlers for test setup stateHandlers: { 'order 123 exists': async () => { await testDb.orders.create({ id: '123', status: 'pending', items: [{ productId: 'prod-1', quantity: 2 }], total: { amount: 99.99, currency: 'USD' }, }); }, }, }); await verifier.verifyProvider(); });}); // ====== SEGREGATED CONTRACT BENEFITS ====== // With fat interface:// - All consumers specify contracts for all endpoints// - Any endpoint change potentially breaks many contracts// - Contract verification is slow (many interactions) // With segregated interfaces:// // CheckoutService contracts: Only order-queries endpoints// - GET /order-queries/orders/:id// - GET /order-queries/orders (with minimal fields)// // AdminDashboard contracts: order-queries + order-commands + order-admin// - GET /order-queries/orders/:id (with more fields)// - POST /order-commands/orders// - DELETE /order-admin/orders/:id//// AnalyticsService contracts: Only order-analytics endpoints// - GET /order-analytics/orders/:id/metrics // Result:// - Adding admin endpoint doesn't affect CheckoutService contract// - Changing analytics format doesn't affect AdminDashboard contract// - Each consumer's contract is small and focusedPact is the most popular consumer-driven contract testing tool, supporting many languages. Spring Cloud Contract is popular in Java/Spring ecosystems. For GraphQL, Apollo Studio provides schema checking. For gRPC, protolock prevents breaking changes to .proto files.
Let's examine proven patterns from production systems that apply ISP at module boundaries. These patterns address common challenges in large-scale distributed systems.
Pattern 1: Layered API Exposure A single service exposes multiple API surfaces, each scoped for different consumers. Each layer has its own authentication, rate limits, and SLA.
Pattern 2: Event-Carried State Transfer Instead of interfaces between services, use domain events. Each consumer subscribes only to events they care about—natural ISP through event filtering.
Pattern 3: GraphQL with Persisted Queries Clients define their data needs through persisted queries. Each client registers the exact fields they use. Backend only guarantees those specific fields for that client.
Pattern 4: Shared Kernel with Minimal Surface For truly shared domain concepts, create a minimal shared kernel—just the essential types. Everything else is translated at boundaries.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
// PATTERN 1: Layered API Exposure // Single service, three API surfacesclass ProductService { // Public API - exposed to internet @PublicAPI({ rateLimit: '100/min', auth: 'api-key' }) getPublicProduct(id: string): PublicProductDTO { // Returns sanitized data } // Internal API - exposed to sister services @InternalAPI({ auth: 'service-mesh' }) getProductWithInternals(id: string): InternalProduct { // Returns full internal state } // Admin API - exposed to ops tools @AdminAPI({ auth: 'admin-jwt', audit: true }) modifyProduct(id: string, changes: ProductChanges): void { // Dangerous operations }} // Each surface is documented separately// Each surface can evolve independently// Breaking change in Admin doesn't affect Public // PATTERN 2: Event-Carried State Transfer // Order Service publishes eventsclass OrderService { async completeOrder(orderId: string): Promise<void> { const order = await this.orders.get(orderId); order.complete(); await this.orders.save(order); // Publish event with full state await this.events.publish(new OrderCompletedEvent({ orderId: order.id, customerId: order.customerId, items: order.items, shippingAddress: order.shippingAddress, total: order.total, completedAt: new Date(), })); }} // Each consumer subscribes to what they need (ISP via filtering) // Shipping only cares about addressshippingService.subscribe(OrderCompletedEvent, (event) => { const { orderId, shippingAddress, items } = event; this.createShipment(orderId, shippingAddress, items);}); // Analytics only cares about amountsanalyticsService.subscribe(OrderCompletedEvent, (event) => { const { orderId, total, completedAt } = event; this.recordSale(orderId, total, completedAt);}); // Email only cares about customeremailService.subscribe(OrderCompletedEvent, (event) => { const { orderId, customerId } = event; this.sendConfirmation(customerId, orderId);}); // PATTERN 3: GraphQL with Persisted Queries // Client registers the exact query they'll use// mobile-app-persisted-queries.graphql # Query ID: mobile-product-card-v1query MobileProductCard($id: ID!) { product(id: $id) { id name thumbnailUrl price { display } }} # Query ID: mobile-product-detail-v1 query MobileProductDetail($id: ID!) { product(id: $id) { id name description images { url } price { display, original } inStock }} // Backend only guarantees these specific fields for this client// If 'description' field changes, only clients using it are affected// MobileProductCard client is NOT affected // PATTERN 4: Shared Kernel with Minimal Surface // Minimal shared types - only the absolute essentials// packages/shared-kernel/src/index.tsexport type { EntityId, // UUID string branded type Money, // { amount: number, currency: string } DateRange, // { start: Date, end: Date }} from './primitives'; export type { Result, // { success: true, data: T } | { success: false, error: E } PagedResult, // { items: T[], paging: Paging }} from './patterns'; // That's IT. No domain objects, no business logic.// Each bounded context defines its own domain types. // Order Contextinterface InternalOrder { id: EntityId; // From shared kernel items: OrderItem[]; // Order Context's type total: Money; // From shared kernel} // Shipping Context interface Shipment { id: EntityId; // From shared kernel parcels: Parcel[]; // Shipping Context's type}Module boundaries are where ISP pays the biggest dividends. Get them right, and you have truly independent components. Get them wrong, and you have a distributed monolith with all the complexity and none of the benefits.
What's Next:
With module boundaries understood, we'll explore how ISP applies specifically to API design—the interfaces exposed to external consumers. API design introduces additional constraints around backward compatibility, discoverability, and documentation that require careful ISP application.
You now understand how to apply ISP at the module level—packages, libraries, and services. Apply these principles to create system architectures where components are truly independent: independently developed, deployed, versioned, and scaled.