I'll Be Backend
Principles · TypeScript

Interface Segregation: split a fat interface

Give each caller an interface with only the methods it calls.

14 steps4 chapters~6 min at 1.25×#solid#interfaces#isp#decoupling
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the TypeScript video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–10

Payoff

Steps 11–12

Recap

Steps 13–14

The code: before and after

Before

interface Inventory {
  stockOf(sku: string): number;
  reserve(sku: string, qty: number): string;
  restock(sku: string, qty: number): string;
}

class WarehouseInventory implements Inventory {
  private readonly stock = new Map([["mug", 12]]);

  stockOf(sku: string): number {
    return this.stock.get(sku) ?? 0;
  }

  reserve(sku: string, qty: number): string {
    this.stock.set(sku, this.stockOf(sku) - qty);
    return `reserved ${qty} ${sku}`;
  }

  restock(sku: string, qty: number): string {
    this.stock.set(sku, this.stockOf(sku) + qty);
    return `restocked ${qty} ${sku}`;
  }
}

class ProductPage {
  show(inventory: Inventory, sku: string) {
    return `${sku}: ${inventory.stockOf(sku)} in stock`;
  }
}

class Checkout {
  buy(inventory: Inventory, sku: string, qty: number) {
    return inventory.reserve(sku, qty);
  }
}

class Receiving {
  receive(inventory: Inventory, sku: string, qty: number) {
    return inventory.restock(sku, qty);
  }
}

function shop(): string[] {
  const warehouse = new WarehouseInventory();
  return [
    new ProductPage().show(warehouse, "mug"),
    new Checkout().buy(warehouse, "mug", 2),
    new Receiving().receive(warehouse, "mug", 5),
  ];
}

After

interface StockLevels {
  stockOf(sku: string): number;
}

interface Reservations {
  reserve(sku: string, qty: number): string;
}

interface Restocking {
  restock(sku: string, qty: number): string;
}

interface Inventory
  extends StockLevels, Reservations, Restocking {}

class WarehouseInventory implements Inventory {
  private readonly stock = new Map([["mug", 12]]);

  stockOf(sku: string): number {
    return this.stock.get(sku) ?? 0;
  }

  reserve(sku: string, qty: number): string {
    this.stock.set(sku, this.stockOf(sku) - qty);
    return `reserved ${qty} ${sku}`;
  }

  restock(sku: string, qty: number): string {
    this.stock.set(sku, this.stockOf(sku) + qty);
    return `restocked ${qty} ${sku}`;
  }
}

class CachedStock implements StockLevels {
  constructor(
    private readonly counts: Map<string, number>,
  ) {}

  stockOf(sku: string): number {
    return this.counts.get(sku) ?? 0;
  }
}

class ProductPage {
  show(levels: StockLevels, sku: string) {
    return `${sku}: ${levels.stockOf(sku)} in stock`;
  }
}

class Checkout {
  buy(reservations: Reservations, sku: string, qty: number) {
    return reservations.reserve(sku, qty);
  }
}

class Receiving {
  receive(restocking: Restocking, sku: string, qty: number) {
    return restocking.restock(sku, qty);
  }
}

function shop(): string[] {
  const warehouse = new WarehouseInventory();
  const cache = new CachedStock(new Map([["mug", 12]]));
  return [
    new ProductPage().show(cache, "mug"),
    new Checkout().buy(warehouse, "mug", 2),
    new Receiving().receive(warehouse, "mug", 5),
  ];
}

Transcript, step by step

Problem

Step 1: A shop that tracks stock Before

Inventory is an interface with three methods, called stock-of, reserve and restock. Warehouse Inventory implements all three over a map, and it starts with twelve mugs. Three small classes call it, and each one needs a different method. Product Page calls only the stock-of method, Checkout calls only reserve, and Receiving calls only restock. The shop function hands the warehouse to all three, so the page returns mug, twelve in stock. Every caller so far talks to the warehouse, so one interface for all of them looked fine.

Step 2: The page reads a cache Code smell

