BrandGhost
Liskov Substitution Principle C#: Correct Inheritance Every Time

Liskov Substitution Principle C#: Correct Inheritance Every Time

Liskov Substitution Principle C#: Correct Inheritance Every Time

The liskov substitution principle c# developers need to masteris the most intellectually rigorous of the SOLID principles. Where the others feel intuitive, LSP requires you to think carefully about behavior, not just types.

Barbara Liskov introduced the principle in 1987. Simply put: if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program. In everyday terms -- if you use a subclass everywhere the base class is expected, nothing should break. Nothing unexpected should happen. The program should behave correctly.

This sounds obvious. It turns out to be surprisingly easy to violate. Especially in C# where inheritance is easy to reach for and hard to reason about at scale. The Liskov substitution principle isn't about type signatures. It's about behavioral contracts. Understanding that distinction is everything.

Behavioral Subtyping: Contracts, Not Just Type Signatures

LSP is about behavioral subtyping. The type signature -- method name, parameters, return type -- is only part of the contract. The behavioral contract is the part that matters for substitutability.

The behavioral contract has three components:

  • Preconditions -- what must be true before calling a method
  • Postconditions -- what is guaranteed to be true after a method returns
  • Invariants -- what properties the object must always maintain

A subtype must:

  • Accept at least as broad a set of inputs as the base type (no strengthening preconditions)
  • Produce at least as specific outputs as the base type (no weakening postconditions)
  • Maintain the same invariants the base type guarantees

If a subclass throws an exception the base class never throws, that can be an LSP violation -- when it changes the documented contract. If a subclass silently ignores a method call the base class would have acted on, that can also be a substitutability concern. The callers don't know they're holding a subclass -- and they shouldn't need to. That's the whole point of polymorphism. The liskov substitution principle c# makes this guarantee formal.

The Classic Violation: Rectangle and Square

This is the canonical liskov substitution principle c# violation that every C# developer encounters. Mathematically, a square is a rectangle.In code, making Square extend Rectangle is tempting and wrong.

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int Area() => Width * Height;
}

// Square tries to enforce Width == Height by overriding the setters
public class Square : Rectangle
{
    public override int Width
    {
        get => base.Width;
        set
        {
            base.Width = value;
            base.Height = value; // Silently changes Height too
        }
    }

    public override int Height
    {
        get => base.Height;
        set
        {
            base.Height = value;
            base.Width = value; // Silently changes Width too
        }
    }
}

Now look at what happens when you write code against the base class:

// This method is valid for Rectangle -- it relies on Width and Height being independent
static void ResizeAndCheck(Rectangle r)
{
    r.Width = 5;
    r.Height = 10;

    // For a real Rectangle: width is 5, height is 10, area is 50. Correct.
    // For a Square: setting Height = 10 also sets Width = 10, so area is 100. Wrong.
    Console.WriteLine($"Expected area: 50, Actual area: {r.Area()}");
}

var rect = new Rectangle();
ResizeAndCheck(rect);   // Output: Expected area: 50, Actual area: 50  ✓

var square = new Square();
ResizeAndCheck(square); // Output: Expected area: 50, Actual area: 100 ✗

Square has violated the postcondition of Rectangle. Setting Width to 5 on a Rectangle guarantees Width == 5 afterwards. Square silently breaks that guarantee. Code that worked correctly with Rectangle produces wrong results with Square. That's the Liskov substitution principle being violated.

The problem isn't that the code throws. It's that the behavior is silently wrong in a way the caller can't detect without knowing the concrete type -- which defeats the entire purpose of polymorphism.

How to Fix the Rectangle/Square Problem

Applying the liskov substitution principle c# here means modeling types based on behavior contracts, not mathematical relationships. A common fix is to step back from the inheritance hierarchy. There are two strong approaches.

Option 1: Use a shared interface instead of inheritance.

public interface IShape
{
    int Area();
}

// Rectangle and Square are now peers, not parent/child
public sealed class Rectangle(int width, int height) : IShape
{
    public int Width { get; } = width;
    public int Height { get; } = height;

    public int Area() => Width * Height;
}

public sealed class Square(int side) : IShape
{
    public int Side { get; } = side;

    public int Area() => Side * Side;
}

