Behavioural subtyping, preconditions, postconditions and invariants, NotSupportedException smells, the read-only collection example, and fixing hierarchies.
LSP says that code written against a base type must keep working when given any subtype, without knowing which one. Subtypes must not strengthen preconditions, weaken postconditions or break the base type's invariants, and should not throw new kinds of exceptions for normal use. Classic violations: a ReadOnlyList deriving from a mutable list and throwing NotSupportedException on Add, or Square : Rectangle breaking independent width and height. Detect it with is/type checks in callers, NotSupportedException overrides and contract tests that fail for some subtypes. Fix by redesigning the hierarchy: smaller interfaces, composition, or separate types.
"Liskov Substitution says that code written against a base type or interface must keep working correctly when handed any subtype, without knowing which one it got. That's a behavioural promise, not just matching signatures. A subtype must not strengthen preconditions (demand more from callers), must not weaken postconditions (promise less), must preserve invariants, and shouldn't introduce surprising exceptions for normal use. The classic .NET violation is an override that throws
NotSupportedException: the base says 'I can schedule', one subclass can't, and the scheduler crashes in production. The textbook one is a mutableSquare : Rectangle, which breaks the independence of width and height. I detect it through type checks in callers (if (x is AdHocExporter)),NotSupportedException/NotImplementedExceptionoverrides, and contract tests that fail for some implementations. I fix it by redesigning the abstraction: split interfaces by capability, use composition instead of inheritance, or use immutable types, so every implementation can honestly keep every promise."
| Rule | Subtype may | Subtype must not | Example violation |
|---|---|---|---|
| Preconditions | Accept more input | Demand more (stricter input) | Base accepts any amount; subtype throws for amounts > 1000 |
| Postconditions | Guarantee more | Guarantee less | Base promises non-null result; subtype returns null |
| Invariants | Add its own | Break the base's | Square makes width == height, breaking independent sides |
| History constraint | — | Allow state changes the base forbids | Mutable subtype of an immutable base |
| Exceptions | Throw subtypes of documented exceptions | Throw new kinds for normal use | NotSupportedException from a core operation |
| Violation | Why callers break |
|---|---|
override Schedule(...) => throw new NotSupportedException() |
Code iterating all exporters crashes |
Repository subtype that ignores the passed CancellationToken |
Callers' cancellation contract silently broken |
| Cached repository returning stale data where the base promised current data | Weaker postcondition: wrong business decisions |
Stream reporting CanSeek = true but throwing on Seek |
Lies about capability |
IAsyncDisposable.DisposeAsync that blocks |
Breaks the interface's async expectation |
Arrays as IList<T> throwing on Add |
Known BCL compromise; callers must check IsReadOnly |
| Signal | Where to look |
|---|---|
is / as / GetType() checks for specific subtypes in callers |
Consumers of the abstraction |
NotSupportedException / NotImplementedException in overrides |
Implementations |
| Shared tests that pass for some implementations and fail for others | Contract test suite |
| Docs saying "don't call X on this implementation" | Comments, README |
| Production errors only with certain implementations | Logs grouped by type |
| Fix | When |
|---|---|
Split interfaces by capability (IExporter / IScheduledExporter) |
Some implementations lack a capability |
Read-only interfaces (IReadOnlyList<T>, IReadOnlyRepository<T>) |
Mutation isn't universal |
| Composition instead of inheritance | Reuse was the goal, not substitutability |
Immutable value types (record Rectangle(w, h), record Square(side) both IShape) |
Mutable state broke invariants |
| Smaller base contract | The base promised too much |
Our [reporting platform] had a ReportExporter base class with ExportAsync and Schedule. A new AdHocExporter overrode Schedule to throw NotSupportedException, and the nightly scheduler, which resolved all exporters and scheduled each one, crashed after the new exporter was registered. That's a Liskov violation: the base promised scheduling that one subtype couldn't honour. I split the abstraction into IExporter and IScheduledExporter : IExporter, so the scheduler depends only on IEnumerable<IScheduledExporter> and can't receive an exporter that can't be scheduled. I added a contract test suite per interface that every implementation must pass, and we now treat NotSupportedException overrides as a review blocker.
Checks deep understanding of subtyping.
Green flags
IsReadOnly and IReadOnlyList<T> nuance.Red flags
Barbara Liskov (1987) and Liskov & Wing (1994) defined behavioural subtyping: if S is a subtype of T, properties provable about T objects must hold for S objects. Bertrand Meyer's Design by Contract gives the practical rules: preconditions can't be strengthened, postconditions can't be weakened, invariants must be preserved.
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
public class Square : Rectangle
{
public override int Width { get => base.Width; set { base.Width = value; base.Height = value; } }
public override int Height { get => base.Height; set { base.Width = value; base.Height = value; } }
}
Rectangle r = new Square();
r.Width = 4; r.Height = 5;
Console.WriteLine(r.Area); // caller expects 20, gets 25
The implicit invariant of a mutable Rectangle is "setting Height doesn't change Width". Square can't honour it. Immutable shapes avoid the problem:
public interface IShape { double Area { get; } }
public sealed record Rectangle(double Width, double Height) : IShape { public double Area => Width * Height; }
public sealed record Square(double Side) : IShape { public double Area => Side * Side; }
public abstract class Account
{
public decimal Balance { get; protected set; }
// Invariant: Balance never goes below -OverdraftLimit
public abstract decimal OverdraftLimit { get; }
public virtual void Withdraw(decimal amount)
{
if (Balance - amount < -OverdraftLimit) throw new InvalidOperationException("Insufficient funds");
Balance -= amount;
}
}
A subclass overriding Withdraw without the check ("premium accounts can go anywhere") breaks the invariant that reporting and risk code rely on. If premium accounts need a different limit, express it through OverdraftLimit, not by bypassing the rule.
Using the IExporter interface from the code example (Task ExportAsync(Report r, CancellationToken ct)):
public abstract class ExporterContract
{
protected abstract IExporter CreateSut();
[Fact]
public async Task Export_completes_for_a_valid_report()
{
var sut = CreateSut();
await sut.ExportAsync(new Report("Sales"), CancellationToken.None); // must not throw
}
[Fact]
public async Task Export_honours_cancellation()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => CreateSut().ExportAsync(new Report("Sales"), cts.Token));
}
}
public sealed class AdHocExporterTests : ExporterContract { protected override IExporter CreateSut() => new AdHocExporter(); }
public sealed class NightlyExporterTests : ExporterContract { protected override IExporter CreateSut() => new NightlyExporter(); }
Every implementation runs the same suite; a failing implementation is an LSP violation by definition. (The sample exporters in the code example return Task.CompletedTask and would fail the cancellation test, which is exactly the kind of gap contract tests reveal.)
IRepository<T> must behave like one (consistency, cancellation, exceptions).IEnumerable<out T>) and contravariance (IComparer<in T>) are type-level substitutability; LSP adds the behavioural part.ICollection<T>.IsReadOnly and Stream.CanSeek/CanWrite express capabilities at run time, so callers must check. Newer APIs prefer type-level capability (IReadOnlyList<T>, IReadOnlyCollection<T>, ReadOnlySpan<T>), so violations become compile errors instead of run-time exceptions.
Rectangle/Square pair and a test SettingWidthThenHeight_GivesProductArea; run it for both.IShape records and rerun.NotSupportedException and NotImplementedException in overrides and classify each.// Violation: the base promises scheduling; one subtype cannot do it.
public abstract class ReportExporterBad
{
public abstract Task ExportAsync(Report r, CancellationToken ct);
public abstract void Schedule(Report r, TimeSpan every);
}
public sealed class AdHocExporterBad : ReportExporterBad
{
public override Task ExportAsync(Report r, CancellationToken ct) => Task.CompletedTask;
public override void Schedule(Report r, TimeSpan every) =>
throw new NotSupportedException("Ad hoc exports cannot be scheduled"); // LSP smell
}
// Fix: split by capability so every implementation honours its contract.
public interface IExporter
{
Task ExportAsync(Report r, CancellationToken ct);
}
public interface IScheduledExporter : IExporter
{
void Schedule(Report r, TimeSpan every);
}
public sealed class AdHocExporter : IExporter
{
public Task ExportAsync(Report r, CancellationToken ct) => Task.CompletedTask;
}
public sealed class NightlyExporter : IScheduledExporter
{
public Task ExportAsync(Report r, CancellationToken ct) => Task.CompletedTask;
public void Schedule(Report r, TimeSpan every) => Console.WriteLine($"{r.Name} every {every}");
}
// The scheduler can only receive exporters that support scheduling.
public sealed class Scheduler(IEnumerable<IScheduledExporter> exporters)
{
public void ScheduleAll(Report r) { foreach (var e in exporters) e.Schedule(r, TimeSpan.FromDays(1)); }
}
public sealed record Report(string Name);
NotSupportedExceptionWhat happens: a subtype can't do something the base promises. Why it is wrong: code written against the base fails at run time for that subtype. Do instead: split the abstraction so the subtype doesn't claim the capability.
What happens: Square : Rectangle, Penguin : Bird with Fly().
Why it is wrong: taxonomy doesn't guarantee behavioural compatibility.
Do instead: check every promise of the base against every subtype; prefer capability interfaces.
What happens: if (exporter is AdHocExporter) skip;.
Why it is wrong: knowledge of subtypes spreads; every new subtype needs new checks.
Do instead: move behaviour into the types or redesign the hierarchy.
What happens: a caching decorator returns stale data where the base promised current data.
Why it is wrong: callers make decisions on wrong data with no signal.
Do instead: make freshness explicit in the contract (e.g. a separate ICachedReader) or document and accept it deliberately.
What happens: an implementation rejects inputs the interface accepts (e.g. requires non-empty strings). Why it is wrong: valid calls fail for one implementation. Do instead: accept the same inputs, or put the stricter rule in the interface contract for everyone.
What happens: an implementation ignores CancellationToken or blocks in an async method.
Why it is wrong: callers rely on cancellation and non-blocking behaviour.
Do instead: include cancellation, exceptions and threading behaviour in contract tests.
What happens: an override skips validation the base performed. Why it is wrong: other code depends on the invariant (e.g. balance limits). Do instead: use template methods/hooks so subtypes vary only the allowed parts.
What happens: each implementation has its own tests with different expectations. Why it is wrong: contract differences go unnoticed. Do instead: one shared contract test suite.
What happens: IStorage with 20 members; half throw in some backends.
Why it is wrong: forces LSP violations.
Do instead: segregate interfaces (ISP).
What happens: "it compiles, so it's substitutable". Why it is wrong: LSP is about behaviour. Do instead: reason about preconditions, postconditions and invariants.
LSP is the principle that tells you whether an inheritance or interface hierarchy is honest. I treat NotSupportedException overrides and caller type checks as design bugs, and I protect important abstractions with contract tests.
| Evidence | Fix |
|---|---|
NotSupportedException in overrides |
Split interfaces by capability |
is/as checks on subtypes in callers |
Move behaviour into types |
| Mutable subtype breaks invariants | Immutable types or composition |
| Implementations behave differently in tests | Shared contract test suite |
Default to composition: build behaviour from small objects behind interfaces and inject them. It keeps coupling low, lets behaviour change at run time and is easy to test. Use inheritance only when all of these hold: there is a true is-a relationship where every subtype can substitute for the base (Liskov), the base was designed for extension (documented virtual hooks, protected constructor), there is one axis of variation, and you want to share state plus a fixed algorithm (template method). Framework extension points such as BackgroundService, DelegatingHandler and DbContext are good inheritance cases. When a hierarchy explodes or subclasses override things to throw NotSupportedException, refactor by extracting the varying behaviour into strategies and composing them.
ISP says clients should not be forced to depend on methods they do not use. A fat interface such as IOrderService with 25 members couples every consumer to all of them, makes test doubles painful and pushes implementers to throw NotImplementedException. Split interfaces by what clients need (role interfaces): IOrderReader for queries, IOrderWriter for commands, IClock instead of a giant IUtilities. The BCL does this well (IReadOnlyCollection<T> versus ICollection<T>, IAsyncDisposable). Do not go to the extreme of one interface per method; group members that the same client uses together.
The Open/Closed Principle says modules should be open for extension but closed for modification: you add new behaviour by adding code, not by editing tested code. In C# this usually means a strategy interface with one implementation per variant, registered in DI, so adding a new payment method is a new class rather than another case in a growing switch. I apply it where change actually happens repeatedly. A small, stable switch (or a switch expression over a closed set) is fine; building extension points for variants that never arrive is speculative generality.
How to tell it (STAR)
Our [nightly scheduler] crashed after we added an ad hoc exporter that threw when scheduled. That was a Liskov violation: the base promised scheduling that one subtype could not do. I split the abstraction by capability so the scheduler only gets exporters that can be scheduled, and added contract tests for each interface.
Last updated 31 Oct 2026.