TL;DR

  • LSP means you can use subtype objects anywhere you use base type objects without breaking code.
  • Square/Rectangle inheritance that breaks existing code? That’s a classic LSP problem.
  • Watch for subclasses that throw unexpected exceptions or silently change behavior.
  • When behaviors are too different, reach for composition instead of inheritance.
  • Make interfaces that clearly show what objects can and can’t do to avoid surprises.
  • LSP violations often sneak past unit tests and show up as runtime bugs.

The Liskov Substitution Principle isn’t just academic theory - it helps you avoid those “it worked yesterday” bugs that show up in production. When a subclass doesn’t behave like its parent promises, your code breaks in ways that unit tests often miss.

Diagram showing the flow of Liskov Substitution Principle (LSP) compliance: a base class contract leads to a check if the derived class honors the contract. If yes, you get reliable polymorphism and predictable behavior; if not, you get runtime bugs, brittle code, and testing headaches.

LSP Compliance: When derived classes honor the base class contract, you achieve reliable polymorphism and predictable behavior. Violating LSP leads to runtime bugs, brittle code, and testing headaches.

The Classic Problem: Behavioral Contract Violation

Here’s the infamous Rectangle/Square example that demonstrates LSP violation:

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

public class Square : Rectangle
{
    public override double Width
    {
        get => base.Width;
        set
        {
            base.Width = value;
            base.Height = value; // Violates LSP!
        }
    }
    
    public override double Height
    {
        get => base.Height;
        set
        {
            base.Width = value;  // Violates LSP!
            base.Height = value;
        }
    }
}

The problem becomes clear in client code:

public void TestRectangle(Rectangle rectangle)
{
    rectangle.Width = 5;
    rectangle.Height = 4;
    
    // Client expects: 5 * 4 = 20
    // With Square: 4 * 4 = 16 (last assignment wins)
    Assert.AreEqual(20, rectangle.Area); // Fails with Square!
}

A Real-World Example: Repository Contracts

Here’s a more practical LSP violation in data access:

public interface IRepository<T>
{
    Task<T> GetByIdAsync(int id);
    Task SaveAsync(T entity);
}

public class DatabaseRepository<T> : IRepository<T>
{
    public async Task<T> GetByIdAsync(int id)
    {
        // Returns null if not found
        return await _context.Set<T>().FindAsync(id);
    }
    
    public async Task SaveAsync(T entity)
    {
        _context.Set<T>().Add(entity);
        await _context.SaveChangesAsync();
    }
}

public class CachedRepository<T> : IRepository<T>
{
    public async Task<T> GetByIdAsync(int id)
    {
        var cached = _cache.Get<T>($"entity_{id}");
        if (cached != null) return cached;
        
        // LSP violation: throws instead of returning null
        throw new InvalidOperationException("Entity not in cache");
    }
    
    public async Task SaveAsync(T entity)
    {
        // Another LSP violation: doesn't persist to database
        _cache.Set($"entity_{entity.Id}", entity);
    }
}

Client code expecting the base contract breaks:

public async Task<User> GetUserSafelyAsync(int id, IRepository<User> repository)
{
    var user = await repository.GetByIdAsync(id); // Throws with CachedRepository!
    return user ?? new User { Name = "Guest" };
}

Modern C# 12 and .NET 8 Approach: Better Design with Required Properties and Records

In C# 12, we can use required properties and records to design better class hierarchies that respect LSP:

// Instead of inheritance, use composition and behaviors
public record Shape(string Name);

public record Rectangle(double Width, double Height) : Shape("Rectangle")
{
    public virtual double Area => Width * Height;
    
    // Immutable approach with With methods avoids LSP violations
    public virtual Rectangle WithWidth(double newWidth) => this with { Width = newWidth };
    public virtual Rectangle WithHeight(double newHeight) => this with { Height = newHeight };
}

public record Square(double Side) : Shape("Square")
{
    public double Area => Side * Side;
    
    // No inheritance relationship with Rectangle - no LSP issues
    public Square WithSide(double newSide) => this with { Side = newSide };
}

// Client code uses pattern matching instead of inheritance
public double CalculateArea(Shape shape) => shape switch
{
    Rectangle rectangle => rectangle.Area,
    Square square => square.Area,
    _ => throw new ArgumentException($"Unknown shape type: {shape.GetType().Name}")
};

