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:
Subtypes can:
But subtypes cannot:
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