NDepend Blog

Improve your .NET code quality with NDepend

SOLID Design in C#: The Liskov Substitution Principle (LSP)

May 25, 2026 9 minutes read

SOLID Design in C# The Liskov Substitution Principle

The Liskov Substitution Principle (LSP) is the third of the five SOLID design principles, and for many C# developers it is also the most misunderstood. Named after computer scientist Barbara Liskov, who introduced it in a 1987 conference keynote, the principle states:

Objects of a base class shall be replaceable with objects of its derived classes without breaking the behavior expected by the client code.

On paper the rule sounds like a restatement of polymorphism. In practice, applying the Liskov Substitution Principle in C# is what separates an inheritance hierarchy that ages well from one that quietly accumulates NotImplementedException, NotSupportedException, and surprised assertions in client code.

This article walks through what LSP means in a .NET context, the most common LSP violations you will encounter (including one shipped inside the .NET Base Class Library itself), and the techniques we use at NDepend to spot these violations before they reach production.

What Is the Liskov Substitution Principle in C#?

In a single sentence: if S is a subtype of T, then any instance of T in a program should be replaceable by an instance of S without altering the correctness of that program.

For C# and .NET developers, “correctness” is the key word. The compiler already guarantees that a Square can be assigned to a Rectangle reference if Square : Rectangle. What it cannot guarantee is that the derived class honors the behavioral contract of the base class:

  • The same input range must remain valid.
  • The same exceptions must be thrown for the same reasons.
  • The same invariants on object state must hold after each call.
  • Side effects must not surprise a caller that only sees the base type.

Whenever one of those points is broken in a subclass, you have an LSP violation, even if the code compiles, runs, and passes a unit test for the happy path.

A useful one-liner used in the community to summarize LSP comes from a play on the Duck Test: “If it looks like a duck, quacks like a duck, but needs batteries, you probably have the wrong abstraction.”

SOLID Principles Summary

SOLID Principles - Liskov Substitution Principle

Before going deeper into LSP, here is where it sits among the SOLID design principles in C#:

  • The Single Responsibility Principle (SRP): A class should have one reason to change. This principle is about how to partition your logic into classes and avoid ending up with monster classes (known as god classes).
  • The Open-Close Principle (OCP): Modules should be open for extension and closed for modification. To implement a new feature, it is better to add a new derived class rather than modify existing code.
  • The Liskov Substitution Principle (LSP): Methods that use references to base classes must be able to use objects of derived classes without knowing it. Array implements IList<T> but throws NotSupportedException on IList<T>.Add(). That is a textbook LSP violation, and we will look at it in detail below.
  • The Interface Segregation Principle (ISP): The client should not depend by design on methods it does not use. This is why .NET designers later introduced interfaces like IReadOnlyCollection<T>. Often the client just needs read-only access instead of full read-write access.
  • The Dependency Inversion Principle (DIP): Depend on abstractions, not on implementations. Interfaces are much less subject to changes than classes, especially when they obey both ISP and LSP.

Notice how LSP sits in the middle: SRP and OCP shape how you split responsibilities and where you extend them, while ISP and DIP shape what you depend on. LSP is the principle that decides whether your inheritance hierarchy is actually safe to extend at all.

Let’s now detail the common polymorphism pitfalls that LSP attempts to prevent, by reminding the developer to adopt the client’s perspective.

LSP Violation #1: Methods That Cannot Be Implemented

Modern C# refactoring tools (Visual Studio, Rider, ReSharper) offer to generate the method stubs when a class derives from a base class or implements an interface. The default body of such a generated method is typically throw new NotImplementedException().

The Liskov Substitution Principle Implement Base Methods

This behavior should be strictly temporary. It must never reach production. Client code that holds a reference on the interface or the base class doesn’t expect a NotImplementedException when calling a method that is part of the contract. This is exactly why NDepend ships a default rule named Do implement methods that throw NotImplementedException, designed to catch this category of LSP violation before it leaks into a release.

Conversely, if implementing a base method on a particular subclass simply doesn’t make sense, that is not a coding problem. It is a design problem. Here is the classic example, which assumes that all birds can fly:

The fix is not to find a clever implementation of Fly() for an ostrich. The fix is to refactor the hierarchy so the contract matches reality:

The original Bird with its Fly() method was too coarse. The hierarchy needed a refinement layer because not all birds fly. From my experience, this kind of over-promising assumption on an interface or a base class happens far more often than the SOLID textbooks suggest. When you stumble on such a situation, treat it as a refactoring opportunity, if you still can. Sometimes refactoring is not an option, because too many clients already depend on the wrong abstraction.

LSP Violation #2: System.Array and ICollection<T> in the .NET BCL

The most cited LSP violation in .NET is System.Array implementing the ICollection<T> interface. Because the interface declares ICollection<T>.Add(), Array has no choice but to “implement” it. Calling that method on an array throws NotSupportedException at runtime:

System.Array NotSupportedException on ICollection.Add

The C# compiler doesn’t warn on this kind of simple but broken program. Nothing in the type system signals that an array, when seen through the ICollection<T> reference, has just lost half of its declared API.

The official escape hatch is to test ICollection<T>.IsReadOnly before mutating a collection through an IList<T> or ICollection<T> reference. But frankly, who does that? In more than twenty years of daily C# programming, I have stumbled on this trap more times than I would like to admit. It is, quite simply, an error-prone design.

Reverting the original decision is no longer an option. Even when .NET Core was introduced, the team had to keep this API intact. Millions of programs depend on the old shape. The interface IReadOnlyCollection<T> was added in .NET 4.5 back in 2012 to give new code a clean read-only abstraction, but the existing ICollection<T> contract is here to stay. The lesson for our own code: an LSP violation that ships in a public API tends to be permanent.

LSP Violation #3: The Rectangle / Square Trap

The Liskov Substitution Principle is often taught with the Rectangle and Square paradigm. A square is-a rectangle, isn’t it? So this should work:

This is a textbook misuse of the IS-A relationship. Yes, a square is a special rectangle in mathematics. No, that does not mean Square can be safely substituted for Rectangle in code. The client legitimately expects to set Width and Height independently, and the subtype silently breaks that expectation.

Real codebases produce this pattern all the time, particularly in complex UI control hierarchies with many fields maintained at various inheritance levels. Keeping the object state coherent at runtime becomes increasingly tricky, and the bugs that emerge tend to be the kind that only fire under specific user actions, which makes them very expensive to track down.

LSP, Covariance, and Contravariance in C#

So far we have looked at LSP through the lens of methods that should not exist on a subclass. LSP is also a statement about the parameters a method accepts and the values it returns:

  • Parameters (contravariance): when you override a method, the subclass must accept at least the same set of input values as the base method. Loosening the precondition (accepting more) is fine. Tightening the precondition (accepting less) is forbidden, because callers that target the base class would suddenly hit exceptions.
  • Return values (covariance): the overridden method must return a value that satisfies the same contract as the base method. Returning a more specific subtype or a narrower subset of valid values is fine. Returning a wider set, or returning values that violate base-class invariants, is forbidden.

These constraints are exactly what is meant by C# Covariance and Contravariance:

CSharp Covariance and Contravariance

