Skip to content

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.

Foundation18 min read

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 idea in one picture
one fat interface Machine print() scan() fax() PrintQueue uses print, still breaks when fax changes one interface per role PrintQueue ScanToEmail Printer Scanner Fax SimplePrinter OfficeMachine each client depends on only what it calls
Scroll or pinch to zoom, drag to pan
Left: PrintQueue only prints, but it depends on scan and fax too. Right: it depends on Printer alone, and SimplePrinter claims only what it can do.
The general shape

ClientA

ClientB

«interface»

RoleA

+whatAUses()

«interface»

RoleB

+whatBUses()

BigImpl

SmallImpl

ClientA

ClientB

«interface»

RoleA

+whatAUses()

«interface»

RoleB

+whatBUses()

BigImpl

SmallImpl

Scroll or pinch to zoom, drag to pan
  • 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.
Easy

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.

ScanToEmail.javascan_to_email.pyScanToEmail.csscan_to_email.gooffice-machine.ts
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");
}
}

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.

Split Machine by what the callers do: printing, scanning and faxing. The office machine implements all three. The cheap printer implements one.

Easy: one interface per job

PrintQueue

+submit(Doc)

ScanToEmail

+send(Doc, email)

«interface»

Printer

+print(Doc)

«interface»

Scanner

+scan(Doc)

«interface»

Fax

+fax(Doc, number)

OfficeMachine

SimplePrinter

PrintQueue

+submit(Doc)

ScanToEmail

+send(Doc, email)

«interface»

Printer

+print(Doc)

«interface»

Scanner

+scan(Doc)

«interface»

Fax

+fax(Doc, number)

OfficeMachine

SimplePrinter

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
ScanToEmail.javascan_to_email.pyScanToEmail.csscan_to_email.gooffice-machine.ts
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
}
}

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:

A change request arrives
One Machine interface
  • Machineprint, scan and fax
  • OfficeMachine
  • SimplePrinterscan() and fax() throw
  • PrintQueueonly prints
  • ScanToEmailonly scans

One interface per job
  • Printer
  • Scanner
  • Fax
  • OfficeMachine
  • SimplePrinteronly Printer
  • PrintQueue
  • ScanToEmail

Pick a change request to see which classes it touches in each design.

  • The split follows the callers. There is a Printer because a print queue exists, and a Scanner because 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 SimplePrinter can’t stand in for a Machine. Fixing the interface fixes both.
  • Mistakes move to compile time. Handing a SimplePrinter to ScanToEmail no 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.
Interviewer

Here’s our Machine interface. A new device only scans. How do you add it?

What they are testing

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.

Say this out loud

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.

Medium

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.

One Device interface has every command any device has ever needed. Devices that can’t do something leave the method empty:

Hub.javathermostat.pyHub.cst.gothermostat.ts
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();
}
}

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.

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.

Medium: a device is a set of capabilities

Hub

+devicesThat(capability) : List

+awayMode()

«interface»

Device

+name() : String

«interface»

Switchable

+turnOn()

+turnOff()

«interface»

Dimmable

+setBrightness(percent)

«interface»

Lockable

+lock()

+unlock()

«interface»

ClimateControl

+setTargetTemp(celsius)

Light

Thermostat

DoorLock

Hub

+devicesThat(capability) : List

+awayMode()

«interface»

Device

+name() : String

«interface»

Switchable

+turnOn()

+turnOff()

«interface»

Dimmable

+setBrightness(percent)

«interface»

Lockable

+lock()

+unlock()

«interface»

ClimateControl

+setTargetTemp(celsius)

Light

Thermostat

DoorLock

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Hub.javahub.pyHub.cshub.gohub.ts
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();
}
}

Send three requests through both designs:

A change request arrives
One Device interface
  • Deviceevery command for every device
  • Light
  • Thermostat
  • DoorLock
  • Hubsends everything to everyone

Capabilities
  • Devicejust a name
  • Switchable, Dimmable, Lockable, ClimateControl
  • Light
  • Thermostat
  • DoorLock
  • Hubfinds devices by capability

Pick a change request to see which classes it touches in each design.

  • 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 Dimmable can be switched, so Dimmable extends Switchable. 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.
Interviewer

Design the device model for a smart home hub. We’ll add device types every quarter.

What they are testing

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.

Hard

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.

SqlOrderRepository.javasql_order_repository.pyCheckout.cssql_order_repository.gosql-order-repository.ts
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
}
}

The 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.

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.

Hard: readers and writers, routed in the wiring

primary

replica

Checkout

+place(Order) : Order

OrderHistoryPage

+render(customer) : int

«interface»

OrderWriter

+save(Order)

«interface»

OrderReader

+findById(id) : Order

+findByCustomer(customer) : List<Order>

PrimaryOrderStore

ReplicaOrderReader

Order

+id

+customer

+totalCents

primary

replica

Checkout

+place(Order) : Order

OrderHistoryPage

+render(customer) : int

«interface»

OrderWriter

+save(Order)

«interface»

OrderReader

+findById(id) : Order

+findByCustomer(customer) : List<Order>

PrimaryOrderStore

ReplicaOrderReader

Order

+id

+customer

+totalCents

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Main.javaorder_history_page.pyProgram.csorder_history_page.goorder-history-page.ts
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
}
}

The 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:

Who reads from where
OrderHistoryPageReplicaPrimaryCheckoutOrderHistoryPageReplicaPrimaryCheckoutsave(order)findById(order.id)the order (always there)findByCustomer("ada")[] (a second behind)replication catches upfindByCustomer("ada")[the order]OrderHistoryPageReplicaPrimaryCheckoutOrderHistoryPageReplicaPrimaryCheckoutsave(order)findById(order.id)the order (always there)findByCustomer("ada")[] (a second behind)replication catches upfindByCustomer("ada")[the order]
Scroll or pinch to zoom, drag to pan
  • 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.

  • 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. PrimaryOrderStore still 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.
Interviewer

The database is overloaded and we’re adding read replicas. How do you change the code so we can use them safely?

What they are testing

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.

Say this out loud

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.

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.

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.

On this site:

  • Vending machine: Hardware is deliberately kept as one interface, with the case for when it should be split. A good example of not splitting early.
  • Rate limiter: the Bucket interface 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.

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