InterviewKata
Low-level design · Module 03d of 11 · Level 1: Foundations

Interface segregation in depth

Module 03 gave interface segregation one example. This module is about sizing: how to tell an interface is too fat, why one interface per method is just as bad, how to cut along callers' roles, and how the same idea works in Python and TypeScript.

1. The idea

A hotel gives guests a key card that opens their room and the gym, gives cleaners one that opens every room on their floor, and gives the electrician one for the plant room. Nobody carries a master key "just in case". Each card is shaped by what its holder does, not by what the building contains.

The principle: no caller should be forced to depend on methods it doesn't use. Single responsibility looks at a class from the inside (who asks it to change). Interface segregation looks from the outside: who calls it, and what does each caller need? One object can still do many jobs; it just shows each caller a different, smaller face.

Signs an interface is too fatWhy it hurts
Implementations with empty methods or "not supported" errorsThe interface promises things not everyone can do (and Liskov follows close behind)
Each caller uses a small, different sliceA change to one slice ripples to callers that never touched it: rebuilds, redeploys, re-tests
Test fakes need a dozen stub methodsTesting a restock screen shouldn't require faking cash collection
Method names grouped by prefix (admin…, report…)The groups are interfaces waiting to be separated

Group by caller, not by method

One interface per method is as bad as one for everything: callers juggle ten tiny types. Split along roles: the customer's panel, the restocker's panel, the engineer's panel.

Roles can overlap

Customers and restockers both read the menu. Two interfaces may share a method, or both can extend a small shared one. Don't contort the design to avoid overlap.

No interface keyword? Same idea

In Python, accept a Protocol with just the methods you call; in TypeScript, types are structural, so a parameter type lists what the function needs. The rule is about what a function asks for.

2. How it works

A vending machine's software is updated with a new firmware routine for engineers. Follow the change through two designs.

3. Try it yourself

Size the interface

Four callers use eight methods of a vending machine. Choose how to cut the interfaces and see what each caller ends up depending on.

4. In code

One VendingMachine class implements three small interfaces, one per kind of caller. Each function asks only for the panel it uses, so a change to one panel can't reach the others and test fakes stay tiny.

from __future__ import annotations
from abc import ABC, abstractmethod


def money(cents: int) -> str:
    return f"{cents // 100}.{cents % 100:02d}"


# One interface per kind of caller, holding exactly what that caller needs.
class CustomerPanel(ABC):
    @abstractmethod
    def menu(self) -> list[tuple[str, int]]: ...

    @abstractmethod
    def buy(self, item: str, paid: int) -> int: ...          # returns change


class RestockPanel(ABC):
    @abstractmethod
    def add_stock(self, item: str, qty: int) -> int: ...     # returns new count


class ServicePanel(ABC):
    @abstractmethod
    def self_test(self) -> str: ...

    @abstractmethod
    def collect_cash(self) -> int: ...


# One machine can play all three roles; each caller only ever sees its own.
class VendingMachine(CustomerPanel, RestockPanel, ServicePanel):
    def __init__(self, prices: dict[str, int]) -> None:
        self.prices = prices
        self.stock = {item: 0 for item in prices}
        self.cash = 0

    def menu(self) -> list[tuple[str, int]]:
        return [(i, p) for i, p in self.prices.items() if self.stock[i] > 0]

    def buy(self, item: str, paid: int) -> int:
        if self.stock.get(item, 0) == 0 or paid < self.prices[item]:
            raise ValueError("cannot sell")
        self.stock[item] -= 1
        self.cash += self.prices[item]
        return paid - self.prices[item]

    def add_stock(self, item: str, qty: int) -> int:
        self.stock[item] += qty
        return self.stock[item]

    def self_test(self) -> str:
        return "ok" if all(q >= 0 for q in self.stock.values()) else "fault"

    def collect_cash(self) -> int:
        taken, self.cash = self.cash, 0
        return taken


def customer_visit(panel: CustomerPanel) -> None:
    print("menu: " + ", ".join(f"{i} {money(p)}" for i, p in panel.menu()))
    print(f"bought Cola with 2.00, change {money(panel.buy('Cola', 200))}")