Now Rectangle and Square both satisfy IShape. Neither inherits from the other. There are no shared mutable setters, so no postcondition can be broken. Both are sealed -- no further subclassing is possible. The design is explicit about what these types share (an area calculation) and honest about what they don't (independent width and height).

Option 2: Make the base type immutable.

Mutable state is the root cause of the Rectangle/Square LSP violation. If Rectangle were immutable, the postcondition problem disappears entirely -- there's nothing to mutate in a breaking way.

// Immutable rectangle -- no mutation, no postcondition to violate
public record Rectangle(int Width, int Height)
{
    public int Area() => Width * Height;
}

// Creating a "square" is now a factory concern, not an inheritance concern
public static class ShapeFactory
{
    public static Rectangle CreateSquare(int side) => new Rectangle(side, side);
}

Records in C# are immutable by default. Width and Height are set at construction time and never change. The behavioral contract is automatically preserved because there's nothing to override.

NotImplementedException as an LSP Signal

One of the most reliable signals of a liskov substitution principle c# violation is the presence of NotImplementedException in derived classes. Here's a practical rule for spotting LSP violations in the wild: if you find yourself throwing NotImplementedException or NotSupportedException from a method that the base class or interface promises to implement, you have an LSP violation.

public interface IStorage
{
    void Write(string key, string value);
    string Read(string key);
    void Delete(string key);
}

// This class claims to be IStorage but can't delete -- LSP violation
public sealed class ReadOnlyStorage : IStorage
{
    private readonly Dictionary<string, string> _data = new();

    public void Write(string key, string value) => _data[key] = value;
    public string Read(string key) => _data.GetValueOrDefault(key, string.Empty);

    public void Delete(string key)
    {
        // ❌ Violates LSP -- callers of IStorage expect Delete to work
        throw new NotSupportedException("This storage is read-only.");
    }
}

If someone holds an IStorage reference and calls Delete, they'll get an exception they have no way to anticipate from the type signature alone. A common fix is to split the interface into focused contracts -- which also satisfies the Interface Segregation Principle:

public interface IReadableStorage
{
    string Read(string key);
}

public interface IWritableStorage : IReadableStorage
{
    void Write(string key, string value);
}

public interface IFullStorage : IWritableStorage
{
    void Delete(string key);
}

// ReadOnlyStorage only claims what it can actually deliver
public sealed class ReadOnlyStorage : IReadableStorage
{
    private readonly Dictionary<string, string> _data = new();

    public string Read(string key) => _data.GetValueOrDefault(key, string.Empty);
}

// Full storage honors the complete contract
public sealed class InMemoryStorage : IFullStorage
{
    private readonly Dictionary<string, string> _data = new();

    public void Write(string key, string value) => _data[key] = value;
    public string Read(string key) => _data.GetValueOrDefault(key, string.Empty);
    public void Delete(string key) => _data.Remove(key);
}

When you find a NotImplementedException, the interface is too broad for that implementation. The Liskov substitution principle is telling you to split the contract.

Preconditions and Postconditions in Plain English

Liskov's original formulation used formal logic notation. Here's what it means in practice with C# examples.

Preconditions are what must be true before you call a method. A subclass must not strengthen them -- it can't demand more from callers than the base class did.

public class OrderProcessor
{
    // Precondition: amount > 0
    public virtual void Process(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be positive.");
        // process...
    }
}

// ❌ Strengthened precondition -- demands more than the base class requires
public class StrictOrderProcessor : OrderProcessor
{
    public override void Process(decimal amount)
    {
        // Now requires amount >= 10, not just > 0 -- callers of OrderProcessor won't expect this
        if (amount < 10)
            throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be at least 10.");

        base.Process(amount);
    }
}

Code that passes amount = 5 works fine with OrderProcessor but throws with StrictOrderProcessor. The precondition is strengthened. That's an LSP violation.

Postconditions are what the method guarantees after it returns. A subclass must not weaken them -- it can't guarantee less than the base class did.

public class InvoiceRepository
{
    // Postcondition: returned list is never null; callers may safely iterate without a null check
    public virtual IReadOnlyList<Invoice> GetOverdueInvoices()
        => Array.Empty<Invoice>();
}

// ❌ Weakened postcondition -- returns null, breaking the non-null guarantee callers rely on
public class LazyInvoiceRepository : InvoiceRepository
{
    public override IReadOnlyList<Invoice> GetOverdueInvoices()
        => null!; // Caller expected a non-null list -- will throw NullReferenceException at iteration
}

