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];
}
}

Join the conversation! Your thoughts help the community grow.