📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Explain the Liskov Substitution Principle with a C# example of a violation, and how you would detect and fix it.

Behavioural subtyping, preconditions, postconditions and invariants, NotSupportedException smells, the read-only collection example, and fixing hierarchies.

Senior Architecture Advanced design-principles inheritance lsp solid

🎯 Key points

  • Subtypes must be usable wherever the base type is expected
  • Do not strengthen preconditions or weaken postconditions; keep invariants
  • NotSupportedException overrides and caller type checks are smells
  • Mutable Square : Rectangle is the classic violation
  • Fix with capability-based interfaces, composition or immutable types
  • Contract tests run the same suite against every implementation
  • Prefer IReadOnlyList<T> over IList<T> with IsReadOnly checks

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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 mutable Square : Rectangle, which breaks the independence of width and height. I detect it through type checks in callers (if (x is AdHocExporter)), NotSupportedException/NotImplementedException overrides, 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."


1. The behavioural contract

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

2. Violations seen in real .NET code

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

3. Detecting

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

4. Fixing

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

Production example

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.

🔍 What the interviewer is checking

Checks deep understanding of subtyping.

Green flags

  • Explains preconditions, postconditions and invariants.
  • Gives a realistic .NET violation and fix.
  • Mentions contract tests and type-check smells.
  • Knows the BCL IsReadOnly and IReadOnlyList<T> nuance.

Red flags

  • Only recites the textbook definition.
  • Thinks LSP is about method signatures only.

📚 Learn in depth

1. Where it comes from

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.

2. Rectangle and square

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; }

3. Invariants in practice

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.

4. Contract tests

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.)

5. LSP beyond inheritance

  • Interfaces: any implementation of IRepository<T> must behave like one (consistency, cancellation, exceptions).
  • Generics and variance: C# covariance (IEnumerable<out T>) and contravariance (IComparer<in T>) are type-level substitutability; LSP adds the behavioural part.
  • Services and APIs: a new version of an HTTP API is a "subtype" of the old contract; breaking postconditions (fields becoming null) breaks clients just like a bad override.

6. The BCL compromise

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.

7. Try it yourself

  1. Write the mutable Rectangle/Square pair and a test SettingWidthThenHeight_GivesProductArea; run it for both.
  2. Redesign with immutable IShape records and rerun.
  3. Build the exporter contract test base class, run it against the sample exporters, and fix them so they honour cancellation.
  4. Search your codebase for NotSupportedException and NotImplementedException in overrides and classify each.
  5. Find callers that type-check for specific implementations and move that behaviour into the types.

💻 Code

// 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);

⚠ Common mistakes

1. Overrides that throw NotSupportedException

What 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.

2. Modelling real-world "is-a" directly as inheritance

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.

3. Type checks in callers

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.

4. Weakening postconditions quietly

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.

5. Strengthening preconditions

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.

6. Ignoring non-functional contract parts

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.

7. Breaking base invariants in overrides

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.

8. Ad hoc tests per implementation

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.

9. Large interfaces that few implementations fully support

What happens: IStorage with 20 members; half throw in some backends. Why it is wrong: forces LSP violations. Do instead: segregate interfaces (ISP).

10. Treating LSP as only about signatures

What happens: "it compiles, so it's substitutable". Why it is wrong: LSP is about behaviour. Do instead: reason about preconditions, postconditions and invariants.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate fixes a broken hierarchy Why: Probes composition versus inheritance
    How to answer

    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.

  2. When: Candidate splits capabilities into smaller interfaces Why: Moves to interface segregation
    How to answer

    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.

  3. When: Candidate relies on substitutable strategies Why: Connects LSP with OCP
    How to answer

    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.

🛠 Real-world problems: how you handle them

Nightly scheduler crashing on an exporter that could not be scheduled
Situation
A [reporting platform] had a `ReportExporter` base with `Export` and `Schedule`; a new ad hoc exporter threw from `Schedule`.
Signals
The nightly scheduler crashed with `NotSupportedException` after the new exporter was registered.
Investigation
The scheduler resolved all exporters and called `Schedule` on each; one subtype could not honour that contract.
Resolution
Split into `IExporter` and `IScheduledExporter`; the scheduler now depends only on schedulable exporters.
Prevention
Added a contract test suite for each interface and banned `NotSupportedException` overrides in reviews.

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.

↩ Usually asked after

More in SOLID Principles

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me