Skip to main content

Command Palette

Search for a command to run...

How .Net breaks Liskov Substitution Principle and Solves it at the same time

Updated
2 min readView as Markdown
How .Net breaks Liskov Substitution Principle and Solves it at the same time
A
Associate Software Developer with experience building scalable, production-grade systems and backend services. Strong foundation in data structures, system design, and core computer science concepts, with a passion for learning new technologies and sharing practical engineering knowledge through writing.

Ever traced the .NET collection hierarchy and wondered why ICollection<T> has an IsReadOnly flag? Let's walk through it.

It starts with IEnumerable<T> — the most basic contract. Just "give me one item at a time."

public interface IEnumerable<T>
{
    IEnumerator<T> GetEnumerator();
}

Then comes ICollection<T> which extends IEnumerable<T> and adds mutation:

public interface ICollection<T> : IEnumerable<T>
{
    void Add(T item);
    bool Remove(T item);
    void Clear();
    int Count { get; }
    bool IsReadOnly { get; }
}

Add and Remove methods to make the collection mutable. Notice how a ReadOnly flag is added here, which will play a key factor later.

Then IList<T> extends ICollection<T> with indexers:

public interface IList<T> : ICollection<T>
{
    T this[int index] { get; set; }
    int IndexOf(T item);
    void Insert(int index, T item);
    void RemoveAt(int index);
}

Here the List gets the properties or inherits the properties of ICollection which inherits the properties of IEnumerable.

List<T> implements IList<T> and are fully mutable as expected.

.Net also have ReadOnlyCollection<T> which implements ICollection<T>. The ReadOnlyCollection<T> here is unmuttable but ReadOnlyCollection<T> promise to perform ICollection<T> tasks.

Here .NET gives you ReadOnlyCollection<T> via AsReadOnly()

ICollection<int> nums = new List<int> { 1, 2, 3 }.AsReadOnly();
nums.Add(4); // Compiles fine... throws error on Runtime

This is a textbook LSP violation. The principle states: a subtype must be substitutable for its base type without altering the correctness of the program. If code trusts ICollection<T>.Add() because the interface says it exists, and it explodes at runtime then the substitution has failed.

How .NET "solves" it: It does so at runtime

Instead of splitting the contract cleanly (mutable vs immutable interfaces), .NET bolts on:

  • An IsReadOnly flag

  • A NotSupportedException

if (!nums.IsReadOnly)
    nums.Add(4); // now the caller has to defensively check

That defensive if is the tell. You shouldn't need to interrogate an object about whether it will honor its own contract, that's exactly what LSP is meant to prevent.

The takeaway: Type-checking ≠ behavior-checking. .NET's collection hierarchy compiles cleanly but breaks the substitutability guarantee LSP demands.