Skip to content

Liskov Substitution Principle (LSP)

Definition: "Objects of a parent class should be replaceable with objects of its child classes without breaking the correctness of the program." Based on the work of Barbara Liskov (1987).

In simple words: if your code works with a parent type, it must keep working when you give it any child type. A child class must not surprise the code that uses it: no unexpected exceptions, no ignored calls, no different meaning.

Real-life example

You rent a car from a company. The booking says "any car". Whatever car they give you, it must have a steering wheel, brakes and an accelerator that work the normal way. If they give you a "car" whose brake pedal makes it go faster, it is technically a car, but it breaks your expectations, and someone will get hurt. That "car" violates LSP.

The classic example: Rectangle and Square

Mathematically, a square is a rectangle. So it seems natural to write:

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : Rectangle
{
    public override int Width
    {
        set { base.Width = value; base.Height = value; }   // keep sides equal
    }
    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

Now a method written for Rectangle:

static void Resize(Rectangle r)
{
    r.Width = 5;
    r.Height = 4;
    Console.WriteLine($"Expected area 20, actual area {r.Area}");
}

Resize(new Rectangle());   // Expected area 20, actual area 20
Resize(new Square());      // Expected area 20, actual area 16  <-- broken!

Square compiles and "is a" rectangle, but it behaves differently: setting the height silently changed the width. Code written for Rectangle is now wrong. The lesson: "is-a" in real life does not always mean "is-a" in code. What matters is behaviour.

An industry example: payment refunds

An online shop has different payment types. A developer creates a base class with Pay and Refund:

public abstract class Payment
{
    public decimal Amount { get; init; }
    public abstract Task PayAsync();
    public abstract Task RefundAsync();
}

public class CardPayment : Payment
{
    public override Task PayAsync()    { /* charge card */  return Task.CompletedTask; }
    public override Task RefundAsync() { /* refund card */  return Task.CompletedTask; }
}

public class UpiPayment : Payment
{
    public override Task PayAsync()    { /* collect UPI */  return Task.CompletedTask; }
    public override Task RefundAsync() { /* refund UPI */   return Task.CompletedTask; }
}

// Cash on Delivery has no online refund: money is returned by the delivery agent.
public class CashOnDeliveryPayment : Payment
{
    public override Task PayAsync() => Task.CompletedTask;

    public override Task RefundAsync() =>
        throw new NotSupportedException("COD orders can't be refunded online");
}

The cancellation service was written and tested with card and UPI payments:

public class OrderCancellationService
{
    public async Task CancelAsync(Order order)
    {
        order.Status = OrderStatus.Cancelled;
        await order.Payment.RefundAsync();   // works for all payments... right?
    }
}

The day a customer cancels a COD order, production throws NotSupportedException and the cancellation fails. CashOnDeliveryPayment cannot be substituted for Payment. That is an LSP violation.

The wrong fix: type checks

if (order.Payment is not CashOnDeliveryPayment)
{
    await order.Payment.RefundAsync();
}

This hides the problem. Every place that refunds must remember this check, and the next non-refundable type (gift voucher, loyalty points) needs another if. It also breaks the Open/Closed Principle.

The right fix: model the real capabilities

Not every payment can be refunded, so "refund" must not be promised by the base type. Split the capability into its own interface:

public abstract class Payment
{
    public decimal Amount { get; init; }
    public abstract Task PayAsync();
}

public interface IRefundable
{
    Task RefundAsync();
}

public class CardPayment : Payment, IRefundable
{
    public override Task PayAsync()   { /* charge card */ return Task.CompletedTask; }
    public Task RefundAsync()         { /* refund card */ return Task.CompletedTask; }
}

public class UpiPayment : Payment, IRefundable
{
    public override Task PayAsync()   { /* collect UPI */ return Task.CompletedTask; }
    public Task RefundAsync()         { /* refund UPI */  return Task.CompletedTask; }
}

public class CashOnDeliveryPayment : Payment
{
    public override Task PayAsync() => Task.CompletedTask;
    // no RefundAsync: COD never promised it
}
public class OrderCancellationService
{
    private readonly IManualRefundQueue _manualRefunds;

    public OrderCancellationService(IManualRefundQueue manualRefunds) => _manualRefunds = manualRefunds;

    public async Task CancelAsync(Order order)
    {
        order.Status = OrderStatus.Cancelled;

        if (order.Payment is IRefundable refundable)
            await refundable.RefundAsync();                 // online refund
        else
            await _manualRefunds.AddAsync(order);           // handled by the delivery agent
    }
}

Now every type keeps every promise it makes. Code that works with Payment only calls PayAsync, which all payments support. Code that needs refunds asks for IRefundable. The check is on a capability, not on one concrete class, so new refundable types (wallets) work automatically.

The rules a child class must follow

To be safely substitutable, a derived class (or interface implementation) must:

RuleMeaningViolation example
Don't strengthen preconditionsDon't demand more from the caller than the parent didParent accepts any amount; child throws if amount > 10,000
Don't weaken postconditionsDeliver at least what the parent promisedParent's Save() stores the data; child's Save() does nothing
Keep invariantsRules that are always true for the parent stay trueSquare changes width when height is set
No new unexpected exceptionsDon't throw exceptions callers of the parent can't expectNotSupportedException / NotImplementedException from an overridden method

LSP violation inside .NET itself

ReadOnlyCollection<T> implements ICollection<T>, which has an Add method. Calling it throws:

ICollection<string> names = new List<string> { "Amit" }.AsReadOnly();
names.Add("Riya");   // NotSupportedException: Collection is read-only.

This is a well-known LSP compromise from early .NET. Later versions added IReadOnlyCollection<T> and IReadOnlyList<T>, interfaces that only promise what a read-only collection can actually do. That is the same fix we used for refunds.

How to spot an LSP violation

  • An override throws NotImplementedException or NotSupportedException.
  • An override is empty and silently does nothing.
  • Callers check concrete types: if (x is CashOnDeliveryPayment) or x.GetType() == typeof(...).
  • Unit tests written for the base class fail when run against a child class.

Tip: a great way to verify LSP is to write one set of tests against the base type or interface and run it for every implementation. If any implementation fails the shared tests, it is not substitutable.

Summary

  • A child class must work everywhere the parent is used, without surprises.
  • "Is-a" in the real world is not enough; the behaviour must match.
  • Throwing NotSupportedException or doing nothing in an override is a red flag.
  • Fix violations by modelling capabilities with smaller interfaces (IRefundable), not by adding type checks.
  • LSP keeps polymorphism safe, which is what makes OCP and DIP work.