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
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 fat | Why it hurts |
|---|---|
| Implementations with empty methods or "not supported" errors | The interface promises things not everyone can do (and Liskov follows close behind) |
| Each caller uses a small, different slice | A change to one slice ripples to callers that never touched it: rebuilds, redeploys, re-tests |
| Test fakes need a dozen stub methods | Testing 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
- Ask "who calls this?" when you draw an interface. "The customer app needs menu and buy; the restocker needs stock levels; the engineer needs diagnostics: three interfaces, one machine class."
- Pass the narrow type.
restockRound(panel: RestockPanel), notrestockRound(machine: VendingMachine). It documents what the function uses and makes the test fake tiny. - Don't confuse it with single responsibility. SRP: one reason for a class to change. ISP: no caller sees more than it needs. A class can satisfy SRP and still offer a fat interface.
- Avoid the opposite mistake: if you're about to create an interface with one method that only one caller uses alongside three others, it probably belongs with them.
6. Remember it
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 hotel | The code |
|---|---|
| A guest's card | CustomerPanel: menu, buy |
| A cleaner's card | RestockPanel: add stock |
| The electrician's card | ServicePanel: self-test, collect cash |
| The building | VendingMachine implements all three |
Watch it on an example
- A firmware change touches only the engineer's interface.
- Testing the restock round needs a one-method fake.
- 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
8. Practice exercise (25 minutes)
- List callers (5 min). For the ATM problem, list who calls the ATM's core object: customer, cash loader, bank network, technician.
- Draw their needs (5 min). For each caller, write the methods it needs. Where do they overlap?
- Cut the interfaces (10 min). One interface per caller (or per shared role). Which methods appear in more than one?
- 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?
How should interfaces be grouped?
What's the difference between SRP and ISP?
Customers and restockers both need the menu. What do you do?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Module 03e: Dependency inversion in depth | Next | Who owns these small interfaces, and how implementations are plugged in. |
| P07: Vending machine | 45 min | The full problem this module's example comes from. |
| Python typing: Protocols | 10 min | Narrow interfaces without inheritance. |