đź“„

Liskov Substitution Principle (LSP)

Intermediate 2 min read 300 words

Objects of a superclass should be replaceable with objects of its subclasses without affecting program correctness

Liskov Substitution Principle (LSP)

“If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.” — Barbara Liskov

In simpler terms: derived classes must be substitutable for their base classes. Code that works with a base class should work with any derived class without knowing the difference.

Understanding LSP

If you have:

void DoSomething(Animal animal) { animal.Move(); }

Then calling DoSomething(new Dog()) should work just as well as DoSomething(new Cat()). The method shouldn’t need to check what type of animal it received.

Classic Violation: Rectangle-Square Problem

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

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

// Square IS-A Rectangle (mathematically)
public class Square : Rectangle
{
    public override int Width
    {
        get => base.Width;
        set
        {
            base.Width = value;
            base.Height = value; // Keep it square!
        }
    }

    public override int Height
    {
        get => base.Height;
        set
        {
            base.Height = value;
            base.Width = value; // Keep it square!
        }
    }
}

// This code works with Rectangle...
public void ResizeRectangle(Rectangle rect)
{
    rect.Width = 5;
    rect.Height = 10;
    Console.WriteLine($"Area: {rect.GetArea()}"); // Expected: 50
}

// ...but breaks with Square!
var square = new Square();
ResizeRectangle(square);
// Area: 100 (not 50!) - Square's behavior is different

Problem: Square changes behavior - setting Width also sets Height. Code expecting Rectangle behavior breaks.

Another Classic Violation: Birds That Can’t Fly

// BAD: Not all birds can fly!
public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("Flying high!");
    }

    public virtual int GetAltitude() => 1000;
}

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

    public override int GetAltitude() => 0;
}

// This code expects all birds to fly
public void ReleaseBird(Bird bird)
{
    bird.Fly(); // Crashes with Penguin!
}

var penguin = new Penguin();
ReleaseBird(penguin); // NotImplementedException!

Applying LSP: Better Bird Design

// GOOD: Separate interfaces for different capabilities
public interface IAnimal
{
    void Move();
}

public interface IFlyer
{
    void Fly();
    int GetAltitude();
}

public interface ISwimmer
{
    void Swim();
    int GetDepth();
}

// Base class with common behavior
public abstract class Bird : IAnimal
{
    public string Name { get; set; }
    public abstract void Move();
}

// Eagle can fly
public class Eagle : Bird, IFlyer
{
    public override void Move() => Fly();

    public void Fly() => Console.WriteLine($"{Name} soars through the sky");
    public int GetAltitude() => 8000;
}

// Duck can fly AND swim
public class Duck : Bird, IFlyer, ISwimmer
{
    public override void Move() => Fly();

    public void Fly() => Console.WriteLine($"{Name} flaps wings");
    public int GetAltitude() => 2000;

    public void Swim() => Console.WriteLine($"{Name} paddles");
    public int GetDepth() => 3;
}

// Penguin can only swim
public class Penguin : Bird, ISwimmer
{
    public override void Move() => Swim();

    public void Swim() => Console.WriteLine($"{Name} dives into water");
    public int GetDepth() => 50;
}

// Now type-safe methods
public void ReleaseFlyingBird(IFlyer flyer)
{
    flyer.Fly(); // Safe - only accepts things that can fly
}

public void ReleaseSwimmingBird(ISwimmer swimmer)
{
    swimmer.Swim(); // Safe - only accepts things that can swim
}

// Usage
var eagle = new Eagle { Name = "Eddie" };
var penguin = new Penguin { Name = "Pete" };

ReleaseFlyingBird(eagle);    // Works!
// ReleaseFlyingBird(penguin); // Compile error - Penguin doesn't implement IFlyer

ReleaseSwimmingBird(penguin); // Works!

LSP Rules (Contract Rules)

1. Preconditions Cannot Be Strengthened

// Base class accepts any positive amount
public class BankAccount
{
    public virtual void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive");
        // ... deposit logic
    }
}

