Low-Level Design (Object-Oriented Design) Module 4 – Design principles: SOLID and beyond
Interface Segregation Principle (ISP)
The Interface Segregation Principle in practice: split a fat interface into role interfaces sized by their callers, in 13 languages, and measure the gain.
What you will learn
- Recognise a fat interface by its stubs, its test fakes and the edits that one caller's change spreads
- Split a fat interface into role interfaces cut from what each kind of caller uses
- Measure what a fat interface costs in stub methods, fake methods and classes a change touches
- Explain what isinstance() checks on a runtime-checkable protocol, and what it misses
- Compose two roles where one caller needs both, and stop splitting where methods change together
Before you start
What you’ll learn · Before you start
What you will learn
- Recognise a fat interface by its stubs, its test fakes and the edits that one caller's change spreads
- Split a fat interface into role interfaces cut from what each kind of caller uses
- Measure what a fat interface costs in stub methods, fake methods and classes a change touches
- Explain what isinstance() checks on a runtime-checkable protocol, and what it misses
- Compose two roles where one caller needs both, and stop splitting where methods change together
Before you start
On this page
A housing society’s gate software describes its three devices with one interface of four operations, although each device can carry out only two or three of them: six of the twelve methods the devices define are stubs that fail. The Interface Segregation Principle (ISP) says that no caller should have to depend on methods it does not use, so an interface is sized by what one kind of caller needs, and a class implements as many of those roles as it really plays.
You will watch the stubs of that interface fail in two different ways, split it into roles from the callers’ side, read the role design in 13 languages, measure what the split saves and find where splitting should stop. The lesson on the Liskov Substitution Principle fixed a hierarchy whose parent promised too much; this one looks at the same problem from the callers’ side.
No caller should depend on a method it does not use
Robert C. Martin described the principle in 1996, in a column for The C++ Report, in one sentence:
Clients should not be forced to depend upon interfaces that they do not use.
His list of the principles of class design puts it as “Make fine grained interfaces that are client specific.” The argument is about the direction of change. We usually worry about an interface changing under its callers, but callers push changes into interfaces too: one caller needs a new parameter, a new method or a different return value. When one interface serves several kinds of callers, that push reaches every class that implements it and every caller tied to it, including the callers that never use the method that changed. Step through one such change at the gate, where the visitor desk wants each pass to show how long it is valid:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A caller asks. The visitor desk wants every pass to show how long it is valid, so print_pass() needs a new parameter, valid_until.
- The one interface changes. print_pass() is declared on GateDevice, the interface that every device and every caller depends on, so the change starts there.
- The kiosk changes. VisitorKiosk is the device that prints passes, so its print_pass() takes the validity time and prints it. This is the edit the desk asked for.
- Two stubs change. The boom barrier and the pedestrian gate have no printer, but their stub print_pass() methods must still match the interface, so both are edited for a parameter they will never use.
- The fakes change. Every test fake of a GateDevice gains the parameter too, including the fakes in the tests of resident entry and of the lockdown, although neither of them prints anything.
- The same change, behind roles. Behind role interfaces print_pass() lives only in VisitorDesk, which only the kiosk plays. The change goes from the desk to the kiosk and stops there, with no stub and no other caller's fake to edit.
Behind the one interface, a change that one caller asked for edited the kiosk, two stubs in devices that have no printer, and the test fakes of callers that never print anything. Behind roles, it edited the desk’s role and the kiosk, and stopped there.
In C++ in 1996 the visible cost was recompiling and redeploying code that had not changed. In a 2020 post Martin calls the problem especially acute in statically typed languages, and says dynamic languages are affected much less but are not immune. In Python the cost shows up in three places, and this lesson measures each of them: stub methods that cannot do their job, test fakes that must imitate methods the test never calls, and edits to classes that a change was never meant for.
A fat interface fills the devices with stubs that fail
A housing society’s gate has three devices. A boom barrier lifts for residents’ cars and reads the tag on each
windscreen. A pedestrian gate lets people walk in and out. At the guard’s desk, a kiosk prints a pass for each visitor
a resident has invited. The first version of the software describes all three with one abstract base class,
GateDevice, a fat interface: An interface with more methods than any one of its callers uses, so implementers fill the gaps with stubs and every caller depends on methods it never calls. that lists every operation any of them can perform:
fat/main.py
from gate import BoomBarrier, GateDevice, PedestrianGate, VisitorKiosk
def resident_entry(device: GateDevice) -> str:
tag = device.read_tag()
if tag is None:
return "no resident tag in range"
return f"resident tag {tag}: {device.open()}"
def night_lockdown(devices: list[GateDevice]) -> None:
for device in devices:
try:
print(f"night lockdown: {device.close()}")
except NotImplementedError as error:
print(f"night lockdown stopped at {type(device).__name__}: {error}")
return
car_gate = BoomBarrier(tag_in_range="TAG-1042")
foot_gate = PedestrianGate()
kiosk = VisitorKiosk()
every_device = [car_gate, kiosk, foot_gate] # each one is a GateDevice, so each one "can" close
print(resident_entry(car_gate))
print(kiosk.print_pass("Meera", "B-204"))
print(resident_entry(foot_gate))
night_lockdown(every_device)
print(f"pedestrian gate at the end: {'open' if foot_gate.is_open else 'closed'}") Output
resident tag TAG-1042: barrier opened pass printed for Meera, visiting B-204 no resident tag in range night lockdown: barrier closed night lockdown stopped at VisitorKiosk: a kiosk has no gate to close pedestrian gate at the end: open
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py
fat/gate.py
"""The devices at a housing society's gate, behind one interface that lists everything any of them can do."""
from abc import ABC, abstractmethod
class GateDevice(ABC):
"""Every gate device: a barrier, a tag reader and a visitor desk in one interface."""
@abstractmethod
def open(self) -> str: ...
@abstractmethod
def close(self) -> str: ...
@abstractmethod
def read_tag(self) -> str | None: ...
@abstractmethod
def print_pass(self, visitor: str, flat: str) -> str: ...
class BoomBarrier(GateDevice):
"""The car gate: an arm that lifts, with a reader for the tags on residents' windscreens."""
def __init__(self, tag_in_range=None):
self.is_open = False
self._tag = tag_in_range
def open(self):
self.is_open = True
return "barrier opened"
def close(self):
self.is_open = False
return "barrier closed"
def read_tag(self):
return self._tag
def print_pass(self, visitor, flat):
raise NotImplementedError("a boom barrier has no printer")
class PedestrianGate(GateDevice):
"""The gate people walk through: it only opens and closes."""
def __init__(self):
self.is_open = True
def open(self):
self.is_open = True
return "pedestrian gate opened"
def close(self):
self.is_open = False
return "pedestrian gate closed"
def read_tag(self):
return None # it has no reader, so it never sees a tag
def print_pass(self, visitor, flat):
raise NotImplementedError("a pedestrian gate has no printer")
class VisitorKiosk(GateDevice):
"""The guard's desk: it prints a pass for each visitor a resident has invited."""
def open(self):
raise NotImplementedError("a kiosk has no gate to open")
def close(self):
raise NotImplementedError("a kiosk has no gate to close")
def read_tag(self):
return None
def print_pass(self, visitor, flat):
return f"pass printed for {visitor}, visiting {flat}" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each device can carry out two or three of the four operations, so it fills the rest with stubs, and two kinds of stub cause two kinds of failure:
- Stubs that raise. The night lockdown closes every
GateDevicein its list, because the interface says every device can close. It closes the barrier, reaches the kiosk, getsNotImplementedErrorand stops. - Stubs that quietly return. The pedestrian gate’s
read_tag()returnsNone, which is also what a real reader returns when no tag is in range.resident_entry()cannot tell “nobody is here” from “this device cannot read tags”, so it reports the first when the truth is the second.
Step through the lockdown that stopped at the kiosk, and watch which device it never reached:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The list. The lockdown takes every GateDevice in its list, in order, the boom barrier, the kiosk and the pedestrian gate, because the interface says that each of them can close.
- The barrier closes. close() on the boom barrier works, and the run prints barrier closed.
- The kiosk raises. The kiosk has no gate, so its close() is a stub that raises NotImplementedError.
- The loop stops. The lockdown catches the error and stops, so the device after the kiosk in the list is never asked to close.
- Open all night. The pedestrian gate is still open at the end of the run, and it would stay open all night. A stub in one device broke a caller that only ever wanted to close gates.
The pedestrian gate is still open at the end of the run, and it would stay open all night. Java’s standard library has
a fat interface of the same kind: Collection marks seven of its methods, among them add and clear, as optional
operations, which an implementation that does not support them defines to throw UnsupportedOperationException. The
lists that List.of() returns throw it from every method that would change them, so code that receives a Collection
cannot tell from its type which methods will work.
Making every caller careful
The tempting fix is to make the callers careful: skip any device that raises NotImplementedError, or check
hasattr() before each call. Then every caller has to know which device can do what, which is the knowledge the
interface was supposed to provide, and a stub that returns None still slips through. Fix the interface instead.
Checkpoint
Check: The direction of change behind the principle · Stubs that raise and stubs that quietly return
Role interfaces are cut from what each caller uses
Start from the callers, not from the devices, and write down what each one uses:
resident_entry()reads a tag and opens the barrier:read_tag()andopen().- The visitor desk prints passes:
print_pass(). night_lockdown()closes things:close(), which always comes withopen().
Each group becomes a role interface: An interface cut from one interaction between a supplier and its callers, holding only the methods those callers use together; one class often plays several roles.. Barrier has open() and close(),
TagReader has read_tag(), and VisitorDesk has print_pass(). Resident entry needs two roles at once, so
TagGate combines TagReader and Barrier into a third role without adding a method. In Python the roles are
typing.Protocol classes, matched by structural typing: Matching a type to an interface by the methods it has, not by a declaration: a class with the right methods plays the role. Python's protocols and Go's and TypeScript's interfaces work this way., which PEP 544 calls static
duck typing: a class that has the methods plays the role without inheriting from it. The diagram puts the two designs
one above the other:
The gate devices behind one fat interface, and behind role interfaces
Text description of the diagram
The diagram shows two designs of the same gate software, one above the other.
- One fat interface: the three callers, resident_entry, the visitor desk and night_lockdown, all depend on GateDevice, which has 4 methods. The three devices, BoomBarrier, PedestrianGate and VisitorKiosk, must each provide all 4, so 6 of their 12 methods are stubs that raise NotImplementedError or return None.
- Role interfaces: resident_entry depends on TagGate, which combines TagReader and Barrier; printing a pass depends on VisitorDesk, with print_pass; night_lockdown depends on Barrier, with open and close. BoomBarrier provides TagGate, PedestrianGate provides Barrier and VisitorKiosk provides VisitorDesk. No device has a method it cannot carry out.
The program below is the role design in 13 languages, and every one of them printed the same five lines. C, which has no interfaces, is left out:
Python 3.14 · main.py
from typing import Protocol
class Barrier(Protocol):
def open(self) -> str: ...
def close(self) -> str: ...
class TagReader(Protocol):
def read_tag(self) -> str | None: ... # a resident's tag in range, or None
class TagGate(TagReader, Barrier, Protocol): ... # two roles, composed
class VisitorDesk(Protocol):
def print_pass(self, visitor: str, flat: str) -> str: ...
class Gate:
def __init__(self, name: str, is_open: bool) -> None:
self.name = name
self.is_open = is_open
def open(self) -> str:
self.is_open = True
return f"{self.name} opened"
def close(self) -> str:
self.is_open = False
return f"{self.name} closed"
class BoomBarrier(Gate):
def __init__(self, tag_in_range: str | None) -> None:
super().__init__("barrier", False)
self.tag_in_range = tag_in_range
def read_tag(self) -> str | None:
return self.tag_in_range
class VisitorKiosk:
def print_pass(self, visitor: str, flat: str) -> str:
return f"pass printed for {visitor}, visiting {flat}"
def resident_entry(gate: TagGate) -> str:
tag = gate.read_tag()
if tag is None:
return "no resident tag in range"
return f"resident tag {tag}: {gate.open()}"
def night_lockdown(barriers: list[Barrier]) -> None:
for barrier in barriers:
print(f"night lockdown: {barrier.close()}")
car_gate = BoomBarrier("TAG-1042")
foot_gate = Gate("pedestrian gate", True)
desk: VisitorDesk = VisitorKiosk()
print(resident_entry(car_gate))
print(desk.print_pass("Meera", "B-204"))
night_lockdown([car_gate, foot_gate]) # only barriers: the kiosk has no close()
print(f"pedestrian gate at the end: {'open' if foot_gate.is_open else 'closed'}") In Python: The roles are typing.Protocol classes, matched by structure. Gate and BoomBarrier never name a role, yet a type checker refuses the kiosk in the lockdown's list, because it has no close().
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
/** @typedef {{ open(): string, close(): string }} Barrier */
/** @typedef {{ readTag(): string | null }} TagReader a resident's tag in range, or null */
/** @typedef {TagReader & Barrier} TagGate two roles, composed */
/** @typedef {{ printPass(visitor: string, flat: string): string }} VisitorDesk */
class Gate {
constructor(name, isOpen) {
this.name = name;
this.isOpen = isOpen;
}
open() {
this.isOpen = true;
return `${this.name} opened`;
}
close() {
this.isOpen = false;
return `${this.name} closed`;
}
}
class BoomBarrier extends Gate {
#tagInRange;
constructor(tagInRange) {
super("barrier", false);
this.#tagInRange = tagInRange;
}
readTag() {
return this.#tagInRange;
}
}
class VisitorKiosk {
printPass(visitor, flat) {
return `pass printed for ${visitor}, visiting ${flat}`;
}
}
/** @param {TagGate} gate */
function residentEntry(gate) {
const tag = gate.readTag();
return tag === null ? "no resident tag in range" : `resident tag ${tag}: ${gate.open()}`;
}
/** @param {Barrier[]} barriers */
function nightLockdown(barriers) {
for (const barrier of barriers) {
console.log(`night lockdown: ${barrier.close()}`);
}
}
const carGate = new BoomBarrier("TAG-1042");
const footGate = new Gate("pedestrian gate", true);
/** @type {VisitorDesk} */
const desk = new VisitorKiosk();
console.log(residentEntry(carGate));
console.log(desk.printPass("Meera", "B-204"));
nightLockdown([carGate, footGate]); // only barriers: the kiosk has no close()
console.log(`pedestrian gate at the end: ${footGate.isOpen ? "open" : "closed"}`); In JavaScript: JavaScript has no interfaces, so the roles are JSDoc types that editors and tsc --checkJs read. Nothing checks them while the program runs, so a kiosk in the lockdown's list would fail only at its close() call.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.List;
public class Main {
interface Barrier {
String open();
String close();
}
interface TagReader {
String readTag(); // a resident's tag in range, or null
}
interface TagGate extends TagReader, Barrier {} // two roles, composed
interface VisitorDesk {
String printPass(String visitor, String flat);
}
static class Gate implements Barrier {
final String name;
boolean isOpen;
Gate(String name, boolean isOpen) {
this.name = name;
this.isOpen = isOpen;
}
@Override
public String open() {
isOpen = true;
return name + " opened";
}
@Override
public String close() {
isOpen = false;
return name + " closed";
}
}
static class BoomBarrier extends Gate implements TagGate {
private final String tagInRange;
BoomBarrier(String tagInRange) {
super("barrier", false);
this.tagInRange = tagInRange;
}
@Override
public String readTag() {
return tagInRange;
}
}
static class VisitorKiosk implements VisitorDesk {
@Override
public String printPass(String visitor, String flat) {
return "pass printed for " + visitor + ", visiting " + flat;
}
}
static String residentEntry(TagGate gate) {
String tag = gate.readTag();
return tag == null ? "no resident tag in range" : "resident tag " + tag + ": " + gate.open();
}
static void nightLockdown(List<Barrier> barriers) {
barriers.forEach(barrier -> System.out.println("night lockdown: " + barrier.close()));
}
public static void main(String[] args) {
BoomBarrier carGate = new BoomBarrier("TAG-1042");
Gate footGate = new Gate("pedestrian gate", true);
System.out.println(residentEntry(carGate));
System.out.println(new VisitorKiosk().printPass("Meera", "B-204"));
nightLockdown(List.of(carGate, footGate)); // only barriers: the kiosk has no close()
System.out.println("pedestrian gate at the end: " + (footGate.isOpen ? "open" : "closed"));
}
} In Java: A class plays a role only when it says so. BoomBarrier inherits open and close from Gate and declares implements TagGate, and List<Barrier> refuses a VisitorKiosk when the program is compiled.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <optional>
#include <print>
#include <string>
#include <utility>
#include <vector>
struct Barrier {
virtual ~Barrier() = default;
virtual std::string open() = 0;
virtual std::string close() = 0;
};
struct TagReader {
virtual ~TagReader() = default;
// A resident's tag in range, or none.
virtual std::optional<std::string> readTag() const = 0;
};
struct TagGate : TagReader, virtual Barrier {}; // two roles, composed
struct VisitorDesk {
virtual ~VisitorDesk() = default;
virtual std::string printPass(const std::string &visitor,
const std::string &flat) const = 0;
};
struct Gate : virtual Barrier {
Gate(std::string name, bool isOpen) : name(std::move(name)), isOpen(isOpen) {}
std::string open() override {
isOpen = true;
return name + " opened";
}
std::string close() override {
isOpen = false;
return name + " closed";
}
std::string name;
bool isOpen;
};
class BoomBarrier : public Gate, public TagGate {
public:
explicit BoomBarrier(std::optional<std::string> tagInRange)
: Gate("barrier", false), tagInRange_(std::move(tagInRange)) {}
std::optional<std::string> readTag() const override { return tagInRange_; }
private:
std::optional<std::string> tagInRange_;
};
struct VisitorKiosk : VisitorDesk {
std::string printPass(const std::string &visitor,
const std::string &flat) const override {
return "pass printed for " + visitor + ", visiting " + flat;
}
};
std::string residentEntry(TagGate &gate) {
const auto tag = gate.readTag();
return tag ? "resident tag " + *tag + ": " + gate.open()
: "no resident tag in range";
}
void nightLockdown(const std::vector<Barrier *> &barriers) {
for (Barrier *barrier : barriers) {
std::println("night lockdown: {}", barrier->close());
}
}
int main() {
BoomBarrier carGate("TAG-1042");
Gate footGate("pedestrian gate", true);
std::println("{}", residentEntry(carGate));
std::println("{}", VisitorKiosk{}.printPass("Meera", "B-204"));
// only barriers: the kiosk has no close()
nightLockdown({&carGate, &footGate});
std::println("pedestrian gate at the end: {}",
footGate.isOpen ? "open" : "closed");
} In C++: Barrier is a virtual base, so a BoomBarrier, which is both a Gate and a TagGate, holds one Barrier, whose open and close come from Gate.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
var carGate = new BoomBarrier("TAG-1042");
var footGate = new Gate("pedestrian gate", true);
IVisitorDesk desk = new VisitorKiosk();
Console.WriteLine(ResidentEntry(carGate));
Console.WriteLine(desk.PrintPass("Meera", "B-204"));
NightLockdown([carGate, footGate]); // only barriers: the kiosk has no Close()
Console.WriteLine($"pedestrian gate at the end: {(footGate.IsOpen ? "open" : "closed")}");
static string ResidentEntry(ITagGate gate)
{
string? tag = gate.ReadTag();
return tag is null ? "no resident tag in range" : $"resident tag {tag}: {gate.Open()}";
}
static void NightLockdown(List<IBarrier> barriers)
{
foreach (IBarrier barrier in barriers)
{
Console.WriteLine($"night lockdown: {barrier.Close()}");
}
}
interface IBarrier
{
string Open();
string Close();
}
interface ITagReader
{
string? ReadTag(); // a resident's tag in range, or null
}
interface ITagGate : ITagReader, IBarrier { } // two roles, composed
interface IVisitorDesk
{
string PrintPass(string visitor, string flat);
}
class Gate(string name, bool isOpen) : IBarrier
{
public bool IsOpen { get; private set; } = isOpen;
public string Open()
{
IsOpen = true;
return $"{name} opened";
}
public string Close()
{
IsOpen = false;
return $"{name} closed";
}
}
class BoomBarrier(string? tagInRange) : Gate("barrier", false), ITagGate
{
public string? ReadTag() => tagInRange;
}
class VisitorKiosk : IVisitorDesk
{
public string PrintPass(string visitor, string flat) =>
$"pass printed for {visitor}, visiting {flat}";
} In C#: Interface names start with I, as .NET's naming guidelines ask. The primary constructors of Gate and BoomBarrier keep their parameters as the objects' private state.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
C is left out of this example: C has no interfaces or classes; a struct of function pointers for each role would be another design.
PHP 8.5 · main.php
<?php
declare(strict_types=1);
interface Barrier
{
public function open(): string;
public function close(): string;
}
interface TagReader
{
public function readTag(): ?string; // a resident's tag in range, or null
}
interface TagGate extends TagReader, Barrier {} // two roles, composed
interface VisitorDesk
{
public function printPass(string $visitor, string $flat): string;
}
class Gate implements Barrier
{
public function __construct(private string $name, public bool $isOpen) {}
public function open(): string
{
$this->isOpen = true;
return "{$this->name} opened";
}
public function close(): string
{
$this->isOpen = false;
return "{$this->name} closed";
}
}
final class BoomBarrier extends Gate implements TagGate
{
public function __construct(private ?string $tagInRange)
{
parent::__construct('barrier', false);
}
public function readTag(): ?string
{
return $this->tagInRange;
}
}
final class VisitorKiosk implements VisitorDesk
{
public function printPass(string $visitor, string $flat): string
{
return "pass printed for {$visitor}, visiting {$flat}";
}
}
function residentEntry(TagGate $gate): string
{
$tag = $gate->readTag();
return $tag === null ? 'no resident tag in range' : "resident tag {$tag}: {$gate->open()}";
}
function nightLockdown(Barrier ...$barriers): void
{
foreach ($barriers as $barrier) {
echo "night lockdown: {$barrier->close()}\n";
}
}
$carGate = new BoomBarrier('TAG-1042');
$footGate = new Gate('pedestrian gate', true);
echo residentEntry($carGate), "\n";
echo (new VisitorKiosk())->printPass('Meera', 'B-204'), "\n";
nightLockdown($carGate, $footGate); // only barriers: the kiosk has no close()
echo 'pedestrian gate at the end: ', $footGate->isOpen ? 'open' : 'closed', "\n"; In PHP: TagGate extends two interfaces, which PHP allows for interfaces only. nightLockdown takes Barrier ...$barriers, so each argument's type is checked when the call is made.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
trait Barrier {
fn open(&mut self) -> String;
fn close(&mut self) -> String;
}
trait TagReader {
fn read_tag(&self) -> Option<String>; // a resident's tag in range, or None
}
trait TagGate: TagReader + Barrier {} // two roles, composed
impl<T: TagReader + Barrier> TagGate for T {}
trait VisitorDesk {
fn print_pass(&self, visitor: &str, flat: &str) -> String;
}
struct Gate<R> {
name: &'static str,
is_open: bool,
reader: R,
}
impl<R> Barrier for Gate<R> {
fn open(&mut self) -> String {
self.is_open = true;
format!("{} opened", self.name)
}
fn close(&mut self) -> String {
self.is_open = false;
format!("{} closed", self.name)
}
}
struct TagInRange(Option<&'static str>);
impl TagReader for Gate<TagInRange> {
fn read_tag(&self) -> Option<String> {
self.reader.0.map(String::from)
}
}
struct VisitorKiosk;
impl VisitorDesk for VisitorKiosk {
fn print_pass(&self, visitor: &str, flat: &str) -> String {
format!("pass printed for {visitor}, visiting {flat}")
}
}
fn resident_entry(gate: &mut impl TagGate) -> String {
match gate.read_tag() {
Some(tag) => format!("resident tag {tag}: {}", gate.open()),
None => "no resident tag in range".to_string(),
}
}
fn night_lockdown(barriers: &mut [&mut dyn Barrier]) {
for barrier in barriers.iter_mut() {
println!("night lockdown: {}", barrier.close());
}
}
fn main() {
let mut car_gate = Gate {
name: "barrier",
is_open: false,
reader: TagInRange(Some("TAG-1042")),
};
let mut foot_gate = Gate {
name: "pedestrian gate",
is_open: true,
reader: (),
};
println!("{}", resident_entry(&mut car_gate));
println!("{}", VisitorKiosk.print_pass("Meera", "B-204"));
night_lockdown(&mut [&mut car_gate, &mut foot_gate]); // only barriers: the kiosk has no close()
let state = if foot_gate.is_open { "open" } else { "closed" };
println!("pedestrian gate at the end: {state}");
} In Rust: Rust has no inheritance. The barrier is a Gate whose reader is a TagInRange, and only Gate<TagInRange> implements TagReader, so the pedestrian gate, a Gate<()>, can never reach resident_entry.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
interface Barrier {
open(): string;
close(): string;
}
interface TagReader {
readTag(): string | null; // a resident's tag in range, or null
}
interface TagGate extends TagReader, Barrier {} // two roles, composed
interface VisitorDesk {
printPass(visitor: string, flat: string): string;
}
class Gate implements Barrier {
readonly name: string;
isOpen: boolean;
constructor(name: string, isOpen: boolean) {
this.name = name;
this.isOpen = isOpen;
}
open(): string {
this.isOpen = true;
return `${this.name} opened`;
}
close(): string {
this.isOpen = false;
return `${this.name} closed`;
}
}
class BoomBarrier extends Gate implements TagGate {
readonly #tagInRange: string | null;
constructor(tagInRange: string | null) {
super("barrier", false);
this.#tagInRange = tagInRange;
}
readTag(): string | null {
return this.#tagInRange;
}
}
class VisitorKiosk implements VisitorDesk {
printPass(visitor: string, flat: string): string {
return `pass printed for ${visitor}, visiting ${flat}`;
}
}
function residentEntry(gate: TagGate): string {
const tag = gate.readTag();
return tag === null ? "no resident tag in range" : `resident tag ${tag}: ${gate.open()}`;
}
function nightLockdown(barriers: Barrier[]): void {
for (const barrier of barriers) {
console.log(`night lockdown: ${barrier.close()}`);
}
}
const carGate = new BoomBarrier("TAG-1042");
const footGate = new Gate("pedestrian gate", true);
const desk: VisitorDesk = new VisitorKiosk();
console.log(residentEntry(carGate));
console.log(desk.printPass("Meera", "B-204"));
nightLockdown([carGate, footGate]); // only barriers: the kiosk has no close()
console.log(`pedestrian gate at the end: ${footGate.isOpen ? "open" : "closed"}`); In TypeScript: Interfaces are structural, like Python's protocols, so BoomBarrier would be a TagGate even without implements; implements asks the compiler to check the class against the role where it is declared.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import "fmt"
type Barrier interface {
Open() string
Close() string
}
type TagReader interface {
ReadTag() (tag string, ok bool) // a resident's tag in range, or ok false
}
// TagGate is two roles, composed.
type TagGate interface {
TagReader
Barrier
}
type VisitorDesk interface {
PrintPass(visitor, flat string) string
}
type Gate struct {
name string
isOpen bool
}
func (g *Gate) Open() string {
g.isOpen = true
return g.name + " opened"
}
func (g *Gate) Close() string {
g.isOpen = false
return g.name + " closed"
}
type BoomBarrier struct {
Gate
tagInRange string
}
func (b *BoomBarrier) ReadTag() (string, bool) {
return b.tagInRange, b.tagInRange != ""
}
type VisitorKiosk struct{}
func (VisitorKiosk) PrintPass(visitor, flat string) string {
return fmt.Sprintf("pass printed for %s, visiting %s", visitor, flat)
}
func residentEntry(gate TagGate) string {
tag, ok := gate.ReadTag()
if !ok {
return "no resident tag in range"
}
return fmt.Sprintf("resident tag %s: %s", tag, gate.Open())
}
func nightLockdown(barriers []Barrier) {
for _, barrier := range barriers {
fmt.Println("night lockdown: " + barrier.Close())
}
}
func main() {
carGate := &BoomBarrier{Gate{"barrier", false}, "TAG-1042"}
footGate := &Gate{"pedestrian gate", true}
var desk VisitorDesk = VisitorKiosk{}
fmt.Println(residentEntry(carGate))
fmt.Println(desk.PrintPass("Meera", "B-204"))
nightLockdown([]Barrier{carGate, footGate}) // only barriers: the kiosk has no Close()
state := "closed"
if footGate.isOpen {
state = "open"
}
fmt.Println("pedestrian gate at the end: " + state)
} In Go: Interfaces are satisfied without being named, and TagGate embeds TagReader and Barrier as io.ReadWriter embeds io.Reader and io.Writer. ReadTag reports no tag with a second result, false, instead of a null.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
protocol Barrier {
func open() -> String
func close() -> String
}
protocol TagReader {
func readTag() -> String? // a resident's tag in range, or nil
}
typealias TagGate = TagReader & Barrier // two roles, composed
protocol VisitorDesk {
func printPass(for visitor: String, visiting flat: String) -> String
}
class Gate: Barrier {
let name: String
private(set) var isOpen: Bool
init(name: String, isOpen: Bool) {
self.name = name
self.isOpen = isOpen
}
func open() -> String {
isOpen = true
return "\(name) opened"
}
func close() -> String {
isOpen = false
return "\(name) closed"
}
}
final class BoomBarrier: Gate, TagReader {
private let tagInRange: String?
init(tagInRange: String?) {
self.tagInRange = tagInRange
super.init(name: "barrier", isOpen: false)
}
func readTag() -> String? { tagInRange }
}
struct VisitorKiosk: VisitorDesk {
func printPass(for visitor: String, visiting flat: String) -> String {
"pass printed for \(visitor), visiting \(flat)"
}
}
func residentEntry(_ gate: any TagGate) -> String {
guard let tag = gate.readTag() else { return "no resident tag in range" }
return "resident tag \(tag): \(gate.open())"
}
func nightLockdown(_ barriers: [any Barrier]) {
for barrier in barriers {
print("night lockdown: \(barrier.close())")
}
}
let carGate = BoomBarrier(tagInRange: "TAG-1042")
let footGate = Gate(name: "pedestrian gate", isOpen: true)
let desk: any VisitorDesk = VisitorKiosk()
print(residentEntry(carGate))
print(desk.printPass(for: "Meera", visiting: "B-204"))
nightLockdown([carGate, footGate]) // only barriers: the kiosk has no close()
print("pedestrian gate at the end: \(footGate.isOpen ? "open" : "closed")") In Swift: TagReader & Barrier is a protocol composition, so TagGate needs no protocol of its own. Gate is a class, because closing a struct held in an [any Barrier] array would close only the array's copy.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# Roles, by what each caller uses; Ruby checks none of them before the program runs:
# Barrier: open and close. TagReader: read_tag, a resident's tag in range, or nil.
# TagGate: two roles, composed. VisitorDesk: print_pass.
# A barrier that people or cars pass: it plays Barrier.
class Gate
def initialize(name, open)
@name = name
@open = open
end
def open?
@open
end
def open
@open = true
"#{@name} opened"
end
def close
@open = false
"#{@name} closed"
end
end
# The car gate: a Gate with its own reader, so it plays TagGate.
class BoomBarrier < Gate
def initialize(tag_in_range)
super('barrier', false)
@tag_in_range = tag_in_range
end
def read_tag
@tag_in_range
end
end
# The guard's desk: it plays VisitorDesk.
class VisitorKiosk
def print_pass(visitor, flat)
"pass printed for #{visitor}, visiting #{flat}"
end
end
def resident_entry(gate)
tag = gate.read_tag
return 'no resident tag in range' if tag.nil?
"resident tag #{tag}: #{gate.open}"
end
def night_lockdown(barriers)
barriers.each { |barrier| puts "night lockdown: #{barrier.close}" }
end
car_gate = BoomBarrier.new('TAG-1042')
foot_gate = Gate.new('pedestrian gate', true)
desk = VisitorKiosk.new
puts resident_entry(car_gate)
puts desk.print_pass('Meera', 'B-204')
night_lockdown([car_gate, foot_gate]) # only barriers: the kiosk has no close
puts "pedestrian gate at the end: #{foot_gate.open? ? 'open' : 'closed'}" In Ruby: Ruby has no interface declarations, so the roles are written down as comments and a method call is the only check. open? says whether the gate is open; open opens it.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
interface Barrier {
fun open(): String
fun close(): String
}
interface TagReader {
fun readTag(): String? // a resident's tag in range, or null
}
interface TagGate :
TagReader,
Barrier // two roles, composed
interface VisitorDesk {
fun printPass(
visitor: String,
flat: String,
): String
}
open class Gate(
private val name: String,
isOpen: Boolean,
) : Barrier {
var isOpen = isOpen
private set
override fun open(): String {
isOpen = true
return "$name opened"
}
override fun close(): String {
isOpen = false
return "$name closed"
}
}
class BoomBarrier(
private val tagInRange: String?,
) : Gate("barrier", false),
TagGate {
override fun readTag(): String? = tagInRange
}
class VisitorKiosk : VisitorDesk {
override fun printPass(
visitor: String,
flat: String,
): String = "pass printed for $visitor, visiting $flat"
}
fun residentEntry(gate: TagGate): String {
val tag = gate.readTag() ?: return "no resident tag in range"
return "resident tag $tag: ${gate.open()}"
}
fun nightLockdown(barriers: List<Barrier>) {
for (barrier in barriers) {
println("night lockdown: ${barrier.close()}")
}
}
fun main() {
val carGate = BoomBarrier("TAG-1042")
val footGate = Gate("pedestrian gate", true)
val desk: VisitorDesk = VisitorKiosk()
println(residentEntry(carGate))
println(desk.printPass("Meera", "B-204"))
nightLockdown(listOf(carGate, footGate)) // only barriers: the kiosk has no close()
println("pedestrian gate at the end: ${if (footGate.isOpen) "open" else "closed"}")
} In Kotlin: TagGate composes the two roles in its supertype list. Gate is open, so BoomBarrier may extend it, and isOpen has a private setter, so only open() and close() change it.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
abstract interface class Barrier {
String open();
String close();
}
abstract interface class TagReader {
String? readTag(); // a resident's tag in range, or null
}
abstract interface class TagGate
implements TagReader, Barrier {} // two roles, composed
abstract interface class VisitorDesk {
String printPass(String visitor, String flat);
}
class Gate implements Barrier {
Gate(this.name, this._isOpen);
final String name;
bool _isOpen;
bool get isOpen => _isOpen;
@override
String open() {
_isOpen = true;
return '$name opened';
}
@override
String close() {
_isOpen = false;
return '$name closed';
}
}
final class BoomBarrier extends Gate implements TagGate {
BoomBarrier(this._tagInRange) : super('barrier', false);
final String? _tagInRange;
@override
String? readTag() => _tagInRange;
}
final class VisitorKiosk implements VisitorDesk {
@override
String printPass(String visitor, String flat) =>
'pass printed for $visitor, visiting $flat';
}
String residentEntry(TagGate gate) {
final tag = gate.readTag();
if (tag == null) return 'no resident tag in range';
return 'resident tag $tag: ${gate.open()}';
}
void nightLockdown(List<Barrier> barriers) {
for (final barrier in barriers) {
print('night lockdown: ${barrier.close()}');
}
}
void main() {
final carGate = BoomBarrier('TAG-1042');
final footGate = Gate('pedestrian gate', true);
final VisitorDesk desk = VisitorKiosk();
print(residentEntry(carGate));
print(desk.printPass('Meera', 'B-204'));
nightLockdown([carGate, footGate]); // only barriers: the kiosk has no close()
print('pedestrian gate at the end: ${footGate.isOpen ? 'open' : 'closed'}');
} In Dart: An abstract interface class can be implemented but not extended outside its library. BoomBarrier extends Gate and implements TagGate, so its open and close are Gate's.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
resident tag TAG-1042: barrier opened pass printed for Meera, visiting B-204 night lockdown: barrier closed night lockdown: pedestrian gate closed pedestrian gate at the end: closed
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 10 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 153 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 20 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 718 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 181 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
The lockdown now asks for a list of Barrier objects, and both barriers close, so the pedestrian gate ends the night
closed. The kiosk cannot be put in that list by mistake: Python’s type checker reports that it has no close(), and
so do the compilers of the statically typed languages. The languages part ways on how a class comes to play a role:
- It says so. In Java, C#, Kotlin, PHP, Dart and C++ a class names its roles, as
BoomBarriernamesTagGate. - It has the methods. In Python, TypeScript and Go a class plays every role whose methods it has, and Swift
composes
TagReader & Barrierwithout a new protocol. - Nothing checks. In JavaScript and Ruby a role is checked only when a method is called, so the roles live in JSDoc types or comments.
Rust, which has no inheritance, makes the barrier a Gate whose reader is a TagInRange, and only that kind of gate
implements TagReader. Wherever the language leaves the choice to you, keep the roles next to the code that uses them,
because their shape comes from the callers.
What the split saves, measured from the source
This program reads the source of both designs with Python’s ast module and counts what the fat interface cost:
"""Measures the two gate designs from their source code: interfaces, stub methods, what a test fake for resident
entry must define, and how many device classes a change to print_pass() touches."""
import ast
from pathlib import Path
def classes(*files):
found = {}
for file in files:
tree = ast.parse(Path(file).read_text(encoding="utf-8"))
found.update({node.name: node for node in tree.body if isinstance(node, ast.ClassDef)})
return found
def methods(cls):
return [node for node in cls.body if isinstance(node, ast.FunctionDef)]
def is_interface(cls):
bases = {getattr(base, "id", "") for base in cls.bases}
return bool(bases & {"ABC", "Protocol"})
def is_stub(method):
"""A method that cannot do its job: it only raises NotImplementedError, or only returns None."""
body = [node for node in method.body if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant))]
if len(body) != 1:
return False
statement = body[0]
if isinstance(statement, ast.Raise) and isinstance(statement.exc, ast.Call):
return getattr(statement.exc.func, "id", "") == "NotImplementedError"
if isinstance(statement, ast.Return):
return statement.value is None or (isinstance(statement.value, ast.Constant) and statement.value.value is None)
return False
def all_methods(name, found):
"""The methods of an interface, including those of the interfaces it extends."""
cls = found[name]
names = [m.name for m in methods(cls)]
for base in cls.bases:
if getattr(base, "id", "") in found:
names += all_methods(base.id, found)
return sorted(set(names))
def measure(found, fake_for):
interfaces = [c for c in found.values() if is_interface(c) and methods(c)]
devices = [c for c in found.values() if not is_interface(c)]
return {
"interfaces": str(len(interfaces)),
"methods in each interface": ", ".join(str(len(methods(c))) for c in interfaces),
"stub methods in the devices": str(sum(is_stub(m) for c in devices for m in methods(c))),
"methods a fake for resident_entry needs": str(len(all_methods(fake_for, found))),
"device classes that define print_pass": str(sum(any(m.name == "print_pass" for m in methods(c)) for c in devices)),
}
fat = measure(classes("fat/gate.py"), fake_for="GateDevice")
roles = measure(classes("roles/main.py"), fake_for="TagGate")
print(f"{'':42}{'fat':>10}{'roles':>12}")
for row in fat:
print(f"{row:42}{fat[row]:>10}{roles[row]:>12}") Output
fat roles interfaces 1 3 methods in each interface 4 2, 1, 1 stub methods in the devices 6 0 methods a fake for resident_entry needs 4 3 device classes that define print_pass 3 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure.py
In the fat design the three devices define twelve operations, and six of them are stubs; the role design has none. A
test of resident entry needs a fake gate: to count as a GateDevice it must define all four abstract methods, while a
TagGate needs three. Then suppose the society asks for a validity time on every printed pass, so print_pass()
gets a new parameter. In the fat design three device classes define print_pass(), two of them as stubs that must be
edited to keep matching the interface; in the role design one class does.
The role design has more interfaces, three instead of one, but each is small, and the devices now define only the six operations they can really carry out. ISP does not promise fewer types.
Key idea
An interface sized by its callers lets a change reach only the classes and callers that use what changed: the split adds a few small types and removes the stubs, the fake methods and the edits.
Checkpoint
Check: Role interfaces cut from each caller's use · What a fat interface costs, measured
isinstance() on a protocol checks names, not parameters
A protocol is mainly for a static type checker, which compares the methods of a class with the protocol’s, signatures
included. Decorated with @runtime_checkable, a protocol can also be used with isinstance(), but the typing
documentation calls such protocols simple-minded: the check looks only for the presence of the attributes, ignoring
their type signatures. Run the check on a pedestrian gate and on a kiosk written for an older desk:
"""What isinstance() checks when the interface is a runtime-checkable protocol, and what it does not."""
from typing import Protocol, runtime_checkable
@runtime_checkable
class VisitorDesk(Protocol):
def print_pass(self, visitor: str, flat: str) -> str: ...
class PedestrianGate:
def open(self):
return "pedestrian gate opened"
class OldKiosk:
"""Written for an earlier version of the desk: the method's name matches, its parameters do not."""
def print_pass(self):
return "pass printed"
print("PedestrianGate is a VisitorDesk:", isinstance(PedestrianGate(), VisitorDesk))
print("OldKiosk is a VisitorDesk:", isinstance(OldKiosk(), VisitorDesk))
try:
OldKiosk().print_pass("Meera", "B-204")
except TypeError as error:
print("calling it as a VisitorDesk:", error) Output
PedestrianGate is a VisitorDesk: False OldKiosk is a VisitorDesk: True calling it as a VisitorDesk: OldKiosk.print_pass() takes 1 positional argument but 3 were given
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 protocol_check.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The pedestrian gate has no print_pass(), so it is correctly not a VisitorDesk. The old kiosk has the method’s name
with the wrong parameters, passes the check and fails when it is used as a desk. Use isinstance() with a protocol as
a coarse check, for example when a plugin is loaded, and rely on the type checker and on tests for the rest.
Small roles compose, and splitting stops where callers stop
Go’s standard library is built in this style. Effective Go observes that Go code is full of interfaces with only one
or two methods, each named after its method: io.Writer is the interface of anything with a Write method, and
io.Reader of anything with Read. When code needs both, io.ReadWriter embeds the two interfaces instead of
listing their methods again, the same move as TagGate. The Go project’s guidance on interfaces adds two rules that suit
any language: an interface belongs in the package that uses it, not in the package that implements it, and it should not
be defined before a real use shows what it needs. Python’s collections.abc works the same way: Sized asks only for
__len__(), Iterable for __iter__() and Container for __contains__(), and Collection combines the three.
Splitting can also go too far. Martin Fowler draws the line with two names: a role interface comes from one interaction between a supplier and its callers, while a header interface: An interface that copies every public method of one class, so that its size follows the class instead of what any caller needs. copies all of a class’s public methods. The table compares three ways to cut the gate’s interfaces:
| Cut | Interfaces at the gate | What it costs |
|---|---|---|
| One header interface | GateDevice, 4 methods |
6 stubs; a fake defines 4 methods; a pass change edits 3 classes |
| By caller | Barrier, TagReader, VisitorDesk, and TagGate composed |
No stubs; a fake of TagGate defines 3 methods; the change edits 1 class |
| One method each | Opener, Closer, TagReader, PassPrinter |
No stubs, but two callers each ask for two roles that always travel together |
If Barrier became Opener and Closer, every caller that opens and closes a gate would ask for both, and no device
would ever implement one without the other. Methods that one kind of caller uses together, and that change together,
belong in one role: that is cohesion, measured from the callers’ side.
In a design interview
When you draw an interface, name its callers next to it: “the lockdown uses Barrier; the desk uses VisitorDesk”. If an implementation would need a method that throws “not supported”, say that the interface is too wide and split it on the spot.
Checkpoint
Check: What isinstance() checks on a protocol · Composing small roles, and where splitting stops
Practise: split a school’s staff interface
A school’s software puts every member of staff behind one interface of nine methods, and each class carries six stubs. Split it into three roles, remove every stub and type each caller with the role it needs. The sample tests run in your browser:
Exercise · Medium · Python
Split a school's staff interface into roles
A school's software puts every member of staff behind one interface, StaffMember, with nine methods. Each of the three classes can really do three of them, so each one carries six stubs that raise NotImplementedError, and every caller depends on all nine methods although it uses one.
Split StaffMember into three role interfaces, written as typing.Protocol classes (or as abstract base classes):
Teaching:teach,mark_attendanceandgrade;Transport:drive,check_bus_passandlog_fuel;FeeCounter:collect_fee,issue_receiptandday_total.
Then remove every stub, so that Teacher, BusDriver and Accountant keep only the methods they can carry out, and change the type hints of the three caller functions to the role each one needs. Everything the classes and the callers did before must still work. The sample tests check the old behaviour, the three roles, that no stub is left and that a fake fee counter with only its role's three methods works with close_fee_counter().
Starter code · school_staff.py
"""A school's staff behind one interface. Split StaffMember into three role interfaces (see the prompt)."""
from abc import ABC, abstractmethod
class StaffMember(ABC):
@abstractmethod
def teach(self, period: int) -> str: ...
@abstractmethod
def mark_attendance(self, roll: list[str], present: set[str]) -> dict[str, bool]: ...
@abstractmethod
def grade(self, answers: list[str], key: list[str]) -> int: ...
@abstractmethod
def drive(self, stops: list[str]) -> list[str]: ...
@abstractmethod
def check_bus_pass(self, pass_id: str) -> bool: ...
@abstractmethod
def log_fuel(self, litres: int) -> int: ...
@abstractmethod
def collect_fee(self, student: str, amount: int) -> int: ...
@abstractmethod
def issue_receipt(self, receipt_no: int) -> str: ...
@abstractmethod
def day_total(self) -> int: ...
class Teacher(StaffMember):
def __init__(self, name, subject):
self.name = name
self.subject = subject
def teach(self, period):
return f"{self.name} teaches {self.subject} in period {period}"
def mark_attendance(self, roll, present):
return {student: student in present for student in roll}
def grade(self, answers, key):
return sum(1 for given, right in zip(answers, key) if given == right)
def drive(self, stops):
raise NotImplementedError("teachers do not drive the bus")
def check_bus_pass(self, pass_id):
raise NotImplementedError("teachers do not check bus passes")
def log_fuel(self, litres):
raise NotImplementedError("teachers do not fill the tank")
def collect_fee(self, student, amount):
raise NotImplementedError("teachers do not collect fees")
def issue_receipt(self, receipt_no):
raise NotImplementedError("teachers do not issue receipts")
def day_total(self):
raise NotImplementedError("teachers keep no cash book")
class BusDriver(StaffMember):
def __init__(self, name, valid_passes):
self.name = name
self.valid_passes = set(valid_passes)
self.fuel_litres = 0
def drive(self, stops):
return [f"stop {number}: {stop}" for number, stop in enumerate(stops, start=1)]
def check_bus_pass(self, pass_id):
return pass_id in self.valid_passes
def log_fuel(self, litres):
self.fuel_litres = self.fuel_litres + litres
return self.fuel_litres
def teach(self, period):
raise NotImplementedError("drivers do not teach")
def mark_attendance(self, roll, present):
raise NotImplementedError("drivers do not take the register")
def grade(self, answers, key):
raise NotImplementedError("drivers do not grade papers")
def collect_fee(self, student, amount):
raise NotImplementedError("drivers do not collect fees")
def issue_receipt(self, receipt_no):
raise NotImplementedError("drivers do not issue receipts")
def day_total(self):
raise NotImplementedError("drivers keep no cash book")
class Accountant(StaffMember):
def __init__(self, name):
self.name = name
self._payments = []
def collect_fee(self, student, amount):
if amount <= 0:
raise ValueError("a fee is a positive amount")
self._payments.append((student, amount))
return len(self._payments)
def issue_receipt(self, receipt_no):
student, amount = self._payments[receipt_no - 1]
return f"receipt {receipt_no}: {amount} from {student}"
def day_total(self):
return sum(amount for _, amount in self._payments)
def teach(self, period):
raise NotImplementedError("accountants do not teach")
def mark_attendance(self, roll, present):
raise NotImplementedError("accountants do not take the register")
def grade(self, answers, key):
raise NotImplementedError("accountants do not grade papers")
def drive(self, stops):
raise NotImplementedError("accountants do not drive the bus")
def check_bus_pass(self, pass_id):
raise NotImplementedError("accountants do not check bus passes")
def log_fuel(self, litres):
raise NotImplementedError("accountants do not fill the tank")
def first_period(teachers: list[StaffMember]) -> list[str]:
return [teacher.teach(1) for teacher in teachers]
def morning_route(driver: StaffMember, stops: list[str]) -> list[str]:
return driver.drive(stops)
def close_fee_counter(counter: StaffMember) -> str:
return f"collected today: {counter.day_total()}" The sample tests · test_school_staff.py
import inspect
import school_staff
from school_staff import Accountant, BusDriver, Teacher, close_fee_counter, first_period, morning_route
def role_methods(role):
return {name for name, value in vars(role).items() if callable(value) and not name.startswith("_")}
def test_teacher_still_works():
"""a teacher teaches, takes the register and grades as before"""
teacher = Teacher("Ms Iyer", "maths")
assert teacher.teach(3) == "Ms Iyer teaches maths in period 3"
assert teacher.mark_attendance(["Asha", "Ravi"], {"Ravi"}) == {"Asha": False, "Ravi": True}
assert teacher.grade(["b", "c", "a"], ["b", "a", "a"]) == 2
assert first_period([teacher]) == ["Ms Iyer teaches maths in period 1"]
def test_driver_still_works():
"""a driver drives the route, checks passes and logs fuel as before"""
driver = BusDriver("Mr Khan", valid_passes=["P-17"])
assert morning_route(driver, ["Gate 2", "Market"]) == ["stop 1: Gate 2", "stop 2: Market"]
assert driver.check_bus_pass("P-17") is True
assert driver.check_bus_pass("P-99") is False
assert driver.log_fuel(20) == 20
assert driver.log_fuel(15) == 35
def test_accountant_still_works():
"""an accountant collects fees, issues receipts and totals the day as before"""
accountant = Accountant("Mrs Das")
assert accountant.collect_fee("Asha", 1200) == 1
assert accountant.collect_fee("Ravi", 800) == 2
assert accountant.issue_receipt(2) == "receipt 2: 800 from Ravi"
assert close_fee_counter(accountant) == "collected today: 2000"
try:
accountant.collect_fee("Asha", 0)
except ValueError:
pass
else:
raise AssertionError("a fee of 0 must be refused")
def test_three_role_interfaces():
"""Teaching, Transport and FeeCounter each list the methods of one role"""
assert role_methods(school_staff.Teaching) == {"teach", "mark_attendance", "grade"}
assert role_methods(school_staff.Transport) == {"drive", "check_bus_pass", "log_fuel"}
assert role_methods(school_staff.FeeCounter) == {"collect_fee", "issue_receipt", "day_total"}
def test_no_stub_methods():
"""no class raises NotImplementedError, and no class has a method of another role"""
assert "NotImplementedError" not in inspect.getsource(school_staff)
assert not hasattr(Teacher("Ms Iyer", "maths"), "drive")
assert not hasattr(BusDriver("Mr Khan", []), "collect_fee")
assert not hasattr(Accountant("Mrs Das"), "teach")
def test_a_fake_needs_only_its_role():
"""a fake with only the fee counter's three methods works with close_fee_counter()"""
class FakeCounter:
def collect_fee(self, student, amount):
return 1
def issue_receipt(self, receipt_no):
return "receipt 1"
def day_total(self):
return 500
assert close_fee_counter(FakeCounter()) == "collected today: 500" A hint
Start from the callers, not from the classes: first_period() calls only teach, morning_route() only drive and close_fee_counter() only day_total, and each class's real methods fall into exactly one of the three groups. With typing.Protocol, the classes do not need to inherit from anything: a Teacher with the three teaching methods already is a Teaching, so StaffMember and its abc import can go.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check yourself on the whole lesson, the idea you know least first:
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does the Interface Segregation Principle say, and how do you spot a violation?
No caller should be forced to depend on methods it does not use, so an interface should be the size of what one kind
of caller needs. The signs of a violation are on both sides of the interface. Implementations carry stubs that raise
NotImplementedError or quietly do nothing, because the interface lists operations they cannot perform. Callers and
their test fakes depend on methods they never call, so a change that one caller asks for makes unrelated classes change
too. The fix is role interfaces: group the methods by the callers that use them together, let a class implement
several roles, and type each caller’s parameters with the role it needs. Splitting further, down to one method each,
is only right when callers really use one method.
Interviewers follow up on that answer. Choose a reply to each question below, and read why each reply holds up or falls short:
Checkpoint
Interview round: three follow-up questions
Two harder questions build on the same split, one on a wide interface in a real codebase and one on splitting too far:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your
FileStoreinterface has 12 methods, fromupload()tomake_thumbnail()andbill_usage(). Every new storage backend takes a week to write, and half of each backend's methods raiseNotImplementedError. How would you redesign the interface without breaking the 40 places in the code that use it? -
Fresher to mid
A teammate split a six-method
Cartinterface into six one-method interfaces, one per method, to follow the Interface Segregation Principle. Is the new design better? Argue for or against it, and name the cost that each design creates.
Key takeaways
- The principle says that callers should not depend on methods they do not use, so each interface is sized by what one kind of caller needs.
- A fat interface shows itself in stubs that raise or quietly return, in test fakes that imitate unused methods, and in changes that reach classes they were never meant for.
- Role interfaces are built from the callers: list what each caller uses, group those methods, and compose roles when
one caller needs several, as
TagGateand Go’sio.ReadWriterdo. - Measured on the gate, the split removed all six stubs, cut a resident-entry fake from four methods to three and a pass change from three classes to one.
isinstance()with a runtime-checkable protocol checks method names only; signatures need a type checker or tests.- Methods that one kind of caller uses and changes together stay in one role; one method per interface is a special case, not the goal.
Terms in this lesson
- fat interface
- An interface with more methods than any one of its callers uses, so implementers fill the gaps with stubs and every caller depends on methods it never calls.
- header interface
- An interface that copies every public method of one class, so that its size follows the class instead of what any caller needs.
- role interface
- An interface cut from one interaction between a supplier and its callers, holding only the methods those callers use together; one class often plays several roles.
- structural typing
- Matching a type to an interface by the methods it has, not by a declaration: a class with the right methods plays the role. Python's protocols and Go's and TypeScript's interfaces work this way.
References
- The Interface Segregation Principle (The C++ Report column, archived copy) (Robert C. Martin, via the Internet Archive)
- The Principles of OOD (archived copy) (Robert C. Martin, via the Internet Archive)
- Solid Relevance (Robert C. Martin, The Clean Code Blog)
- PEP 544: Protocols, structural subtyping (static duck typing) (Python Software Foundation)
- typing: Protocol and runtime_checkable (Python Software Foundation)
- collections.abc, abstract base classes for containers (Python Software Foundation)
- Interface Collection (Java SE 25): optional operations (Oracle)
- Interface List (Java SE 25): unmodifiable lists (Oracle)
- Effective Go: interfaces and embedding (The Go Authors)
- Go Wiki: Go Code Review Comments, interfaces (The Go Authors)
- TypeScript Handbook: type compatibility (structural typing) (TypeScript project)
- TypeScript Handbook: JSDoc reference (@typedef) (TypeScript project)
- Names of classes, structs and interfaces (Framework Design Guidelines) (Microsoft)
- Object interfaces (interface inheritance) (The PHP Group)
- Class modifiers: interface and abstract interface (Dart (Google))
- Inheritance: the open keyword (JetBrains (Kotlin))
- The Swift Programming Language: protocols (protocol composition) (Swift.org)
- The Swift Programming Language: structures and classes (Swift.org)
- The Rust Programming Language: traits, defining shared behaviour (The Rust Project)
- Derived classes: virtual base classes (cppreference.com)
- RoleInterface (Martin Fowler)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress