Mert Özen Further With Every Line
Software Design SOLID and Design Principles — #05

Liskov Substitution Principle: Behavioral Subtyping and NotSupportedException in .NET

Mert Özen Nov 7, 2026 15 min 4 views
Liskov Substitution Principle: Behavioral Subtyping and NotSupportedException in .NET

LSP requires a subtype to satisfy not just the compiler but the base type's contract: preconditions aren't strengthened, postconditions aren't weakened, invariants and the history constraint are preserved. Three real violations in Bookstore's stock module, the NotSupportedException that T[] and ReadOnlyCollection throw from IList<T>.Add, capability queries with Stream.CanSeek, and verifying the contract against every implementation with an abstract xUnit test class.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

The bestseller list on Bookstore's home page can come from two places: a List<string> read from the database or a string[] coming from the cache. The method that adds the editor's pick to the list takes an IList<string>, which both implement. The code compiles with no warnings. Do both calls produce the same output?

List<string> fromDatabase = ["Dune", "Emma"];
string[] fromCache = ["Dune", "Emma"];

AddBestseller(fromDatabase, "The Hobbit");
AddBestseller(fromCache, "The Hobbit");

static void AddBestseller(IList<string> titles, string title)
{
    if (!titles.Contains(title))
        titles.Add(title);

    Console.WriteLine($"{titles.Count}: {string.Join(", ", titles)}");
}

The Compiler Accepted It, the Contract Didn't

Output:

3: Dune, Emma, The Hobbit
Unhandled exception. System.NotSupportedException: Collection was of a fixed size.

The first call worked, the second threw an exception. To the compiler, string[] is an IList<string>, and it "implements" IList<T>'s Add method too. But the implementation does only one thing: throw a NotSupportedException. Whoever wrote AddBestseller trusted the promise IList<T> makes: if this is a list, you can add to it. The array doesn't keep that promise.

That is exactly what this post is about: for one type to stand in for another, the compiler's approval is not enough; the behavior has to fit as well. The L in SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion), LSP (Liskov Substitution Principle), sets the rules for that behavioral fit. In this post we look at LSP's original definition, the four rules of behavioral subtyping on Bookstore's stock module, real violations in .NET's own collections and streams, and verifying a contract with tests against every implementation. We touched on LSP briefly with the e-book stock example in "Composition in C# and an Introduction to SOLID" in the C# OOP (Object-Oriented Programming) series; here we open the rules up one by one.

The Definition: Liskov 1987, Liskov and Wing 1994

Barbara Liskov (a computer scientist at the Massachusetts Institute of Technology and winner of the 2008 Turing Award) first stated the principle in 1987 in her keynote "Data Abstraction and Hierarchy" at the OOPSLA (Object-Oriented Programming, Systems, Languages and Applications) conference. The idea was: if S is a subtype of T, a program that uses objects of type T should not change its behavior when given objects of type S instead. Her 1994 paper with Jeannette Wing, "A Behavioral Notion of Subtyping", published in the ACM's (Association for Computing Machinery) TOPLAS (Transactions on Programming Languages and Systems) journal, made this formal: a property provable about every object of type T should also hold for every object of type S.

Robert C. Martin (author of Clean Code and Clean Architecture) translated the idea into the practical language of object-oriented design in the 1990s: code that uses a base class should keep working correctly when it is given a derived class. Martin also stresses the principle's link to the Design by Contract idea of Bertrand Meyer (designer of the Eiffel language): a method's contract is defined by what the caller must provide (precondition) and what the method guarantees (postcondition).

The classic textbook example is Rectangle and Square: in mathematics every square is a rectangle, but a Square derived from a Rectangle whose width and height can be set separately has to change its height when its width changes, surprising the client who thinks "set width to 5 and height to 4, the area will be 20". The example is correct but can be misleading on its own, because people dismiss it as "just a geometry problem". So let's look at the rules in a real Bookstore example.

Four Rules: Precondition, Postcondition and Invariant

Liskov and Wing's rules go beyond the method signature. For a subtype's methods: you cannot strengthen preconditions (you must accept every input the base accepts), you cannot weaken postconditions (you must guarantee everything the base guarantees), and you must preserve the base type's invariants. The fourth rule, the history constraint, is the subject of the next section. In addition, a subtype should not throw new kinds of exceptions the base type doesn't throw.

Bookstore's stock module works through an IInventory interface. We wrote the contract as a comment next to the code; in a real project it would live in an XML (Extensible Markup Language) doc comment and in tests. Three of the four implementations break the contract in a different place. Inventory.cs:

namespace Bookstore.Stock;

// Contract:
// - ArgumentOutOfRangeException if quantity is 0 or negative (precondition)
// - if quantity <= Available, returns true and Available drops by exactly quantity (postcondition)
// - otherwise returns false and Available does not change (postcondition)
// - Available is never negative (invariant)
public interface IInventory
{
    int Available(string isbn);
    bool TryReserve(string isbn, int quantity);
}

public sealed class InMemoryInventory(Dictionary<string, int> stock) : IInventory
{
    public int Available(string isbn) => stock.GetValueOrDefault(isbn);

    public bool TryReserve(string isbn, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        if (quantity > Available(isbn))
            return false;

        stock[isbn] = Available(isbn) - quantity;
        return true;
    }
}

// Strengthens the precondition: the warehouse only ships packs of 5
public sealed class WarehouseInventory(Dictionary<string, int> stock) : IInventory
{
    private readonly InMemoryInventory _inner = new(stock);

    public int Available(string isbn) => _inner.Available(isbn);

    public bool TryReserve(string isbn, int quantity)
    {
        if (quantity % 5 != 0)
            throw new ArgumentException("Quantity must be a multiple of 5.", nameof(quantity));

        return _inner.TryReserve(isbn, quantity);
    }
}

// Breaks the invariant: stock can go negative on preorder
public sealed class PreorderInventory(Dictionary<string, int> stock) : IInventory
{
    public int Available(string isbn) => stock.GetValueOrDefault(isbn);

    public bool TryReserve(string isbn, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        stock[isbn] = Available(isbn) - quantity;
        return true;
    }
}

// Weakens the postcondition: the reservation succeeds but Available shows the old value
public sealed class CachedInventory(IInventory inner) : IInventory
{
    private readonly Dictionary<string, int> _cache = [];

    public int Available(string isbn)
    {
        if (!_cache.TryGetValue(isbn, out int available))
            _cache[isbn] = available = inner.Available(isbn);

        return available;
    }

    public bool TryReserve(string isbn, int quantity) => inner.TryReserve(isbn, quantity);
}

Let's hand the same client code to all four. There are 2 copies in stock; we first try to reserve 3, then 1. Program.cs:

using Bookstore.Stock;

const string Dune = "978-0441013593";

IInventory[] inventories =
[
    new InMemoryInventory(new() { [Dune] = 2 }),
    new WarehouseInventory(new() { [Dune] = 2 }),
    new PreorderInventory(new() { [Dune] = 2 }),
    new CachedInventory(new InMemoryInventory(new() { [Dune] = 2 }))
];

foreach (IInventory inventory in inventories)
{
    Console.Write($"{inventory.GetType().Name,-19}");
    foreach (int quantity in (int[])[3, 1])
    {
        try
        {
            bool reserved = inventory.TryReserve(Dune, quantity);
            Console.Write($" | reserve {quantity}: {reserved,-5} available: {inventory.Available(Dune),2}");
        }
        catch (ArgumentException ex)
        {
            Console.Write($" | reserve {quantity}: {ex.GetType().Name}");
        }
    }

    Console.WriteLine();
}
InMemoryInventory   | reserve 3: False available:  2 | reserve 1: True  available:  1
WarehouseInventory  | reserve 3: ArgumentException | reserve 1: ArgumentException
PreorderInventory   | reserve 3: True  available: -1 | reserve 1: True  available: -2
CachedInventory     | reserve 3: False available:  2 | reserve 1: True  available:  2

The first line is the contract itself: 3 copies aren't enough, false; 1 copy is reserved, 1 remains. The other three lines are three subtypes, each breaking a different rule.

WarehouseInventory strengthens the precondition. The base contract says "any positive quantity"; this implementation says "a positive quantity that is a multiple of 5". The client did nothing wrong, yet got an exception IInventory never mentions. The warehouse partner's rule may be a real business rule, but this is not the place for it: either it is added to the contract explicitly (and every client must know it), or it is handled in a separate layer that rounds quantities to 5.

PreorderInventory breaks the invariant and the postcondition: the stock dropped to -1, then -2, and it returned true with insufficient stock. Preorders are a real need, but something that says "stock can go negative" is not an IInventory. It is a different concept being squeezed behind the same interface.

CachedInventory weakens the postcondition. The reservation succeeded but Available still says 2. From the client's point of view, that means "I reserved it and the stock didn't go down"; the shortest path to selling the same book to two customers. This is the sneakiest violation, because there is no exception; everything seems to work.

The History Constraint: Methods a Subtype Adds

Liskov and Wing's real contribution is the fourth rule. An object's state changes only through its own methods; a subtype may add new methods, but those methods must not allow a state change the base type does not allow. The classic example is a mutable point derived from an immutable point type. In Bookstore, a price quote:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

AdjustableQuote quote = new("978-0441013593", 12.99m);
Checkout checkout = new();

// The cart screen shows the price and keeps the quote
checkout.Show(quote);

// The pricing team updates the price when the campaign ends
quote.Adjust(15.99m);

checkout.Pay();

// Contract: a quote never changes once created; the cart relies on that
public class PriceQuote(string isbn, decimal price)
{
    public string Isbn { get; } = isbn;
    public virtual decimal Price => price;
}

// Adds a new method and allows a state change the base type forbids
public sealed class AdjustableQuote(string isbn, decimal price) : PriceQuote(isbn, price)
{
    private decimal _price = price;

    public override decimal Price => _price;

    public void Adjust(decimal newPrice) => _price = newPrice;
}

public sealed class Checkout
{
    private PriceQuote? _quote;

    public void Show(PriceQuote quote)
    {
        _quote = quote;
        Console.WriteLine($"Shown:   {quote.Price} EUR");
    }

    public void Pay() => Console.WriteLine($"Charged: {_quote!.Price} EUR");
}
Shown:   12.99 EUR
Charged: 15.99 EUR

The customer saw 12.99 and paid 15.99. Checkout only knows PriceQuote, and according to its contract a quote never changes; so keeping the quote and using it again at payment time is reasonable. AdjustableQuote didn't break any base method: Price still returns a price. What it broke is the promise about the base type's behavior over time. Preconditions and postconditions look at a single call; the history constraint looks at the time between calls. In C# the most robust way to put that promise into the language is to make the type truly immutable: a sealed record or types like ImmutableArray<T> don't let a subtype break it.

NotSupportedException in .NET

The array example at the top of the post is not an accident; it is a deliberate design decision in .NET. Let's ask the same questions of four types that implement the same IList<string> interface:

using System.Collections;
using System.Collections.Immutable;
using System.Collections.ObjectModel;

Describe("List<T>", new List<string> { "Dune" });
Describe("T[]", new[] { "Dune" });
Describe("ReadOnlyCollection<T>", new ReadOnlyCollection<string>(["Dune"]));
Describe("ImmutableArray<T>", ImmutableArray.Create("Dune"));

static void Describe(string name, IList<string> list)
{
    // Capability query: the interface itself lets you ask "can you do this?"
    bool fixedSize = list is IList nonGeneric && nonGeneric.IsFixedSize;

    Console.WriteLine($"{name,-22} IsReadOnly={list.IsReadOnly,-5} IsFixedSize={fixedSize,-5} Add: {Try(() => list.Add("Emma"))} | this[0]: {Try(() => list[0] = "Emma")}");
}

static string Try(Action action)
{
    try
    {
        action();
        return "ok";
    }
    catch (NotSupportedException ex)
    {
        return ex.Message;
    }
}
List<T>                IsReadOnly=False IsFixedSize=False Add: ok | this[0]: ok
T[]                    IsReadOnly=True  IsFixedSize=True  Add: Collection was of a fixed size. | this[0]: ok
ReadOnlyCollection<T>  IsReadOnly=True  IsFixedSize=True  Add: Collection is read-only. | this[0]: Collection is read-only.
ImmutableArray<T>      IsReadOnly=True  IsFixedSize=True  Add: Specified method is not supported. | this[0]: Specified method is not supported.

All four are IList<T>, but only List<T> keeps the interface's whole promise. ReadOnlyCollection<T> implements IList<T> and throws on every write. There is a historical reason: ReadOnlyCollection<T> has existed since .NET Framework 2.0, but IReadOnlyList<T> and IReadOnlyCollection<T> were only added in .NET Framework 4.5. In the years in between, the only way to hand out "a read-only list" was to implement IList<T> and say no in the write methods. We will discuss the interface design side of this in the next post, on Interface Segregation.

The tool .NET's designers chose to manage this violation is the capability query: the ICollection<T>.IsReadOnly and non-generic IList.IsFixedSize properties let the client ask before trying the operation. The contract then becomes not "every list accepts additions" but "a list accepts additions if IsReadOnly is false". That is a legitimate way to rescue LSP: the subtype's behavior fits the base type's contract because the contract has already been weakened. But look at the second line of the output: for the array, IsReadOnly says True, yet writing through the indexer works. The meaning of the flag is left ambiguous between "the structure can't change" and "the contents can't change". That is the price of a capability query: every client has to remember to ask and has to interpret the answer correctly. None of it is checked by the compiler.

Capability Query: Stream.CanSeek

A more consistent example of the same design is Stream. Some streams (memory, file) can be rewound and have a known length; others (compression, network) flow in one direction. .NET solves this not with three separate types but with a single base class and the CanRead, CanWrite and CanSeek properties:

using System.IO.Compression;
using System.Text;

using MemoryStream file = new(Encoding.UTF8.GetBytes("978-0441013593;Dune;12.99"));
using GZipStream gzip = new(new MemoryStream(), CompressionMode.Compress);

PrintSize(file);
PrintSize(gzip);

try
{
    Console.WriteLine(gzip.Length);
}
catch (NotSupportedException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}

// The base class contract: call Length only if CanSeek is true
static void PrintSize(Stream stream) =>
    Console.WriteLine(stream.CanSeek
        ? $"{stream.GetType().Name}: {stream.Length} bytes"
        : $"{stream.GetType().Name}: size unknown (CanSeek = false)");
MemoryStream: 25 bytes
GZipStream: size unknown (CanSeek = false)
NotSupportedException: This operation is not supported.

Stream's documentation writes this behavior into the contract: on a stream that doesn't support seeking, calls to Length, SetLength, Position and Seek throw NotSupportedException. So GZipStream is not violating LSP here; PrintSize, which calls according to the contract, works correctly. Code that reaches for Length without asking CanSeek simply hasn't read the contract.

So which is better: a capability query or separate types? A capability query keeps the number of interfaces low and makes it easy to use different streams together in the same pipeline; thanks to it, .NET's input/output API (Application Programming Interface) has revolved around a single Stream type for over twenty years. Separate types move the error to compile time. When deciding in your own code, ask: are implementations without this capability the exception or the norm? If the exception, a capability query is reasonable; if the norm, separate interfaces are more honest.

Verifying the Contract with Tests

As long as the contract lives only in a comment, violations are noticed only in production. Better to write the contract once as tests and run them against every implementation. In xUnit the simplest way is an abstract test class: the test methods live in the base class, and for each implementation only a method that creates the object is overridden:

using Bookstore.Stock;

namespace Bookstore.Stock.Tests;

// IInventory's contract is written once and runs for every implementation
public abstract class InventoryContract
{
    protected const string Dune = "978-0441013593";

    protected abstract IInventory Create(int stock);

    [Fact]
    public void Reserve_within_stock_decreases_available_by_quantity()
    {
        IInventory inventory = Create(stock: 10);

        Assert.True(inventory.TryReserve(Dune, 3));
        Assert.Equal(7, inventory.Available(Dune));
    }

    [Fact]
    public void Reserve_beyond_stock_returns_false_and_keeps_available()
    {
        IInventory inventory = Create(stock: 2);

        Assert.False(inventory.TryReserve(Dune, 3));
        Assert.Equal(2, inventory.Available(Dune));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Non_positive_quantity_is_rejected(int quantity)
    {
        IInventory inventory = Create(stock: 2);

        Assert.Throws<ArgumentOutOfRangeException>(() => inventory.TryReserve(Dune, quantity));
    }
}

public sealed class InMemoryInventoryTests : InventoryContract
{
    protected override IInventory Create(int stock) => new InMemoryInventory(new() { [Dune] = stock });
}

public sealed class WarehouseInventoryTests : InventoryContract
{
    protected override IInventory Create(int stock) => new WarehouseInventory(new() { [Dune] = stock });
}

public sealed class PreorderInventoryTests : InventoryContract
{
    protected override IInventory Create(int stock) => new PreorderInventory(new() { [Dune] = stock });
}

public sealed class CachedInventoryTests : InventoryContract
{
    protected override IInventory Create(int stock)
    {
        CachedInventory inventory = new(new InMemoryInventory(new() { [Dune] = stock }));
        inventory.Available(Dune); // warm the cache: in real use the screen reads the stock first
        return inventory;
    }
}

The relevant lines from the dotnet test output:

  Failed Bookstore.Stock.Tests.WarehouseInventoryTests.Reserve_within_stock_decreases_available_by_quantity [1 ms]
  Error Message:
   System.ArgumentException : Quantity must be a multiple of 5. (Parameter 'quantity')
  Failed Bookstore.Stock.Tests.PreorderInventoryTests.Reserve_beyond_stock_returns_false_and_keeps_available [< 1 ms]
  Error Message:
   Assert.False() Failure
Expected: False
Actual:   True
  Failed Bookstore.Stock.Tests.CachedInventoryTests.Reserve_within_stock_decreases_available_by_quantity [< 1 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 7
Actual:   10
  Failed Bookstore.Stock.Tests.WarehouseInventoryTests.Non_positive_quantity_is_rejected(quantity: -1) [< 1 ms]
  Error Message:
   Assert.Throws() Failure: Exception type was not an exact match
Expected: typeof(System.ArgumentOutOfRangeException)
Actual:   typeof(System.ArgumentException)
  Failed Bookstore.Stock.Tests.WarehouseInventoryTests.Reserve_beyond_stock_returns_false_and_keeps_available [< 1 ms]
  Error Message:
   System.ArgumentException : Quantity must be a multiple of 5. (Parameter 'quantity')
Failed!  - Failed:     5, Passed:    11, Skipped:     0, Total:    16, Duration: 18 ms - Bookstore.Stock.Tests.dll (net10.0)

Five of sixteen tests are red, and each one names one of the violations we found by hand above: WarehouseInventory's multiple-of-5 rule trips three tests, PreorderInventory returns true with insufficient stock, CachedInventory says 10 instead of 7. All of InMemoryInventory's tests are green. The only thing someone writing a new implementation has to do is add a three-line test class; they don't have to rethink the contract. For xUnit basics, see "Unit Testing" in the Advanced C# series. This technique is especially valuable where the same interface has several implementations, such as repositories, caches and adapters for external services: it is the only way to be sure that the in-memory implementation behaves like the real one in tests.

An LSP Violation Breaks OCP Too

Some time after PreorderInventory goes live, a line like this appears in the checkout code: if (inventory is PreorderInventory) { ... }. Or a type switch: inventory switch { PreorderInventory p => ..., CachedInventory c => ..., _ => ... }. Because the client can't trust the abstraction, it has started asking about the concrete type underneath.

Martin ties this together in a clear sentence in his 2000 paper: violations of LSP are latent violations of OCP (Open/Closed Principle). The client now has to be reopened and checked every time a new IInventory implementation arrives: "do I need to handle this one specially too?" The "new behavior = new implementation" equation we set up for OCP in the previous post holds only as long as implementations keep the abstraction's promise. When you see a subtype-specific is check in code, the first question should be: is this type really a kind of this abstraction?

Note: not every is is a sign of a violation. Checking whether a collection is an ICollection<T> to read Count directly is a performance optimization that LINQ's (Language Integrated Query) Count() also performs; it doesn't change behavior, it only speeds things up. The problem starts where different correctness rules are applied depending on the type.

Common Pitfalls

Confusing LSP with the real-world "is-a" relationship. A preorder is a stock operation too, a square is a rectangle, an e-book is a book. But in software the question is not how two concepts relate in the real world; it is what clients expect from the base type. Thinking "behaves-like-a" instead of "is-a" is less misleading.

Leaving an override empty or throwing NotImplementedException. An override that silently does nothing, like public override void Ship() { }, is even more dangerous than one that throws; the client believes the job was done. NotImplementedException means "not written yet" and should not exist in a subtype that reaches production; for a deliberate "not supported" decision, use NotSupportedException together with a capability query.

Changing the exception type. WarehouseInventory throwing ArgumentException instead of ArgumentOutOfRangeException for -1 looks like a small difference, but for a client that wrote catch (ArgumentOutOfRangeException) the error escapes uncaught. The exception type is part of the contract too.

Not writing the contract down. LSP cannot be measured against a contract that doesn't exist. If nothing anywhere says IInventory's stock never goes negative, whoever writes PreorderInventory cannot know they are breaking a rule. A few sentences in an XML doc comment and an abstract test class are the cheapest way to remove that ambiguity.

A Small Experiment

★☆☆ Change AddBestseller from the first example so that it also works with an array. There are two ways: change the parameter type, or ask IsReadOnly. Which did you choose and why? Did the oddity in the output, IsReadOnly=True yet a writable indexer, affect your choice?

★★☆ Fix CachedInventory so that it passes all the contract tests. Then add a new test to the contract: "two different ISBNs (International Standard Book Numbers) don't affect each other's stock". In which implementations did this test break?

★★★ Take PreorderInventory out of IInventory and model preorders as a separate concept (something like IPreorderBook). How does the checkout code tell the two cases apart? How can that distinction be made without turning into an is check?

In the next post we get into the topic we skirted twice in this one: the Interface Segregation Principle (ISP). We will discuss splitting a 14-method IOrderRepository into role interfaces, the story of IReadOnlyList<T>, and the cost of piercing ISP with default interface methods.