// BAD: Derived class adds more restrictions
public class PremiumAccount : BankAccount
{
    public override void Deposit(decimal amount)
    {
        if (amount < 100) // Stronger precondition!
            throw new ArgumentException("Minimum deposit is $100");
        base.Deposit(amount);
    }
}

// Code that works with BankAccount breaks with PremiumAccount
void MakeDeposit(BankAccount account)
{
    account.Deposit(50); // Valid for BankAccount, throws for PremiumAccount!
}

2. Postconditions Cannot Be Weakened

// Base class guarantees non-null return
public class UserRepository
{
    public virtual User GetById(int id)
    {
        var user = _db.Users.Find(id);
        return user ?? throw new NotFoundException();
    }
}

// BAD: Derived class weakens the guarantee
public class CachedUserRepository : UserRepository
{
    public override User GetById(int id)
    {
        // Might return null from cache!
        return _cache.Get<User>($"user_{id}");
    }
}

// Code expects non-null, but might get null
void ProcessUser(UserRepository repo)
{
    var user = repo.GetById(1);
    Console.WriteLine(user.Name); // NullReferenceException possible!
}

3. Invariants Must Be Preserved

// Base class maintains balance >= 0
public class SavingsAccount
{
    protected decimal _balance;

    public decimal Balance
    {
        get => _balance;
        protected set => _balance = Math.Max(0, value); // Invariant: never negative
    }

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

// BAD: Derived class breaks the invariant
public class OverdraftAccount : SavingsAccount
{
    public override void Withdraw(decimal amount)
    {
        _balance -= amount; // Can go negative! Breaks invariant.
    }
}

Real-World LSP Example

// Good LSP design for payment processing
public interface IPaymentMethod
{
    Task<PaymentResult> ProcessAsync(decimal amount);
    bool SupportsRefund { get; }
    Task<RefundResult> RefundAsync(decimal amount);
}

public class CreditCardPayment : IPaymentMethod
{
    public bool SupportsRefund => true;

    public async Task<PaymentResult> ProcessAsync(decimal amount)
    {
        // Process credit card...
        return new PaymentResult { Success = true };
    }

    public async Task<RefundResult> RefundAsync(decimal amount)
    {
        // Process refund...
        return new RefundResult { Success = true };
    }
}

public class CryptoPayment : IPaymentMethod
{
    public bool SupportsRefund => false; // Crypto can't be refunded!

    public async Task<PaymentResult> ProcessAsync(decimal amount)
    {
        // Process crypto payment...
        return new PaymentResult { Success = true };
    }

    public async Task<RefundResult> RefundAsync(decimal amount)
    {
        // Instead of throwing, return appropriate result
        return new RefundResult
        {
            Success = false,
            Reason = "Cryptocurrency payments cannot be refunded"
        };
    }
}

// Consumer checks capability before using
public class PaymentService
{
    public async Task ProcessRefund(IPaymentMethod payment, decimal amount)
    {
        if (!payment.SupportsRefund)
        {
            // Handle gracefully instead of crashing
            throw new InvalidOperationException(
                "This payment method does not support refunds");
        }

        await payment.RefundAsync(amount);
    }
}

LSP Checklist

When creating a derived class, verify:

Check Question
Signature Compatibility Do method signatures match?
Preconditions Are input requirements same or weaker?
Postconditions Are output guarantees same or stronger?
Invariants Are class rules preserved?
Exception Compatibility Are thrown exceptions compatible?
History Constraint Is state change behavior consistent?

Interview Tips

Common Questions:

  • “Explain LSP with an example”
  • “What’s wrong with Square inheriting from Rectangle?”
  • “How do you detect LSP violations?”

Key Points:

  1. If it looks like inheritance but breaks substitutability, use composition instead
  2. Throwing NotImplementedException in overrides is usually an LSP violation
  3. Type checks (if (x is ConcreteType)) often indicate LSP problems
  4. Design interfaces around behavior, not hierarchies

Red Flags:

  • Methods that throw NotImplementedException
  • Type checking with is or as before calling methods
  • Empty override implementations
  • Subclasses that restrict what the base allows