Intuition What this page does
The parent note taught the three contract rules. Here we stress-test them against every kind of situation code can throw at you: strengthened preconditions, weakened postconditions, broken invariants, new exceptions, and the tricky "it looks fine" cases. Each worked example is tagged with the matrix cell it covers.
Before any example, let us list every shape a substitution problem can take . Think of a subclass method as making four promises to a caller who only knows the base. Each promise can be kept or broken — and there are a few degenerate/limiting cases too.
Cell
Case class
What varies
Worked in
A
Precondition widened (legal ✅)
subclass accepts more inputs
Ex 1
B
Precondition strengthened (violation ❌)
subclass rejects inputs base accepted
Ex 2
C
Postcondition strengthened (legal ✅)
subclass promises more
Ex 3
D
Postcondition weakened (violation ❌)
subclass promises less
Ex 4
E
Invariant broken (violation ❌)
hidden relationship changed
Ex 5
F
New exception thrown (violation ❌, rule 3)
subclass throws what base never did
Ex 6
G
Degenerate / zero input
empty, null, 0-size — does the base contract even say what happens?
Ex 7
H
Limiting / edge value
max size, boundary — subclass silently caps it
Ex 8
I
Real-world word problem
payment processors
Ex 9
J
Exam twist — looks like a violation but is legal
return-type narrowing (covariance)
Ex 10
Intuition How to read a "cell"
The whole of LSP is: "A caller wrote code against the base. Can I secretly swap in the subclass and never surprise that caller?" Every cell above is one specific way you might surprise them (or reassure yourself you did not).
The diagram above is our compass. Green arrows = directions a subclass is allowed to move a promise. Red arrows = forbidden. Preconditions may only widen (open the door wider); postconditions may only narrow (promise more). We will hit every arrow below.
Worked example Ex 1 — Cell A: precondition
widened (legal ✅)
Statement. Base Parser.parse(String s) requires s to be non-null. A subclass LenientParser overrides parse to also accept null (treating it as an empty document).
class Parser { Doc parse (String s ){ /* requires s != null */ } }
class LenientParser extends Parser {
Doc parse (String s ){ if (s == null ) return Doc. empty (); return super . parse (s); }
}
Forecast: Does swapping LenientParser for Parser ever surprise a caller? Guess before reading.
List what the caller promises. Why this step? The caller only knows the base contract; it promises to send non-null strings. That is `\text{pre}_{\text{base}} = (s \neq \text{null})$.
List what the subclass accepts. Why this step? We must check the rule pre base ⟹ pre sub . The subclass accepts null and non-null, so pre sub = true (everything).
Test the implication. Why this step? This final check is what decides legality: if every base-legal input still lands inside the subclass's accepted set, no caller is surprised. Is "s = null " ⟹ "true "? Yes — every non-null input is inside the wider accepted set. ✅
Verify: Every input the caller could legally send is still accepted. The caller never sends null anyway (it isn't allowed to know it can), so the extra tolerance is invisible. No surprise → legal. This is the green "widen precondition" arrow.
Worked example Ex 2 — Cell B: precondition
strengthened (violation ❌)
Statement. Base Account.withdraw(int amount) accepts any amount >= 0. A subclass PremiumAccount overrides it to require amount >= 100 (a minimum withdrawal).
Forecast: Which of the small amounts crashes a base-written caller?
Write the two precondition sets. Why this step? pre base = ( am o u n t ≥ 0 ) , pre sub = ( am o u n t ≥ 100 ) .
Test pre base ⟹ pre sub . Why this step? This is exactly rule 1. Pick a witness: am o u n t = 50 . It satisfies the base (50 ≥ 0 ✅) but fails the subclass (50 ≥ 100 ❌).
Trace the crash. Why this step? Finding the witness only proves the implication fails; tracing an actual base caller shows the concrete damage — acct.withdraw(50), perfectly legal against Account, is rejected when handed a PremiumAccount.
Verify: The witness 50 is in pre base but not pre sub , so the implication is false . The caller is surprised. Violation — the red "strengthen precondition" arrow.
Worked example Ex 3 — Cell C: postcondition
strengthened (legal ✅)
Statement. Base Sorter.sort(list) promises the returned list is sorted ascending . Subclass StableSorter.sort(list) promises it is sorted ascending and stable (equal keys keep original order).
Forecast: Does promising more ever hurt a base caller?
Write both postconditions. Why this step? post base = ( sorted ) , post sub = ( sorted ∧ stable ) .
Test rule 2: post sub ⟹ post base . Why this step? "(sorted and stable)" clearly implies "sorted." ✅ So the subclass guarantees at least everything the base promised.
Confirm the caller is happy. Why this step? Passing the rule is abstract; this step checks the lived experience — the base caller only relies on "sorted," gets that, plus a bonus (stability) it never asked about, which cannot break code that ignores it.
Verify: ( sorted ∧ stable ) ⟹ sorted is a tautology → rule 2 holds. Legal. Green "narrow/strengthen postcondition" arrow.
Worked example Ex 4 — Cell D: postcondition
weakened (violation ❌)
Statement. Base Cache.get(key) promises: "returns the stored value, never null, throws if key absent." Subclass LossyCache.get(key) may return null if it evicted the entry.
Forecast: Where does the base caller trip?
State both postconditions. Why this step? post base = ( result = null ) . post sub = ( result may be null ) .
Test rule 2. Why this step? Does "(may be null)" ⟹ "(never null)"? No — a run where it returns null satisfies the sub but violates the base.
Trace the break. Why this step? The failed implication is theory; tracing it makes the damage tangible — a base caller writes cache.get(k).length() trusting non-null, so LossyCache returning null yields a NullPointerException.
Verify: The witness "result = null" satisfies post sub but not post base → implication false. Violation — red "weaken postcondition" arrow. (Compare directly with Ex 3: strengthening was fine, weakening is not — the arrows are one-way.)
Worked example Ex 5 — Cell E:
invariant broken (the Rectangle/Square, in numbers)
Statement. Recall from the parent: a caller proves the property "set 5×4 → area 20" on a Rectangle. Hand it a Square whose setHeight also sets width.
Forecast: What area does the caller actually get — and by how much is it wrong?
Run the caller's steps on a Rectangle. Why this step? Establish the proved property ϕ ( x ) . setWidth(5) → w = 5 ; setHeight(4) → h = 4 (width untouched, that's the invariant). Area = 5 × 4 = 20 .
Run the same steps on a Square. Why this step? Test ϕ ( y ) . setWidth(5) → w = h = 5 . Then setHeight(4) → h = 4 and w = 4 . Area = 4 × 4 = 16 .
Compare. Why this step? The invariant "width and height vary independently" was silently destroyed, so ϕ ( y ) is false: expected 20, got 16.
Verify: 5 × 4 = 20 but 4 × 4 = 16 ; the mismatch is 20 − 16 = 4 . The proved property fails → LSP violated , exactly rule 3's invariant clause.
The figure above draws the two runs side by side: on the left a lavender Rectangle where setHeight(4) leaves width at 5 (area 20); on the right a coral Square where the same setHeight(4) secretly drags width down to 4 (area 16). Follow the coral arrow labelled "same caller code" — identical caller instructions, yet the invariant collapses on the right and the proved area drops from 20 to 16. That silent gap of 4 is the whole violation made visible.
Worked example Ex 6 — Cell F:
new exception (Penguin, formalised)
Statement. Bird.fly() returns normally for every bird a base caller has seen. Penguin.fly() throws UnsupportedOperationException.
Forecast: The base contract says nothing about that exception — so who catches it?
Enumerate the base's declared outcomes. Why this step? Rule 3 forbids new exception types. Bird.fly()'s contract: returns normally (throws nothing the caller must handle).
Enumerate the subclass's outcomes. Why this step? Penguin.fly(): throws UnsupportedOperationException. This type is not in the base's outcome set.
Trace. Why this step? The set mismatch proves rule 3 is broken; tracing shows the runtime consequence — a loop for (Bird b : birds) b.fly(); written against Bird has no catch for that exception, so the whole loop dies at the penguin.
Verify: exception set of subclass ⊆ exception set of base ⇒ rule 3 broken. Violation. Fix: Penguin extends Bird, FlyingBird extends Bird, fly() only on FlyingBird.
Worked example Ex 7 — Cell G:
degenerate / zero input
Statement. Base Averager.mean(int[] xs) contract: "for a non-empty array, return the arithmetic mean; behaviour on an empty array is undefined." Subclass SafeAverager.mean returns 0 for an empty array.
Forecast: Is defining the undefined case a violation, or free real estate?
Locate the empty case in the base contract. Why this step? The base says empty is undefined — no caller may rely on any particular behaviour there. So the precondition of mean effectively is xs.length >= 1.
Check non-empty inputs still behave. Why this step? For xs = {2, 4, 9}, base and subclass both compute 3 2 + 4 + 9 = 5 . Same mean → non-empty contract intact.
Judge the empty case. Why this step? Because the base left it undefined, the subclass returning 0 cannot contradict any promise. It only widens into territory the caller wasn't allowed to enter.
Verify: 3 2 + 4 + 9 = 5 matches for both. The zero/degenerate branch is legal precisely because the base contract chose not to promise anything there — a reminder that "cover all cases" includes the empty one, and its verdict depends on what the base said (or didn't).
Worked example Ex 8 — Cell H:
limiting / boundary value silently capped
Statement. Base Buffer.add(item) accepts additions until memory runs out; contract: "add always succeeds for any count you can afford." Subclass SmallBuffer silently drops the item once size == 100.
Forecast: At which addition does a base caller's mental model break?
State the base promise near the boundary. Why this step? Base: adding the 101st item succeeds (size becomes 101). That's post base : after add, size increased by 1.
State the subclass at the boundary. Why this step? SmallBuffer at size 100: add returns but size stays 100. So post sub fails to guarantee the increment.
Trace. Why this step? The two postconditions only disagree at the cap; tracing pins down exactly where a real caller notices — it adds 101 items, then asserts buffer.size() == 101, but with SmallBuffer it reads 100.
Verify: expected size 101 , actual 100 — off by 1 exactly at the cap. A weakened postcondition (Cell D in disguise) triggered only at the limit . Boundary values are where these violations hide.
Worked example Ex 9 — Cell I:
real-world word problem (payment processors)
Statement. A checkout writes against PaymentProcessor.charge(cents), whose contract: "charges the amount, returns a Receipt, throws only PaymentDeclined." Two subclasses:
StripeProcessor: charges, returns receipt, throws only PaymentDeclined.
CryptoProcessor: requires cents be a multiple of 1000 (rounding to whole tokens) and throws NetworkTimeout.
Forecast: Which subclass is safely substitutable, and which two rules does the other break?
Check preconditions. Why this step? Base accepts any cents >= 0. CryptoProcessor demands cents % 1000 == 0 — a strengthened precondition (Cell B). A caller charging cents = 1599 is legal for the base but rejected here.
Check exceptions. Why this step? CryptoProcessor throws NetworkTimeout, not in the base's {PaymentDeclined} set — a new exception (Cell F, rule 3).
Check StripeProcessor. Why this step? Same accepted inputs, same return, same exception set → all three rules hold → substitutable ✅.
Verify: 1599 % 1000 = 599 \neq 0, so 1599 is a witness inside pre base but outside CryptoProcessor's precondition → violation confirmed on two counts. StripeProcessor introduces no witness → legal. Fix: give CryptoProcessor its own interface (Interface Segregation), don't force it under PaymentProcessor.
Worked example Ex 10 — Cell J:
exam twist — return-type narrowing that is legal
Statement. Base AnimalShelter.adopt() returns an Animal. Subclass CatShelter.adopt() overrides it to return a Cat (a subtype of Animal). A reviewer flags it as "narrowing = violation!" Are they right?
Forecast: Precondition-narrowing is illegal — but is return-type narrowing the same thing?
Classify what narrowed. Why this step? The return type narrowed (Animal → Cat). Return type is an output , part of the postcondition, not an input.
Apply the rule direction. Why this step? Outputs may narrow (covariant, Cell C). A base caller does Animal a = shelter.adopt(); — a Cat is an Animal, so the assignment and every Animal method still work.
Contrast with the trap. Why this step? If instead a parameter had narrowed (adopt(Animal) → adopt(Cat)), that would be an illegal precondition-strengthening (Cell B). The reviewer confused the two.
Verify: Cat ⊆ Animal, and the returned Cat satisfies every property a caller can prove about an Animal, so post sub ⟹ post base holds. Legal — this is covariant return types , which Java explicitly allows. The twist: "narrower" is only bad for inputs, never for outputs.
Recall Which cells are
violations and which are legal ?
Legal ::: A (widen precondition), C (strengthen postcondition), G (define undefined case), J (covariant return). Violations ::: B (strengthen precondition), D (weaken postcondition), E (break invariant), F (new exception), H (limit-value weakening).
Recall Rectangle→Square: expected vs actual area for the 5×4 test
Expected ::: 20 (a real Rectangle). Actual with Square ::: 16, because setHeight(4) also resets width to 4, so 4×4.
Recall Why is Ex 10's return-type narrowing allowed but Ex 2's precondition narrowing forbidden?
Because ::: return types are outputs (postcondition, may narrow/covariant); parameters are inputs (precondition, may only widen/contravariant). Only inputs surprise a caller when tightened.
Mnemonic Direction rule in five words
Inputs open, outputs promise more. (Preconditions widen, postconditions narrow — anything else surprises a base-only caller.)
See also: Design by Contract (where pre/post/invariant come from), Polymorphism (what LSP protects), SOLID — Interface Segregation Principle (the usual fix for Cells F & I), and the sibling principles SOLID — Single Responsibility Principle , SOLID — Open Closed Principle , SOLID — Dependency Inversion Principle .