The product page gets heavy traffic, so it must read stock from a cache instead of the warehouse. The quick way adds Cached Stock, a class over a map of counts, and makes it implement Inventory. Its stock-of method reads that map, which is all the page needs. But Inventory also demands reserve and restock, and the compiler rejects a class that implements it without them. A read-only cache has nothing useful to put in them, so Cached Stock throws an Error from both. The shop function now hands the cache to the product page, and it still returns mug, twelve in stock.

Step 3: A fat interface Code smell

Inventory is a fat interface, which means it has more methods than any one caller uses. Cached Stock writes reserve here and restock here only to throw, because Product Page asks for an Inventory here. TypeScript matches types by shape, yet dropping implements wouldn't help, because this parameter still wants all three methods. You might think the stubs are harmless, because the page never calls them. So what happens if the shop function hands the cache to Checkout instead? Checkout asks for an Inventory, so the compiler accepts the cache, and the first sale throws cache cannot reserve. The rule that gives each caller an interface with only the methods it calls is the Interface Segregation Principle.

Refactor

Step 4: Refactor first Back it out

We take Cached Stock back out, so the shop runs on the warehouse alone again. Then we split Inventory one caller at a time, and the cache comes back once the page asks for less.

Step 5: Stock levels on their own Extract Interface

First, the stock-of method moves into an interface of its own called Stock Levels. Inventory extends Stock Levels and drops its own copy, so it still offers all three methods. Warehouse Inventory doesn't change, and nothing asks for a Stock Levels yet.

Step 6: The page asks for StockLevels Change Function Declaration

Product Page's parameter becomes a Stock Levels, because the stock-of method is the only one it calls. The shop function still passes the warehouse, and the compiler accepts it. TypeScript matches types by shape, so anything with a stock-of method of the right type fits Stock Levels. So now the page needs only the stock-of method, while Checkout and Receiving still ask for all three.

Step 7: Reservations on their own Extract Interface

But Checkout still asks for all three methods, so reserve moves into a second small interface called Reservations. Inventory now extends Stock Levels and Reservations, so it still offers the same three methods.

Step 8: Checkout asks for Reservations Change Function Declaration

Checkout only reserves, so its parameter becomes a Reservations. The shop function passes the same warehouse to it, and it still returns reserved two mug.

Step 9: Restocking on its own Extract Interface

But Receiving still asks for all three, so restock moves into an interface called Restocking. Inventory now declares nothing of its own, and it only joins the three small interfaces together.

Step 10: Receiving asks for Restocking Change Function Declaration

Receiving only restocks, so its parameter becomes a Restocking. Warehouse Inventory still implements Inventory, and its code hasn't changed since we started. We keep the empty Inventory interface, because implements Inventory makes the compiler check that the warehouse has all three methods. Each caller now asks for the one interface it calls, and the warehouse fits all three.

Payoff

Step 11: The cache comes back Payoff

So the cache from the start of the video can come back in. Cached Stock implements Stock Levels alone, so it writes one method and has nothing to throw. The shop function hands it to the page, and the page returns mug, twelve in stock, like before.

Step 12: Nothing else changed Payoff

Warehouse Inventory, Checkout and Receiving didn't change to add the cache. Cached Stock implements one interface here, writes one method here, and Product Page asks only for Stock Levels here. The cache has no stubs left, because no caller makes it carry a method it never uses. Hand the cache to Checkout now, and the compiler rejects it, because Cached Stock has no reserve method. That's the Interface Segregation Principle, with each caller seeing only the methods it calls. But the shop now has four interfaces where it had one, so a reader has more names to learn.

Recap

Step 13: Where we started, where we ended Before / after

On the left, one Inventory interface gave every caller all three methods. So anything handed to the product page had to write reserve and restock as well. On the right, each method has its own small interface, and Inventory only joins them. Product Page asks only for Stock Levels, so the cache had to write that one method alone.

Step 14: When to reach for it Takeaways

Reach for it when an implementation throws in methods its callers never call, or each caller uses a different slice. Skip it when every caller uses the whole interface, like a small repository whose one service calls both find and save. You'll spot it by stubs that only throw, like Cached Stock's reserve, or by test doubles stubbing methods the test never calls. TypeScript's own Iterable works this way, because it asks only for a Symbol iterator method, so arrays, sets and maps all fit.