The Solution: Honor Behavioral Contracts

public class CachedRepository<T> : IRepository<T>
{
    private readonly IRepository<T> _innerRepository;
    private readonly IMemoryCache _cache;
    
    public CachedRepository(IRepository<T> innerRepository, IMemoryCache cache)
    {
        _innerRepository = innerRepository;
        _cache = cache;
    }
    
    public async Task<T> GetByIdAsync(int id)
    {
        var cacheKey = $"entity_{id}";
        var cached = _cache.Get<T>(cacheKey);
        if (cached != null) return cached;
        
        // Honor the contract: return null if not found
        var entity = await _innerRepository.GetByIdAsync(id);
        if (entity != null)
        {
            _cache.Set(cacheKey, entity, TimeSpan.FromMinutes(5));
        }
        
        return entity; // null if not found, just like the base contract
    }
    
    public async Task SaveAsync(T entity)
    {
        // Honor the contract: actually save the entity
        await _innerRepository.SaveAsync(entity);
        
        // Update cache as a side effect
        _cache.Set($"entity_{entity.Id}", entity, TimeSpan.FromMinutes(5));
    }
}

LSP Contract Rules

For your subclasses to work properly:

  • Don’t add new requirements (preconditions)
  • Don’t break promises (postconditions)
  • Don’t throw unexpected errors (exception behavior)
  • Don’t secretly change how things work (side effects)

Real-World Example: Payment Processing with LSP

Let’s look at a payment processing system with potential LSP violations:

// Base payment processor contract
public abstract class PaymentProcessor
{
    // Contract: Should process payment and return result
    // Should never throw for invalid amounts (return failure instead)
    public abstract Task<PaymentResult> ProcessPaymentAsync(decimal amount, PaymentDetails details);
    
    // Contract: Should return true if payment method is available
    public abstract bool IsAvailable();
    
    // Contract: Refunds should work if the processor supports them
    public virtual async Task<RefundResult> RefundAsync(string transactionId, decimal amount)
    {
        // Default behavior: refunds not supported
        return new RefundResult(false, "Refunds not supported by this processor");
    }
}

// LSP-violating implementation
public class ExpressPayProcessor : PaymentProcessor
{
    public override async Task<PaymentResult> ProcessPaymentAsync(decimal amount, PaymentDetails details)
    {
        // LSP violation: strengthening preconditions
        if (amount < 5)
            throw new ArgumentException("Minimum payment amount is $5"); // Violates base contract!
        
        // Payment processing logic...
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
    
    public override bool IsAvailable()
    {
        // LSP violation: unreliable behavior
        return DateTime.Now.Hour >= 9 && DateTime.Now.Hour < 17; // Only available during business hours!
    }
    
    public override async Task<RefundResult> RefundAsync(string transactionId, decimal amount)
    {
        // LSP violation: doesn't actually handle refunds despite overriding
        throw new NotImplementedException("Refunds not implemented yet");
    }
}

// LSP-compliant implementation
public class SecurePayProcessor : PaymentProcessor
{
    // .NET 8 TimeProvider for better testability
    private readonly TimeProvider _timeProvider;
    
    public SecurePayProcessor(TimeProvider timeProvider)
    {
        _timeProvider = timeProvider;
    }
    
    public override async Task<PaymentResult> ProcessPaymentAsync(decimal amount, PaymentDetails details)
    {
        // Respects contract: handles any amount, returns failure for invalid amounts
        if (amount < 1)
        {
            return new PaymentResult(false, "Amount must be at least $1", null);
        }
        
        // Payment processing logic...
        var transactionId = Guid.NewGuid().ToString("N");
        return new PaymentResult(true, "Payment processed", transactionId);
    }
    
    public override bool IsAvailable()
    {
        // Reliable implementation that clients can depend on
        return true; // Always available
    }
    
    public override async Task<RefundResult> RefundAsync(string transactionId, decimal amount)
    {
        // Proper implementation of the refund contract
        if (string.IsNullOrEmpty(transactionId))
        {
            return new RefundResult(false, "Transaction ID is required");
        }
        
        // Refund processing logic...
        return new RefundResult(true, "Refund processed successfully");
    }
}

Common LSP Traps in Modern C# Development