def restock_round(panel: RestockPanel) -> None:
    print(f"restocked Water: {panel.add_stock('Water', 7)} in stock")


def engineer_visit(panel: ServicePanel) -> None:
    print(f"engineer: self-test {panel.self_test()}, collected {money(panel.collect_cash())}")


if __name__ == "__main__":
    machine = VendingMachine({"Cola": 150, "Water": 100})
    machine.add_stock("Cola", 5)
    machine.add_stock("Water", 5)
    customer_visit(machine)
    restock_round(machine)
    engineer_visit(machine)
const money = (c: number): string => `${Math.floor(c / 100)}.${String(c % 100).padStart(2, "0")}`;

// One interface per kind of caller, holding exactly what that caller needs.
interface CustomerPanel { menu(): [string, number][]; buy(item: string, paid: number): number; } // buy returns change
interface RestockPanel { addStock(item: string, qty: number): number; }                         // returns new count
interface ServicePanel { selfTest(): string; collectCash(): number; }

// One machine can play all three roles; each caller only ever sees its own.
class VendingMachine implements CustomerPanel, RestockPanel, ServicePanel {
  private stock = new Map<string, number>();
  private cash = 0;
  constructor(private prices: Map<string, number>) { for (const item of prices.keys()) this.stock.set(item, 0); }
  menu(): [string, number][] { return [...this.prices].filter(([i]) => (this.stock.get(i) ?? 0) > 0); }
  buy(item: string, paid: number): number {
    const price = this.prices.get(item) ?? Infinity;
    if ((this.stock.get(item) ?? 0) === 0 || paid < price) throw new Error("cannot sell");
    this.stock.set(item, (this.stock.get(item) ?? 0) - 1);
    this.cash += price;
    return paid - price;
  }
  addStock(item: string, qty: number): number { this.stock.set(item, (this.stock.get(item) ?? 0) + qty); return this.stock.get(item) ?? 0; }
  selfTest(): string { return [...this.stock.values()].every((q) => q >= 0) ? "ok" : "fault"; }
  collectCash(): number { const taken = this.cash; this.cash = 0; return taken; }
}

function customerVisit(panel: CustomerPanel): void {
  console.log("menu: " + panel.menu().map(([i, p]) => `${i} ${money(p)}`).join(", "));
  console.log(`bought Cola with 2.00, change ${money(panel.buy("Cola", 200))}`);
}
function restockRound(panel: RestockPanel): void { console.log(`restocked Water: ${panel.addStock("Water", 7)} in stock`); }
function engineerVisit(panel: ServicePanel): void { console.log(`engineer: self-test ${panel.selfTest()}, collected ${money(panel.collectCash())}`); }

const machine = new VendingMachine(new Map([["Cola", 150], ["Water", 100]]));
machine.addStock("Cola", 5);
machine.addStock("Water", 5);
customerVisit(machine);
restockRound(machine);
engineerVisit(machine);
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

// One interface per kind of caller, holding exactly what that caller needs.
interface CustomerPanel {
    List<Map.Entry<String, Integer>> menu();
    int buy(String item, int paid); // returns change
}

interface RestockPanel {
    int addStock(String item, int qty); // returns new count
}

interface ServicePanel {
    String selfTest();
    int collectCash();
}

// One machine can play all three roles; each caller only ever sees its own.
class VendingMachine implements CustomerPanel, RestockPanel, ServicePanel {
    private final Map<String, Integer> prices;
    private final Map<String, Integer> stock = new LinkedHashMap<>();
    private int cash = 0;

    VendingMachine(Map<String, Integer> prices) {
        this.prices = prices;
        prices.keySet().forEach(i -> stock.put(i, 0));
    }

    public List<Map.Entry<String, Integer>> menu() {
        List<Map.Entry<String, Integer>> out = new ArrayList<>();
        prices.forEach((i, p) -> { if (stock.get(i) > 0) out.add(Map.entry(i, p)); });
        return out;
    }

    public int buy(String item, int paid) {
        int price = prices.get(item);
        if (stock.get(item) == 0 || paid < price) throw new IllegalStateException("cannot sell");
        stock.put(item, stock.get(item) - 1);
        cash += price;
        return paid - price;
    }

