An interface has an `add(item)` method, and one implementation overrides it to throw an "operation not supported" error (as immutable collections often do). Is that a Liskov Substitution violation, and what are the alternatives?
answer
- Throw-on-override = postcondition weakened
- Precondition strengthened to false
- Compiler silent; fails in production
- Fix: split Readable vs Mutable interface
- Optional operations = legacy compromise (unmodifiableList)
basics
~10 sYes — clients told the interface supports add now crash at runtime. The implementation delivers less than the interface promised. The fix is to split the interface so read-only types never advertise mutation.
solid answer
~50 sIt violates LSP as a postcondition weakening (equivalently, an unsatisfiable precondition): code written against the interface was promised `add` inserts the item, and instead gets an unexpected runtime failure. The compiler cannot help, so the failure surfaces in production the first time an immutable instance flows into mutating code. It also drives clients toward defensive `instanceof`/capability checks, which is the tell-tale symptom. Alternatives, best first: (1) **segregate the interface** — `ReadableCollection` (query only) and `MutableCollection extends ReadableCollection` (adds mutators), so immutability is expressed in the type, not by throwing; (2) **return a new instance** — `add(item): Collection` in a persistent/immutable style, which every implementation can honour; (3) **explicitly widen the base contract** — document "implementations may be read-only and throw UnsupportedOperation", with a queryable `isMutable()`/`supports(...)` capability flag, making the failure part of the promise; (4) **fail at construction/wiring time** rather than at call time. Java's `Collections.unmodifiableList` is the historical example of choosing option 3 badly — a wide legacy interface making optional operations the norm.
code
pseudocode · 13 lines// Violation: signature matches, promise doesn't
interface Collection { add(item) } // post: collection now contains item
class Immutable : Collection {
add(item) = throw UnsupportedOperation() // delivers nothing; new failure mode
}
// Fix 1 — segregate capability into the type (compile-time error at wiring)
interface Readable { size(); contains(x) }
interface Mutable : Readable { add(x) }
fun collect(target: Mutable) // Immutable simply cannot be passed
// Fix 2 — make the operation total
interface Collection { add(item): Collection } // returns a NEW collectiongo deeper
Say yes, it's a violation: callers were told add works and get a crash instead. Suggest separate read-only and mutable interfaces.
Name the mechanism (postcondition weakened / precondition strengthened to false), note the compiler can't catch it, and propose interface segregation with a concrete two-interface sketch.
Weigh the alternatives — segregation, total operations returning new values, explicit capability queries, fail-at-wiring — with their costs; cite the Java optional-operations history and the Kotlin/C# split as the correction. Mention contract tests as the detector.
Treat interface width as a governance decision: a deliberately weak contract is legal but taxes every consumer and destroys type-level reasoning. Discuss when capability negotiation is genuinely right (plugins, drivers, remote backends), migration strategy for splitting a widely-used legacy interface, and adjacent invisible-guarantee cases like thread-safety and read-only views over mutable data.
## What exactly is broken The Liskov Substitution Principle says a subtype must be usable anywhere its supertype is expected without breaking client code. Client code here was written against an interface whose `add(item)` is documented (or reasonably understood) as: *precondition* — the item is valid; *postcondition* — after return, the collection contains the item. An implementation that throws "unsupported operation" delivers **none** of the postcondition and introduces a failure mode the client was never told about. Framed either way it's the same defect: - **Postcondition weakening**: the promised effect doesn't happen, and an undeclared exception appears instead. - **Precondition strengthening to `false`**: there is literally no input for which the operation succeeds, so the implementation demands infinitely more of the caller than the interface did. Because signatures match, no compiler, linter, or type-checker flags it. The defect is discovered when an immutable instance is passed into a code path that mutates — often far from where the substitution happened, in production, at an unrelated call site. ## Why it happens anyway (the real-world defence) This pattern is everywhere: `java.util.Collections.unmodifiableList`, `Arrays.asList` (fixed-size), many framework "read-only view" wrappers, and `Iterator.remove` as an "optional operation". The defence usually given: 1. **Legacy interface width.** The interface was designed before read-only views existed, and splitting it would break every consumer. Optional operations were the cheapest compatible retrofit. 2. **Combinatorial explosion of interfaces.** Fully segregating every capability (readable / appendable / removable / sortable / fixed-size / thread-safe…) can multiply types uncomfortably. 3. **The contract was explicitly widened.** If the interface documents "add is an *optional* operation; implementations that don't support it throw UnsupportedOperationException", then throwing is technically compliant — the promise was always weak. This is precisely how Java's Collections Framework words it. That last point is the important nuance: **LSP compliance is relative to the documented contract.** You can always make a violation "legal" by weakening the base contract — but you pay for it in every client, which now must either handle the failure, check a capability flag, or gamble. The interface stops being useful for reasoning; "I have a List" no longer tells you what you can do with it. Most reviewers judge optional operations as a legacy compromise, not a design to imitate. ## Alternatives, ranked **1. Interface segregation (preferred).** Model capability in the type system: ``` interface ReadableCollection { size(); contains(x); iterate() } interface MutableCollection : ReadableCollection { add(x); remove(x) } ``` Immutable types implement only `ReadableCollection`. Any function that needs to mutate declares `MutableCollection`, and the mismatch becomes a **compile error at the wiring point** rather than a runtime crash deep inside a request. Kotlin (`List` vs `MutableList`), C# (`IReadOnlyList<T>` vs `IList<T>`), and Scala (`immutable` vs `mutable` packages) all take this route — the direct lesson learned from Java's optional operations. This is the LSP↔ISP link: an over-broad interface *forces* implementations into violations. **2. Make the operation total by returning a new value.** In a persistent/functional style, `add(item): Collection` returns a new collection and mutates nothing. Every implementation — mutable or immutable — can satisfy that contract honestly. Cost: allocation per operation (mitigated by structural sharing in persistent data structures) and a different calling style (`c = c.add(x)`), plus the trap that callers ignoring the return value silently lose data. **3. Explicit capability query.** Keep one interface but add `supportsAdd(): Boolean` (or a `capabilities` set) and document the failure as part of the contract. Now the failure is honest and testable, but every caller carries a branch, and forgetting the check is still a runtime crash. Useful for genuinely dynamic plugin systems where capabilities are discovered at runtime, e.g. drivers or file systems. **4. Fail early, at construction/wiring.** If a component requires a mutable collection, validate at construction or dependency-injection time rather than at the moment of use. Doesn't fix the contract, but converts a late random failure into an immediate startup failure. **5. Null Object / no-op** — *usually wrong here.* Making `add` silently do nothing avoids the exception but replaces a loud failure with silent data loss, which is strictly worse for a write operation. A no-op is only acceptable where the operation is genuinely advisory (e.g. a metrics sink, a cache hint). ## How to detect it - Grep for "unsupported"/"not supported" throws inside overrides. - Look for client code doing runtime type checks or `try { add } catch (Unsupported)`. - Contract tests: run the interface's shared behavioural suite against every implementation; a throwing implementation fails immediately — which tells you either the implementation is wrong or the interface is too wide. ## Edge cases - **Read-only *views* over mutable data** are additionally treacherous: the view forbids writes but can still change underneath the holder when the backing collection is mutated elsewhere, breaking the invariant clients infer from "unmodifiable". - **Fixed-size vs immutable** are different capabilities: a fixed-size list allows `set` but not `add`, which is why one boolean flag rarely captures the truth. - **Thread-safety** is the same problem in another dimension: swapping a synchronized implementation for an unsynchronized one weakens a guarantee clients depended on, with no signature change. - **Remote/plugin implementations** may be unable to support an operation for genuinely external reasons; there, an explicit capability contract (option 3) is the honest design, not a compromise.
- Java's `Collections.unmodifiableList` does exactly this — was the JDK wrong?It was a pragmatic retrofit onto an interface that predated read-only views and could not be split without breaking every consumer, and the contract was explicitly widened by documenting `add` as an optional operation. That makes it technically compliant but strictly worse for reasoning; later languages (Kotlin `List`/`MutableList`, C# `IReadOnlyList`) split the interface instead, which is the lesson to carry forward.
- If we simply document the exception in the base interface, is the violation gone?Formally yes — the contract now includes that failure mode, so no promise is broken. Practically the cost moves to callers: every one must check a capability or handle the exception, and "I have a Collection" no longer tells you what you can do. Legitimate for dynamic plugin/driver systems; a poor default for ordinary domain code.
- Why not just make `add` a silent no-op instead of throwing?For a write operation that converts a loud failure into silent data loss, which is worse — the caller believes the item was stored. A no-op (Null Object) is only appropriate when the operation is genuinely advisory, such as a metrics sink or cache hint.
A universal power socket that physically accepts your plug but is wired dead: everything about the interface says "you can plug in here", and the failure is only discovered when the device you actually cared about doesn't power on. The fix isn't a warning sticker — it's a differently-shaped socket that can't accept the plug at all.
saying these in an interview costs you the question
- "It compiles, so it's fine" — signature conformance says nothing about behavioural conformance
- "The JDK does it, so it's a good pattern" — it's a documented legacy compromise, and newer languages deliberately split the interface
- "Callers can just catch the exception" — pushes the interface's design failure onto every call site
- "Make it a silent no-op instead" — trades a visible crash for silent data loss on a write
- "Add an isMutable() check in every caller" — acceptable only for genuinely dynamic capability discovery; otherwise it's a runtime workaround for a type-system problem