public sealed record Invoice(int Id, DateTime DueDate);

Code that calls GetOverdueInvoices and iterates without a null check -- valid per the base class postcondition -- will throw with LazyInvoiceRepository. Weakened postcondition. LSP violation.

Designing LSP-Compliant Hierarchies in .NET 10

Designing hierarchies that honor the liskov substitution principle c# requires a few key habits. A few practical rules for LSP-safe design in modern C#.

Favor interfaces over deep inheritance. Deep inheritance trees are LSP landmines. Each level adds more behavioral contracts that subtypes can accidentally break. Flat hierarchies with interfaces are far easier to reason about and far harder to violate accidentally.

Consider sealing classes that are not designed for inheritance. When a class is sealed, it can't be subclassed and therefore can't have LSP violated through subclassing. Classes not explicitly designed for extension are good candidates for sealing. This is one of the most effective LSP-safety techniques available in C#.

Design for immutability. Mutable state is the most common source of LSP violations, as the Rectangle/Square example demonstrates. Records, init-only properties, and immutable collections all reduce the surface area for postcondition violations significantly.

Document behavioral contracts explicitly. XML doc comments aren't just for IntelliSense summaries. Document what preconditions a method requires, what postconditions it guarantees, and what exceptions it may throw. Subclass authors can't respect contracts they don't know exist.

Write tests against the base type. If you have tests for Rectangle, run them against Square too. If they fail, you have an LSP violation. This technique -- sometimes called the Liskov test -- is the most practical way to catch violations early.

When designing with abstract base classes, the Template Method Design Pattern in C# provides a structured way to enforce an algorithm's invariant steps while leaving subclasses to fill in the variable parts safely. The base class controls the shape of behavior; subclasses can only customize within defined bounds.

LSP and the Proxy/Decorator Patterns

Several structural design patterns depend entirely on LSP for their correctness. Understanding that connection makes both LSP and the patterns click in a new way.

The Proxy Design Pattern in C# depends entirely on substitutability. A proxy implements the same interface as the real subject and is used in its place. For this to work, the proxy must honor the same preconditions, postconditions, and invariants as the real implementation.

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);
    Task SaveAsync(Order order);
}

// Caching proxy -- same interface, fully LSP-compliant
public sealed class CachedOrderRepository(
    IOrderRepository inner,
    IMemoryCache cache,
    ILogger<CachedOrderRepository> logger) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id)
    {
        if (cache.TryGetValue(id, out Order? cached))
        {
            logger.LogDebug("Cache hit for order {OrderId}", id);
            return cached; // Returns same type, same nullability contract
        }

        var order = await inner.GetByIdAsync(id);

        if (order is not null)
            cache.Set(id, order, TimeSpan.FromMinutes(5));

        return order; // Honors the same postcondition: null when not found, Order when found
    }

    public Task SaveAsync(Order order)
    {
        // Invalidate cache on write -- maintains the invariant that reads reflect latest state
        cache.Remove(order.Id);
        return inner.SaveAsync(order);
    }
}

The caller holds an IOrderRepository reference. It doesn't know whether it's talking to the database or the cache. It doesn't need to know. The proxy is fully LSP-compliant: it returns null when the real repository would return null, it throws the same exceptions, and it maintains the same invariants.

Proxy vs Decorator Pattern in C# covers the distinction between the two in detail -- but both patterns depend on LSP. A Decorator that strengthens preconditions or weakens postconditions would break every caller that holds the base interface type.

A proxy or decorator is not automatically LSP-compliant -- decorators can subtly strengthen preconditions, alter exception behavior, or add side effects that change the contract.

The Visitor Design Pattern in C# also relies on LSP. Each concrete element type must accept visitors in a way that's consistent with the base Accept contract. A subtype that ignores certain visitor types or throws unexpectedly breaks the dispatch mechanism.

The Iterator Design Pattern in C# is another LSP-dependent pattern. Custom iterators that implement IEnumerable<T> and IEnumerator<T> must respect the behavioral contract of those interfaces -- MoveNext, Current, Dispose -- or foreach loops and LINQ queries break in ways that are extremely difficult to diagnose.

