OOP/OOD  

Liskov Substitution Principle (LSP) in C#: Inheritance Done Right

Summary

This article explores the Liskov Substitution Principle (LSP) objects of a derived class should be replaceable with objects of the base class without breaking the application. You'll learn how to design inheritance hierarchies correctly, identify common LSP violations, and refactor problematic code into designs where subtypes truly substitute for their base types. Through practical C# examples, we'll ensure your inheritance relationships are sound and maintainable.

Prerequisites

  • Strong understanding of C# inheritance and polymorphism

  • Familiarity with abstract classes and virtual methods

  • Knowledge of SRP and OCP recommended

  • Basic understanding of exception handling

The Liskov Substitution Principle states: Objects of a derived class should be able to replace objects of the base class without breaking the application.

Named after Barbara Liskov, this principle ensures that inheritance is used correctly. If it sounds abstract, don't worry—we'll make it concrete through examples.

The Classic Bird Problem

Let's start with the most famous LSP violation. You're modeling birds:

public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("Bird is flying");
    }
}

public class Sparrow : Bird
{
    public override void Fly()
    {
        Console.WriteLine("Sparrow is flying");
    }
}

public class Eagle : Bird
{
    public override void Fly()
    {
        Console.WriteLine("Eagle is soaring high");
    }
}

Works great. Now you add a penguin:

public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException("Penguins can't fly!");
    }
}

This violates LSP. Why? Consider code that uses Bird:

public void MakeBirdFly(Bird bird)
{
    bird.Fly(); // Boom! If bird is a Penguin, we get an exception
}

The client code expects any Bird to fly. A Penguin breaks that expectation. The substitution fails.

Refactoring to Respect LSP

The problem is our abstraction. Not all birds fly. Let's fix it:

public abstract class Bird
{
    public abstract void Move();
}

public abstract class FlyingBird : Bird
{
    public override void Move()
    {
        Fly();
    }

    public abstract void Fly();
}

public class Sparrow : FlyingBird
{
    public override void Fly()
    {
        Console.WriteLine("Sparrow is flying");
    }
}

public class Eagle : FlyingBird
{
    public override void Fly()
    {
        Console.WriteLine("Eagle is soaring");
    }
}

public class Penguin : Bird
{
    public override void Move()
    {
        Swim();
    }

    public void Swim()
    {
        Console.WriteLine("Penguin is swimming");
    }
}

Now our hierarchy is correct. Penguin doesn't pretend to be a flying bird. Code working with FlyingBird will never encounter a penguin.

The Rectangle-Square Problem

Here's another classic violation. Mathematically, a square is a rectangle. So this should work, right?

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

    public int CalculateArea()
    {
        return Width * Height;
    }
}

public class Square : Rectangle
{
    private int _side;

    public override int Width
    {
        get => _side;
        set => _side = value;
    }

    public override int Height
    {
        get => _side;
        set => _side = value;
    }
}

Looks reasonable. But watch what happens:

public void TestRectangle(Rectangle rectangle)
{
    rectangle.Width = 5;
    rectangle.Height = 10;

    Console.WriteLine($"Expected area: 50");
    Console.WriteLine($"Actual area: {rectangle.CalculateArea()}");
}

// Usage
var rect = new Rectangle();
TestRectangle(rect); // Works: Expected 50, got 50

var square = new Square();
TestRectangle(square); // Broken: Expected 50, got 100!

The Square violates LSP. When you set Width, it also sets Height. This breaks the expectation that width and height are independent.

The Correct Design

The issue is that squares and rectangles have different behavioral contracts. Don't force inheritance:

public interface IShape
{
    int CalculateArea();
}

public class Rectangle : IShape
{
    public int Width { get; set; }
    public int Height { get; set; }

    public int CalculateArea()
    {
        return Width * Height;
    }
}

public class Square : IShape
{
    public int Side { get; set; }

    public int CalculateArea()
    {
        return Side * Side;
    }
}