    public int addStock(String item, int qty) { stock.put(item, stock.get(item) + qty); return stock.get(item); }
    public String selfTest() { return stock.values().stream().allMatch(q -> q >= 0) ? "ok" : "fault"; }
    public int collectCash() { int taken = cash; cash = 0; return taken; }
}

public class Main {
    static String money(int c) { return (c / 100) + "." + String.format("%02d", c % 100); }

    static void customerVisit(CustomerPanel panel) {
        System.out.println("menu: " + panel.menu().stream().map(e -> e.getKey() + " " + money(e.getValue())).collect(Collectors.joining(", ")));
        System.out.println("bought Cola with 2.00, change " + money(panel.buy("Cola", 200)));
    }
    static void restockRound(RestockPanel panel) { System.out.println("restocked Water: " + panel.addStock("Water", 7) + " in stock"); }
    static void engineerVisit(ServicePanel panel) { System.out.println("engineer: self-test " + panel.selfTest() + ", collected " + money(panel.collectCash())); }

    public static void main(String[] args) {
        Map<String, Integer> prices = new LinkedHashMap<>();
        prices.put("Cola", 150);
        prices.put("Water", 100);
        VendingMachine machine = new VendingMachine(prices);
        machine.addStock("Cola", 5);
        machine.addStock("Water", 5);
        customerVisit(machine);
        restockRound(machine);
        engineerVisit(machine);
    }
}

Output (the same in all three languages)

menu: Cola 1.50, Water 1.00
bought Cola with 2.00, change 0.50
restocked Water: 12 in stock
engineer: self-test ok, collected 1.50

5. In the interview

6. Remember it

Memory hook: "Each caller gets its own key card"Interfaces are shaped by what each caller needs, not by everything the object can do.
How this hook works: what “Each caller gets its own key card” means

What the slogan means, phrase by phrase

“Each caller”
Look from the outside: who calls this code?
“its own key card”
A small interface with exactly that caller's methods.

Why this picture fits: The hotel: guests, cleaners and the electrician each carry a card for what they do, not a master key.

The story → the concept

The hotelThe code
A guest's cardCustomerPanel: menu, buy
A cleaner's cardRestockPanel: add stock
The electrician's cardServicePanel: self-test, collect cash
The buildingVendingMachine implements all three

Watch it on an example

  1. A firmware change touches only the engineer's interface.
  2. Testing the restock round needs a one-method fake.
  3. Customers and restockers can both see the menu: overlap is fine.

The trap it saves you from

One interface per method: callers depend on a pile of tiny types that always travel together.

It still works when…

Without an interface keyword: Python Protocols and TypeScript structural types express the same narrow need.

7. Common mistakes

One interface for the whole objectEvery caller depends on everything, and every change reaches every caller.
One interface per methodOver-split: callers juggle many types that always go together.
Passing the concrete class anywaySmall interfaces only help if functions ask for them.
Stub methods that throwA sign the class shouldn't implement that interface at all.

8. Practice exercise (25 minutes)

  1. List callers (5 min). For the ATM problem, list who calls the ATM's core object: customer, cash loader, bank network, technician.
  2. Draw their needs (5 min). For each caller, write the methods it needs. Where do they overlap?
  3. Cut the interfaces (10 min). One interface per caller (or per shared role). Which methods appear in more than one?
  4. Shrink a fake (5 min). Write the test fake for the cash loader's interface. How many methods did it need?

9. Self-check quiz

A restock screen calls one method, but its parameter type has twelve. What's the problem?

Changes to any of those eleven can reach it, and its test fake must stub them all.

How should interfaces be grouped?

Role interfaces keep each caller's dependency small without scattering it.

What's the difference between SRP and ISP?

Inside view vs outside view.

Customers and restockers both need the menu. What do you do?

Overlap between roles is normal.

10. Go deeper

ResourceTimeWhy
Module 03e: Dependency inversion in depthNextWho owns these small interfaces, and how implementations are plugged in.
P07: Vending machine45 minThe full problem this module's example comes from.
Python typing: Protocols10 minNarrow interfaces without inheritance.