Low Level DesignSOLID principles
Interface segregation principle
The interface segregation principle (ISP) through three real examples, from an office printer to smart home devices to read replicas, with live demos, class diagrams and code in five languages.
- Read first
- Liskov substitution
The interface segregation principle says no client should be forced to depend on methods it does not use. Give each kind of caller an interface with just the methods it calls, instead of one big interface that every caller shares.
Robert Martin found it in the 1990s while consulting for Xerox. Their printer software had one
Job class used by every task: printing, stapling, faxing. A one-line change for stapling meant
rebuilding and redeploying the code for every other task, and builds took an hour. Splitting
Job into one small interface per task stopped unrelated changes from spreading.
Two costs come from a fat interface, and they are easy to tell apart. Classes that implement it are forced to write methods they can’t support. So they throw, or do nothing. Classes that use it are tied to methods they never call. So they are rebuilt, retested and redeployed when those change. Pick a level below. The rest of the page is shared.
The shape
Section titled “The shape”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Three things make it work:
- Role interfaces. Each names one job a caller needs done:
Printer,Lockable,OrderReader. They are usually small. But size isn’t the goal. Cohesion is. - Clients that ask for the role, not the class. A client that only prints takes a
Printer. Its type now says exactly what it needs. - Implementations that list their roles. A machine that prints and scans implements both. One that only prints implements one, and the compiler stops anyone asking it to scan.
Worked examples
Section titled “Worked examples”An office printer
- Spotting a fat interface
- Role interfaces
- One class, several roles
An office has a big machine that prints, scans and faxes, and a cheap printer that only prints.
The software has a print queue and a scan-to-email button. Someone wrote one Machine interface
for all of it.
Without the principle
Section titled “Without the principle”record Doc(String name) {}
// Before: one interface for everything an office machine might do.interface Machine { void print(Doc doc); void scan(Doc doc); void fax(Doc doc, String number);}
class OfficeMachine implements Machine { public void print(Doc doc) { System.out.println("printing " + doc.name()); } public void scan(Doc doc) { System.out.println("scanning " + doc.name()); } public void fax(Doc doc, String number) { System.out.println("faxing " + doc.name() + " to " + number); }}
// flaw: a plain printer is forced to have methods it can't support, so it throws.// Any code holding a Machine can call them, and the compiler won't stop it.class SimplePrinter implements Machine { public void print(Doc doc) { System.out.println("printing " + doc.name()); } public void scan(Doc doc) { throw new UnsupportedOperationException("no scanner"); } public void fax(Doc doc, String number) { throw new UnsupportedOperationException("no fax"); }}
// flaw: the print queue only prints, but it depends on scan and fax too. A change to the// fax method (a cover page, a retry option) rebuilds and retests the print queue.class PrintQueue { private final Machine machine; PrintQueue(Machine machine) { this.machine = machine; } void submit(Doc doc) { machine.print(doc); }}
class ScanToEmail { private final Machine machine; ScanToEmail(Machine machine) { this.machine = machine; } void send(Doc doc, String email) { machine.scan(doc); System.out.println("emailed scan of " + doc.name() + " to " + email); }}
class Main { public static void main(String[] args) { new PrintQueue(new SimplePrinter()).submit(new Doc("invoice.pdf")); new ScanToEmail(new OfficeMachine()).send(new Doc("contract.pdf"), "ada@example.com"); // Compiles. Fails only when someone uses the scan-to-email button on the cheap printer. new ScanToEmail(new SimplePrinter()).send(new Doc("receipt.pdf"), "ada@example.com"); }}from dataclasses import dataclassfrom typing import Protocol
@dataclass(frozen=True)class Doc: name: str
# Before: one protocol for everything an office machine might do.class Machine(Protocol): def print(self, doc: Doc) -> None: ... def scan(self, doc: Doc) -> None: ... def fax(self, doc: Doc, number: str) -> None: ...
class OfficeMachine: def print(self, doc: Doc) -> None: print(f"printing {doc.name}")
def scan(self, doc: Doc) -> None: print(f"scanning {doc.name}")
def fax(self, doc: Doc, number: str) -> None: print(f"faxing {doc.name} to {number}")
# flaw: a plain printer is forced to have methods it can't support, so it raises.# Any code holding a Machine can call them, and the type checker won't stop it.class SimplePrinter: def print(self, doc: Doc) -> None: print(f"printing {doc.name}")
def scan(self, doc: Doc) -> None: raise NotImplementedError("no scanner")
def fax(self, doc: Doc, number: str) -> None: raise NotImplementedError("no fax")
# flaw: the print queue only prints, but it depends on scan and fax too. A change to the# fax method (a cover page, a retry option) changes the type the print queue is checked against.class PrintQueue: def __init__(self, machine: Machine) -> None: self._machine = machine
def submit(self, doc: Doc) -> None: self._machine.print(doc)
class ScanToEmail: def __init__(self, machine: Machine) -> None: self._machine = machine
def send(self, doc: Doc, email: str) -> None: self._machine.scan(doc) print(f"emailed scan of {doc.name} to {email}")
if __name__ == "__main__": PrintQueue(SimplePrinter()).submit(Doc("invoice.pdf")) ScanToEmail(OfficeMachine()).send(Doc("contract.pdf"), "ada@example.com") # Type checks. Fails only when someone uses scan-to-email on the cheap printer. ScanToEmail(SimplePrinter()).send(Doc("receipt.pdf"), "ada@example.com")using System;
record Doc(string Name);
// Before: one interface for everything an office machine might do.interface IMachine{ void Print(Doc doc); void Scan(Doc doc); void Fax(Doc doc, string number);}
class OfficeMachine : IMachine{ public void Print(Doc doc) => Console.WriteLine($"printing {doc.Name}"); public void Scan(Doc doc) => Console.WriteLine($"scanning {doc.Name}"); public void Fax(Doc doc, string number) => Console.WriteLine($"faxing {doc.Name} to {number}");}
// flaw: a plain printer is forced to have methods it can't support, so it throws.// Any code holding an IMachine can call them, and the compiler won't stop it.class SimplePrinter : IMachine{ public void Print(Doc doc) => Console.WriteLine($"printing {doc.Name}"); public void Scan(Doc doc) => throw new NotSupportedException("no scanner"); public void Fax(Doc doc, string number) => throw new NotSupportedException("no fax");}
// flaw: the print queue only prints, but it depends on Scan and Fax too. A change to the// Fax method (a cover page, a retry option) rebuilds and retests the print queue.class PrintQueue{ private readonly IMachine _machine; public PrintQueue(IMachine machine) => _machine = machine; public void Submit(Doc doc) => _machine.Print(doc);}
class ScanToEmail{ private readonly IMachine _machine; public ScanToEmail(IMachine machine) => _machine = machine; public void Send(Doc doc, string email) { _machine.Scan(doc); Console.WriteLine($"emailed scan of {doc.Name} to {email}"); }}
static class Program{ static void Main() { new PrintQueue(new SimplePrinter()).Submit(new Doc("invoice.pdf")); new ScanToEmail(new OfficeMachine()).Send(new Doc("contract.pdf"), "ada@example.com"); // Compiles. Fails only when someone uses the scan-to-email button on the cheap printer. new ScanToEmail(new SimplePrinter()).Send(new Doc("receipt.pdf"), "ada@example.com"); }}package main
import ( "errors" "fmt")
type Doc struct{ Name string }
// Machine before: one interface for everything an office machine might do.type Machine interface { Print(doc Doc) error Scan(doc Doc) error Fax(doc Doc, number string) error}
type OfficeMachine struct{}
func (OfficeMachine) Print(doc Doc) error { fmt.Println("printing", doc.Name); return nil }func (OfficeMachine) Scan(doc Doc) error { fmt.Println("scanning", doc.Name); return nil }func (OfficeMachine) Fax(doc Doc, number string) error { fmt.Println("faxing", doc.Name, "to", number) return nil}
// SimplePrinter is forced to have methods it can't support, so they fail.// flaw: any code holding a Machine can call them, and the compiler won't stop it.type SimplePrinter struct{}
func (SimplePrinter) Print(doc Doc) error { fmt.Println("printing", doc.Name); return nil }func (SimplePrinter) Scan(Doc) error { return errors.New("no scanner") }func (SimplePrinter) Fax(Doc, string) error { return errors.New("no fax") }
// PrintQueue only prints, but it depends on Scan and Fax too.// flaw: a change to Fax (a cover page, a retry option) rebuilds and retests the print queue.type PrintQueue struct{ Machine Machine }
func (q PrintQueue) Submit(doc Doc) error { return q.Machine.Print(doc) }
type ScanToEmail struct{ Machine Machine }
func (s ScanToEmail) Send(doc Doc, email string) error { if err := s.Machine.Scan(doc); err != nil { return err } fmt.Println("emailed scan of", doc.Name, "to", email) return nil}
func main() { PrintQueue{SimplePrinter{}}.Submit(Doc{"invoice.pdf"}) ScanToEmail{OfficeMachine{}}.Send(Doc{"contract.pdf"}, "ada@example.com") // Compiles. Fails only when someone uses the scan-to-email button on the cheap printer. if err := (ScanToEmail{SimplePrinter{}}).Send(Doc{"receipt.pdf"}, "ada@example.com"); err != nil { fmt.Println("error:", err) }}interface Doc { readonly name: string;}
// Before: one interface for everything an office machine might do.interface Machine { print(doc: Doc): void; scan(doc: Doc): void; fax(doc: Doc, number: string): void;}
class OfficeMachine implements Machine { print(doc: Doc) { console.log(`printing ${doc.name}`); } scan(doc: Doc) { console.log(`scanning ${doc.name}`); } fax(doc: Doc, number: string) { console.log(`faxing ${doc.name} to ${number}`); }}
// flaw: a plain printer is forced to have methods it can't support, so it throws.// Any code holding a Machine can call them, and the compiler won't stop it.class SimplePrinter implements Machine { print(doc: Doc) { console.log(`printing ${doc.name}`); } scan(_doc: Doc): void { throw new Error("no scanner"); } fax(_doc: Doc, _number: string): void { throw new Error("no fax"); }}
// flaw: the print queue only prints, but it depends on scan and fax too. A change to the// fax method (a cover page, a retry option) changes the type the print queue compiles against.class PrintQueue { constructor(private readonly machine: Machine) {} submit(doc: Doc) { this.machine.print(doc); }}
class ScanToEmail { constructor(private readonly machine: Machine) {} send(doc: Doc, email: string) { this.machine.scan(doc); console.log(`emailed scan of ${doc.name} to ${email}`); }}
new PrintQueue(new SimplePrinter()).submit({ name: "invoice.pdf" });new ScanToEmail(new OfficeMachine()).send({ name: "contract.pdf" }, "ada@example.com");// Compiles. Fails only when someone uses the scan-to-email button on the cheap printer.new ScanToEmail(new SimplePrinter()).send({ name: "receipt.pdf" }, "ada@example.com");SimplePrinter must have scan and fax, so it throws. Nothing stops code from passing it to
the scan-to-email button, and the mistake shows up when someone presses the button. And
PrintQueue, which only prints, depends on fax as well.
With ISP
Section titled “With ISP”Split Machine by what the callers do: printing, scanning and faxing. The office machine implements
all three. The cheap printer implements one.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
record Doc(String name) {}
// One small interface per job a caller needs done. Each is one role a device can play.interface Printer { void print(Doc doc);}
interface Scanner { void scan(Doc doc);}
interface Fax { void fax(Doc doc, String number);}
// A machine that does all three says so, by implementing all three.class OfficeMachine implements Printer, Scanner, Fax { public void print(Doc doc) { System.out.println("printing " + doc.name()); } public void scan(Doc doc) { System.out.println("scanning " + doc.name()); } public void fax(Doc doc, String number) { System.out.println("faxing " + doc.name() + " to " + number); }}
// A plain printer claims only what it can do. No methods that throw.class SimplePrinter implements Printer { public void print(Doc doc) { System.out.println("printing " + doc.name()); }}
// Depends on printing and nothing else. A change to Fax can't reach it.class PrintQueue { private final Printer printer; PrintQueue(Printer printer) { this.printer = printer; } void submit(Doc doc) { printer.print(doc); }}
class ScanToEmail { private final Scanner scanner; ScanToEmail(Scanner scanner) { this.scanner = scanner; } void send(Doc doc, String email) { scanner.scan(doc); System.out.println("emailed scan of " + doc.name() + " to " + email); }}
class Main { public static void main(String[] args) { OfficeMachine office = new OfficeMachine(); new PrintQueue(new SimplePrinter()).submit(new Doc("invoice.pdf")); new PrintQueue(office).submit(new Doc("report.pdf")); // the office machine is a Printer too new ScanToEmail(office).send(new Doc("contract.pdf"), "ada@example.com"); // new ScanToEmail(new SimplePrinter()); // does not compile: a SimplePrinter is not a Scanner }}from dataclasses import dataclassfrom typing import Protocol
@dataclass(frozen=True)class Doc: name: str
# One small protocol per job a caller needs done. Each is one role a device can play.class Printer(Protocol): def print(self, doc: Doc) -> None: ...
class Scanner(Protocol): def scan(self, doc: Doc) -> None: ...
class Fax(Protocol): def fax(self, doc: Doc, number: str) -> None: ...
# A machine that does all three simply has all three methods. Protocols are structural,# so it fits every role without declaring anything.class OfficeMachine: def print(self, doc: Doc) -> None: print(f"printing {doc.name}")
def scan(self, doc: Doc) -> None: print(f"scanning {doc.name}")
def fax(self, doc: Doc, number: str) -> None: print(f"faxing {doc.name} to {number}")
# A plain printer has only what it can do. No methods that raise.class SimplePrinter: def print(self, doc: Doc) -> None: print(f"printing {doc.name}")
# Depends on printing and nothing else. A change to Fax can't reach it.class PrintQueue: def __init__(self, printer: Printer) -> None: self._printer = printer
def submit(self, doc: Doc) -> None: self._printer.print(doc)
class ScanToEmail: def __init__(self, scanner: Scanner) -> None: self._scanner = scanner
def send(self, doc: Doc, email: str) -> None: self._scanner.scan(doc) print(f"emailed scan of {doc.name} to {email}")
if __name__ == "__main__": office = OfficeMachine() PrintQueue(SimplePrinter()).submit(Doc("invoice.pdf")) PrintQueue(office).submit(Doc("report.pdf")) # the office machine is a Printer too ScanToEmail(office).send(Doc("contract.pdf"), "ada@example.com") # ScanToEmail(SimplePrinter()) # type checker: SimplePrinter is not a Scannerusing System;
record Doc(string Name);
// One small interface per job a caller needs done. Each is one role a device can play.interface IPrinter{ void Print(Doc doc);}
interface IScanner{ void Scan(Doc doc);}
interface IFax{ void Fax(Doc doc, string number);}
// A machine that does all three says so, by implementing all three.class OfficeMachine : IPrinter, IScanner, IFax{ public void Print(Doc doc) => Console.WriteLine($"printing {doc.Name}"); public void Scan(Doc doc) => Console.WriteLine($"scanning {doc.Name}"); public void Fax(Doc doc, string number) => Console.WriteLine($"faxing {doc.Name} to {number}");}
// A plain printer claims only what it can do. No methods that throw.class SimplePrinter : IPrinter{ public void Print(Doc doc) => Console.WriteLine($"printing {doc.Name}");}
// Depends on printing and nothing else. A change to IFax can't reach it.class PrintQueue{ private readonly IPrinter _printer; public PrintQueue(IPrinter printer) => _printer = printer; public void Submit(Doc doc) => _printer.Print(doc);}
class ScanToEmail{ private readonly IScanner _scanner; public ScanToEmail(IScanner scanner) => _scanner = scanner; public void Send(Doc doc, string email) { _scanner.Scan(doc); Console.WriteLine($"emailed scan of {doc.Name} to {email}"); }}
static class Program{ static void Main() { var office = new OfficeMachine(); new PrintQueue(new SimplePrinter()).Submit(new Doc("invoice.pdf")); new PrintQueue(office).Submit(new Doc("report.pdf")); // the office machine is a printer too new ScanToEmail(office).Send(new Doc("contract.pdf"), "ada@example.com"); // new ScanToEmail(new SimplePrinter()); // does not compile: a SimplePrinter is not an IScanner }}package main
import "fmt"
type Doc struct{ Name string }
// One small interface per job a caller needs done. Go's own style: io.Reader, io.Writer,// each a single method, combined only where a caller needs both.type Printer interface{ Print(doc Doc) }type Scanner interface{ Scan(doc Doc) }type Fax interface{ Fax(doc Doc, number string) }
// OfficeMachine has all three methods, so it satisfies all three interfaces. Go needs no// declaration: a type fits every interface whose methods it has.type OfficeMachine struct{}
func (OfficeMachine) Print(doc Doc) { fmt.Println("printing", doc.Name) }func (OfficeMachine) Scan(doc Doc) { fmt.Println("scanning", doc.Name) }func (OfficeMachine) Fax(doc Doc, number string) { fmt.Println("faxing", doc.Name, "to", number) }
// SimplePrinter has only what it can do. No methods that fail by design.type SimplePrinter struct{}
func (SimplePrinter) Print(doc Doc) { fmt.Println("printing", doc.Name) }
// PrintQueue depends on printing and nothing else. A change to Fax can't reach it.type PrintQueue struct{ Printer Printer }
func (q PrintQueue) Submit(doc Doc) { q.Printer.Print(doc) }
type ScanToEmail struct{ Scanner Scanner }
func (s ScanToEmail) Send(doc Doc, email string) { s.Scanner.Scan(doc) fmt.Println("emailed scan of", doc.Name, "to", email)}
func main() { office := OfficeMachine{} PrintQueue{SimplePrinter{}}.Submit(Doc{"invoice.pdf"}) PrintQueue{office}.Submit(Doc{"report.pdf"}) // the office machine is a Printer too ScanToEmail{office}.Send(Doc{"contract.pdf"}, "ada@example.com") // ScanToEmail{SimplePrinter{}} // does not compile: SimplePrinter has no Scan method}interface Doc { readonly name: string;}
// One small interface per job a caller needs done. Each is one role a device can play.interface Printer { print(doc: Doc): void;}
interface Scanner { scan(doc: Doc): void;}
interface Fax { fax(doc: Doc, number: string): void;}
// A machine that does all three says so, by implementing all three.class OfficeMachine implements Printer, Scanner, Fax { print(doc: Doc) { console.log(`printing ${doc.name}`); } scan(doc: Doc) { console.log(`scanning ${doc.name}`); } fax(doc: Doc, number: string) { console.log(`faxing ${doc.name} to ${number}`); }}
// A plain printer claims only what it can do. No methods that throw.class SimplePrinter implements Printer { print(doc: Doc) { console.log(`printing ${doc.name}`); }}
// Depends on printing and nothing else. A change to Fax can't reach it.class PrintQueue { constructor(private readonly printer: Printer) {} submit(doc: Doc) { this.printer.print(doc); }}
class ScanToEmail { constructor(private readonly scanner: Scanner) {} send(doc: Doc, email: string) { this.scanner.scan(doc); console.log(`emailed scan of ${doc.name} to ${email}`); }}
const office = new OfficeMachine();new PrintQueue(new SimplePrinter()).submit({ name: "invoice.pdf" });new PrintQueue(office).submit({ name: "report.pdf" }); // the office machine is a Printer toonew ScanToEmail(office).send({ name: "contract.pdf" }, "ada@example.com");// new ScanToEmail(new SimplePrinter()); // does not compile: SimplePrinter has no scan()Send three requests through both designs. Here, a class counts as touched if it is edited, or if it depends on something that was. Then it has to be rebuilt and retested:
- Machineprint, scan and fax
- OfficeMachine
- SimplePrinterscan() and fax() throw
- PrintQueueonly prints
- ScanToEmailonly scans
- DeskScannernewprint() and fax() throw
- Printer
- Scanner
- Fax
- OfficeMachine
- SimplePrinteronly Printer
- PrintQueue
- ScanToEmail
- DeskScannernewonly Scanner
Pick a change request to see which classes it touches in each design.
What to notice
Section titled “What to notice”- The split follows the callers. There is a
Printerbecause a print queue exists, and aScannerbecause scan-to-email exists. The interfaces are named after jobs, not machines. - Throwing “not supported” is the smell. It is also a Liskov
break: a
SimplePrintercan’t stand in for aMachine. Fixing the interface fixes both. - Mistakes move to compile time. Handing a
SimplePrintertoScanToEmailno longer compiles. Before, it failed when someone pressed the button. - The office machine didn’t change. Same class, same code. It now says three things about itself instead of one.
Here’s our
Machineinterface. A new device only scans. How do you add it?
Whether you notice the new class would need two methods that throw, call that the problem, and
split the interface by what its callers use. Under ten minutes. The red flag is writing the
throwing methods, or a canScan() flag the callers have to check.
A device that only scans shouldn’t have to pretend to print. I’d split Machine into Printer, Scanner and Fax. Then each client asks for the one it uses, and each device claims only what it can do.
Smart home devices
- Capabilities instead of one device type
- Interfaces that build on each other
- Finding objects by capability
A smart home hub controls lights, a thermostat and a door lock. Routines like “away mode” send commands to many devices at once. New device types arrive all the time: blinds, plugs, sprinklers.
Without the principle
Section titled “Without the principle”One Device interface has every command any device has ever needed. Devices that can’t do
something leave the method empty:
import java.util.List;
// Before: one Device interface with every command any device has ever needed.interface Device { String name(); void turnOn(); void turnOff(); void setBrightness(int percent); void setTargetTemp(double celsius); void lock(); void unlock();}
// flaw: every device implements every command. The ones that don't apply silently do// nothing, so a routine that sends the wrong command gets no error at all.class Light implements Device { public String name() { return "hall light"; } public void turnOn() { System.out.println(name() + ": on"); } public void turnOff() { System.out.println(name() + ": off"); } public void setBrightness(int percent) { System.out.println(name() + ": " + percent + "%"); } public void setTargetTemp(double celsius) {} public void lock() {} public void unlock() {}}
class Thermostat implements Device { public String name() { return "thermostat"; } public void turnOn() { System.out.println(name() + ": heating on"); } // flaw: "off" means something very different for a thermostat. A routine that turns // everything off can leave a house unheated through a winter holiday. public void turnOff() { System.out.println(name() + ": heating OFF"); } public void setBrightness(int percent) {} public void setTargetTemp(double celsius) { System.out.println(name() + ": target " + celsius + "C"); } public void lock() {} public void unlock() {}}
class DoorLock implements Device { public String name() { return "front door"; } public void turnOn() {} public void turnOff() {} public void setBrightness(int percent) {} public void setTargetTemp(double celsius) {} public void lock() { System.out.println(name() + ": locked"); } public void unlock() { System.out.println(name() + ": unlocked"); }}
class Hub { private final List<Device> devices; Hub(List<Device> devices) { this.devices = devices; }
// Sends every command to every device and hopes the no-ops sort it out. void awayMode() { for (Device d : devices) { d.turnOff(); d.lock(); } }}
class Main { public static void main(String[] args) { new Hub(List.of(new Light(), new Thermostat(), new DoorLock())).awayMode(); }}from typing import Protocol
# Before: one Device protocol with every command any device has ever needed.class Device(Protocol): name: str
def turn_on(self) -> None: ... def turn_off(self) -> None: ... def set_brightness(self, percent: int) -> None: ... def set_target_temp(self, celsius: float) -> None: ... def lock(self) -> None: ... def unlock(self) -> None: ...
# flaw: every device implements every command. The ones that don't apply silently do# nothing, so a routine that sends the wrong command gets no error at all.class Light: name = "hall light"
def turn_on(self) -> None: print(f"{self.name}: on")
def turn_off(self) -> None: print(f"{self.name}: off")
def set_brightness(self, percent: int) -> None: print(f"{self.name}: {percent}%")
def set_target_temp(self, celsius: float) -> None: ... def lock(self) -> None: ... def unlock(self) -> None: ...
class Thermostat: name = "thermostat"
def turn_on(self) -> None: print(f"{self.name}: heating on")
# flaw: "off" means something very different for a thermostat. A routine that turns # everything off can leave a house unheated through a winter holiday. def turn_off(self) -> None: print(f"{self.name}: heating OFF")
def set_brightness(self, percent: int) -> None: ...
def set_target_temp(self, celsius: float) -> None: print(f"{self.name}: target {celsius}C")
def lock(self) -> None: ... def unlock(self) -> None: ...
class DoorLock: name = "front door"
def turn_on(self) -> None: ... def turn_off(self) -> None: ... def set_brightness(self, percent: int) -> None: ... def set_target_temp(self, celsius: float) -> None: ...
def lock(self) -> None: print(f"{self.name}: locked")
def unlock(self) -> None: print(f"{self.name}: unlocked")
class Hub: def __init__(self, devices: list[Device]) -> None: self._devices = devices
# Sends every command to every device and hopes the no-ops sort it out. def away_mode(self) -> None: for d in self._devices: d.turn_off() d.lock()
if __name__ == "__main__": Hub([Light(), Thermostat(), DoorLock()]).away_mode()using System;using System.Collections.Generic;
// Before: one IDevice interface with every command any device has ever needed.interface IDevice{ string Name { get; } void TurnOn(); void TurnOff(); void SetBrightness(int percent); void SetTargetTemp(double celsius); void Lock(); void Unlock();}
// flaw: every device implements every command. The ones that don't apply silently do// nothing, so a routine that sends the wrong command gets no error at all.class Light : IDevice{ public string Name => "hall light"; public void TurnOn() => Console.WriteLine($"{Name}: on"); public void TurnOff() => Console.WriteLine($"{Name}: off"); public void SetBrightness(int percent) => Console.WriteLine($"{Name}: {percent}%"); public void SetTargetTemp(double celsius) { } public void Lock() { } public void Unlock() { }}
class Thermostat : IDevice{ public string Name => "thermostat"; public void TurnOn() => Console.WriteLine($"{Name}: heating on"); // flaw: "off" means something very different for a thermostat. A routine that turns // everything off can leave a house unheated through a winter holiday. public void TurnOff() => Console.WriteLine($"{Name}: heating OFF"); public void SetBrightness(int percent) { } public void SetTargetTemp(double celsius) => Console.WriteLine($"{Name}: target {celsius}C"); public void Lock() { } public void Unlock() { }}
class DoorLock : IDevice{ public string Name => "front door"; public void TurnOn() { } public void TurnOff() { } public void SetBrightness(int percent) { } public void SetTargetTemp(double celsius) { } public void Lock() => Console.WriteLine($"{Name}: locked"); public void Unlock() => Console.WriteLine($"{Name}: unlocked");}
class Hub{ private readonly IReadOnlyList<IDevice> _devices; public Hub(IReadOnlyList<IDevice> devices) => _devices = devices;
// Sends every command to every device and hopes the no-ops sort it out. public void AwayMode() { foreach (var d in _devices) { d.TurnOff(); d.Lock(); } }}
static class Program{ static void Main() => new Hub(new IDevice[] { new Light(), new Thermostat(), new DoorLock() }).AwayMode();}package main
import "fmt"
// Device before: one interface with every command any device has ever needed.type Device interface { Name() string TurnOn() TurnOff() SetBrightness(percent int) SetTargetTemp(celsius float64) Lock() Unlock()}
// Every device implements every command.// flaw: the ones that don't apply silently do nothing, so a routine that sends the wrong// command gets no error at all.type Light struct{}
func (Light) Name() string { return "hall light" }func (l Light) TurnOn() { fmt.Println(l.Name() + ": on") }func (l Light) TurnOff() { fmt.Println(l.Name() + ": off") }func (l Light) SetBrightness(p int) { fmt.Printf("%s: %d%%\n", l.Name(), p) }func (Light) SetTargetTemp(float64) {}func (Light) Lock() {}func (Light) Unlock() {}
type Thermostat struct{}
func (Thermostat) Name() string { return "thermostat" }func (t Thermostat) TurnOn() { fmt.Println(t.Name() + ": heating on") }
// TurnOff on a thermostat. flaw: "off" means something very different here. A routine that// turns everything off can leave a house unheated through a winter holiday.func (t Thermostat) TurnOff() { fmt.Println(t.Name() + ": heating OFF") }func (Thermostat) SetBrightness(int) {}func (t Thermostat) SetTargetTemp(c float64) { fmt.Printf("%s: target %.1fC\n", t.Name(), c) }func (Thermostat) Lock() {}func (Thermostat) Unlock() {}
type DoorLock struct{}
func (DoorLock) Name() string { return "front door" }func (DoorLock) TurnOn() {}func (DoorLock) TurnOff() {}func (DoorLock) SetBrightness(int) {}func (DoorLock) SetTargetTemp(float64) {}func (d DoorLock) Lock() { fmt.Println(d.Name() + ": locked") }func (d DoorLock) Unlock() { fmt.Println(d.Name() + ": unlocked") }
type Hub struct{ Devices []Device }
// AwayMode sends every command to every device and hopes the no-ops sort it out.func (h Hub) AwayMode() { for _, d := range h.Devices { d.TurnOff() d.Lock() }}
func main() { Hub{[]Device{Light{}, Thermostat{}, DoorLock{}}}.AwayMode()}// Before: one Device interface with every command any device has ever needed.interface Device { readonly name: string; turnOn(): void; turnOff(): void; setBrightness(percent: number): void; setTargetTemp(celsius: number): void; lock(): void; unlock(): void;}
// flaw: every device implements every command. The ones that don't apply silently do// nothing, so a routine that sends the wrong command gets no error at all.class Light implements Device { readonly name = "hall light"; turnOn() { console.log(`${this.name}: on`); } turnOff() { console.log(`${this.name}: off`); } setBrightness(percent: number) { console.log(`${this.name}: ${percent}%`); } setTargetTemp() {} lock() {} unlock() {}}
class Thermostat implements Device { readonly name = "thermostat"; turnOn() { console.log(`${this.name}: heating on`); } // flaw: "off" means something very different for a thermostat. A routine that turns // everything off can leave a house unheated through a winter holiday. turnOff() { console.log(`${this.name}: heating OFF`); } setBrightness() {} setTargetTemp(celsius: number) { console.log(`${this.name}: target ${celsius}C`); } lock() {} unlock() {}}
class DoorLock implements Device { readonly name = "front door"; turnOn() {} turnOff() {} setBrightness() {} setTargetTemp() {} lock() { console.log(`${this.name}: locked`); } unlock() { console.log(`${this.name}: unlocked`); }}
class Hub { constructor(private readonly devices: readonly Device[]) {}
// Sends every command to every device and hopes the no-ops sort it out. awayMode(): void { for (const d of this.devices) { d.turnOff(); d.lock(); } }}
new Hub([new Light(), new Thermostat(), new DoorLock()]).awayMode();Away mode sends turnOff and lock to everything and relies on empty methods to ignore the ones
that don’t apply. That hides a real bug: the thermostat does support turnOff, so away mode
switches the heating off for the whole holiday. And every new command, like a blind’s position,
is added to Device and to every device class as another empty method.
With ISP
Section titled “With ISP”Model each device as a set of capabilities: Switchable, Dimmable, Lockable,
ClimateControl. This is how real standards do it. Matter is the smart home standard from Apple,
Google and Amazon. It describes a device as a list of clusters (On/Off, Level Control, Door Lock,
Thermostat), instead of one device type.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.List;
// Every device has a name. That's the only thing they all share.interface Device { String name();}
// One interface per capability, the way Matter and HomeKit model devices: a device is a// set of capabilities, not one fat type. Routines ask for the capability they use.interface Switchable { void turnOn(); void turnOff();}
// A dimmer can always be switched, so Dimmable builds on Switchable.interface Dimmable extends Switchable { void setBrightness(int percent);}
interface Lockable { void lock(); void unlock();}
// Deliberately not Switchable: "off" is not a safe command for heating.interface ClimateControl { void setTargetTemp(double celsius);}
class Light implements Device, Dimmable { public String name() { return "hall light"; } public void turnOn() { System.out.println(name() + ": on"); } public void turnOff() { System.out.println(name() + ": off"); } public void setBrightness(int percent) { System.out.println(name() + ": " + percent + "%"); }}
class Thermostat implements Device, ClimateControl { public String name() { return "thermostat"; } public void setTargetTemp(double celsius) { System.out.println(name() + ": target " + celsius + "C"); }}
class DoorLock implements Device, Lockable { public String name() { return "front door"; } public void lock() { System.out.println(name() + ": locked"); } public void unlock() { System.out.println(name() + ": unlocked"); }}
class Hub { private final List<Device> devices; Hub(List<Device> devices) { this.devices = List.copyOf(devices); }
// Finds devices by what they can do. A new kind of device is picked up by every routine // that cares about its capabilities, with no change here. <T> List<T> devicesThat(Class<T> capability) { return devices.stream().filter(capability::isInstance).map(capability::cast).toList(); }
// Each step talks to exactly the devices it means, through exactly the commands it needs. void awayMode() { devicesThat(Switchable.class).forEach(Switchable::turnOff); devicesThat(Lockable.class).forEach(Lockable::lock); devicesThat(ClimateControl.class).forEach(c -> c.setTargetTemp(16.0)); // eco, not off }}
class Main { public static void main(String[] args) { new Hub(List.of(new Light(), new Thermostat(), new DoorLock())).awayMode(); }}from typing import Protocol, TypeVar, runtime_checkable
# Every device has a name. That's the only thing they all share.class Device(Protocol): name: str
# One protocol per capability, the way Matter and HomeKit model devices: a device is a# set of capabilities, not one fat type. Routines ask for the capability they use.@runtime_checkableclass Switchable(Protocol): def turn_on(self) -> None: ... def turn_off(self) -> None: ...
# A dimmer can always be switched, so Dimmable builds on Switchable.@runtime_checkableclass Dimmable(Switchable, Protocol): def set_brightness(self, percent: int) -> None: ...
@runtime_checkableclass Lockable(Protocol): def lock(self) -> None: ... def unlock(self) -> None: ...
# Deliberately not Switchable: "off" is not a safe command for heating.@runtime_checkableclass ClimateControl(Protocol): def set_target_temp(self, celsius: float) -> None: ...
class Light: name = "hall light"
def turn_on(self) -> None: print(f"{self.name}: on")
def turn_off(self) -> None: print(f"{self.name}: off")
def set_brightness(self, percent: int) -> None: print(f"{self.name}: {percent}%")
class Thermostat: name = "thermostat"
def set_target_temp(self, celsius: float) -> None: print(f"{self.name}: target {celsius}C")
class DoorLock: name = "front door"
def lock(self) -> None: print(f"{self.name}: locked")
def unlock(self) -> None: print(f"{self.name}: unlocked")
T = TypeVar("T")
class Hub: def __init__(self, devices: list[Device]) -> None: self._devices = tuple(devices)
# Finds devices by what they can do. A new kind of device is picked up by every routine # that cares about its capabilities, with no change here. def devices_that(self, capability: type[T]) -> list[T]: return [d for d in self._devices if isinstance(d, capability)]
# Each step talks to exactly the devices it means, through exactly the commands it needs. def away_mode(self) -> None: for s in self.devices_that(Switchable): s.turn_off() for l in self.devices_that(Lockable): l.lock() for c in self.devices_that(ClimateControl): c.set_target_temp(16.0) # eco, not off
if __name__ == "__main__": Hub([Light(), Thermostat(), DoorLock()]).away_mode()using System;using System.Collections.Generic;using System.Linq;
// Every device has a name. That's the only thing they all share.interface IDevice{ string Name { get; }}
// One interface per capability, the way Matter and HomeKit model devices: a device is a// set of capabilities, not one fat type. Routines ask for the capability they use.interface ISwitchable{ void TurnOn(); void TurnOff();}
// A dimmer can always be switched, so IDimmable builds on ISwitchable.interface IDimmable : ISwitchable{ void SetBrightness(int percent);}
interface ILockable{ void Lock(); void Unlock();}
// Deliberately not ISwitchable: "off" is not a safe command for heating.interface IClimateControl{ void SetTargetTemp(double celsius);}
class Light : IDevice, IDimmable{ public string Name => "hall light"; public void TurnOn() => Console.WriteLine($"{Name}: on"); public void TurnOff() => Console.WriteLine($"{Name}: off"); public void SetBrightness(int percent) => Console.WriteLine($"{Name}: {percent}%");}
class Thermostat : IDevice, IClimateControl{ public string Name => "thermostat"; public void SetTargetTemp(double celsius) => Console.WriteLine($"{Name}: target {celsius}C");}
class DoorLock : IDevice, ILockable{ public string Name => "front door"; public void Lock() => Console.WriteLine($"{Name}: locked"); public void Unlock() => Console.WriteLine($"{Name}: unlocked");}
class Hub{ private readonly IReadOnlyList<IDevice> _devices; public Hub(IEnumerable<IDevice> devices) => _devices = devices.ToList();
// Finds devices by what they can do. A new kind of device is picked up by every routine // that cares about its capabilities, with no change here. public IEnumerable<T> DevicesThat<T>() => _devices.OfType<T>();
// Each step talks to exactly the devices it means, through exactly the commands it needs. public void AwayMode() { foreach (var s in DevicesThat<ISwitchable>()) s.TurnOff(); foreach (var l in DevicesThat<ILockable>()) l.Lock(); foreach (var c in DevicesThat<IClimateControl>()) c.SetTargetTemp(16.0); // eco, not off }}
static class Program{ static void Main() => new Hub(new IDevice[] { new Light(), new Thermostat(), new DoorLock() }).AwayMode();}package main
import "fmt"
// Device is the only thing every device shares: a name.type Device interface{ Name() string }
// One interface per capability, the way Matter and HomeKit model devices: a device is a// set of capabilities, not one fat type. Routines ask for the capability they use.type Switchable interface { TurnOn() TurnOff()}
// Dimmable embeds Switchable: a dimmer can always be switched.type Dimmable interface { Switchable SetBrightness(percent int)}
type Lockable interface { Lock() Unlock()}
// ClimateControl is deliberately not Switchable: "off" is not a safe command for heating.type ClimateControl interface{ SetTargetTemp(celsius float64) }
type Light struct{}
func (Light) Name() string { return "hall light" }func (l Light) TurnOn() { fmt.Println(l.Name() + ": on") }func (l Light) TurnOff() { fmt.Println(l.Name() + ": off") }func (l Light) SetBrightness(p int) { fmt.Printf("%s: %d%%\n", l.Name(), p) }
type Thermostat struct{}
func (Thermostat) Name() string { return "thermostat" }func (t Thermostat) SetTargetTemp(c float64) { fmt.Printf("%s: target %.1fC\n", t.Name(), c) }
type DoorLock struct{}
func (DoorLock) Name() string { return "front door" }func (d DoorLock) Lock() { fmt.Println(d.Name() + ": locked") }func (d DoorLock) Unlock() { fmt.Println(d.Name() + ": unlocked") }
type Hub struct{ Devices []Device }
// DevicesThat finds devices by what they can do, with a type assertion per device. A new// kind of device is picked up by every routine that cares about its capabilities.func DevicesThat[T any](h Hub) []T { var out []T for _, d := range h.Devices { if c, ok := d.(T); ok { out = append(out, c) } } return out}
// AwayMode talks to exactly the devices it means, through exactly the commands it needs.func (h Hub) AwayMode() { for _, s := range DevicesThat[Switchable](h) { s.TurnOff() } for _, l := range DevicesThat[Lockable](h) { l.Lock() } for _, c := range DevicesThat[ClimateControl](h) { c.SetTargetTemp(16) // eco, not off }}
func main() { Hub{[]Device{Light{}, Thermostat{}, DoorLock{}}}.AwayMode()}// Every device has a name. That's the only thing they all share.interface Device { readonly name: string;}
// One interface per capability, the way Matter and HomeKit model devices: a device is a// set of capabilities, not one fat type. Routines ask for the capability they use.interface Switchable { turnOn(): void; turnOff(): void;}
// A dimmer can always be switched, so Dimmable builds on Switchable.interface Dimmable extends Switchable { setBrightness(percent: number): void;}
interface Lockable { lock(): void; unlock(): void;}
// Deliberately not Switchable: "off" is not a safe command for heating.interface ClimateControl { setTargetTemp(celsius: number): void;}
// Interfaces vanish at run time, so each capability gets a small type guard.const has = (d: object, ...methods: string[]) => methods.every((m) => typeof (d as Record<string, unknown>)[m] === "function");const isSwitchable = (d: Device): d is Device & Switchable => has(d, "turnOn", "turnOff");const isLockable = (d: Device): d is Device & Lockable => has(d, "lock", "unlock");const isClimate = (d: Device): d is Device & ClimateControl => has(d, "setTargetTemp");
class Light implements Device, Dimmable { readonly name = "hall light"; turnOn() { console.log(`${this.name}: on`); } turnOff() { console.log(`${this.name}: off`); } setBrightness(percent: number) { console.log(`${this.name}: ${percent}%`); }}
class Thermostat implements Device, ClimateControl { readonly name = "thermostat"; setTargetTemp(celsius: number) { console.log(`${this.name}: target ${celsius}C`); }}
class DoorLock implements Device, Lockable { readonly name = "front door"; lock() { console.log(`${this.name}: locked`); } unlock() { console.log(`${this.name}: unlocked`); }}
class Hub { constructor(private readonly devices: readonly Device[]) {}
// Finds devices by what they can do. A new kind of device is picked up by every routine // that cares about its capabilities, with no change here. devicesThat<T extends Device>(guard: (d: Device) => d is T): T[] { return this.devices.filter(guard); }
// Each step talks to exactly the devices it means, through exactly the commands it needs. awayMode(): void { this.devicesThat(isSwitchable).forEach((s) => s.turnOff()); this.devicesThat(isLockable).forEach((l) => l.lock()); this.devicesThat(isClimate).forEach((c) => c.setTargetTemp(16)); // eco, not off }}
new Hub([new Light(), new Thermostat(), new DoorLock()]).awayMode();Send three requests through both designs:
- Deviceevery command for every device
- Light
- Thermostat
- DoorLock
- Hubsends everything to everyone
- Blindnewfive empty methods
- Devicejust a name
- Switchable, Dimmable, Lockable, ClimateControl
- Light
- Thermostat
- DoorLock
- Hubfinds devices by capability
- PositionablenewsetPosition(percent)
- Blindnewonly Positionable
Pick a change request to see which classes it touches in each design.
What to notice
Section titled “What to notice”- Capabilities fix a real bug, not just tidiness. The thermostat is deliberately not
Switchable, because “off” isn’t a safe command for heating. With one fat interface there was no way to say that. - Interfaces can build on each other. Anything
Dimmablecan be switched, soDimmableextendsSwitchable. A dimmer is never asked to say whether it switches. - The hub asks by capability.
devicesThat(Lockable)finds every lock, including next year’s. That is a type check, and it’s fine here: the hub’s job is to route commands to devices that can take them. It’s not a sign of a broken hierarchy. - No empty methods means honest errors. Before, a wrong command did nothing silently. Now it doesn’t compile.
Design the device model for a smart home hub. We’ll add device types every quarter.
Whether you model capabilities rather than one Device with every command, mention that one
device has several, and say how routines find devices (“all lockables”). Strong answers mention
Matter clusters or HomeKit services. The red flag is a deep inheritance tree like
DimmableColorLight extends DimmableLight extends Light, which breaks on the first device that
mixes features differently.
Read replicas for a store
- Splitting reads from writes
- Routing by interface
- Read-your-writes in the wiring
An online store’s database is busy. The order history page gets far more traffic than checkout. The
team wants to send its reads to a read replica: a copy of the database that is a second or so
behind. All data access goes through one OrderRepository.
Without the principle
Section titled “Without the principle”import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.Optional;
record Order(String id, String customer, int totalCents) {}
// A database, simplified: rows plus a count of the queries it has served.class Database { final Map<String, Order> rows = new HashMap<>(); int queries = 0;}
// Before: one repository interface with every query any part of the app has needed.interface OrderRepository { Optional<Order> findById(String id); List<Order> findByCustomer(String customer); void save(Order order); void delete(String id); int revenueCents();}
class SqlOrderRepository implements OrderRepository { private final Database db; SqlOrderRepository(Database db) { this.db = db; } public Optional<Order> findById(String id) { db.queries++; return Optional.ofNullable(db.rows.get(id)); } public List<Order> findByCustomer(String c) { db.queries++; return db.rows.values().stream().filter(o -> o.customer().equals(c)).toList(); } public void save(Order o) { db.queries++; db.rows.put(o.id(), o); } public void delete(String id) { db.queries++; db.rows.remove(id); } public int revenueCents() { db.queries++; return db.rows.values().stream().mapToInt(Order::totalCents).sum(); }}
class Checkout { private final OrderRepository repo; Checkout(OrderRepository repo) { this.repo = repo; } Order place(Order o) { repo.save(o); return repo.findById(o.id()).orElseThrow(); // confirm what was stored }}
// flaw: the history page only reads, but it holds an interface that can save and delete.// Nothing in its type says it is safe to point at a read-only replica, so nobody dares to.// flaw: a test fake for this page must implement five methods to exercise one.class OrderHistoryPage { private final OrderRepository repo; OrderHistoryPage(OrderRepository repo) { this.repo = repo; } int render(String customer) { return repo.findByCustomer(customer).size(); }}
class Main { public static void main(String[] args) { Database primary = new Database(); OrderRepository repo = new SqlOrderRepository(primary); new Checkout(repo).place(new Order("o1", "ada", 4_200)); OrderHistoryPage history = new OrderHistoryPage(repo); for (int i = 0; i < 50; i++) history.render("ada"); // Every page view lands on the one database that also takes every write. System.out.println("primary queries: " + primary.queries); // 52 }}from dataclasses import dataclass, fieldfrom typing import Protocol
@dataclass(frozen=True)class Order: id: str customer: str total_cents: int
# A database, simplified: rows plus a count of the queries it has served.@dataclassclass Database: rows: dict[str, Order] = field(default_factory=dict) queries: int = 0
# Before: one repository protocol with every query any part of the app has needed.class OrderRepository(Protocol): def find_by_id(self, id: str) -> Order | None: ... def find_by_customer(self, customer: str) -> list[Order]: ... def save(self, order: Order) -> None: ... def delete(self, id: str) -> None: ... def revenue_cents(self) -> int: ...
class SqlOrderRepository: def __init__(self, db: Database) -> None: self._db = db
def find_by_id(self, id: str) -> Order | None: self._db.queries += 1 return self._db.rows.get(id)
def find_by_customer(self, customer: str) -> list[Order]: self._db.queries += 1 return [o for o in self._db.rows.values() if o.customer == customer]
def save(self, order: Order) -> None: self._db.queries += 1 self._db.rows[order.id] = order
def delete(self, id: str) -> None: self._db.queries += 1 self._db.rows.pop(id, None)
def revenue_cents(self) -> int: self._db.queries += 1 return sum(o.total_cents for o in self._db.rows.values())
class Checkout: def __init__(self, repo: OrderRepository) -> None: self._repo = repo
def place(self, order: Order) -> Order: self._repo.save(order) stored = self._repo.find_by_id(order.id) # confirm what was stored assert stored is not None return stored
# flaw: the history page only reads, but it holds a protocol that can save and delete.# Nothing in its type says it is safe to point at a read-only replica, so nobody dares to.# flaw: a test fake for this page must implement five methods to exercise one.class OrderHistoryPage: def __init__(self, repo: OrderRepository) -> None: self._repo = repo
def render(self, customer: str) -> int: return len(self._repo.find_by_customer(customer))
if __name__ == "__main__": primary = Database() repo = SqlOrderRepository(primary) Checkout(repo).place(Order("o1", "ada", 4_200)) history = OrderHistoryPage(repo) for _ in range(50): history.render("ada") # Every page view lands on the one database that also takes every write. print("primary queries:", primary.queries) # 52using System;using System.Collections.Generic;using System.Linq;
record Order(string Id, string Customer, int TotalCents);
// A database, simplified: rows plus a count of the queries it has served.class Database{ public Dictionary<string, Order> Rows { get; } = new(); public int Queries { get; set; }}
// Before: one repository interface with every query any part of the app has needed.interface IOrderRepository{ Order? FindById(string id); List<Order> FindByCustomer(string customer); void Save(Order order); void Delete(string id); int RevenueCents();}
class SqlOrderRepository : IOrderRepository{ private readonly Database _db; public SqlOrderRepository(Database db) => _db = db; public Order? FindById(string id) { _db.Queries++; return _db.Rows.GetValueOrDefault(id); } public List<Order> FindByCustomer(string c) { _db.Queries++; return _db.Rows.Values.Where(o => o.Customer == c).ToList(); } public void Save(Order o) { _db.Queries++; _db.Rows[o.Id] = o; } public void Delete(string id) { _db.Queries++; _db.Rows.Remove(id); } public int RevenueCents() { _db.Queries++; return _db.Rows.Values.Sum(o => o.TotalCents); }}
class Checkout{ private readonly IOrderRepository _repo; public Checkout(IOrderRepository repo) => _repo = repo; public Order Place(Order o) { _repo.Save(o); return _repo.FindById(o.Id) ?? throw new InvalidOperationException("not stored"); // confirm what was stored }}
// flaw: the history page only reads, but it holds an interface that can save and delete.// Nothing in its type says it is safe to point at a read-only replica, so nobody dares to.// flaw: a test fake for this page must implement five methods to exercise one.class OrderHistoryPage{ private readonly IOrderRepository _repo; public OrderHistoryPage(IOrderRepository repo) => _repo = repo; public int Render(string customer) => _repo.FindByCustomer(customer).Count;}
static class Program{ static void Main() { var primary = new Database(); var repo = new SqlOrderRepository(primary); new Checkout(repo).Place(new Order("o1", "ada", 4_200)); var history = new OrderHistoryPage(repo); for (var i = 0; i < 50; i++) history.Render("ada"); // Every page view lands on the one database that also takes every write. Console.WriteLine($"primary queries: {primary.Queries}"); // 52 }}package main
import ( "errors" "fmt")
type Order struct { ID, Customer string TotalCents int}
// Database is simplified: rows plus a count of the queries it has served.type Database struct { Rows map[string]Order Queries int}
func NewDatabase() *Database { return &Database{Rows: map[string]Order{}} }
// OrderRepository before: one interface with every query any part of the app has needed.type OrderRepository interface { FindByID(id string) (Order, bool) FindByCustomer(customer string) []Order Save(o Order) Delete(id string) RevenueCents() int}
type SqlOrderRepository struct{ db *Database }
func (r SqlOrderRepository) FindByID(id string) (Order, bool) { r.db.Queries++ o, ok := r.db.Rows[id] return o, ok}
func (r SqlOrderRepository) FindByCustomer(c string) []Order { r.db.Queries++ var out []Order for _, o := range r.db.Rows { if o.Customer == c { out = append(out, o) } } return out}
func (r SqlOrderRepository) Save(o Order) { r.db.Queries++; r.db.Rows[o.ID] = o }func (r SqlOrderRepository) Delete(id string) { r.db.Queries++; delete(r.db.Rows, id) }func (r SqlOrderRepository) RevenueCents() int { r.db.Queries++ sum := 0 for _, o := range r.db.Rows { sum += o.TotalCents } return sum}
type Checkout struct{ Repo OrderRepository }
func (c Checkout) Place(o Order) (Order, error) { c.Repo.Save(o) stored, ok := c.Repo.FindByID(o.ID) // confirm what was stored if !ok { return Order{}, errors.New("not stored") } return stored, nil}
// OrderHistoryPage only reads, but it holds an interface that can save and delete.// flaw: nothing in its type says it is safe to point at a read-only replica, so nobody dares.// flaw: a test fake for this page must implement five methods to exercise one.type OrderHistoryPage struct{ Repo OrderRepository }
func (p OrderHistoryPage) Render(customer string) int { return len(p.Repo.FindByCustomer(customer)) }
func main() { primary := NewDatabase() repo := SqlOrderRepository{primary} Checkout{repo}.Place(Order{"o1", "ada", 4_200}) history := OrderHistoryPage{repo} for i := 0; i < 50; i++ { history.Render("ada") } // Every page view lands on the one database that also takes every write. fmt.Println("primary queries:", primary.Queries) // 52}interface Order { readonly id: string; readonly customer: string; readonly totalCents: number;}
// A database, simplified: rows plus a count of the queries it has served.class Database { readonly rows = new Map<string, Order>(); queries = 0;}
// Before: one repository interface with every query any part of the app has needed.interface OrderRepository { findById(id: string): Order | undefined; findByCustomer(customer: string): Order[]; save(order: Order): void; delete(id: string): void; revenueCents(): number;}
class SqlOrderRepository implements OrderRepository { constructor(private readonly db: Database) {} findById(id: string) { this.db.queries++; return this.db.rows.get(id); } findByCustomer(customer: string) { this.db.queries++; return [...this.db.rows.values()].filter((o) => o.customer === customer); } save(order: Order) { this.db.queries++; this.db.rows.set(order.id, order); } delete(id: string) { this.db.queries++; this.db.rows.delete(id); } revenueCents() { this.db.queries++; return [...this.db.rows.values()].reduce((s, o) => s + o.totalCents, 0); }}
class Checkout { constructor(private readonly repo: OrderRepository) {} place(order: Order): Order { this.repo.save(order); const stored = this.repo.findById(order.id); // confirm what was stored if (!stored) throw new Error("not stored"); return stored; }}
// flaw: the history page only reads, but it holds an interface that can save and delete.// Nothing in its type says it is safe to point at a read-only replica, so nobody dares to.// flaw: a test fake for this page must implement five methods to exercise one.class OrderHistoryPage { constructor(private readonly repo: OrderRepository) {} render(customer: string): number { return this.repo.findByCustomer(customer).length; }}
const primary = new Database();const repo = new SqlOrderRepository(primary);new Checkout(repo).place({ id: "o1", customer: "ada", totalCents: 4_200 });const history = new OrderHistoryPage(repo);for (let i = 0; i < 50; i++) history.render("ada");// Every page view lands on the one database that also takes every write.console.log("primary queries:", primary.queries); // 52The history page only reads, but its type says it can save and delete. Nothing tells you it is safe to give it a read-only connection. So the team can’t move it without reading all of its code, and everything it calls. Meanwhile, all 52 queries hit the primary. And a test fake for the page has to implement five methods, to test one.
With ISP
Section titled “With ISP”Split the repository by what callers do: OrderReader and OrderWriter. The primary store
implements both. A replica can only be an OrderReader, because it can’t take writes. Each
client asks for exactly what it uses, and the wiring decides where each one reads from.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.Optional;
record Order(String id, String customer, int totalCents) {}
// A database, simplified: rows plus a count of the queries it has served.class Database { final Map<String, Order> rows = new HashMap<>(); int queries = 0;}
// Interfaces shaped by what each client does, not by what the database can do.// Reading and writing are separate, so they can be routed to different places.interface OrderReader { Optional<Order> findById(String id); List<Order> findByCustomer(String customer);}
interface OrderWriter { void save(Order order);}
// The primary takes writes, and can read too: reads here always see the latest write.class PrimaryOrderStore implements OrderReader, OrderWriter { private final Database db; PrimaryOrderStore(Database db) { this.db = db; } public Optional<Order> findById(String id) { db.queries++; return Optional.ofNullable(db.rows.get(id)); } public List<Order> findByCustomer(String c) { db.queries++; return db.rows.values().stream().filter(o -> o.customer().equals(c)).toList(); } public void save(Order o) { db.queries++; db.rows.put(o.id(), o); }}
// A read replica can only be an OrderReader: there is no save() to call by mistake.// Its contract says reads may be a little behind the primary.class ReplicaOrderReader implements OrderReader { private final Database replica; ReplicaOrderReader(Database replica) { this.replica = replica; } public Optional<Order> findById(String id) { replica.queries++; return Optional.ofNullable(replica.rows.get(id)); } public List<Order> findByCustomer(String c) { replica.queries++; return replica.rows.values().stream().filter(o -> o.customer().equals(c)).toList(); }}
// Checkout writes, then reads back what it wrote. That read must see the write, so it is// given the primary as its reader. Read-your-writes is decided in the wiring, on purpose.class Checkout { private final OrderWriter writer; private final OrderReader reader; Checkout(OrderWriter writer, OrderReader reader) { this.writer = writer; this.reader = reader; } Order place(Order o) { writer.save(o); return reader.findById(o.id()).orElseThrow(); }}
// Only reads, and can live with data a second old, so it is handed the replica.// A test fake for it now implements two methods, not five.class OrderHistoryPage { private final OrderReader reader; OrderHistoryPage(OrderReader reader) { this.reader = reader; } int render(String customer) { return reader.findByCustomer(customer).size(); }}
class Main { public static void main(String[] args) { Database primary = new Database(); Database replica = new Database(); PrimaryOrderStore store = new PrimaryOrderStore(primary);
// The composition root decides who reads from where. Checkout checkout = new Checkout(store, store); OrderHistoryPage history = new OrderHistoryPage(new ReplicaOrderReader(replica));
checkout.place(new Order("o1", "ada", 4_200)); System.out.println("history right after checkout: " + history.render("ada")); // 0: replica is behind replica.rows.putAll(primary.rows); // replication catches up for (int i = 0; i < 50; i++) history.render("ada"); System.out.println("history after replication: " + history.render("ada")); // 1 System.out.println("primary queries: " + primary.queries + ", replica queries: " + replica.queries); // 2, 52 }}from dataclasses import dataclass, fieldfrom typing import Protocol
@dataclass(frozen=True)class Order: id: str customer: str total_cents: int
# A database, simplified: rows plus a count of the queries it has served.@dataclassclass Database: rows: dict[str, Order] = field(default_factory=dict) queries: int = 0
# Protocols shaped by what each client does, not by what the database can do.# Reading and writing are separate, so they can be routed to different places.class OrderReader(Protocol): def find_by_id(self, id: str) -> Order | None: ... def find_by_customer(self, customer: str) -> list[Order]: ...
class OrderWriter(Protocol): def save(self, order: Order) -> None: ...
# The primary takes writes, and can read too: reads here always see the latest write.class PrimaryOrderStore: def __init__(self, db: Database) -> None: self._db = db
def find_by_id(self, id: str) -> Order | None: self._db.queries += 1 return self._db.rows.get(id)
def find_by_customer(self, customer: str) -> list[Order]: self._db.queries += 1 return [o for o in self._db.rows.values() if o.customer == customer]
def save(self, order: Order) -> None: self._db.queries += 1 self._db.rows[order.id] = order
# A read replica can only be an OrderReader: there is no save() to call by mistake.# Its contract says reads may be a little behind the primary.class ReplicaOrderReader: def __init__(self, replica: Database) -> None: self._replica = replica
def find_by_id(self, id: str) -> Order | None: self._replica.queries += 1 return self._replica.rows.get(id)
def find_by_customer(self, customer: str) -> list[Order]: self._replica.queries += 1 return [o for o in self._replica.rows.values() if o.customer == customer]
# Checkout writes, then reads back what it wrote. That read must see the write, so it is# given the primary as its reader. Read-your-writes is decided in the wiring, on purpose.class Checkout: def __init__(self, writer: OrderWriter, reader: OrderReader) -> None: self._writer = writer self._reader = reader
def place(self, order: Order) -> Order: self._writer.save(order) stored = self._reader.find_by_id(order.id) assert stored is not None return stored
# Only reads, and can live with data a second old, so it is handed the replica.# A test fake for it now implements two methods, not five.class OrderHistoryPage: def __init__(self, reader: OrderReader) -> None: self._reader = reader
def render(self, customer: str) -> int: return len(self._reader.find_by_customer(customer))
if __name__ == "__main__": primary, replica = Database(), Database() store = PrimaryOrderStore(primary)
# The composition root decides who reads from where. checkout = Checkout(store, store) history = OrderHistoryPage(ReplicaOrderReader(replica))
checkout.place(Order("o1", "ada", 4_200)) print("history right after checkout:", history.render("ada")) # 0: replica is behind replica.rows.update(primary.rows) # replication catches up for _ in range(50): history.render("ada") print("history after replication:", history.render("ada")) # 1 print(f"primary queries: {primary.queries}, replica queries: {replica.queries}") # 2, 52using System;using System.Collections.Generic;using System.Linq;
record Order(string Id, string Customer, int TotalCents);
// A database, simplified: rows plus a count of the queries it has served.class Database{ public Dictionary<string, Order> Rows { get; } = new(); public int Queries { get; set; }}
// Interfaces shaped by what each client does, not by what the database can do.// Reading and writing are separate, so they can be routed to different places.interface IOrderReader{ Order? FindById(string id); List<Order> FindByCustomer(string customer);}
interface IOrderWriter{ void Save(Order order);}
// The primary takes writes, and can read too: reads here always see the latest write.class PrimaryOrderStore : IOrderReader, IOrderWriter{ private readonly Database _db; public PrimaryOrderStore(Database db) => _db = db; public Order? FindById(string id) { _db.Queries++; return _db.Rows.GetValueOrDefault(id); } public List<Order> FindByCustomer(string c) { _db.Queries++; return _db.Rows.Values.Where(o => o.Customer == c).ToList(); } public void Save(Order o) { _db.Queries++; _db.Rows[o.Id] = o; }}
// A read replica can only be an IOrderReader: there is no Save() to call by mistake.// Its contract says reads may be a little behind the primary.class ReplicaOrderReader : IOrderReader{ private readonly Database _replica; public ReplicaOrderReader(Database replica) => _replica = replica; public Order? FindById(string id) { _replica.Queries++; return _replica.Rows.GetValueOrDefault(id); } public List<Order> FindByCustomer(string c) { _replica.Queries++; return _replica.Rows.Values.Where(o => o.Customer == c).ToList(); }}
// Checkout writes, then reads back what it wrote. That read must see the write, so it is// given the primary as its reader. Read-your-writes is decided in the wiring, on purpose.class Checkout{ private readonly IOrderWriter _writer; private readonly IOrderReader _reader; public Checkout(IOrderWriter writer, IOrderReader reader) { _writer = writer; _reader = reader; } public Order Place(Order o) { _writer.Save(o); return _reader.FindById(o.Id) ?? throw new InvalidOperationException("not stored"); }}
// Only reads, and can live with data a second old, so it is handed the replica.// A test fake for it now implements two methods, not five.class OrderHistoryPage{ private readonly IOrderReader _reader; public OrderHistoryPage(IOrderReader reader) => _reader = reader; public int Render(string customer) => _reader.FindByCustomer(customer).Count;}
static class Program{ static void Main() { var primary = new Database(); var replica = new Database(); var store = new PrimaryOrderStore(primary);
// The composition root decides who reads from where. var checkout = new Checkout(store, store); var history = new OrderHistoryPage(new ReplicaOrderReader(replica));
checkout.Place(new Order("o1", "ada", 4_200)); Console.WriteLine($"history right after checkout: {history.Render("ada")}"); // 0: replica is behind foreach (var (k, v) in primary.Rows) replica.Rows[k] = v; // replication catches up for (var i = 0; i < 50; i++) history.Render("ada"); Console.WriteLine($"history after replication: {history.Render("ada")}"); // 1 Console.WriteLine($"primary queries: {primary.Queries}, replica queries: {replica.Queries}"); // 2, 52 }}package main
import ( "errors" "fmt")
type Order struct { ID, Customer string TotalCents int}
// Database is simplified: rows plus a count of the queries it has served.type Database struct { Rows map[string]Order Queries int}
func NewDatabase() *Database { return &Database{Rows: map[string]Order{}} }
// Interfaces shaped by what each client does, not by what the database can do. Reading and// writing are separate, so they can be routed to different places. In Go they would usually// be declared next to the code that uses them.type OrderReader interface { FindByID(id string) (Order, bool) FindByCustomer(customer string) []Order}
type OrderWriter interface { Save(o Order)}
// tableReader holds the read queries both stores share.type tableReader struct{ db *Database }
func (r tableReader) FindByID(id string) (Order, bool) { r.db.Queries++ o, ok := r.db.Rows[id] return o, ok}
func (r tableReader) FindByCustomer(c string) []Order { r.db.Queries++ var out []Order for _, o := range r.db.Rows { if o.Customer == c { out = append(out, o) } } return out}
// PrimaryOrderStore takes writes, and can read too: reads here always see the latest write.type PrimaryOrderStore struct{ tableReader }
func (p PrimaryOrderStore) Save(o Order) { p.db.Queries++; p.db.Rows[o.ID] = o }
// ReplicaOrderReader can only be an OrderReader: there is no Save to call by mistake.// Its contract says reads may be a little behind the primary.type ReplicaOrderReader struct{ tableReader }
// Checkout writes, then reads back what it wrote. That read must see the write, so it is// given the primary as its reader. Read-your-writes is decided in the wiring, on purpose.type Checkout struct { Writer OrderWriter Reader OrderReader}
func (c Checkout) Place(o Order) (Order, error) { c.Writer.Save(o) stored, ok := c.Reader.FindByID(o.ID) if !ok { return Order{}, errors.New("not stored") } return stored, nil}
// OrderHistoryPage only reads, and can live with data a second old, so it is handed the// replica. A test fake for it now implements two methods, not five.type OrderHistoryPage struct{ Reader OrderReader }
func (p OrderHistoryPage) Render(customer string) int { return len(p.Reader.FindByCustomer(customer)) }
func main() { primary, replica := NewDatabase(), NewDatabase() store := PrimaryOrderStore{tableReader{primary}}
// The composition root decides who reads from where. checkout := Checkout{Writer: store, Reader: store} history := OrderHistoryPage{ReplicaOrderReader{tableReader{replica}}}
checkout.Place(Order{"o1", "ada", 4_200}) fmt.Println("history right after checkout:", history.Render("ada")) // 0: replica is behind for k, v := range primary.Rows { // replication catches up replica.Rows[k] = v } for i := 0; i < 50; i++ { history.Render("ada") } fmt.Println("history after replication:", history.Render("ada")) // 1 fmt.Printf("primary queries: %d, replica queries: %d\n", primary.Queries, replica.Queries) // 2, 52}interface Order { readonly id: string; readonly customer: string; readonly totalCents: number;}
// A database, simplified: rows plus a count of the queries it has served.class Database { readonly rows = new Map<string, Order>(); queries = 0;}
// Interfaces shaped by what each client does, not by what the database can do.// Reading and writing are separate, so they can be routed to different places.interface OrderReader { findById(id: string): Order | undefined; findByCustomer(customer: string): Order[];}
interface OrderWriter { save(order: Order): void;}
// Shared read queries, used by both stores below.class TableReader implements OrderReader { constructor(protected readonly db: Database) {} findById(id: string) { this.db.queries++; return this.db.rows.get(id); } findByCustomer(customer: string) { this.db.queries++; return [...this.db.rows.values()].filter((o) => o.customer === customer); }}
// The primary takes writes, and can read too: reads here always see the latest write.class PrimaryOrderStore extends TableReader implements OrderWriter { save(order: Order) { this.db.queries++; this.db.rows.set(order.id, order); }}
// A read replica can only be an OrderReader: there is no save() to call by mistake.// Its contract says reads may be a little behind the primary.class ReplicaOrderReader extends TableReader {}
// Checkout writes, then reads back what it wrote. That read must see the write, so it is// given the primary as its reader. Read-your-writes is decided in the wiring, on purpose.class Checkout { constructor( private readonly writer: OrderWriter, private readonly reader: OrderReader, ) {} place(order: Order): Order { this.writer.save(order); const stored = this.reader.findById(order.id); if (!stored) throw new Error("not stored"); return stored; }}
// Only reads, and can live with data a second old, so it is handed the replica.// A test fake for it now implements two methods, not five.class OrderHistoryPage { constructor(private readonly reader: OrderReader) {} render(customer: string): number { return this.reader.findByCustomer(customer).length; }}
const primary = new Database();const replica = new Database();const store = new PrimaryOrderStore(primary);
// The composition root decides who reads from where.const checkout = new Checkout(store, store);const history = new OrderHistoryPage(new ReplicaOrderReader(replica));
checkout.place({ id: "o1", customer: "ada", totalCents: 4_200 });console.log("history right after checkout:", history.render("ada")); // 0: replica is behindfor (const [k, v] of primary.rows) replica.rows.set(k, v); // replication catches upfor (let i = 0; i < 50; i++) history.render("ada");console.log("history after replication:", history.render("ada")); // 1console.log(`primary queries: ${primary.queries}, replica queries: ${replica.queries}`); // 2, 52The interesting part is checkout. It writes an order and then reads it back, and that read must see the write. If it read from the replica, the order might not be there yet:
- Color key
- Facade
- Database
So Checkout takes a writer and a reader, and the composition root gives it the primary for
both. That decision is now written in one place, where reviewers can see it: read-your-writes for
checkout, and slightly stale reads for history. It’s no longer buried in the repository.
What to notice
Section titled “What to notice”- A type can say what an implementation can’t do. A replica has no
save, so it can’t be handed to code that writes. That’s the same move as the printer, at the scale of a system. - Separate interfaces let you route separately. Reads and writes can now go to different databases, caches or services. CQRS takes this to its end, with separate models for reading and writing, but you don’t need CQRS to get the benefit.
- Splitting the interface doesn’t split the class.
PrimaryOrderStorestill does both, with one connection pool. Only the callers’ view of it changed. - Consistency becomes a wiring decision. Which reads need the latest data is visible in the composition root. Before, it was an accident of which repository instance a class got.
- Fakes get small. Testing the history page now needs a two-method
OrderReader, not a five-method repository.
The database is overloaded and we’re adding read replicas. How do you change the code so we can use them safely?
Whether you split readers from writers, so read-only clients can be moved by wiring alone. Then
whether you spot replication lag: any flow that writes and then reads must read from the
primary. Senior answers mention read-your-writes, and where that decision lives. The red flag is
a global switch that sends every find to the replica.
I’d split the repository into OrderReader and OrderWriter. Read-only pages take a reader, so we can point them at the replica in the wiring. Checkout reads back what it wrote, so it gets the primary for both.
Tradeoffs
Section titled “Tradeoffs”Split an interface when:
- Implementations throw “not supported” or leave methods empty.
- Different kinds of clients use clearly different subsets of it.
- A change for one kind of client keeps forcing rebuilds or retests of others.
- Some implementations can’t support some methods, like a replica that can’t write.
Leave it alone when every client uses most of it, and every implementation supports all of it. An interface with ten methods that all go together is fine.
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| Idea | Looks like ISP because | The difference |
|---|---|---|
| Single responsibility | Both split things that do too much | SRP splits a class by who asks for changes. ISP splits an interface by who calls it. A class with one responsibility can still need several interfaces. |
| Liskov substitution | A fat interface causes LSP breaks | Methods that throw “not supported” break LSP. ISP removes the reason to write them. |
| Facade | Both give a client a narrower view | A facade is a new class that hides a subsystem. An ISP role interface is implemented by the existing class. |
| CQRS | Both separate reads from writes | CQRS uses separate models and often separate storage. ISP only separates the interfaces; one class can still do both. |
Where it shows up
Section titled “Where it shows up”On this site:
- Vending machine:
Hardwareis deliberately kept as one interface, with the case for when it should be split. A good example of not splitting early. - Rate limiter: the
Bucketinterface has only the three methods the limiter calls.
In real systems, you’ll see it in Java’s Readable and Appendable, in .NET’s IReadOnlyList<T>
next to IList<T>, and in Go’s io.Reader, io.Writer and io.ReadWriter. Also in Matter device
clusters, and in read/write splitting in database drivers and ORMs.
Interview questions
Section titled “Interview questions”Interviewer asksHow do you know an interface is too fat?
Look at the implementations and the callers. Implementations that throw “not supported” or do nothing, and callers that each use a different small slice, are the signs. So are test fakes that are mostly stubs.
Interviewer asksIsn't checking `instanceof Lockable` a code smell?
It depends on whose job it is. A hub that routes commands to devices by what they can do is asking a legitimate question, and the check sits in one place. The smell is a client that checks the type of something it was handed as an abstraction, because the abstraction lied about what it supports.
Interviewer asksHow does ISP relate to microservices or APIs?
The same idea scales up. An API endpoint that returns everything forces every client to depend on every field. GraphQL, separate read APIs and backend-for-frontend services all let each client depend on just what it uses.
Interviewer asksDoesn't this create too many interfaces?
It can. Split only where callers really differ, or where some implementations can’t support part of the interface. Interfaces whose methods are always used together should stay together.
| Decision | We chose | We gave up |
|---|---|---|
| How interfaces are shaped | By what one kind of client does | One interface that describes the whole class |
| What an implementation claims | Only the roles it supports | One type for every device |
| Unsupported operations | Not in the type, so not callable | A runtime error or a silent no-op |
| Reads and writes | Separate interfaces, routed in the wiring | One repository used the same way everywhere |
| How far to split | Until clients stop sharing unused methods | Fewer, larger types |
Further reading
Section titled “Further reading”- Liskov substitution, which fat interfaces tend to break
- Dependency inversion, where client-owned interfaces go next
- Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (2002), with the Xerox story
- The Matter specification overview, for device clusters
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.