  1. Throwing exceptions where the base class/interface doesn’t

    // Base contract
    public interface IUserRepository
    {
        // Contract implies null return for not found
        Task<User> GetByIdAsync(int id);
    }
    
    // Violating implementation
    public class OAuthUserRepository : IUserRepository
    {
        public async Task<User> GetByIdAsync(int id)
        {
            // Violates LSP by throwing instead of returning null
            throw new NotSupportedException("Use GetByExternalIdAsync instead");
        }
    }
    
  2. Not honoring cancellation tokens

    // Base contract
    public interface IDataProcessor
    {
        Task ProcessAsync(Data data, CancellationToken cancellationToken);
    }
    
    // Violating implementation
    public class BackgroundProcessor : IDataProcessor
    {
        public async Task ProcessAsync(Data data, CancellationToken cancellationToken)
        {
            // Violates LSP by ignoring the cancellation token
            await Task.Delay(5000); // Should be: await Task.Delay(5000, cancellationToken)
            // Process data without checking cancellation...
        }
    }
    
  3. Changing method behavior semantics

    // Base class
    public class Logger
    {
        public virtual void Log(string message)
        {
            // Writes to file synchronously
        }
    }
    
    // Violating subclass
    public class AsyncLogger : Logger
    {
        public override void Log(string message)
        {
            // LSP violation: base class implies synchronous behavior
            // This implementation makes it asynchronous without changing signature
            Task.Run(() => WriteToFileAsync(message));
        }
    }
    

A Real-World Example: Collection Types

Here’s an LSP violation many developers make without even noticing:

// Base collection interface
public interface IReadOnlyCollection<T> 
{
    int Count { get; }
    bool Contains(T item);
    IEnumerator<T> GetEnumerator();
}

// Subtypes must respect these behaviors
public interface ICollection<T> : IReadOnlyCollection<T>
{
    void Add(T item);
    void Clear();
    bool Remove(T item);
}

// LSP violation in a custom collection
public class LimitedSizeCollection<T> : ICollection<T>
{
    private readonly List<T> _items = new();
    private readonly int _maxSize;
    
    public LimitedSizeCollection(int maxSize) => _maxSize = maxSize;
    
    public int Count => _items.Count;
    public bool Contains(T item) => _items.Contains(item);
    public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
    
    public void Clear() => _items.Clear();
    public bool Remove(T item) => _items.Remove(item);
    
    // LSP violation! Throws when at capacity instead of behaving like a normal collection
    public void Add(T item)
    {
        if (_items.Count >= _maxSize)
            throw new InvalidOperationException("Collection is full"); // Breaks client code
            
        _items.Add(item);
    }
}

Client code breaks unexpectedly:

// Client expects all ICollection<T> to behave the same
public void ProcessItems<T>(ICollection<T> collection, IEnumerable<T> itemsToAdd)
{
    foreach (var item in itemsToAdd)
    {
        // With LimitedSizeCollection, this might throw!
        collection.Add(item); 
    }
}

A better LSP-compliant design:

// Create a more specific interface that clearly communicates the behavior
public interface ILimitedCollection<T> : ICollection<T>
{
    bool IsFull { get; }
    bool TryAdd(T item); // Safer alternative that won't throw
}

// Implementation that respects LSP
public class LimitedSizeCollection<T> : ILimitedCollection<T>
{
    // ...existing code...
    
    public bool IsFull => _items.Count >= _maxSize;
    
    // Standard implementation satisfies ICollection contract
    public void Add(T item)
    {
        if (IsFull)
            return; // Silently ignore or log instead of throwing
            
        _items.Add(item);
    }
    
    // New method for clients that care about capacity
    public bool TryAdd(T item)
    {
        if (IsFull)
            return false;
            
        _items.Add(item);
        return true;
    }
}

The Quick LSP Test

Ask yourself: “Can I swap in any subclass without my code breaking?” If not, you’ve violated LSP.

Tips for Maintaining LSP in Modern C# Applications