Now they're siblings, not parent-child. No inheritance, no LSP violation.

The ReadOnly Collection Trap

Here's a real-world violation you might not notice:

public class ProductCollection
{
    protected List _products = new List();

    public virtual void Add(Product product)
    {
        _products.Add(product);
    }

    public virtual void Remove(Product product)
    {
        _products.Remove(product);
    }

    public int Count => _products.Count;
}

public class ReadOnlyProductCollection : ProductCollection
{
    public override void Add(Product product)
    {
        throw new InvalidOperationException("Collection is read-only");
    }

    public override void Remove(Product product)
    {
        throw new InvalidOperationException("Collection is read-only");
    }
}

This violates LSP. Code expecting a ProductCollection will crash if it gets a ReadOnlyProductCollection:

public void AddProducts(ProductCollection collection, List products)
{
    foreach (var product in products)
    {
        collection.Add(product); // Crashes with ReadOnlyProductCollection
    }
}

The Fix: Separate Abstractions

public interface IReadOnlyProductCollection
{
    int Count { get; }
    Product GetProduct(int index);
}

public interface IProductCollection : IReadOnlyProductCollection
{
    void Add(Product product);
    void Remove(Product product);
}

public class ProductCollection : IProductCollection
{
    private readonly List _products = new List();

    public void Add(Product product)
    {
        _products.Add(product);
    }

    public void Remove(Product product)
    {
        _products.Remove(product);
    }

    public int Count => _products.Count;

    public Product GetProduct(int index)
    {
        return _products[index];
    }
}

public class ReadOnlyProductCollection : IReadOnlyProductCollection
{
    private readonly List _products;

    public ReadOnlyProductCollection(List products)
    {
        _products = new List(products); // Copy
    }

    public int Count => _products.Count;

    public Product GetProduct(int index)
    {
        return _products[index];
    }
}

Now code expecting an IReadOnlyProductCollection will never encounter Add or Remove methods. And code needing IProductCollection won't accidentally receive a read-only version.

The Account Withdrawal Problem

You're building a banking system:

public class Account
{
    public decimal Balance { get; protected set; }

    public virtual void Withdraw(decimal amount)
    {
        if (amount <= Balance)
        {
            Balance -= amount;
        }
        else
        {
            throw new InvalidOperationException("Insufficient funds");
        }
    }
}

public class FixedDepositAccount : Account
{
    public override void Withdraw(decimal amount)
    {
        throw new InvalidOperationException("Cannot withdraw from fixed deposit");
    }
}

LSP violation. Code using Account expects Withdraw to either succeed or fail based on balance—not the account type:

public void ProcessWithdrawal(Account account, decimal amount)
{
    try
    {
        account.Withdraw(amount);
        Console.WriteLine("Withdrawal successful");
    }
    catch (InvalidOperationException)
    {
        Console.WriteLine("Insufficient funds"); // Wrong message for FixedDeposit!
    }
}

Better Design

public abstract class Account
{
    public decimal Balance { get; protected set; }
    public abstract bool CanWithdraw { get; }
}

public class SavingsAccount : Account
{
    public override bool CanWithdraw => true;

    public void Withdraw(decimal amount)
    {
        if (amount <= Balance)
        {
            Balance -= amount;
        }
        else
        {
            throw new InvalidOperationException("Insufficient funds");
        }
    }
}

public class FixedDepositAccount : Account
{
    public override bool CanWithdraw => false;
    public DateTime MaturityDate { get; set; }
}

Now, clients check CanWithdraw before attempting withdrawal. No surprise exceptions.

Preconditions and Postconditions

LSP is closely related to contracts:

  • Preconditions – What must be true before calling a method

  • Postconditions – What must be true after calling a method

Subtypes can:

  • Weaken preconditions (accept more inputs)

  • Strengthen postconditions (guarantee more)

But subtypes cannot:

  • Strengthen preconditions (accept fewer inputs)

  • Weaken postconditions (guarantee less)

Example of violation:

public class FileProcessor
{
    public virtual void ProcessFile(string fileName)
    {
        // Precondition: fileName can be any valid string
        if (string.IsNullOrEmpty(fileName))
            throw new ArgumentException("File name required");

        // Process the file
    }
}

public class ImageProcessor : FileProcessor
{
    public override void ProcessFile(string fileName)
    {
        // Strengthening precondition - LSP violation!
        if (!fileName.EndsWith(".jpg") && !fileName.EndsWith(".png"))
            throw new ArgumentException("Only JPG and PNG files allowed");

        base.ProcessFile(fileName);
    }
}

Code working with FileProcessor might pass a .txt file and expect it to work. ImageProcessor breaks that expectation.

How to Identify LSP Violations

Watch for these red flags:

1. Type Checking in Client Code

if (shape is Square)
{
    // Special handling for Square
}

If clients need to check the actual type, substitution has failed.

2. Throwing Unexpected Exceptions

public override void DoSomething()
{
    throw new NotImplementedException();
}

If a subtype can't fulfill the base contract, inheritance is wrong.

3. Empty or No-Op Overrides

public override void Save()
{
    // Do nothing - this object doesn't save
}

If a method doesn't make sense for a subtype, that subtype shouldn't inherit.

4. Changing Expected Behavior

// Base class returns sorted results
public virtual List GetProducts()
{
    return _products.OrderBy(p => p.Name).ToList();
}

// Derived class returns unsorted results - LSP violation
public override List GetProducts()
{
    return _products.ToList();
}

LSP and Other SOLID Principles

LSP complements the other principles:

LSP + OCP: When subtypes are properly substitutable, extending behavior through inheritance works smoothly. If LSP is violated, you can't safely rely on polymorphism.

LSP + SRP: Classes with single responsibilities are easier to substitute because they have fewer behavioral contracts to violate.

LSP + Interface Segregation: Small, focused interfaces make it easier to create substitutable implementations because there are fewer methods to get wrong.

When to Use Composition Instead

If you're struggling to maintain LSP, consider composition over inheritance:

// Instead of this (inheritance)
public class PremiumAccount : Account
{
    // Violates LSP by adding constraints
}

// Consider this (composition)
public class Account
{
    private readonly IAccountPolicy _policy;

    public Account(IAccountPolicy policy)
    {
        _policy = policy;
    }

    public void Withdraw(decimal amount)
    {
        if (_policy.CanWithdraw(amount, Balance))
        {
            Balance -= amount;
        }
    }
}

Composition often provides more flexibility without the constraints of inheritance.

Conclusion

The Liskov Substitution Principle ensures that inheritance hierarchies are sound. When a derived class truly is a subtype of its base class—not just in name, but in behavior—your code becomes more predictable, testable, and maintainable.

The key insight is this: inheritance should model "is-a" relationships based on behavior, not just data. A square is mathematically a rectangle, but behaviorally it's different. A penguin is biologically a bird, but if your code defines birds as things that fly, penguins don't fit.

When you find yourself throwing exceptions from overridden methods, adding type checks in client code, or creating empty implementations, step back. Your inheritance hierarchy likely violates LSP, and refactoring is needed.

In the next article, we'll explore the Interface Segregation Principle and learn how to create lean, focused interfaces.

Key Takeaways

  • ✅ Behavioral substitution – Subtypes must honor base class contracts

  • ✅ No surprise exceptions – Derived classes shouldn't throw unexpected errors

  • ✅ Avoid type checking – If clients check types, substitution has failed

  • ✅ Respect contracts – Don't strengthen preconditions or weaken postconditions

  • ✅ Consider composition – Sometimes composition is better than inheritance

  • ✅ Model behavior – Inheritance should reflect behavioral relationships

  • ✅ Separate abstractions – If subtypes can't fulfill the contract, split the hierarchy

  • ✅ Test substitutability – Write tests that use base types to catch LSP violations

What LSP violations have you encountered? Share your experiences below!

References and Further Reading