Loading learning content...
APIs are contracts with the outside world. Unlike internal interfaces that you control on both sides, external APIs have consumers you may never meet, using your API in ways you never anticipated. Once published, an API is difficult to change—breaking changes break trust.
This permanence makes ISP critically important in API design. Every endpoint you expose, every field you return, every parameter you accept becomes a commitment. Fat APIs lock you into maintaining functionality that no one uses. They force breaking changes when loosely related methods evolve. They confuse new integrators with overwhelming options.
The API designer's dilemma:
You want to expose enough for clients to accomplish their goals, but not so much that you're locked into maintaining a kitchen sink forever. You want flexibility for diverse client needs, but not so much flexibility that the API becomes unpredictable. You want backward compatibility, but not at the cost of never improving.
ISP provides the framework for navigating this dilemma: expose minimal, focused interfaces that do one thing well. Let clients compose multiple interfaces rather than depending on one that does everything.
By the end of this page, you will understand how to apply ISP to REST, GraphQL, gRPC, and event-driven APIs. You'll learn techniques for segmenting APIs, strategies for backward-compatible evolution, and patterns used by companies like Stripe, Twilio, and GitHub to build best-in-class API surfaces.
Fat APIs extract a hidden toll on every stakeholder: the API provider, the API consumers, and ultimately the end users. Understanding these costs motivates disciplined API segregation.
Costs to the Provider:
Maintenance burden — Every endpoint requires ongoing support: documentation updates, regression tests, performance monitoring, security patches. Double the endpoints means double the work.
Breaking change paralysis — When everything's in one API, any change risks breaking something. Teams become afraid to improve, leading to stagnation.
Technical debt accumulation — Unused features can't be removed without 'breaking' changes. Dead code persists, making the codebase increasingly difficult to work with.
Performance challenges — Fat endpoints often return more data than clients need. Over-fetching wastes bandwidth, slows response times, and increases compute costs.
Costs to Consumers:
Integration complexity — More endpoints mean more to learn, more documentation to read, more decisions to make. Onboarding takes weeks instead of hours.
Upgrade risk — When the API versions, consumers must audit their entire integration, even for changes to endpoints they don't use.
SDK bloat — Client SDKs must include code for all endpoints, even when the consumer only uses a fraction.
Testing overhead — Integration tests must account for the full API surface, even when only a subset is relevant.
| Company | Fat API Problem | Measured Cost | ISP Solution |
|---|---|---|---|
| E-commerce Platform | Product API with 200+ fields | 3.2s average response time | Sparse fieldsets reduced to 180ms |
| Payment Processor | Single /payments endpoint handling 15 payment types | 6 weeks average integration time | Segmented by payment type: 1 week integration |
| Social Platform | User profile API returning everything | 47% payload was unused by 90% of consumers | Profile segments reduced payload 85% |
| Analytics Service | Query API accepting 50+ parameters | 80% of support tickets were usage questions | Purpose-specific endpoints reduced tickets 70% |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// THE FAT API PROBLEM IN PRACTICE // ❌ Fat endpoint returning everything// GET /api/users/:id{ "id": "user-123", "email": "user@example.com", "profile": { "firstName": "Jane", "lastName": "Doe", "avatar": "https://...", "bio": "...", "location": "..." }, "settings": { "emailNotifications": true, "smsNotifications": false, "darkMode": true, // ... 30 more settings }, "subscription": { "plan": "premium", "validUntil": "2025-12-31", "paymentMethod": { /* sensitive! */ } }, "securitySettings": { "twoFactorEnabled": true, "lastPasswordChange": "2024-01-15", "sessions": [ /* potentially sensitive */ ] }, "adminMetadata": { // Should this even be here? "accountFlags": [...], "internalNotes": "..." }} // PROBLEMS:// 1. Mobile app showing user avatar fetches 50 KB instead of 500 bytes// 2. Profile display page gets subscription/payment data it shouldn't have// 3. Any change to settings structure breaks all consumers// 4. Security audit must review this massive attack surface// 5. Adding a new field to any section = new API version for everyone // ✅ Segregated APIs by purpose // GET /api/users/:id/identity// For authentication/authorization flows{ "id": "user-123", "email": "user@example.com", "emailVerified": true} // GET /api/users/:id/profile// For display purposes{ "id": "user-123", "displayName": "Jane Doe", "avatar": "https://...", "bio": "..."} // GET /api/users/:id/settings // For settings screens{ "emailNotifications": true, "smsNotifications": false, "theme": "dark"} // GET /api/users/:id/subscription// For billing pages (requires elevated permission){ "plan": "premium", "validUntil": "2025-12-31", "seats": 5} // Result:// - Each endpoint has clear purpose and audience// - Mobile avatar display: 500 bytes, not 50 KB// - Settings can evolve without touching profile// - Security surface per endpoint is minimal// - New subscription field doesn't affect settings consumersREST APIs benefit from ISP through careful resource decomposition and endpoint design. Several proven patterns help create segregated REST APIs that serve diverse clients without becoming bloated.
Pattern 1: Resource Decomposition
Break monolithic resources into focused sub-resources. Instead of one /users endpoint that does everything, have /users/profile, /users/settings, /users/notifications.
Pattern 2: Purpose-Specific Endpoints
Create endpoints for specific use cases rather than generic CRUD. /checkout/initiate, /checkout/complete, /checkout/abandon rather than generic /orders with complex state management.
Pattern 3: Sparse Fieldsets
Allow clients to request only the fields they need. GET /products/123?fields=id,name,price returns minimal data. Makes the API feel right-sized for every client.
Pattern 4: API Versioning by Segment Version different API segments independently. Read APIs can be stable while write APIs evolve. Admin APIs can break without affecting public APIs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
// REST API SEGREGATION PATTERNS // ====== PATTERN 1: Resource Decomposition ====== // ❌ Monolithic resource// GET /api/orders/:id - returns absolutely everything about an order // ✅ Decomposed resources// GET /api/orders/:id/summary - Basic order info// GET /api/orders/:id/items - Line items only// GET /api/orders/:id/shipping - Shipping details// GET /api/orders/:id/payment - Payment info (elevated permissions)// GET /api/orders/:id/timeline - Status history class OrderAPIController { // Summary: Most common access pattern @Get('/orders/:id/summary') async getOrderSummary(orderId: string): Promise<OrderSummary> { return { id: orderId, status: 'processing', total: { amount: 99.99, currency: 'USD' }, createdAt: new Date('2024-01-15'), }; } // Items: Shopping cart views, order confirmation @Get('/orders/:id/items') async getOrderItems(orderId: string): Promise<OrderItem[]> { return this.orderService.getItems(orderId); } // Shipping: Delivery tracking integrations @Get('/orders/:id/shipping') async getOrderShipping(orderId: string): Promise<ShippingDetails> { return this.orderService.getShipping(orderId); } // Payment: Internal billing systems only @Get('/orders/:id/payment') @RequireScope('billing:read') // Access control per resource async getOrderPayment(orderId: string): Promise<PaymentDetails> { return this.orderService.getPayment(orderId); }} // ====== PATTERN 2: Purpose-Specific Endpoints ====== // ❌ Generic CRUD with complex state// POST /api/orders + { status: 'draft' }// PUT /api/orders/:id + { status: 'submitted' }// PUT /api/orders/:id + { status: 'cancelled' } // ✅ Purpose-specific actionsclass CheckoutAPIController { // Clear purpose: start a checkout @Post('/checkout/initiate') async initiateCheckout(cart: CartContents): Promise<CheckoutSession> { return this.checkoutService.initiate(cart); } // Clear purpose: complete the checkout @Post('/checkout/:sessionId/complete') async completeCheckout( sessionId: string, payment: PaymentInfo ): Promise<CompletedOrder> { return this.checkoutService.complete(sessionId, payment); } // Clear purpose: abandon checkout @Post('/checkout/:sessionId/abandon') async abandonCheckout(sessionId: string): Promise<void> { return this.checkoutService.abandon(sessionId); } // Clear purpose: resume later @Post('/checkout/:sessionId/save-for-later') async saveForLater(sessionId: string): Promise<SavedCart> { return this.checkoutService.saveForLater(sessionId); }} // ====== PATTERN 3: Sparse Fieldsets ====== // Client requests only what it needs// GET /api/products/123?fields=id,name,price class ProductAPIController { @Get('/products/:id') async getProduct( productId: string, @Query('fields') fields?: string ): Promise<Partial<Product>> { const product = await this.productService.get(productId); if (fields) { const requestedFields = fields.split(','); return this.projection(product, requestedFields); } // Default: return summary fields, not everything return this.projection(product, ['id', 'name', 'price', 'thumbnail']); } private projection<T extends object>( obj: T, fields: string[] ): Partial<T> { const result: Partial<T> = {}; for (const field of fields) { if (field in obj) { result[field as keyof T] = obj[field as keyof T]; } } return result; }} // Usage:// Mobile app avatar: GET /products/123?fields=id,thumbnail// Product page: GET /products/123?fields=id,name,description,price,images// Admin view: GET /products/123?fields=* (requires admin scope) // ====== PATTERN 4: Segment-Based Versioning ====== // Different API segments version independently // Public catalog: Very stable, rarely changes// GET /api/v2/catalog/products/:id // Order management: Moderate changes// POST /api/v3/orders// GET /api/v3/orders/:id // Admin operations: More frequent changes// DELETE /api/v4/admin/products/:id// POST /api/v4/admin/products/bulk-import // Each segment can evolve independently// Upgrading admin API doesn't require catalog API upgradeThe JSON:API specification standardizes sparse fieldsets with parameters like ?fields[articles]=title,body. Following established standards means clients already know how to request minimal data, and there are existing client libraries that handle the complexity.
GraphQL is often praised as solving many API problems—clients request exactly what they need. But GraphQL doesn't automatically provide ISP benefits. A poorly designed GraphQL schema can be just as problematic as a fat REST API.
GraphQL's ISP Tension:
GraphQL encourages large, interconnected schemas. The appeal is that clients can traverse the graph: from User to Orders to Products to Reviews in one query. But this connectivity can create hidden couplings. If every type connects to every other type, changing any type potentially affects every query.
ISP Principles for GraphQL:
Schema domains — Organize schema into distinct domains (types for Products, types for Orders) with minimal cross-references.
Interface segregation in schema — Use GraphQL interface types to expose different views. Node, Displayable, Searchable let queries depend only on relevant capabilities.
Persisted queries — Lock down exactly which queries each client can execute. This creates an explicit contract per client, enabling independent evolution.
Federated schemas — Break large schemas into subgraphs owned by different teams. Each subgraph is its own bounded context with its own release cycle.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
# GRAPHQL ISP PATTERNS # ====== INTERFACE SEGREGATION IN SCHEMA ====== # Define capability interfacesinterface Node { id: ID!} interface Displayable { displayName: String! thumbnailUrl: String} interface Searchable { searchableText: String! searchRank: Float} interface Purchasable { price: Money! inStock: Boolean!} # Types implement only relevant interfacestype Product implements Node & Displayable & Searchable & Purchasable { id: ID! displayName: String! thumbnailUrl: String searchableText: String! searchRank: Float price: Money! inStock: Boolean! # Product-specific fields description: String! variants: [ProductVariant!]!} type Article implements Node & Displayable & Searchable { id: ID! displayName: String! thumbnailUrl: String searchableText: String! searchRank: Float # Article-specific fields content: String! author: User!} # Queries can use interfaces for generic operationstype Query { # Search returns anything searchable search(query: String!): [Searchable!]! # Display components work with any Displayable featuredItems: [Displayable!]! # Shopping cart only deals with Purchasable cartItems: [Purchasable!]!} # ====== DOMAIN SEPARATION ====== # Keep domains loosely coupled# Order domain references Product by ID, not full type type Order implements Node { id: ID! status: OrderStatus! items: [OrderItem!]! # NOT: products: [Product!]! (would couple domains)} type OrderItem { productId: ID! # Reference by ID quantity: Int! unitPrice: Money! # Snapshot at order time # Resolver fetches Product when needed, not automatic coupling product: Product # Nullable - product might not exist anymore} # ====== FEDERATED SUBGRAPHS ====== # Product subgraph (owned by Catalog team)# products-subgraph/schema.graphqlextend type Query { product(id: ID!): Product products(filter: ProductFilter): ProductConnection!} type Product @key(fields: "id") { id: ID! name: String! price: Money!} # Order subgraph (owned by Orders team) # orders-subgraph/schema.graphqlextend type Query { order(id: ID!): Order myOrders: OrderConnection!} type Order @key(fields: "id") { id: ID! status: OrderStatus! items: [OrderItem!]!} type OrderItem { product: Product! # Resolved via federation quantity: Int!} # Extend Product to add order-specific fieldextend type Product @key(fields: "id") { id: ID! @external # Add field when accessed in order context recentOrders: [Order!]! @requires(fields: "id")} # ====== PERSISTED QUERIES ====== # Register exactly what each client needs # mobile-app-queries.graphql# Query: ProductCard (id: "mobile-product-card-v1")query ProductCard($id: ID!) { product(id: $id) { id displayName thumbnailUrl price { formatted } }} # Query: ProductDetail (id: "mobile-product-detail-v1")query ProductDetail($id: ID!) { product(id: $id) { id displayName description images { url altText } price { formatted original } inStock variants { id name price { formatted } } }} # Only these registered queries are allowed# Schema changes affecting unregistered fields don't break mobile appHighly connected GraphQL schemas can encourage N+1 query patterns that destroy performance. ISP-inspired domain separation helps: when Order doesn't eagerly connect to full Product objects, you avoid the temptation to traverse deep graphs in a single query.
gRPC uses Protocol Buffers to define strongly-typed service contracts. The .proto file is the interface—changes to it affect all clients using the generated code. ISP is essential here because protobuf compilation creates tight coupling.
gRPC ISP Principles:
Multiple services, not mega-services — Break capabilities into separate gRPC service definitions. Each service can be in its own proto file, versioned independently.
Request/Response type hygiene — Each RPC should have its own request and response types. Don't share types across unrelated RPCs—changes ripple unexpectedly.
Protocol buffers package separation — Organize protos into packages matching service boundaries. Clients import only the packages they need.
Backward-compatible evolution — Use proto3's backward compatibility rules (add fields, don't rename/remove) to evolve without breaking clients.
Service composition over mega-services — If a client needs multiple capabilities, they call multiple services. The client composes; the server doesn't bundle.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
// GRPC ISP PATTERNS // ====== MEGA-SERVICE ANTI-PATTERN ====== // ❌ One service doing everythingservice ECommerceService { rpc GetProduct(GetProductRequest) returns (Product); rpc ListProducts(ListProductsRequest) returns (ProductList); rpc CreateOrder(CreateOrderRequest) returns (Order); rpc ProcessPayment(PaymentRequest) returns (PaymentResult); rpc ShipOrder(ShipOrderRequest) returns (ShippingInfo); rpc GetUserProfile(GetUserRequest) returns (UserProfile); // ... 50 more RPCs} // Problems:// - All clients import all types (Shipping, Payment, User...)// - Changing any RPC regenerates entire client// - One service = one team = bottleneck // ====== SEGREGATED SERVICES ====== // ✅ Separate services per capability // products.proto - Catalog team ownspackage ecommerce.catalog.v1; service ProductCatalog { rpc GetProduct(GetProductRequest) returns (Product); rpc ListProducts(ListProductsRequest) returns (ProductList); rpc SearchProducts(SearchRequest) returns (SearchResults);} message Product { string id = 1; string name = 2; MoneyAmount price = 3; // ... product fields} // orders.proto - Orders team ownspackage ecommerce.orders.v1; service OrderService { rpc CreateOrder(CreateOrderRequest) returns (Order); rpc GetOrder(GetOrderRequest) returns (Order); rpc ListOrders(ListOrdersRequest) returns (OrderList);} message Order { string id = 1; OrderStatus status = 2; repeated OrderItem items = 3; // References product by ID, doesn't embed Product message // This prevents coupling orders.proto to products.proto} message OrderItem { string product_id = 1; // Reference by ID int32 quantity = 2; MoneyAmount unit_price = 3; // Snapshot, not from Product} // payments.proto - Payments team ownspackage ecommerce.payments.v1; service PaymentProcessor { rpc ProcessPayment(ProcessPaymentRequest) returns (PaymentResult); rpc RefundPayment(RefundRequest) returns (RefundResult); rpc GetPaymentStatus(GetPaymentStatusRequest) returns (PaymentStatus);} // shipping.proto - Shipping team ownspackage ecommerce.shipping.v1; service ShippingService { rpc CreateShipment(CreateShipmentRequest) returns (Shipment); rpc TrackShipment(TrackRequest) returns (TrackingInfo); rpc GetShippingRates(RatesRequest) returns (ShippingRates);} // ====== TYPE HYGIENE ====== // ✅ Each RPC gets its own request/response types // DON'T: Share request types// rpc GetProduct(GenericIdRequest) returns (Product);// rpc GetOrder(GenericIdRequest) returns (Order); // DO: Separate request types (can evolve independently)message GetProductRequest { string product_id = 1; repeated string fields = 2; // Sparse fieldset support} message GetOrderRequest { string order_id = 1; bool include_items = 2; // Order-specific option bool include_timeline = 3;} // ====== BACKWARD COMPATIBLE EVOLUTION ====== // Version 1message Product { string id = 1; string name = 2; MoneyAmount price = 3;} // Version 2 - Backward compatible (adding fields)message Product { string id = 1; string name = 2; MoneyAmount price = 3; // NEW FIELDS - old clients ignore these string description = 4; repeated string image_urls = 5; bool in_stock = 6; // DEPRECATED - kept for compatibility reserved 7; // Was: string old_category_id reserved "old_category_id";} // Clients using old proto still work// Only need new proto to access new fields| Practice | Why It Matters | Example |
|---|---|---|
| Separate proto files per service | Independent compilation and versioning | products.proto, orders.proto, payments.proto |
| Separate packages | Clients import only what they need | ecommerce.catalog.v1, ecommerce.orders.v1 |
| Request/response type per RPC | Changes don't ripple unexpectedly | GetProductRequest, ListProductsRequest |
| Reference by ID, not embedding | Decouples proto definitions | string product_id, not Product product |
| Reserved fields for deprecation | Prevents field number reuse bugs | reserved 7; reserved "old_field"; |
Event-driven architectures replace request-response APIs with publish-subscribe messaging. While this provides natural decoupling, events are also APIs that need ISP discipline. Event schemas become contracts just as binding as HTTP endpoints.
Events as Segregated Interfaces:
Each event type is essentially an interface. Producers emit events; consumers depend on event structure. ISP for events means:
Purpose-specific events — Not one UserUpdated with 50 fields; separate events like UserProfileUpdated, UserPasswordChanged, UserSubscriptionChanged.
Minimal event payloads — Events carry only what consumers need. If you're unsure, carry IDs and let consumers fetch full data if they need it (event notification, not event-carried state transfer for everything).
Event versioning — Schema registry with versioned events. Consumers can consume at their preferred version while producers evolve.
Topic segregation — Separate topics by consumer type or event category. This is physical ISP—consumers only subscribe to relevant topics.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
// EVENT-DRIVEN ISP PATTERNS // ====== FAT EVENT ANTI-PATTERN ====== // ❌ One event for all user changesinterface UserUpdatedEvent { type: 'UserUpdated'; userId: string; timestamp: Date; // Every possible change email?: string; password?: string; // Security: should this be in events at all? profile?: { firstName?: string; lastName?: string; avatar?: string; bio?: string; }; settings?: { emailNotifications?: boolean; theme?: string; // ... 30 more settings }; subscription?: { plan?: string; validUntil?: Date; }; // ... more sections} // Problems:// - Consumers must filter for relevant changes// - Sensitive data (password) in events// - Any profile change triggers subscription system// - Can't version independently // ====== SEGREGATED EVENTS ====== // ✅ Purpose-specific events // Profile changes (marketing, personalization consumers)interface UserProfileUpdatedEvent { type: 'user.profile.updated'; version: '1.0'; userId: string; timestamp: Date; changes: { displayName?: string; avatar?: string; bio?: string; };} // Auth events (security, audit consumers)interface UserPasswordChangedEvent { type: 'user.auth.password_changed'; version: '1.0'; userId: string; timestamp: Date; // Note: NO password in payload! ipAddress: string; userAgent: string;} // Subscription events (billing, access control consumers)interface UserSubscriptionChangedEvent { type: 'user.subscription.changed'; version: '1.0'; userId: string; timestamp: Date; previousPlan: string; newPlan: string; effectiveDate: Date;} // Settings events (only settings-dependent systems care)interface UserSettingsChangedEvent { type: 'user.settings.changed'; version: '1.0'; userId: string; timestamp: Date; changedSettings: string[]; // Just which settings, not values // Detail available via settings service if needed} // ====== TOPIC SEGREGATION ====== // Separate topics for different event streams // topics/user-profiles - Marketing, personalization subscribe// topics/user-auth - Security, audit, session management subscribe// topics/user-billing - Billing, access control subscribe// topics/user-activity - Analytics, ML training subscribe // Consumers subscribe to relevant topics onlyclass ProfilePersonalizationService { constructor(private kafka: Kafka) { // Only subscribes to profile topic this.kafka.subscribe('user-profiles'); } async handle(event: UserProfileUpdatedEvent): Promise<void> { await this.updatePersonalizationModel(event.userId, event.changes); }} class BillingService { constructor(private kafka: Kafka) { // Only subscribes to billing topic this.kafka.subscribe('user-billing'); } async handle(event: UserSubscriptionChangedEvent): Promise<void> { await this.updateBillingRecords(event.userId, event.newPlan); }} // ====== EVENT VERSIONING ====== // Schema registry tracks event versions// Producers can emit new version, old consumers still work // Version 1.0interface OrderCreatedEventV1 { type: 'order.created'; version: '1.0'; orderId: string; userId: string; total: number; // Simple number} // Version 1.1 - Backward compatibleinterface OrderCreatedEventV1_1 { type: 'order.created'; version: '1.1'; orderId: string; userId: string; total: MoneyAmount; // Richer type currency: string; // New field // Consumers on 1.0 can ignore new fields} // Version 2.0 - Breaking change (new topic!)interface OrderCreatedEventV2 { type: 'order.created.v2'; // Different type version: '2.0'; orderId: string; userId: string; total: MoneyAmount; lineItems: OrderLineItem[]; // Structure changed} // Publish to different topic for breaking version// kafka.publish('orders-v2', orderCreatedV2);Event Notification says 'something happened, fetch details if you care.' Event-Carried State Transfer says 'here's all the data you need.' ISP favors notification for segregation—consumers only pull data they actually need. Use ECST when consumer latency or producer availability matters more than payload size.
The greatest advantage of segregated APIs is evolution flexibility. Instead of maintaining one monolithic version that all consumers depend on, you maintain smaller surfaces that can evolve independently. Here are strategies for backward-compatible evolution of segregated APIs.
Strategy 1: Additive Changes Only The safest evolution: only add new fields, new endpoints, new methods. Never remove or rename. Old clients ignore new fields; new clients can use them.
Strategy 2: Expansion/Contraction Pattern For breaking changes without breaking clients: expand (add new version alongside old), migrate (move clients to new), contract (remove old when unused).
Strategy 3: Version in Request
Clients declare which version they expect. Server responds accordingly. This can be via path (/v2/), header (API-Version: 2), or content negotiation.
Strategy 4: Feature Flags for API Changes Bigger than a version bump but smaller than a breaking change. Clients opt into new behavior with a flag.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
// BACKWARD COMPATIBILITY STRATEGIES // ====== STRATEGY 1: ADDITIVE CHANGES ====== // Version 1 responseinterface ProductResponseV1 { id: string; name: string; price: number;} // Version 1.1 - Only additionsinterface ProductResponseV1_1 { id: string; name: string; price: number; // New fields - old clients ignore these description?: string; images?: string[]; inStock?: boolean;} // Server always returns V1.1// V1 clients work (ignore new fields)// V1.1 clients get new features // ====== STRATEGY 2: EXPAND/CONTRACT ====== // Phase 1: EXPAND - Add new endpoint alongside old// Old: GET /api/v1/orders/:id (returns OrderV1)// New: GET /api/v2/orders/:id (returns OrderV2) // Both work simultaneously// Clients can migrate at their own pace // Phase 2: MIGRATE - Track usage, nudge clientsasync function getOrderV1Handler(req: Request): Promise<OrderV1> { // Log deprecation warning logger.warn('v1/orders deprecated', { client: req.headers['x-client-id'], date: new Date() }); // Still works return this.orderService.getOrderV1(req.params.id);} // Phase 3: CONTRACT - Remove when usage is zero// After monitoring shows no v1 traffic// DELETE endpoint, clean up code // ====== STRATEGY 3: VERSION IN REQUEST ====== class ProductAPIController { @Get('/products/:id') async getProduct( @Param('id') id: string, @Header('API-Version') version: string = '1.0' ): Promise<ProductResponse> { const product = await this.productService.get(id); // Return appropriate version switch (version) { case '2.0': return this.toV2Response(product); case '1.1': return this.toV1_1Response(product); case '1.0': default: return this.toV1Response(product); } } private toV1Response(product: Product): ProductV1 { return { id: product.id, name: product.name, price: product.price.amount, }; } private toV2Response(product: Product): ProductV2 { return { id: product.id, name: product.name, price: { amount: product.price.amount, currency: product.price.currency, formatted: this.formatPrice(product.price), }, inventory: { inStock: product.inventory > 0, quantity: product.inventory, }, }; }} // ====== STRATEGY 4: FEATURE FLAGS ====== class OrderAPIController { @Post('/orders') async createOrder( @Body() order: CreateOrderRequest, @Header('X-Enable-Beta-Features') betaFeatures?: string ): Promise<OrderResponse> { const features = this.parseFeatureFlags(betaFeatures); // Create order with feature-dependent behavior const result = await this.orderService.create(order, { usePredictiveInventory: features.includes('predictive-inventory'), useNewPricingEngine: features.includes('pricing-v2'), }); // Response includes feature-dependent fields return { ...this.toOrderResponse(result), // Beta fields only present when feature enabled predictedDelivery: features.includes('predictive-delivery') ? await this.deliveryPredictor.predict(result) : undefined, }; }} // Client: GET /orders/123// X-Enable-Beta-Features: predictive-delivery,pricing-v2 // Allows gradual rollout of new behavior// Clients opt-in, no forced upgrade| Strategy | When to Use | Complexity | Client Impact |
|---|---|---|---|
| Additive Changes | New optional fields | Low | None - clients ignore new fields |
| Expand/Contract | Breaking changes to structure | Medium | Migration period required |
| Version in Request | Multiple versions long-term | Medium | Client specifies version explicitly |
| Feature Flags | Optional new behavior | Low | Opt-in, gradual adoption |
World-class API providers like Stripe, Twilio, and GitHub apply ISP principles rigorously. Here are patterns from their APIs that demonstrate ISP in production.
Stripe's Expandable Fields:
By default, Stripe returns minimal data with IDs for related objects. Clients use ?expand[]= to request full objects inline. This is ISP—clients declare exactly what they need.
GitHub's Hypermedia: GitHub returns URLs for related resources instead of embedded data. Clients follow links to fetch what they need. Natural ISP—each endpoint is minimal, clients compose.
Twilio's Resource-Per-Capability: Twilio has separate resources for SMS, Voice, Email. You don't get a mega communication endpoint—each capability is independent with its own versioning.
Slack's Scoped OAuth: Slack grants permissions per capability. An app requesting only chat:write doesn't get access to user profiles. API access is segregated by function.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
// REAL-WORLD API PATTERNS // ====== STRIPE: EXPANDABLE FIELDS ====== // Default response: Minimal, with references// GET /v1/charges/ch_123 { "id": "ch_123", "amount": 2000, "currency": "usd", "customer": "cus_456", // Just the ID "payment_method": "pm_789", // Just the ID "status": "succeeded"} // Expanded response: Client requests what they need// GET /v1/charges/ch_123?expand[]=customer&expand[]=payment_method { "id": "ch_123", "amount": 2000, "currency": "usd", "customer": { // Full object when expanded "id": "cus_456", "email": "customer@example.com", "name": "Jane Doe" }, "payment_method": { // Full object when expanded "id": "pm_789", "type": "card", "card": { "brand": "visa", "last4": "4242" } }, "status": "succeeded"} // Implementation principle:// - Default: minimal response// - Expand: client explicitly requests more// - Each expansion is independent // ====== GITHUB: HYPERMEDIA LINKS ====== // Response includes URLs to related resources// GET /repos/owner/repo/issues/1 { "id": 1, "title": "Found a bug", "state": "open", "user": { "login": "octocat", "url": "https://api.github.com/users/octocat" }, "labels_url": "https://api.github.com/repos/owner/repo/issues/1/labels", "comments_url": "https://api.github.com/repos/owner/repo/issues/1/comments", "events_url": "https://api.github.com/repos/owner/repo/issues/1/events"} // Client decides what to fetch// - Display only? Don't follow any links// - Need comments? Fetch comments_url// - Need full user? Fetch user.url// Each resource is independently addressable // ====== TWILIO: RESOURCE PER CAPABILITY ====== // SMS - its own resource, versioning, docs// POST /2010-04-01/Accounts/{AccountSid}/Messages // Voice - completely separate// POST /2010-04-01/Accounts/{AccountSid}/Calls // Verify (2FA) - separate product, separate API// POST /v2/Services/{ServiceSid}/Verifications // Each capability:// - Has own API version (2010-04-01, v2)// - Has own resource structure// - Can evolve independently// - Has own rate limits and pricing // ====== SLACK: SCOPED OAUTH ====== // App requests specific scopesconst slackOAuthConfig = { scopes: [ 'chat:write', // Post messages 'channels:read', // List channels // NOT: users:read (we don't need user profiles) // NOT: admin.* (we're not an admin app) ]}; // API endpoints require matching scope// POST /api/chat.postMessage - requires chat:write ✓// GET /api/channels.list - requires channels:read ✓// GET /api/users.info - requires users:read ✗ (denied!) // ISP principle:// - Each capability has its own scope// - Clients request only needed scopes// - Server enforces scope-based access// - Minimal privilege automatically // ====== APPLYING THESE PATTERNS ====== class ProductAPIController { @Get('/products/:id') async getProduct( @Param('id') id: string, @Query('expand') expand?: string[] ): Promise<ProductResponse> { const product = await this.productService.get(id); const response: ProductResponse = { id: product.id, name: product.name, price: product.price, // References by default category_id: product.categoryId, brand_id: product.brandId, // Hypermedia links _links: { self: `/products/${product.id}`, category: `/categories/${product.categoryId}`, brand: `/brands/${product.brandId}`, reviews: `/products/${product.id}/reviews`, } }; // Stripe-style expansion if (expand?.includes('category')) { response.category = await this.categoryService.get(product.categoryId); } if (expand?.includes('brand')) { response.brand = await this.brandService.get(product.brandId); } return response; }}APIs are contracts you often can't easily change. Applying ISP to API design means building APIs that clients can use partially—they depend only on the endpoints and fields they actually need. This enables independent evolution, reduces integration complexity, and creates APIs that scale gracefully with diverse client needs.
Module Complete:
You have now completed the ISP and Dependency Management module. You understand how ISP reduces transitive dependencies, enables interface-based decoupling, applies to module boundaries, and shapes world-class API design. These principles are foundational for building large-scale systems that can evolve, scale, and adapt without becoming entangled monoliths.
You've mastered ISP for dependency management. Apply these concepts to create systems with clean dependency graphs, independent modules, and APIs that serve diverse clients without becoming bloated. Next, explore ISP in Practice for real-world examples and common pitfalls.