  1. Use composition over inheritance when behavior might differ
  2. Consider interfaces over abstract classes for more flexible contracts
  3. Make explicit what’s optional - use optional interface methods pattern in C# 8+
  4. Document contract expectations clearly in interface/abstract class XML comments
  5. Use unit tests that verify LSP - same test suite should pass for all implementations
  6. Consider the Template Method pattern to enforce common behavior
  7. Apply the “Tell, Don’t Ask” principle to reduce behavioral dependencies

Benefits of Following LSP

When you design your classes to follow LSP:

  • Your code just works - Subclasses work anywhere the parent class does
  • Your tests do more - Testing the parent tests all the children too
  • Swapping parts is safe - Mock objects and new implementations just drop in
  • You can reuse more code - Mix and match classes without surprises
  • Bugs are easier to find - No more “it worked with ClassA but breaks with ClassB”

The Decorator Pattern: A Perfect LSP Companion

The decorator pattern lets you add new features without breaking LSP:

// Base contract
public interface INotificationService
{
    Task SendAsync(string recipient, string message);
}

// Base implementation
public class EmailNotificationService : INotificationService
{
    public async Task SendAsync(string recipient, string message)
    {
        // Send email implementation
    }
}

// LSP-compliant decorator
public class LoggingNotificationDecorator : INotificationService
{
    private readonly INotificationService _inner;
    private readonly ILogger _logger;
    
    public LoggingNotificationDecorator(
        INotificationService inner,
        ILogger logger)
    {
        _inner = inner;
        _logger = logger;
    }
    
    public async Task SendAsync(string recipient, string message)
    {
        _logger.LogInformation("Sending notification to {Recipient}", recipient);
        await _inner.SendAsync(recipient, message);
        _logger.LogInformation("Notification sent successfully");
    }
}

// Another decorator - respects LSP while adding retry behavior
public class RetryingNotificationDecorator : INotificationService
{
    private readonly INotificationService _inner;
    private readonly int _maxRetries;
    
    public RetryingNotificationDecorator(
        INotificationService inner,
        int maxRetries = 3)
    {
        _inner = inner;
        _maxRetries = maxRetries;
    }
    
    public async Task SendAsync(string recipient, string message)
    {
        int attempts = 0;
        while (true)
        {
            try
            {
                attempts++;
                await _inner.SendAsync(recipient, message);
                return; // Success!
            }
            catch when (attempts < _maxRetries)
            {
                // Wait before retrying
                await Task.Delay(100 * attempts);
            }
        }
    }
}

Real-World LSP Violations: Stream Classes

.NET’s Stream classes are a perfect example of both LSP compliance and violation:

// Usage pattern assuming LSP compliance
public async Task CopyDataAsync(Stream source, Stream destination)
{
    // Basic usage - should work with any Stream subclass
    byte[] buffer = new byte[4096];
    int bytesRead;
    
    while ((bytesRead = await source.ReadAsync(buffer)) > 0)
    {
        await destination.WriteAsync(buffer, 0, bytesRead);
    }
}

When does this break?

// This works fine
using var sourceFile = File.OpenRead("source.dat");
using var destFile = File.Create("dest.dat");
await CopyDataAsync(sourceFile, destFile);

// This breaks! NetworkStream doesn't support seeking
using var sourceFile = File.OpenRead("source.dat");
using var networkStream = GetNetworkStream(); // Some TCP connection
await CopyDataAsync(sourceFile, networkStream); // Works

// But this fails - why?
await CopyDataAsync(networkStream, destFile); // Throws NotSupportedException

The issue is that many Stream implementations have different capabilities:

  • Some are read-only (FileStream opened for reading)
  • Some are write-only (NetworkStream for sending)
  • Some don’t support seeking (NetworkStream)

The fix? Better interfaces that express capabilities clearly:

// .NET solved this with more specific interfaces
public interface IReadableStream { Task<int> ReadAsync(byte[] buffer, ...); }
public interface IWriteableStream { Task WriteAsync(byte[] buffer, ...); }
public interface ISeekableStream { long Position { get; set; } }

// Now client code can be more explicit about requirements
public async Task CopyDataAsync(
    IReadableStream source, 
    IWriteableStream destination)
{
    // Now contract is clear, LSP violations are avoided
}

This example from the .NET framework shows why LSP matters in practice: mixing types with different behaviors leads to runtime exceptions that good design prevents.

Decorators let you add features like logging, caching, or validation without changing the original code. Since decorators follow LSP, your code stays predictable and reliable.