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.

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
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"); } }
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... } }
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
- Use composition over inheritance when behavior might differ
- Consider interfaces over abstract classes for more flexible contracts
- Make explicit what’s optional - use optional interface methods pattern in C# 8+
- Document contract expectations clearly in interface/abstract class XML comments
- Use unit tests that verify LSP - same test suite should pass for all implementations
- Consider the Template Method pattern to enforce common behavior
- 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.