Why Square Breaks Callers That Expect a Rectangle
The textbook example earns its fame by being subtle. A Rectangle class with setWidth(), setHeight(), and area() works correctly. You subclass it as Square, enforcing width == height in the setters — mathematically honest, obviously reasonable. Then a caller written against the base type does this:
class Printer {
double printInches(Rectangle r) {
double before = r.area(); // expect: 10 * 20 = 200
r.setWidth(5); // caller expects height to stay 20
double after = r.area(); // expected 100, actually 25
// caller's invariant 'height unchanged' is silently broken
}
}
The caller held an invariant that the subtype silently destroyed. That is the whole principle: subtypes must honor the contracts their base types promise to callers. Liskov's original framing is stronger and more useful than the classroom version — objects should be substitutable for their base types without altering the correctness of any program using them. The contract a subtype inherits has three parts:
| Contract element | Example from Rectangle | What Square does wrong |
|---|---|---|
| Precondition | caller may set width and height independently | narrows what callers may do (only equal values) |
| Postcondition | setting width leaves height and area predictable | breaks the area expectation of callers |
| History constraint | no operation may change another's state behind the caller's back | setWidth mutates height invisibly |
Notice where the defect lives: not in Square's math, which is correct, and not in Printer's logic, which is correct against the documented Rectangle contract. The defect is in the inheritance decision — a Square is a Rectangle in geometry, but the hierarchy was promising behavior, and geometry is not behavior. The fixes are design-level, and choosing between them is the actual skill: make Square immutable so setters do not exist (a narrower interface callers cannot misuse), give both classes a Shape interface with only area() and no setters (remove the promise that was broken), or model side effects explicitly. What does not fix it is renaming — 'SquareRectangle' or 'BetterRectangle' changes nothing about substitutability.
LSP violations in production look nothing like geometry, but they follow the same shape: a subtype that throws UnsupportedOperationException, returns null where the base returns values, or silently degrades a guarantee. The classic real-world specimen is a read-only collection subclassing a mutable one: add() compiles, then throws at runtime — a precondition the subtype secretly narrowed. The caller holding a List cannot know, which is exactly the Rectangle problem. Every such violation pushes a type check into callers, which is the third chapter's subject.
The interview version is short enough to hold in your head: LSP says a subtype must preserve preconditions (never demand more than the base), postconditions (never deliver less), and history constraints (never mutate state callers cannot see). Rectangle/Square breaks the third; the read-only list breaks the first; both end with defensive if checks scattered through client code, which is the smell that ties Liskov to Interface Segregation. One more production specimen worth memorizing for interviews: the cache or retry decorator that silently weakens a guarantee. A base client promises fresh reads; the caching subclass returns data up to five minutes stale without saying so. Callers holding the base type wrote freshness-sensitive reconciliation logic against a promise the subtype no longer keeps, and the bug appears as a data discrepancy weeks later, far from the inheritance decision that caused it. The pattern is always the same: the subtype changed what callers may rely on without changing what they can see.
The Interface Nobody Implements Fully: Splitting Fat Contracts
A payments team defines one interface for everything that can process money:
interface PaymentProcessor {
ChargeResult charge(Money m);
RefundResult refund(ChargeId id, Money m);
void schedulePayout(Schedule s); // marketplace sellers only
TokenResult tokenize(Card card); // vault-integration only
DunningPlan dunningSettings(); // billing team only
}
Four classes implement it. The card-vault proxy implements charge by throwing UnsupportedOperationException; the marketplace payout service throws on refund; and every caller that receives a PaymentProcessor must now know which concrete type it really has before calling, or catch the exception. Interface segregation avoids forcing clients to depend on methods they never call — the interface has become a liability precisely because it promises more than any one consumer or producer needs.
The split follows the actual clients. Invert the dependency arrow: instead of implementations conforming to one god interface, let each client declare the minimum it uses.
interface Chargeable { ChargeResult charge(Money m); }
interface Refundable { RefundResult refund(ChargeId id, Money m); }
interface Tokenizable { TokenResult tokenize(Card card); }
interface PayableOut { void schedulePayout(Schedule s); }
// Checkout controller depends on the smallest thing it needs:
class CheckoutController {
private final Chargeable charger;
}
// Refund endpoint depends on Refundable; the vault proxy
// implements Tokenizable only — and throws nothing, ever.
Three properties emerge that matter more than tidiness. Implementations become complete: a class implementing Tokenizable implements all of it, so no method lies. Clients become precise: the constructor signature documents exactly which capability is required, which is testability — a unit test passes a one-method fake instead of a five-method mock. And the interface hierarchy stops encoding organizational history: the old fat interface existed because 'payments' was one team; the split exists because it is five capabilities that happened to share a prefix.
Watch for the two failure modes on the way out. The first is the role interface that accretes: teams keep adding methods to Chargeable until it is the old god interface wearing a new name — the discipline is that new methods go into new small interfaces, and classes implement several. The second is the opposite over-correction, one-interface-per-method, which produces a confetti of types that nobody can navigate; three to five methods per role interface is the practical band. The evidence for a split is the same evidence SRP uses: if more than one implementing class throws UnsupportedOperationException on the same method, that method belongs in a different interface — the throw is a confession that the contract was never universal.
A Liskov-flavored detail closes the loop: after segregation, no implementor ever needs to violate a precondition to exist. The vault proxy no longer pretends to charge; callers no longer probe concrete types. Both classic violations — narrowed preconditions and scattered type checks — dissolve together, which is why these two principles are always taught as a pair. A worked split for the payments example shows the mechanics end to end. The checkout controller takes Chargeable; the refund endpoint takes Refundable; the vault service implements Tokenizable and nothing else; the marketplace payout service implements PayableOut. A composite gateway that genuinely supports everything implements all four, and its class declaration documents that capability honestly. During the migration, callers are updated one import at a time — each constructor signature change is a compile-time inventory of exactly which capability that caller consumes, which is why the compiler finishes the refactor faster than any checklist.
Switch Statements Over Concrete Types Are Substitution Failure in Disguise
There is a diagnostic that needs no design document: search the codebase for switches over type codes or instanceof chains, and you are looking at every place the hierarchy failed to carry behavior. Violations surface as switch statements over concrete types scattered through the code, and the mechanism is worth tracing once so the smell is recognizable everywhere.
An engineer ships a new notification channel — SlackChannel — beside EmailChannel and SmsChannel. Everywhere channels are used, the code grows a branch:
void notify(User u, Message m, Channel c) {
if (c instanceof EmailChannel) mailer.send(u.email(), m);
else if (c instanceof SmsChannel) twilio.send(u.phone(), m);
else if (c instanceof SlackChannel) slack.post(u.slackId(), m);
else throw new IllegalArgumentException("unknown channel " + c);
}
// ...and the same ladder appears in the preview renderer,
// the retry policy, and the rate limiter — three repos, three copies.
Count what the hierarchy actually delivered: Channel promised nothing that these four sites could rely on, so each site reimplemented the dispatch. The type ladder is duplicated per site, and SlackChannel shipped as a partial change — new class plus edits in three files in three repos — the exact 'must modify existing modules to extend' failure that open-closed forbids, caused by the base type having no behavioral contract. Liskov's lens names it: the subtype cannot substitute because the supertype specified nothing to substitute for.
The repair moves the dispatch into the hierarchy: give the base type the operations clients actually perform, and let each subtype implement them.
abstract class Channel {
abstract void deliver(User u, Message m);
abstract Preview render(Message m); // preview site
abstract RetryPolicy retryPolicy(); // retry site
}
// The three call sites shrink to c.deliver(...), c.render(...),
// and shipping SlackChannel becomes: one class, one registration.
When the set of operations grows (say, an analytics export per channel), the class-based fix forces edits to every subclass — the classic expression-problem trade. If your codebase leans on a closed, stable set of variants with many growing operations, a single switch over a sealed type in one module is a legitimate design (exhaustiveness checking even helps the compiler catch missing cases). The difference between the ladder above and a good switch is ownership: the ladder is copies scattered across teams, the good switch is one dispatch point that owns the whole variant set and evolves as one unit.
The practical triage for review: a switch over concrete types inside the module that owns the hierarchy is a candidate refactor; a switch over types owned by another team's hierarchy is a design defect in their contract — take it back to them, because the missing abstraction belongs in the base type where substitution can actually work. With substitution and interface size settled, the remaining SOLID piece is where dependencies point: Dependency Inversion and Decoupling. For code review triage, measure before you argue. Count the ladder copies, count the variant sites, and count how many branches the last new variant had to touch. Numbers turn 'this switch smells' into 'a new channel required edits in three repositories and six call sites', which is the language that gets the hierarchy fix scheduled instead of deferred. Then check the inverse: if the ladder sits in one module, owns its full variant set, and the compiler enforces exhaustiveness, spending a refactor on it is theater — spend the effort on the next real seam instead.
Quiz
1. A `ReadOnlyList` extends `ArrayList`, but `add()` and `remove()` throw `UnsupportedOperationException`. Callers holding a `List` catch the exception everywhere. Which contract element did the subtype break?
2. `Square extends Rectangle` and overrides the setters to keep width equal height. What is the correct architectural diagnosis?
3. Two of five classes implementing `PaymentProcessor` throw `UnsupportedOperationException` for `schedulePayout`. What does this evidence indicate, and what is the fix?
4. The same `instanceof` ladder over notification channels appears in four call sites across three repositories. What is the best first move?
Flashcards
Question
State the Liskov substitution principle in contract terms: the three obligations a subtype inherits.
Click to reveal answer
Answer
A subtype must never narrow preconditions (demand more than the base permitted), must never weaken postconditions (deliver less than the base promised), and must preserve history constraints (no operation may mutate state callers of the base cannot observe — e.g., setWidth secretly changing height). Programs written against the base must remain correct with the subtype substituted.
Question
Why is Rectangle/Square a hierarchy defect rather than a caller bug, and what are the two valid fixes?
Click to reveal answer
Answer
Both classes are internally correct, but Square cannot honor Rectangle's history constraint (setters changing hidden state), so substitutability — the hierarchy's only promise — fails. Fixes: share a narrower Shape interface with only area() (remove the broken promise), or make shapes immutable so the setters do not exist. Renaming or state-juggling patches change nothing.
Question
What is interface segregation, and what evidence shows a fat interface violates it?
Click to reveal answer
Answer
Clients should depend on small interfaces covering the minimum they use, so no class is forced to implement methods it cannot meaningfully serve. Evidence: multiple implementers throwing UnsupportedOperationException on the same method (a confession the contract was never universal), and callers probing concrete types or catching exceptions before calling.
Question
After splitting a god interface into role interfaces, what two failure modes should be watched for?
Click to reveal answer
Answer
1) Accretion: methods keep getting added to one role interface until it re-becomes the god interface — new capabilities go into new small interfaces and classes implement several. 2) Confetti: one-interface-per-method produces an unnavigable type zoo — three to five methods per role interface is the practical band.
Question
When is a switch over concrete types a defect, and when is it a legitimate design?
Click to reveal answer
Answer
It is a defect when the ladders are duplicated across call sites or teams, because the supertype specified no behavior to substitute for — the repair moves operations into the hierarchy. It is legitimate when it is one dispatch point that owns the full variant set (ideally a sealed type with exhaustiveness checking), in code whose variants are closed and stable while operations keep growing.
Revision Notes
Key Takeaways
- 1. LSP is a contract obligation — preconditions, postconditions, history constraints — not a taxonomy argument; Square's math was right and the hierarchy was still wrong
- 2. The universal diagnostic for both principles is client-side type checking: instanceof ladders and exception catching are the code paying for missing contracts
- 3. Interface segregation inverts who declares the contract: clients state the minimum capability they use, implementers become complete, and unit tests get one-method fakes instead of five-method mocks
- 4. Repeated UnsupportedOperationException on the same method across implementers is measurable evidence the contract was never universal — the split follows the clients
- 5. A switch over concrete types is a defect only when dispatch is duplicated; one owned dispatch point over a closed sealed variant set is a legitimate, compiler-checked design
Interview Tips
- • 'What is the Liskov substitution principle?' — the three contract obligations, then Rectangle/Square with the history-constraint diagnosis and the narrower-interface fix
- • 'Give a real-world LSP violation' — read-only collections extending mutable ones (narrowed precondition), and the caller-side catch-everywhere cost it creates
- • 'How do you decide to split an interface?' — evidence: N implementers throwing on the same method, M callers type-checking; then split by client capability, keeping role interfaces 3–5 methods
- • 'Are all switches over types bad?' — no: duplicated ladders are a missing abstraction; a single dispatch point over a sealed closed set with exhaustiveness checking is the legitimate form — shows calibration, not dogma
Cheat Sheet
Liskov Substitution and Interface Segregation
Subtype contract — three obligations:
| Obligation | Violation example |
|---|---|
| Precondition: never demand more | ReadOnlyList.add() throws |
| Postcondition: never deliver less | cache layer returning stale where base promised fresh |
| History constraint: no hidden mutation | Square.setWidth() changing height |
Diagnosis rule: if both the subtype and the caller are individually correct, the defect is the inheritance decision. Fixes: narrower shared interface (Shape.area()) or immutability — not renaming.
ISP: clients declare the minimum capability they use.
interface Chargeable { ChargeResult charge(Money m); }
interface Refundable { RefundResult refund(ChargeId id, Money m); }
class CheckoutController { private final Chargeable charger; }
Evidence to split: 2+ implementers throwing UnsupportedOperationException on the same method; callers probing concrete types. Keep 3–5 methods per role interface; new capabilities = new interfaces.
Type-switch triage:
- Ladders copied across sites/teams → defect: move operations into the hierarchy (deliver/render/retryPolicy), making a new variant a one-class change
- One dispatch point, closed variant set, exhaustiveness checking → legitimate
- Ladder over another team's types → their contract is missing behavior; take it back
Pair effect: segregation removes the need to violate preconditions; both violations dissolve together.