What concrete signals in a codebase tell you the Liskov Substitution Principle is being violated, and what refactorings restore substitutability?
answer
- preconditions weaker, postconditions stronger
- throwing 'not supported' override
- instanceof checks in callers = broken substitution
- mutable Square is not a Rectangle
- contract tests run against every implementation
basics
~20 sSignals: an override that throws 'not supported', callers doing type checks before calling, or a subclass that rejects inputs the parent accepts. Fix by narrowing the interface, using composition instead of inheritance, or splitting the hierarchy so each type only promises what it can deliver.
solid answer
~50 sLSP says objects of a subtype must be usable anywhere the supertype is expected, without callers knowing the difference. Behavioural rules (Liskov & Wing): a subtype may not strengthen preconditions, may not weaken postconditions, must preserve invariants, and must respect the history constraint (no state changes the supertype forbids). Practical signals: overrides that throw UnsupportedOperationException or silently do nothing; `if (x is Sub)` checks in callers; a subclass narrowing accepted input ranges or adding a null check the parent lacks; documentation saying 'do not call setX on this subclass'; a shared test suite that passes for the base and fails for a subtype. Refactorings: extract a narrower interface containing only the honestly shared behaviour (ISP), replace inheritance with composition/delegation, split the hierarchy (Shape vs. ResizableShape), make the type immutable so mutation contracts vanish, or weaken the base contract to what all subtypes can honour. Detect systematically with contract tests run against every implementation.
code
pseudocode · 13 lines// Violation: subtype strengthens preconditions and throws a new exception
open class FileStore { open fun save(bytes: ByteArray) { /* any size */ } }
class SmallFileStore : FileStore() {
override fun save(bytes: ByteArray) {
require(bytes.size < 1024) // precondition STRENGTHENED -> breaks callers
super.save(bytes)
}
}
// Restored: capability split into honest interfaces
interface Store { fun save(bytes: ByteArray) }
interface BoundedStore : Store { val maxBytes: Int }
// callers that must handle limits depend on BoundedStore explicitlygo deeper
Say subtypes must work anywhere the parent is used; give the 'override that throws not-supported' and 'caller does instanceof' signals and the idea of splitting interfaces.
State the pre/postcondition and invariant rules explicitly, give the mutable Square/Rectangle analysis, and name composition and interface-splitting as the fixes.
Add the history constraint and exception rule, propose contract tests as automated detection, and discuss when weakening the base contract is acceptable versus when it makes the abstraction useless.
Extend to service and API boundaries - backward compatibility, drop-in replacement implementations, consumer-driven contracts - and treat drastic performance or failure-mode changes as contract violations even when the signature matches.
## The principle **LSP (Liskov Substitution Principle)** - if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any desirable property of the program. It is about **behavioural subtyping**, not merely compilable inheritance: the compiler checks signatures, LSP concerns *contracts*. The formal conditions (Liskov & Wing, 1994): | Rule | Meaning | Violation example | |---|---|---| | Preconditions may not be strengthened | The subtype must accept at least everything the supertype accepts | Base accepts any positive amount; subtype rejects amounts over 100 | | Postconditions may not be weakened | The subtype must guarantee at least what the supertype guarantees | Base guarantees the item is persisted; subtype queues it and may drop it | | Invariants must be preserved | Properties always true of the supertype stay true | Base guarantees `size >= 0`; subtype allows a transient negative | | History constraint | The subtype may not permit state changes the supertype forbids | Immutable base; subtype adds a mutator | | Exceptions | The subtype may not throw new exception types the caller was not told about | Override throws `UnsupportedOperationException` | ## Signals you can look for in code 1. **Degenerate overrides** - `throw new UnsupportedOperationException()`, an empty body, or a `return null` where the base always returned a value. The classic library case is an immutable list implementing a mutable list interface. 2. **Type checks in callers** - `if (shape instanceof Square) ... else ...`. If callers must know the concrete type, substitutability has already failed - and this also breaks OCP, since new subtypes force caller edits. 3. **Tightened input validation** in the subclass (extra argument checks, narrower ranges, new null rejections). 4. **Documentation caveats** - 'this implementation ignores the timeout parameter', 'do not call refresh() on the cached variant'. 5. **Test asymmetry** - a suite written against the base type fails when run against a subtype. This is the strongest signal and the basis of the fix below. 6. **Configuration flags on the subtype** that callers must set to make it behave like the base. 7. **The Square/Rectangle case** - `Square extends Rectangle` breaks the invariant a caller relies on (`setWidth` leaves height unchanged), so code correct for rectangles is wrong for squares. ## Refactorings that restore substitutability - **Extract a narrower interface.** If only some implementations can write, split `Repository` into `ReadableRepository` and `WritableRepository`. Callers depend on the one they need. This is ISP applied in service of LSP. - **Replace inheritance with composition/delegation.** Let the odd type *hold* the base type and expose only the operations it can honour. Removes the false 'is-a'. - **Split the hierarchy along real capabilities.** `Shape` (has area) vs `ResizableShape` (has independent width/height). Square is a Shape, not a Rectangle. - **Make the type immutable.** Most Square/Rectangle-style breakages come from setters; with `withWidth()` returning a new object, the invariant problem disappears. - **Weaken the base contract** to the intersection of what all subtypes truly guarantee - honest, but be careful: an over-weak contract makes the abstraction useless to callers. - **Introduce capability queries** as a last resort (`supportsSeek()`), acknowledging the design cost - callers now branch, which is exactly what LSP wanted to avoid. ## Detecting it systematically Write a **contract test** (also called an abstract or conformance test): a test suite parameterised over every implementation of the interface, asserting the interface's promises. Any implementation that fails it is an LSP violation, caught in CI rather than in production. Consumer-driven contract tests do the same job across service boundaries. ## Trade-offs and edge cases - **Not every 'is-a' in English is an 'is-a' in code.** A square is a rectangle in geometry, but *mutable* Square is not a behavioural subtype of *mutable* Rectangle. - **Documented weaker contracts are legitimate.** If the base contract explicitly says 'may throw if unsupported' and every caller handles it, the subtype is conforming - though the interface is now weak and arguably too fat (an ISP problem). - **Performance is part of the contract in practice.** A subtype that turns an O(1) operation into a network round trip is technically substitutable but will break callers' assumptions; treat drastic performance changes as a contract change. - **Deep hierarchies multiply risk.** Every level adds contract obligations; favour shallow hierarchies and composition. - **Language features do not save you.** `final`/`sealed` types prevent unwanted subtyping but do not make an existing hierarchy behaviourally correct.
- Why is a mutable Square that extends a mutable Rectangle an LSP violation, when geometrically a square is a rectangle?Because the *contract* of mutable Rectangle includes an invariant callers rely on: setting the width does not change the height, so after setWidth(5); setHeight(4) the area is 20. Square must keep sides equal, so it either breaks that invariant or produces area 16. Code written correctly against Rectangle becomes wrong. Making the types immutable (withWidth returns a new object) removes the conflict, because there is no shared mutable invariant to break.
- How would you catch LSP violations automatically rather than by review?Write a contract/conformance test suite against the interface and run it, parameterised, against every implementation - including test doubles. Property-based tests are especially effective for invariants. Across services, consumer-driven contract tests play the same role: the new implementation must satisfy the promises existing consumers depend on.
- Is it ever acceptable for an implementation to throw 'operation not supported'?Only if the base contract explicitly declares that possibility and every caller is expected to handle it - as some collection interfaces do for historical reasons. Even then it is a signal the interface is too fat: the cleaner fix is to split it (ISP) so that types only implement operations they can honour.
A rental car company promises 'any car in class B seats five and takes unleaded'. If one 'class B' car seats two and takes diesel, every customer must now check the specific car - the class label has stopped being a promise, which is exactly an LSP violation.
saying these in an interview costs you the question
- Treating LSP as a compile-time/signature rule rather than a behavioural-contract rule.
- Saying LSP just means 'use inheritance correctly' with no mention of pre/postconditions or invariants.
- Claiming Square/Rectangle is a violation of geometry rather than of the mutable Rectangle contract.
- Fixing an override that throws by catching the exception in callers - that hard-codes the violation into every call site.
- Assuming a subtype may freely throw new checked/unchecked exceptions as long as it compiles.