The Chain of Responsibility Design Pattern depends on LSP at every link. Each handler in the chain claims to be a handler. If one strengthens the preconditions it accepts or changes the postconditions of the result, requests that were expected to flow through the chain correctly start failing silently.

The Bridge Design Pattern in C# separates abstraction from implementation. The entire premise is that implementations are substitutable -- the abstraction layer doesn't care which concrete implementation it's backed by, as long as each one honors the interface contract. That substitutability guarantee is LSP.

FAQ

What is the Liskov substitution principle in C# in simple terms?

The liskov substitution principle c# means a subclass should be usable anywhere the base class is used, and the program should still work correctly. It's not enough for the subclass to have the same method signatures -- the behavior must also match what callers expect from the base type, including what preconditions are assumed, what postconditions are guaranteed, and what exceptions might be thrown.

Why does the Rectangle/Square problem violate LSP?

A mutable Square inheriting from a mutable Rectangle breaks the postcondition that setting Width leaves Height unchanged. Code written to work with Rectangle -- such as independently setting width and height -- produces wrong results when given a Square. The subtype isn't substitutable without altering correctness. That's the definition of an LSP violation.

Is throwing NotImplementedException always an LSP violation?

Often, yes -- especially when the type is meant to be substitutable at runtime. If a class implements an interface but throws NotImplementedException for one of the interface's methods, it's claiming a capability it doesn't have. Any caller that relies on that method will fail unexpectedly. The fix is typically to split the interface into smaller, focused contracts so each class only claims what it can actually deliver.

How does LSP relate to the Interface Segregation Principle?

They're complementary. ISP says interfaces should be small and focused so implementing classes don't have to stub out methods they don't support. When you find an LSP violation caused by a class throwing NotImplementedException, splitting the interface (ISP) is often the direct fix. LSP catches the symptom; ISP addresses the cause.

Can a record type in C# violate LSP?

Records are immutable by default, which eliminates the most common source of LSP violations -- postconditions broken by unexpected mutation. However, a record subtype can still violate LSP by overriding methods and returning different values or throwing unexpected exceptions. Immutability reduces risk significantly but doesn't eliminate it entirely.

How do I test for LSP compliance in practice?

The practical approach is to write tests against the base type or interface, then run the same tests against every subtype. If a subtype fails a test that the base type passes, you have an LSP violation. Some teams formalize this with abstract test base classes that each subtype's test class inherits from. This ensures every implementation is held to the same behavioral contract automatically.

Does LSP apply to interfaces as well as class inheritance?

Yes. When a class implements an interface, it enters a substitutability relationship with that interface. The class must honor every aspect of the interface's behavioral contract -- not just the type signature. If the interface documentation states that a method never returns null, every implementation must respect that. If it states that a method is idempotent, every implementation must be idempotent. The type system enforces signatures; developers must enforce behavior.

Conclusion: Applying liskov substitution principle c# Correctly

The liskov substitution principle c# is the discipline of honest inheritance. A subtype that claims to be its parent type must actually behave like its parent type. Not just structurally -- behaviorally.

The Rectangle/Square example is famous for a reason. It takes something that seems intuitively correct -- a square is a rectangle -- and reveals that the inheritance relationship breaks down precisely because of how mutable C# objects work. The behavioral contract breaks, and LSP catches it before it becomes a production bug.

The practical rules are straightforward. Favor interfaces and sealed classes over deep inheritance trees. Design for immutability. Split large interfaces when implementations can't honestly honor all of them. Document behavioral contracts. Test every subtype against the base type's test suite.

The Liskov substitution principle in C# is what makes polymorphism trustworthy. When you can swap any implementation for another without thinking twice, you've built something that genuinely scales. That's the goal worth designing toward every time.

Open Closed Principle C#: Extending Without Modifying

Learn the open closed principle c# with real examples. Extend behavior without modifying existing code using Strategy pattern and .NET 10 features.

Examples of Inheritance in C# - A Simplified Introduction to OOP

See examples of inheritance in C# in this introductory guide to object oriented programming. Learn about when to use inheritance in C# and... when not to!

Single Responsibility Principle C#: One Class, One Reason to Change

Learn the single responsibility principle c# with real before/after examples. Understand SRP, cohesion, coupling, and .NET 10 best practices.

An error has occurred. This application may no longer respond until reloaded. Reload