Below is a concrete example of an LSP violation caused by tightening preconditions (a contravariance failure). The BaseClass.SquareRoot() method accepts any non-negative double. The DerivedClass.SquareRoot() only accepts doubles greater than or equal to 1. From the client’s perspective, BaseClass cannot be replaced with DerivedClass: values in [0, 1[ return a result with the base class and throw with the derived class.

How to Enforce the Liskov Substitution Principle at Development Time

The Bird, Rectangle, and Array examples show how quickly polymorphism and inheritance lead to a rotten design, even in seemingly simple situations. So how do we actually keep LSP violations out of our C# code?

Take the point of view of the client of your API

When you are about to design a public API that relies on polymorphism, do not start with the abstraction. Start with the call site. Imagine the code that is going to use your interface or base class, and write the questions a real client would ask:

  • Do all birds really fly? What happens when a caller invokes Fly() on a Bird reference that secretly points to an ostrich?
  • Is a square really substitutable for a rectangle? What does a caller expect when it sets Width independently of Height?

Question your abstractions in domain terms

In the real world, the same questions show up dressed in business language:

  • Can all collections really be modified? What happens if I add or remove an element from an array hidden behind an ICollection<T>?
  • Are all controls scrollable? What happens if a scrollbar is displayed on a control that should not scroll?
  • Does withdrawal apply to all bank accounts? What happens when we try to withdraw money from a frozen long-term deposit account? Do we want the call to fail badly, or do we want to prevent the situation by design with a dedicated IAccountWithdrawable abstraction?

A pattern emerges. For each member of your interface, ask yourself: does this member apply naturally to every type that will ever implement this interface? If the honest answer is “no”, split the interface, or push the offending member down into a more specific abstraction. That is, in fact, how LSP and the Interface Segregation Principle reinforce each other.

This advice is not specific to inheritance. It applies to API design in general. When you write an API, take the point of view of the client first. This is the only way to end up with an API that other developers will enjoy consuming. It is also the design intuition behind Test-Driven Development (TDD): client code written for tests forces you to feel the contract from the outside before you commit to an implementation.

Seal classes that were not designed to be inherited

In a previous post we explained why NDepend ships the default rule Class with no descendant should be sealed if possible. In the real world, a class is never well designed for inheritance by chance. It should therefore be sealed by default. Designing a class properly for inheritance requires a deep understanding of the domain and of the way clients are going to bend it. As with everything in software, it is hard to build something that others find easy to use, and inheritance amplifies every mistake.

Frequently Asked Questions about LSP in C#

What does the Liskov Substitution Principle mean in plain C# terms?

It means that any code holding a reference of type T must keep working when that reference actually points to an instance of a subtype S. The compiler only checks the type. LSP is about the runtime behavior: preconditions, postconditions, invariants, exceptions, and side effects.

Is the Liskov Substitution Principle the same as polymorphism?

No. Polymorphism is a language feature that lets a base reference dispatch to a derived implementation. LSP is a design rule about what the derived implementation is allowed to do. You can write perfectly polymorphic C# code that breaks LSP, and the compiler will not say a word.

How does LSP relate to the rest of SOLID?

SRP and OCP shape how you split and extend responsibilities. LSP decides whether your inheritance hierarchy is sound. ISP and DIP decide what your code depends on. Together they push you toward small, focused interfaces consumed through abstractions, which is exactly where LSP violations are easiest to avoid.

Is throwing NotImplementedException always an LSP violation?

In production code, yes. It is a promise to the client that something will work, broken at runtime. NDepend’s Do implement methods that throw NotImplementedException rule exists exactly for this reason. NotSupportedException is a closer-to-honest variant for cases like Array.Add(), but it still points to a contract that was too wide to begin with.

Is the .NET BCL itself free of LSP violations?

Not at all. The most famous example is System.Array implementing ICollection<T>.Add(). The team eventually introduced IReadOnlyCollection<T> in .NET 4.5 to give new code a clean alternative, but the original API cannot be changed without breaking the entire ecosystem. The take-away: an LSP violation in a public API tends to be permanent, so it is worth getting the design right the first time.

Conclusion

Mastering the Liskov Substitution Principle is a real step forward in applying the SOLID design principles to C# and .NET code. It is what turns an inheritance hierarchy from a clever compile-time trick into a contract that callers can actually trust. The investment pays off in code that is easier to extend, easier to test, and far less likely to surprise the next developer who calls a method on a base reference.

The most useful habit to develop is the simplest one: every time you are tempted to add a member to a base class or an interface, ask whether that member will still make sense for every subtype that is ever going to implement it. If the answer is uncertain, split the abstraction now. The cost of doing it later, as the System.Array story shows, can be measured in decades.

 

 

This article is brought to you by the team behind NDepend — a proven .NET static analysis tool for improving code maintainability, security, and overall quality. Whether you’re modernizing a legacy .NET application or starting fresh in C#, get started with your free full-featured trial today!

Comments:

  1. Okay, suppose if I have an interface say INotify which has two methods sms and email. How should I use LSP is this case!

  2. Your interface violets the Single Responsibility Principle. You should create iNotify with one method notify() and implement it in two concrete classes – SmsSender and EmailSender

  3. Lev Elbert says:

    Why not to implement ICollecttion.Add like this:

    void Add(T val)
    {
    Array.REsize(ref arr, arr.Length+1)
    arr[arr.Length-1]=val;
    }

  4. Why Rectangle has mutable properties with side effects in the first place? It rather should have a constructor accepting single value, the size of its sides. And if you will, a method Resize() that would return another Rectangle.

Leave a Reply

Your email address will not be published. Required fields are marked *