beginner 2 min answer

After a refactor replaced a list implementation with an immutable one, a nightly job starts failing with an unsupported-operation error on a line nobody has edited in two years. Every type matches and the compiler was happy. What broke, and what is the design fix?

liskovinterface segregationcontractssilent failureanti pattern
Show the full answer Hide the answer

The diagnosis

An interface is a contract wider than its signatures, and the compiler only checks the signatures. The declared type promises that adding an element works. The new implementation satisfies the shape of that promise and breaks its content, so a caller written correctly against the declared type now fails at runtime.

That is the Liskov substitution principle as a fact about running software rather than a slogan about squares and rectangles. The precise rule it violates: a subtype may weaken a precondition and strengthen a postcondition, never the reverse. Throwing where the supertype succeeds strengthens the precondition — the caller must now additionally ensure the object is mutable — and no caller was told.

Why nothing caught it

  • The compiler verified substitutability of shape. There is no language mechanism here for "this method actually works".
  • The suite covered the line. The tests that touch this code read the collection. Coverage records execution, not assertion, so a line exercised only by read paths reports as covered while the write path has never run.
  • The path runs once a night. A branch executed once a day rather than on each of 4 million daily requests is the classic place where contract violations hide in production, because the change and the failure are separated by hours and by nobody's attention.

The fix

Make the type say what is true. Return a read-only type from the API — an iterable, or an explicitly named read-only collection — and let the few callers that need to mutate ask for a mutable type or take a copy. The method that throws should not be on the interface at all, which is interface segregation doing real work rather than being quoted in a review.

The general form: when an implementation cannot honour part of an interface, the interface is too wide. Splitting it converts a runtime failure into a compile error, which is the only kind of this bug that is cheap.

When the simpler fix is right

If exactly one call site mutates, copy into a mutable list at that site and stop. Two lines, no new types, no migration. Prefer the type change only when the wrong usage is plausible in many places, or when the type crosses a module boundary other teams depend on. For a single internal call site it is cost without benefit.

Common weak answers

  • "Catch the exception." The job then completes while producing a wrong result, which is strictly worse than failing. A caught contract violation is a silent data defect.
  • "Make the new implementation mutable." This discards the reason for the refactor, and if the motivation was shared mutable state across threads, it restores a harder bug than the one you are fixing.
  • "Add a check for whether the collection is mutable." Callers branching on capability is the symptom that the interface is wrong. Every future implementation adds another branch, and the branches end up in different places with different fallbacks.