SOLID Principle

Liskov Substitution Principle

Subtypes must be substitutable for their base types without altering the correctness of the program — Barbara Liskov, 1987

29 Q&As 6 Bug Studies 10 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

The "is-a" lie: LSP does NOT simply mean "use inheritance correctly." It means if you have code that works with a base type, every subtype must work without surprises — no exceptions where the base didn't throw, no weaker behavior, no broken contractsThe implicit or explicit agreement about what a method does: what inputs it accepts (preconditions), what outputs it guarantees (postconditions), and what remains true throughout (invariants). If a subtype breaks any of these, it violates LSP..

What: If S is a subtype of T, then objects of type T can be replaced with objects of type S without breaking the program. A Square that overrides SetWidth to also change height violates LSP — code expecting a RectangleThe classic LSP violation example. A Rectangle promises that width and height are independent. A Square (where width always equals height) breaks this promise. Even though mathematically "a square is a rectangle," in code, substituting a Square for a Rectangle causes bugs. breaks silently.

Why: Inheritance hierarchies that look correct in UML diagrams can blow up at runtime. LSP violations cause silent data corruptionThe worst kind of bug — no exception is thrown, no error is logged, but the data is wrong. For example, a Square overriding SetWidth also changes height, so a method that calculates area gets a wrong answer without any error. — the code runs without exceptions but produces wrong results. They're the hardest bugs to find because "it works, just not correctly."

Modern .NET: LSP is enforced by interface contractsIn modern .NET, interfaces define behavioral contracts, not just method signatures. IReadOnlyCollection<T> promises read-only access — any implementation that secretly allows writes violates LSP even though it compiles fine., IReadOnlyCollection<T> vs ICollection<T> (BCL enforces it), and the .NET generic varianceC# generics support covariance (out T) and contravariance (in T) to ensure type substitution is safe. IEnumerable<out T> is covariant — you can use IEnumerable<Dog> where IEnumerable<Animal> is expected, because reading dogs as animals is safe. system (in/out keywords).

Quick Code:

LSP-at-a-glance.cs
// ✗ VIOLATES LSP — Square breaks Rectangle's contract
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
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; } // surprise!
    }
    public override int Height
    {
        get => base.Height;
        set { base.Width = value; base.Height = value; } // surprise!
    }
}

// This test PASSES for Rectangle, FAILS for Square
void Resize(Rectangle r)
{
    r.Width = 5;
    r.Height = 10;
    Debug.Assert(r.Area == 50); // Square: 100 — BOOM!
}

// ✓ RESPECTS LSP — separate types, shared interface for what's truly common
public interface IShape { int Area { get; } }
public record Rectangle(int Width, int Height) : IShape
{
    public int Area => Width * Height;
}
public record Square(int Side) : IShape
{
    public int Area => Side * Side;
}
Section 2

Prerequisites

Before reading this, you should understand:
Inheritance & Polymorphism — You need to understand virtual/override, base class references, and upcasting in C#. Interfaces — Specifically how IDisposable, IEnumerable<T>, and custom interfaces work in .NET. SRP — Classes with one responsibility are easier to substitute correctly. Fat classes often violate LSP because subtypes can't fulfill every responsibility. OCP — LSP is the mechanism that makes OCP work. If subtypes aren't substitutable, extending via new classes breaks existing code.
Why LSP comes 3rd in SOLID: SRP teaches you to separate responsibilities. OCP teaches you to extend via new classes. LSP teaches you when inheritance is the wrong tool — and it's wrong more often than you think.
Section 3

Analogies

The Power Outlet Analogy

A power outlet (the base type) promises: "plug anything in, it gets electricity." A lampRepresents a well-behaved subtype. The lamp fulfills the outlet's contract — it accepts electricity and does something useful with it, without damaging anything. works. A phone charger works. But imagine an outlet that, when you plug in a lamp, also turns on your microwave and changes the thermostat. That outlet violates the contract — it has side effects the user didn't expect. LSP says every "subtype outlet" must behave as the original promised: supply electricity, nothing more, nothing less.

Real WorldCode Concept
Power outlet standardBase class / interface contract
Any device that fits the outletSubtype / implementing class
"Gets electricity" promisePostcondition (guaranteed output)
Outlet that also trips breakerSubtype with unexpected side effects
Adapter plug (EU → US)Adapter pattern instead of bad inheritance
The Rental Car

A rental car contract says "returns a car that drives." If the company gives you a truck that can't fit in parking garages, the contract is technically fulfilled but your plans are ruined. LSP says every returned vehicle must behave like the "car" you expected — no surprises.

The Substitute Teacher

A substitute teacher must teach the same curriculum. If they skip chapters, assign different homework, or change grading rules, students are confused. Same interface, same behavioral guarantees — that's LSP.

The Battery

Any AA battery should work in your remote. A battery that outputs 3V instead of 1.5V (stronger postconditionWhat a method guarantees after it runs. A stronger postcondition (more guarantees) is fine — a weaker one (fewer guarantees) violates LSP. A battery promising 1.5V that delivers 0.5V breaks the contract.) might fry it. One that outputs 0.5V (weaker postcondition) won't work. Both violate the expected behavior.

Section 4

Core Concept Diagram

LSP is about behavioral compatibility, not just structural compatibility. A subtype must honor the base type's contract — preconditions, postconditions, and invariants.

LSP Contract Rules: preconditions, postconditions, and invariants Base Type T Preconditions (what it requires) Postconditions (what it guarantees) Invariants (what stays true) ✓ Good Subtype S Same or weaker preconditions Same or stronger postconditions Invariants preserved No new exceptions Can substitute for T anywhere ✗ Bad Subtype S Stronger preconditions (demands more) Weaker postconditions (guarantees less) Invariants broken New exceptions thrown Breaks callers who expect T
The formal rule: PreconditionsWhat must be true BEFORE a method is called. Example: "balance must be ≥ 0" or "argument must not be null." A subtype must accept at least everything the base type accepts — it cannot demand MORE from the caller. cannot be strengthened, postconditionsWhat must be true AFTER a method returns. Example: "balance is updated" or "return value is non-negative." A subtype must guarantee at least everything the base type guarantees — it can guarantee MORE but not LESS. cannot be weakened, and invariantsProperties that remain true throughout an object's lifetime. Example: "a collection's Count is always ≥ 0" or "Width and Height are independent." If any subtype breaks an invariant that the base type establishes, LSP is violated. must be preserved.
Section 5

Code Implementations

Three classic LSP violation scenarios and how to fix each one.

Violation: Square inherits Rectangle

The classic example. Mathematically, a square "is a" rectangle. But in code, Rectangle promises that Width and Height are independent — Square breaks this invariantThe property that Width and Height can be set independently. When Square overrides setters to keep them equal, any code that sets Width expecting Height to stay unchanged gets wrong results..

RectangleSquare-Violation.cs
// ✗ VIOLATES LSP — Square breaks Rectangle's independence invariant
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
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; }
    }
    public override int Height
    {
        get => base.Height;
        set { base.Width = value; base.Height = value; }
    }
}

// Caller code — works with Rectangle, breaks with Square
public static void DoubleWidth(Rectangle r)
{
    var expectedHeight = r.Height;
    r.Width *= 2;
    // Rectangle: height unchanged ✓
    // Square: height also doubled ✗ — SILENT BUG
    Debug.Assert(r.Height == expectedHeight, "LSP violated!");
}

Fix: Separate types, shared interface

RectangleSquare-Fixed.cs
// ✓ RESPECTS LSP — no inheritance, only shared behavior via interface
public interface IShape
{
    int Area { get; }
}

public record Rectangle(int Width, int Height) : IShape
{
    public int Area => Width * Height;
}

public record Square(int Side) : IShape
{
    public int Area => Side * Side;
}

// Immutable records eliminate the mutation problem entirely.
// If you need a "resized" shape, return a NEW instance:
public static Rectangle WithDoubledWidth(Rectangle r) =>
    r with { Width = r.Width * 2 };  // Height stays unchanged — LSP safe
Key Insight

Using immutable recordsC# 9+ records are immutable value types by default. Since you can't mutate them, the "Square changes Height when you set Width" problem disappears entirely. Immutability is a powerful LSP enforcement tool. (record) eliminates an entire class of LSP violations. If the object can't be mutated, subtypes can't have surprising mutation side effects.

Violation: Read-only collection that secretly writes

An IReadOnlyList<T> implementation that caches or logs on read access violates the "no side effects" expectationWhen code accepts IReadOnlyList<T>, it expects zero side effects — reading shouldn't change state. If your implementation writes to a database or modifies a counter every time someone reads an item, callers will be surprised.. But the more egregious violation is implementing IReadOnlyList<T> on a class that also has Add()/Remove() methods, then passing it where read-only is expected.

ReadOnly-Violation.cs
// ✗ VIOLATES LSP — "read-only" list that throws on read for certain indices
public class LazyLoadingList<T> : IReadOnlyList<T>
{
    private readonly Func<int, T> _loader;
    private readonly Dictionary<int, T> _cache = new();
    private readonly int _count;

    public LazyLoadingList(Func<int, T> loader, int count)
    {
        _loader = loader;
        _count = count;
    }

    public T this[int index]
    {
        get
        {
            if (!_cache.ContainsKey(index))
            {
                _cache[index] = _loader(index);  // Side effect: DB call on read!
                // If the DB is down, this THROWS — IReadOnlyList never promised that
            }
            return _cache[index];
        }
    }

    public int Count => _count;
    public IEnumerator<T> GetEnumerator() => /* ... */;
}

Fix: Be explicit about what can fail

ReadOnly-Fixed.cs
// ✓ RESPECTS LSP — separate concerns: loading vs reading
public interface IAsyncDataSource<T>
{
    Task<T> GetAsync(int index);   // Callers KNOW this can fail
    Task<int> CountAsync();
}

public class DbDataSource<T> : IAsyncDataSource<T>
{
    private readonly DbContext _db;
    public async Task<T> GetAsync(int index) => await _db.Set<T>().FindAsync(index);
    public async Task<int> CountAsync() => await _db.Set<T>().CountAsync();
}

// When you need an in-memory snapshot, load all items and materialize:
var all = new List<Product>();
for (int i = 0; i < await dataSource.CountAsync(); i++)
    all.Add(await dataSource.GetAsync(i));
IReadOnlyList<Product> products = all.AsReadOnly();
// Now products is a true IReadOnlyList — no surprises, no hidden DB calls

Violation: Subtype throws exceptions the base never mentioned

The base class Save() only throws IOException. A subtype that throws AuthenticationException on save violates LSP — callers catching IOException are blindsided.

Exception-Violation.cs
// ✗ VIOLATES LSP — subtype throws exception the base never mentioned
public interface IFileStorage
{
    /// <summary>Saves a file. May throw IOException on disk failure.</summary>
    Task SaveAsync(string path, byte[] data);
}

public class LocalFileStorage : IFileStorage
{
    public async Task SaveAsync(string path, byte[] data)
    {
        await File.WriteAllBytesAsync(path, data);  // Throws IOException — expected
    }
}

public class CloudFileStorage : IFileStorage
{
    public async Task SaveAsync(string path, byte[] data)
    {
        if (!await _authService.IsAuthenticatedAsync())
            throw new AuthenticationException("Not logged in");  // ← LSP VIOLATION
        // Also throws HttpRequestException on network failure   // ← Another surprise
        await _httpClient.PutAsync($"/files/{path}", new ByteArrayContent(data));
    }
}

// Caller only catches what the contract promised:
try { await storage.SaveAsync("report.pdf", data); }
catch (IOException ex) { /* handle disk error */ }
// CloudFileStorage: AuthenticationException escapes uncaught — CRASH

Fix: Wrap exceptions in the expected type

Exception-Fixed.cs
// ✓ RESPECTS LSP — define a contract-level exception
public class StorageException : Exception
{
    public StorageException(string message, Exception? inner = null) : base(message, inner) { }
}

public interface IFileStorage
{
    /// <summary>Saves a file. Throws StorageException on any failure.</summary>
    Task SaveAsync(string path, byte[] data);
}

public class CloudFileStorage : IFileStorage
{
    public async Task SaveAsync(string path, byte[] data)
    {
        try
        {
            await EnsureAuthenticated();
            await _httpClient.PutAsync($"/files/{path}", new ByteArrayContent(data));
        }
        catch (Exception ex) when (ex is not StorageException)
        {
            // Wrap ALL unexpected exceptions in the contract's type
            throw new StorageException($"Failed to save {path} to cloud", ex);
        }
    }
}

// Caller catches the ONE exception type the contract promises:
try { await storage.SaveAsync("report.pdf", data); }
catch (StorageException ex) { logger.LogError(ex, "Storage failed"); }
Section 6

Junior vs Senior

Scenario: Build a payment processing system that supports credit cards, PayPal, and store credit. Store credit has a balance limit — what happens when the balance is insufficient?

How a Junior Thinks

"They all process payments, so they all implement IPaymentProcessor. Store credit just throws an exception if the balance is too low — the caller can catch it."

JuniorPayment.cs
public interface IPaymentProcessor
{
    void ProcessPayment(decimal amount);  // Returns void — "always succeeds"
}

public class CreditCardProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        // Charges the card — always works (card company handles limits)
        _gateway.Charge(amount);
    }
}

public class StoreCreditProcessor : IPaymentProcessor
{
    private decimal _balance;

    public void ProcessPayment(decimal amount)
    {
        if (amount > _balance)
            throw new InsufficientBalanceException();  // ← LSP VIOLATION!
        // Base contract: "ProcessPayment always succeeds"
        // This subtype: "ProcessPayment sometimes throws"
        _balance -= amount;
    }
}

// Caller assumes the contract — "ProcessPayment always works":
public void Checkout(IPaymentProcessor processor, decimal total)
{
    processor.ProcessPayment(total);  // StoreCreditProcessor: BOOM at runtime
    SendConfirmationEmail();          // Never reached for store credit failures
}
What Goes Wrong

The interface says "ProcessPayment works." Store credit says "ProcessPayment might not work." Every caller now needs try/catch — but they don't know that because the contract doesn't mention it. This is textbook strengthened preconditionsThe base type's precondition is: "give me any amount." The subtype's precondition is: "give me an amount ≤ balance." That's a STRONGER requirement — the subtype demands more from the caller than the base type did. LSP violation..

How a Senior Thinks

"Store credit has a fundamentally different capability — it can fail due to insufficient funds. That's not an implementation detail, it's a behavioral difference. The interface must reflect this reality."

IPaymentProcessor.cs
// The interface contract now INCLUDES the possibility of failure
public interface IPaymentProcessor
{
    /// <summary>
    /// Attempts to process a payment.
    /// Returns a result indicating success or failure — never throws for business logic.
    /// </summary>
    PaymentResult ProcessPayment(decimal amount);

    /// <summary>Can this processor handle the given amount right now?</summary>
    bool CanProcess(decimal amount);

    string ProcessorName { get; }
}

public record PaymentResult(bool Success, string? ErrorMessage = null)
{
    public static PaymentResult Ok() => new(true);
    public static PaymentResult Fail(string reason) => new(false, reason);
}
CreditCardProcessor.cs
public class CreditCardProcessor(IPaymentGateway gateway) : IPaymentProcessor
{
    public string ProcessorName => "Credit Card";

    public bool CanProcess(decimal amount) => amount > 0;  // Always can attempt

    public PaymentResult ProcessPayment(decimal amount)
    {
        try
        {
            gateway.Charge(amount);
            return PaymentResult.Ok();
        }
        catch (GatewayException ex)
        {
            return PaymentResult.Fail($"Card declined: {ex.Message}");
        }
    }
}
StoreCreditProcessor.cs
public class StoreCreditProcessor : IPaymentProcessor
{
    private decimal _balance;
    public string ProcessorName => "Store Credit";

    public StoreCreditProcessor(decimal initialBalance) => _balance = initialBalance;

    public bool CanProcess(decimal amount) => amount > 0 && amount <= _balance;

    public PaymentResult ProcessPayment(decimal amount)
    {
        if (!CanProcess(amount))
            return PaymentResult.Fail($"Insufficient store credit. Balance: {_balance:C}");

        _balance -= amount;
        return PaymentResult.Ok();
    }
    // No exception thrown — contract honored. Failure is a RESULT, not a surprise.
}
CheckoutService.cs
public class CheckoutService(IEnumerable<IPaymentProcessor> processors)
{
    public PaymentResult Checkout(string processorName, decimal total)
    {
        var processor = processors.FirstOrDefault(p =>
            p.ProcessorName == processorName)
            ?? throw new ArgumentException($"Unknown processor: {processorName}");

        // LSP-safe: EVERY processor behaves the same way
        if (!processor.CanProcess(total))
            return PaymentResult.Fail($"{processorName} cannot process {total:C}");

        return processor.ProcessPayment(total);
        // No try/catch needed — the contract guarantees a result, not an exception
    }
}

Design Decisions

Result Objects > Exceptions

Result patternInstead of throwing exceptions for expected failures (like "insufficient balance"), return a result object with Success/Failure status. This makes failure an explicit part of the contract — every caller handles it the same way. makes failure a first-class citizen of the contract. Every implementation returns PaymentResult, so callers handle all processors identically.

CanProcess() — Test Before You Fly

The CanProcess() method lets callers check capability before committing. This follows the Tester-Doer patternA .NET pattern where you test if an operation will succeed before executing it. TryParse() is the most famous example — it doesn't throw on invalid input, it returns false. This pattern is LSP-friendly because it makes precondition checking part of the public API. and keeps preconditions consistent across subtypes.

Section 7

Evolution Timeline

1987 — Barbara Liskov's Original Paper

Barbara Liskov introduced the substitution principle in her 1987 keynote address "Data Abstraction and Hierarchy" at OOPSLA. The key insight: type compatibility isn't about structure (does it have the right methods?) — it's about behavior (does it behave correctly when substituted?).

The original mathematical formulation: "If for each object o₁ of type S there is an object o₂ of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o₁ is substituted for o₂, then S is a subtype of T." This was later formalized with Jeannette Wing in their 1994 paper "A Behavioral Notion of Subtyping" (ACM TOPLAS).

Fun fact: Liskov won the Turing AwardThe highest distinction in computer science, often called the "Nobel Prize of Computing." Liskov received it in 2008 for her contributions to practical and theoretical foundations of programming language and system design — including LSP. in 2008, partly for this work. LSP became one of the most cited principles in software engineering.

Robert C. Martin (Uncle Bob) included LSP as the "L" in SOLID, translating Liskov's formal definition into practical guidance for object-oriented programmers. His contribution was making it actionable — he introduced the Rectangle/Square example and framed LSP as "design by contract for inheritance."

Martin's key insight: LSP isn't just about correctness — it's about architecture. If your subtypes aren't substitutable, your abstractions leak, your polymorphism breaks, and OCP fails.

.NET 2.0 introduced genericsGenerics let you write type-safe code without committing to a specific type. List<T> works with any T. Crucially, generics enabled the BCL to express LSP-safe contracts — IEnumerable<T> promises read-only iteration, ICollection<T> promises read-write access., enabling type-safe collection hierarchies. But it also exposed LSP violations — List<string> couldn't be passed where List<object> was expected because List<T> is invariant (for good reason — adding an int to a List<object> that's actually a List<string> would be an LSP violation).

C# 4.0 added in/out keywords for generic type parameters, finally giving .NET safe varianceCovariance (out T): IEnumerable<Dog> can be used as IEnumerable<Animal> — safe because T only appears in output positions. Contravariance (in T): IComparer<Animal> can be used as IComparer<Dog> — safe because T only appears in input (parameter) positions. The in/out keywords enforce LSP at the compiler level.:

  • IEnumerable<out T> — covariant (read-only, so safe to treat IEnumerable<Dog> as IEnumerable<Animal>)
  • IComparer<in T> — contravariant (write-only, so safe to treat IComparer<Animal> as IComparer<Dog>)
  • IList<T> — invariant (read+write, no variance is safe)

This was the compiler enforcing LSP at the type system level.

Modern .NET provides extensive LSP-enforcement tools:

  • C# 9 Records: Immutable by default → eliminates mutation-based LSP violations
  • Default Interface Methods (C# 8): Add behavior to interfaces without breaking existing implementations
  • IReadOnlyCollection/IReadOnlyList/IReadOnlyDictionary: BCL hierarchy that separates read from write contracts
  • Nullable reference types (C# 8+): Compiler warns when subtypes return null where base promised non-null
  • Analyzers (Roslyn): CA1033 (interface methods visible to subtypes), CA1065 (don't throw from unexpected places)
Section 8

.NET Core Ecosystem

How the .NET BCL and popular libraries enforce (or violate) LSP.

BCL Collection Hierarchy — LSP by Design

The .NET collection interfaces are a masterclass in LSP. The hierarchy separates read from write contracts:

Collection-Hierarchy.cs
// Read-only contracts (covariant — out T)
IEnumerable<out T>           // Iterate
  └─ IReadOnlyCollection<out T>  // Iterate + Count
       └─ IReadOnlyList<out T>        // Iterate + Count + Index
       └─ IReadOnlyDictionary<TKey, out TValue>  // Key-based lookup

// Read-write contracts (invariant)
ICollection<T>               // Add, Remove, Clear, Count
  └─ IList<T>                     // Index-based access
  └─ IDictionary<TKey, TValue>    // Key-based read-write

// LSP in action: List<T> implements BOTH hierarchies
public class List<T> : IList<T>, IReadOnlyList<T> { }

// Method that only needs to READ should accept IReadOnlyList<T>:
public decimal CalculateTotal(IReadOnlyList<OrderItem> items)
{
    // Guaranteed: no one can Add/Remove items through this reference
    return items.Sum(i => i.Price * i.Quantity);
}

// Method that needs to MODIFY should accept IList<T>:
public void SortAndDeduplicate(IList<OrderItem> items)
{
    // Full read-write access — contract makes this clear
}
Key LSP Takeaway

Accept the most restrictive interface your method needs. If you only read, accept IReadOnlyList<T>, not IList<T>. This makes substitution safe — any IReadOnlyList<T> implementation works because the contract only promises reading.

ASP.NET Core's dependency injectionThe DI container resolves interfaces to concrete implementations at runtime. When you register services.AddScoped<IPaymentProcessor, StripeProcessor>(), the container substitutes StripeProcessor wherever IPaymentProcessor is injected. If StripeProcessor violates LSP, every consumer breaks. is built entirely on the assumption that LSP holds. When you register services.AddScoped<IOrderRepository, SqlOrderRepository>(), every consumer of IOrderRepository gets SqlOrderRepository. If SqlOrderRepository doesn't honor the contract, the entire app breaks.

DI-LSP.cs
// Registration — substitution happens here
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

// Consumer — trusts the contract
public class OrderService(IOrderRepository repo)
{
    public async Task<Order> GetAsync(int id)
    {
        // LSP guarantees: this works regardless of which implementation is injected
        var order = await repo.GetByIdAsync(id);
        return order ?? throw new NotFoundException($"Order {id} not found");
    }
}

// ✗ An LSP-violating implementation destroys the entire DI premise:
public class CachedOrderRepository : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id)
    {
        if (!_cache.TryGetValue(id, out var order))
            return null;  // Violates if contract says "always queries DB as fallback"
        return order;
    }
}

EF Core's DbSet<T> implements IQueryable<T>, but not all LINQ operations translate to SQL. Calling .Last() on a DbSet throws InvalidOperationException because SQL doesn't have a "last" concept without ORDER BY. This is a practical LSP tension — IQueryable<T> inherits from IEnumerable<T>, where .Last() always works.

EF-LSP-Tension.cs
// Works with IEnumerable<T> (in-memory):
var last = myList.Last();  // ✓ Always works

// Fails with IQueryable<T> (EF Core):
var last = dbContext.Orders.Last();  // ✗ InvalidOperationException at runtime!

// LSP-safe approach — be explicit about what's translatable:
var last = await dbContext.Orders
    .OrderByDescending(o => o.Id)
    .FirstOrDefaultAsync();  // ✓ Translates to SQL correctly

// Even better — use the Repository pattern to hide the ORM:
public interface IOrderQueries
{
    Task<Order?> GetLatestAsync();  // No LINQ leakage — contract is clear
}
This is a known BCL tension: IQueryable<T> extending IEnumerable<T> is widely considered an LSP violation in practice. The Repository patternA pattern that encapsulates data access behind a domain-specific interface. Instead of leaking IQueryable<T> (which might not support all operations), the repository exposes explicit methods like GetByIdAsync(), GetLatestAsync(). Each method's contract is crystal clear. solves this by providing explicit, LSP-safe method contracts instead of exposing the raw IQueryable.

The System.IO.Stream class is a famous LSP case study. The base class has Read(), Write(), and Seek() — but not all streams support all operations. A NetworkStream can't Seek(). A read-only GZipStream can't Write().

Stream-LSP.cs
// Stream base class has capability flags (the BCL's LSP compromise):
public abstract class Stream
{
    public abstract bool CanRead { get; }
    public abstract bool CanWrite { get; }
    public abstract bool CanSeek { get; }

    public abstract int Read(byte[] buffer, int offset, int count);
    public abstract void Write(byte[] buffer, int offset, int count);
    public abstract long Seek(long offset, SeekOrigin origin);
}

// NetworkStream:
//   CanRead = true, CanWrite = true, CanSeek = false
//   Seek() throws NotSupportedException ← technically an LSP violation

// The "correct" LSP design would have been separate interfaces:
public interface IReadableStream { int Read(byte[] buf, int off, int count); }
public interface IWritableStream { void Write(byte[] buf, int off, int count); }
public interface ISeekableStream { long Seek(long offset, SeekOrigin origin); }

// But the BCL chose pragmatism over purity — the CanRead/CanWrite/CanSeek
// flags are a runtime workaround for a compile-time LSP violation.
Interview gold: Mentioning that System.IO.Stream violates LSP and explaining the trade-off shows deep understanding. The BCL team chose runtime capability checksThe CanRead/CanWrite/CanSeek flags let callers check capabilities before calling methods. This is a pragmatic compromise — it's technically an LSP violation (the base type's contract includes Read/Write/Seek), but the capability flags make the violation manageable. over a complex interface hierarchy for pragmatic reasons.

ASP.NET Core Identity's UserManager<TUser> works with any TUser : IdentityUser. Your custom ApplicationUser : IdentityUser must be fully substitutable — if it overrides ToString() or changes equality semantics, Identity's internal logic can break.

Identity-LSP.cs
// ✓ LSP-safe: adds properties but doesn't change base behavior
public class ApplicationUser : IdentityUser
{
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public DateOnly DateOfBirth { get; set; }
}

// ✗ LSP violation: changing how equality works
public class BadUser : IdentityUser
{
    // Overriding Equals breaks Identity's internal dictionary lookups!
    public override bool Equals(object? obj) =>
        obj is BadUser other && Email == other.Email;

    public override int GetHashCode() => Email?.GetHashCode() ?? 0;
    // Identity stores users by Id, not Email — this breaks UserManager's internals
}

MediatR's IRequestHandler<TRequest, TResponse> establishes a contract: given a request, return a response. If a handler for GetOrderQuery throws NotImplementedException instead of returning an OrderDto, the entire CQRS pipelineCommand Query Responsibility Segregation — separating read operations (queries) from write operations (commands). MediatR implements this by routing each command/query to its specific handler. LSP ensures every handler fulfills its contract. falls apart.

MediatR-LSP.cs
// Contract: handle GetOrderQuery, return OrderDto
public record GetOrderQuery(int Id) : IRequest<OrderDto>;

// ✓ LSP-safe handler
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
    {
        var order = await _repo.GetByIdAsync(request.Id);
        return order?.ToDto() ?? throw new NotFoundException($"Order {request.Id}");
    }
}

// ✗ LSP-violating handler — weakens postcondition
public class BadGetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
    public Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
    {
        return Task.FromResult<OrderDto>(null!);  // Returns null — contract says OrderDto!
    }
}
Section 9

When To Check for LSP / When Not To Worry

Check LSP When

  • Using inheritance — every virtual/override pair is an LSP risk. Ask: "If I substitute the child, does the caller still work?" This applies to template method hooksAbstract or virtual methods called by a base class's template method. The subclass overrides them to provide specific behavior. If the override changes preconditions/postconditions of the hook, the entire template method's behavior becomes unpredictable. especially.
  • Implementing interfaces in DI — your implementation will be injected everywhere. If it doesn't honor the contract, every consumer breaks.
  • Extending third-party base classesIdentityUser, DbContext, Controller. Overriding behavior can break framework internals.
  • Working with collections — accepting IList<T> but getting a read-only wrapperList<T>.AsReadOnly() returns a ReadOnlyCollection<T> that implements IList<T> but throws NotSupportedException on Add/Remove. This is an LSP violation — IList<T> promises write access. that throws on Add().
  • Designing domain hierarchies — "Employee is-a Person" seems correct until TemporaryEmployee can't receive bonuses but Employee.CalculateBonus() exists on the base.
  • Writing decorators — a caching decorator must still honor the original contract (return same types, handle same errors).

Don't Over-Think LSP When

  • Composition over inheritance — if you're using interfaces + DI (not class inheritance), LSP is mostly about honoring the interface contract.
  • Simple DTOs / value objects — a record Address extending record ContactInfo is fine if it doesn't override behavior.
  • Sealed classesThe sealed keyword in C# prevents a class from being inherited. No subtypes means no substitution, which means no LSP concerns from inheritance. It also enables JIT devirtualization for better performance. Consider sealing classes by default. — if the class is sealed, no subtypes exist, so LSP doesn't apply.
  • Internal implementation details — private methods and internal classes have limited substitution surface.
  • One-level flat hierarchiesinterface → implementation with no further subtyping is simpler to reason about.
Rule of thumb: If you're about to write override or implement an interface, ask yourself: "Would the caller notice any difference if my implementation was swapped in?" If yes, you're probably violating LSP.
Section 10

Comparisons

AspectLSPOCPISP
FocusBehavioral compatibility of subtypesExtending without modifyingInterface granularity
Violation symptomSubtype breaks caller expectationsAdding features requires editing existing codeImplementations throw NotSupportedException
EnforcementContract tests, code reviewAbstractions + DISmall, focused interfaces
Classic exampleSquare/RectangleSwitch statement for new typesFat IWorker with Eat() for robots
RelationshipLSP enables OCP (safe polymorphism)ISP prevents LSP violations (no unnecessary methods)
AspectLSP (Substitutability)Design by Contract
OriginLiskov & Wing, 1987Bertrand Meyer, 1986 (Eiffel language)
ScopeType hierarchy behaviorIndividual method pre/post conditions
EnforcementTests + code reviewRuntime assertions (Eiffel) or static analysis
In C#No built-in enforcement — discipline + testsDebug.Assert(), Code ContractsMicrosoft's System.Diagnostics.Contracts library (now deprecated) tried to bring Design by Contract to C#. It was replaced by nullable reference types and Roslyn analyzers — lighter-weight contract enforcement built into the compiler. (deprecated), nullable refs
RelationshipLSP = Design by Contract applied to inheritance hierarchies
AspectLSPCovariance / Contravariance
What it's aboutBehavioral substitutabilityType-level substitutability
Checked byTests (runtime)Compiler (compile-time)
ExampleSquare substituted for RectangleIEnumerable<Dog> used as IEnumerable<Animal>
Key insightSame methods ≠ same behaviorout T = safe to read as parent type
RelationshipVariance is the type system's attempt to enforce a subset of LSP at compile time
Section 11

Design Patterns That Rely on LSP

LSP is the silent foundation of most design patterns. If subtypes aren't substitutable, these patterns fall apart:

Strategy Pattern

Strategy works by substituting different algorithm implementations. If a QuickSortStrategy and a BubbleSortStrategy both implement ISortStrategy, the caller expects both to sort correctly. If BubbleSortStrategy returns a partially-sorted array, LSP is violated and the pattern fails.

Strategy-LSP.cs
public interface ISortStrategy<T>
{
    // Contract: returns a NEW list that is sorted ascending.
    // Postcondition: result.Count == input.Count AND result is sorted.
    IReadOnlyList<T> Sort(IReadOnlyList<T> items, IComparer<T> comparer);
}

// ✓ Both honor the contract — LSP holds, Strategy pattern works
public class QuickSortStrategy<T> : ISortStrategy<T> { /* sorts correctly */ }
public class MergeSortStrategy<T> : ISortStrategy<T> { /* sorts correctly */ }

// ✗ Violates LSP — Strategy pattern breaks
public class CappedSortStrategy<T> : ISortStrategy<T>
{
    public IReadOnlyList<T> Sort(IReadOnlyList<T> items, IComparer<T> comparer)
    {
        if (items.Count > 10000) return items.ToList();  // Returns UNSORTED! ← violation
        // ... actual sort for small lists
    }
}

A DecoratorWraps an existing implementation to add behavior (caching, logging, retry). The decorator implements the same interface and delegates to the inner instance. If the decorator changes the behavioral contract, LSP is violated and consumers get unexpected behavior. wraps an implementation and adds behavior. The decorator MUST honor the original contract. A CachingRepository that returns stale data when the base contract promises fresh data violates LSP.

Decorator-LSP.cs
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);
    Task SaveAsync(Order order);
}

// ✓ Decorator that respects the contract
public class LoggingOrderRepository(IOrderRepository inner, ILogger log) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id)
    {
        log.LogInformation("Getting order {Id}", id);
        return await inner.GetByIdAsync(id);  // Same result, just with logging
    }
    public async Task SaveAsync(Order order)
    {
        log.LogInformation("Saving order {Id}", order.Id);
        await inner.SaveAsync(order);
    }
}

// ✗ Decorator that violates LSP
public class CachingOrderRepository(IOrderRepository inner) : IOrderRepository
{
    private readonly Dictionary<int, Order> _cache = new();

    public async Task<Order?> GetByIdAsync(int id)
    {
        if (_cache.TryGetValue(id, out var cached)) return cached;  // Stale data!
        var order = await inner.GetByIdAsync(id);
        if (order != null) _cache[id] = order;
        return order;
    }
    public async Task SaveAsync(Order order)
    {
        await inner.SaveAsync(order);
        // BUG: Forgot to update/invalidate cache!
        // Next GetByIdAsync returns stale pre-save data
    }
}

Template Method relies on subclasses overriding abstract/virtual methods. If a subclass's override changes the expected sequence or skips critical steps, the template breaks.

TemplateMethod-LSP.cs
public abstract class DataImporter
{
    // Template method — defines the algorithm skeleton
    public async Task ImportAsync(Stream source)
    {
        var rawData = await ReadAsync(source);       // Step 1
        var validated = Validate(rawData);            // Step 2
        await SaveAsync(validated);                   // Step 3
    }

    protected abstract Task<RawData> ReadAsync(Stream source);
    protected abstract ValidatedData Validate(RawData data);
    protected abstract Task SaveAsync(ValidatedData data);
}

// ✓ LSP-safe: follows the contract of each step
public class CsvImporter : DataImporter
{
    protected override async Task<RawData> ReadAsync(Stream source) =>
        await CsvParser.ParseAsync(source);
    protected override ValidatedData Validate(RawData data) =>
        DataValidator.Validate(data);  // Returns validated data
    protected override async Task SaveAsync(ValidatedData data) =>
        await _repo.BulkInsertAsync(data);
}

// ✗ LSP violation: Validate() returns unvalidated data
public class FastImporter : DataImporter
{
    protected override ValidatedData Validate(RawData data) =>
        new ValidatedData(data.Rows);  // Skips validation for "speed" — postcondition violated!
}

Factory Method creates objects — but the created objects must be substitutable. If a factory produces a PremiumNotification that can't be sent via the standard Send() method, the factory's contract is broken.

Factory-LSP.cs
public interface INotification
{
    Task SendAsync(string recipient, string message);
}

public interface INotificationFactory
{
    INotification Create(string channel);
}

// ✓ All created objects honor INotification's contract
public class NotificationFactory : INotificationFactory
{
    public INotification Create(string channel) => channel switch
    {
        "email" => new EmailNotification(),
        "sms" => new SmsNotification(),
        "push" => new PushNotification(),
        _ => throw new ArgumentException($"Unknown channel: {channel}")
    };
}

// ✗ Created object violates LSP
public class StubNotification : INotification
{
    public Task SendAsync(string recipient, string message) =>
        Task.CompletedTask;  // Silently does nothing — postcondition "message is sent" violated
}
Section 12

Bug Studies

Real-world bugs caused by LSP violations. Each one looked perfectly correct during code review, passed all the basic unit tests, and then broke in production. These aren't theoretical examples; they're patterns that come up again and again in real .NET codebases.

Bug 1: The Read-Only List That Wasn't

The Incident

E-commerce pricing service. The team built a checkout endpoint that calculated order totals. The pricing method accepted an IReadOnlyList<Product> parameter because, logically, a pricing calculation should never modify the product list. Read-only means "hands off," right?

During development, everything worked. The developer passed in a List<Product> (which implements IReadOnlyList<T>), and the prices came back correct every time. But in production, the application handled many requests at once. One thread calculated pricing while another thread added items to the same shared product list.

Customers started seeing wrong totals. Sometimes the total was too low (items skipped), sometimes too high (items counted twice), and occasionally the whole request crashed with an IndexOutOfRangeException. The confusing part: the error was intermittent. It only happened under load, making it nearly impossible to reproduce in development.

The root problem was a subtle misunderstanding. IReadOnlyList<T> is not an immutable collection. It's a read-only view of a collection that might still be mutable underneath. Think of it like a window into a room: just because you can only look through the window doesn't mean nobody inside the room is rearranging furniture. The developer's code trusted that "read-only" meant "can't change," but the actual contract only promises "I won't change it through this particular interface."

IReadOnlyList is a VIEW, not a WALL List<Product> (mutable) [Laptop, Phone, Tablet, ...] Thread 1 (Pricing) via IReadOnlyList reads Thread 2 (Loader) via List<T> reference mutates! IndexOutOfRangeException / wrong totals / data corruption
Bug1-ReadOnlyList.cs
// The pricing method trusts IReadOnlyList — "this collection won't change"
public decimal CalculateTotal(IReadOnlyList<Product> products)
{
    decimal total = 0;
    for (int i = 0; i < products.Count; i++)  // Count = 5
    {
        total += products[i].Price;  // Another thread adds item → Count = 6
        // Eventually: IndexOutOfRangeException or wrong total
    }
    return total;
}

// The caller passed a mutable List:
var products = new List<Product>();
// Thread 1: CalculateTotal(products);  ← IReadOnlyList reference
// Thread 2: products.Add(newProduct);  ← Mutating the same list!

Walking through the buggy code: The CalculateTotal method looks perfectly safe. It takes an IReadOnlyList, which tells every developer who reads this code: "I'm only reading, not writing." But here's the trap. The caller passes in a regular List<Product>. That list implements IReadOnlyList, so the compiler is happy. But the list is still mutable through the original variable. Thread 1 starts iterating at Count = 5. Midway through the loop, Thread 2 calls products.Add(). Now the internal array might get resized, indices shift, and the loop either reads garbage data, skips items, or crashes with an index out of range.

Bug1-Fix.cs
// FIX 1: Snapshot the list before passing it — creates a separate copy
CalculateTotal(products.ToList().AsReadOnly());

// FIX 2 (better): Use ImmutableList — truly immutable, thread-safe by design
using System.Collections.Immutable;

var products = ImmutableList<Product>.Empty
    .Add(laptop)
    .Add(phone);

// Even if another thread "adds" to this, it creates a NEW list
// The original never changes — pricing is always consistent
CalculateTotal(products);

Why the fix works: The first fix takes a snapshot. Calling .ToList() creates a brand-new copy of the list at that moment in time, and .AsReadOnly() wraps it so nobody can mutate the copy either. Thread 2 can keep adding to the original list all it wants; the pricing method has its own private copy. The second fix is even better: ImmutableList<T> never changes, period. If someone "adds" to it, they get a new list object. The original stays frozen. This makes the guarantee structural, not just a convention.

How to Spot This in Your Code

Search for methods that accept IReadOnlyList<T> or IReadOnlyCollection<T> but are called with regular List<T> arguments, especially in multi-threaded contexts (controllers, background services, parallel loops). If you see a mutable list being shared between threads, even through a read-only interface, you've found this bug.

The Incident

Order management API. The team wrapped their IOrderRepository with a caching decorator to speed up reads. The idea was simple: first request loads from the database, every request after that gets it from an in-memory dictionary. The decorator implemented the same interface, so it was a clean drop-in replacement.

For the first few days, everything looked great. Response times dropped from 50ms to 1ms. But then the support team started getting strange reports: customers said their orders showed incorrect statuses. One customer's order showed "Shipped" even though it was just placed. Another customer's discount was being applied to orders from completely different accounts.

The problem was that the cache stored object references, not copies. When Request 1 fetched Order #42 and changed its status to "Shipped," it was mutating the exact same object sitting in the cache. Request 2 fetched Order #42 and got the already-mutated version. Worse, because these were EF Core tracked entities, some of these accidental mutations got persisted to the database on the next SaveChanges() call.

The IOrderRepository contract implicitly promises: "each call gives you your own copy to work with." The original SQL-based repository honored this because every database query returns fresh objects. But the caching decorator broke that contract by sharing the same object between every caller.

Cache returns the SAME object, not a copy Cache Dictionary [42] = Order { Status = "New" } Request 1 Sets Status = "Shipped" Mutates the cached object! Request 2 Gets Order #42 Sees "Shipped" (wrong!) same ref same ref
Bug2-CachedRepo.cs
public class CachedOrderRepository : IOrderRepository
{
    private readonly ConcurrentDictionary<int, Order> _cache = new();
    private readonly IOrderRepository _inner;

    public async Task<Order?> GetByIdAsync(int id)
    {
        // ❌ Returns the SAME object reference every time
        return _cache.GetOrAdd(id, _ => _inner.GetByIdAsync(id).Result);
    }
}

// Request 1: var order = await repo.GetByIdAsync(42);
//            order.Status = "Shipped";  ← mutates the cached object!
// Request 2: var order = await repo.GetByIdAsync(42);
//            order.Status is already "Shipped" — wrong!

Walking through the buggy code: The GetOrAdd call either returns an existing cached object or loads one from the database and caches it. The critical word here is "returns." It returns a reference to the object, not a copy. So when Request 1 gets Order #42 and changes its Status property, it's changing the one and only Order #42 object that's sitting in the cache dictionary. Every future caller who asks for Order #42 gets that same mutated object. The original database repository would return a fresh object per query, so callers never interfered with each other. This caching decorator silently broke that isolation.

Bug2-Fix.cs
public class CachedOrderRepository : IOrderRepository
{
    private readonly ConcurrentDictionary<int, Order> _cache = new();
    private readonly IOrderRepository _inner;

    public async Task<Order?> GetByIdAsync(int id)
    {
        if (_cache.TryGetValue(id, out var cached))
            return cached.DeepCopy();  // ✅ New instance every time

        var order = await _inner.GetByIdAsync(id);
        if (order != null)
            _cache[id] = order.DeepCopy();  // ✅ Cache a copy, not the original
        return order;
    }
}

// Alternative: use AsNoTracking() in the inner repo
// so EF Core doesn't track the entities at all
public async Task<Order?> GetByIdAsync(int id) =>
    await _context.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id);

Why the fix works: The key change is returning a deep copy from the cache instead of the cached object itself. Each caller gets their own private copy. If Request 1 sets Status = "Shipped", it only affects its own copy. The cache still holds the original, untouched version. The alternative approach using AsNoTracking() prevents EF Core from tracking the entity, so even if someone accidentally mutates the object, those changes won't get persisted to the database.

How to Spot This in Your Code

Look for any ConcurrentDictionary or MemoryCache that stores mutable objects (especially EF Core entities). If a cached object is returned directly to callers without cloning, and those callers modify the object, you have this bug. Also watch for decorator/wrapper repositories that claim to be drop-in replacements for the original.

The Incident

E-commerce checkout service. The team had a clean interface: IDiscountCalculator with one method that returns a discount percentage between 0% and 100%. Several implementations existed already (volume discounts, seasonal discounts, coupon codes), and they all worked perfectly.

Then a product manager asked for a "loyalty discount" based on how long a customer had been a member. A developer wrote a simple formula: (years - 2) * 5%. Members for 5+ years got up to 15% off. Sounds reasonable, and the formula is simple. But nobody caught the edge case: brand-new customers with 0 years of membership got (0 - 2) * 5% = -10%. A negative discount.

The checkout code trusted the contract: finalPrice = total * (1 - discount). With a -10% discount, the formula became total * 1.10. Customers were being charged 10% more than the listed price. The bug went unnoticed for two days because the team only tested with long-time customers during development. It took a flood of support tickets from new signups to uncover it.

This is a classic postconditionA postcondition is a guarantee about what a method will return or what state will be true after the method finishes. Here, the contract promises the return value is between 0.0 and 1.0. Any implementation that returns values outside this range has violated the postcondition. violation. The interface said "returns a value between 0 and 1." The new implementation returned values outside that range. Every caller that trusted the contract got burned.

Postcondition: discount must be 0.0 to 1.0 Valid Range: 0.0 to 1.0 0.0 1.0 VolumeDiscount returns 0.10 SeasonalDiscount returns 0.20 LoyaltyDiscount returns -0.10 OUTSIDE RANGE
Bug3-NegativeDiscount.cs
public interface IDiscountCalculator
{
    /// <summary>Returns discount as a percentage (0.0 to 1.0).</summary>
    decimal CalculateDiscount(Order order, Customer customer);
}

// ✓ VolumeDiscount follows the contract
public class VolumeDiscount : IDiscountCalculator
{
    public decimal CalculateDiscount(Order order, Customer customer) =>
        order.Total > 1000 ? 0.1m : 0m;  // 0% to 10% — always in range
}

// ✗ LoyaltyDiscount violates postcondition
public class LoyaltyDiscount : IDiscountCalculator
{
    public decimal CalculateDiscount(Order order, Customer customer)
    {
        var years = customer.MemberSince.YearsSince();
        return years > 5 ? 0.15m : (years - 2) * 0.05m;
        // New customer (0 years): (0-2) * 0.05 = -0.10 ← NEGATIVE!
    }
}

// Caller trusts the contract:
var finalPrice = order.Total * (1 - discount);
// With discount = -0.10: finalPrice = total * 1.10
// Customer is charged MORE than listed price!

Walking through the buggy code: The interface XML comment clearly says "returns a percentage between 0.0 and 1.0." The VolumeDiscount obeys this: it returns either 0 or 0.1, always in range. But look at the LoyaltyDiscount formula. For a customer who joined 3 years ago: (3 - 2) * 0.05 = 0.05. Fine. For a 1-year customer: (1 - 2) * 0.05 = -0.05. That's a negative number. The checkout code does total * (1 - discount), so a negative discount flips the subtraction into addition, increasing the price. The developer was thinking about the math of loyalty tiers and forgot about the contract boundaries.

Bug3-Fix.cs
// FIX 1: Clamp the result to honor the postcondition
public class LoyaltyDiscount : IDiscountCalculator
{
    public decimal CalculateDiscount(Order order, Customer customer)
    {
        var years = customer.MemberSince.YearsSince();
        var raw = years > 5 ? 0.15m : (years - 2) * 0.05m;
        return Math.Clamp(raw, 0m, 1m);  // ✅ Always within 0.0–1.0
    }
}

// FIX 2 (better): Enforce the contract at the interface level
public abstract class DiscountCalculatorBase : IDiscountCalculator
{
    public decimal CalculateDiscount(Order order, Customer customer)
    {
        var discount = CalculateRawDiscount(order, customer);
        if (discount < 0m || discount > 1m)
            throw new InvalidOperationException(
                $"Discount {discount} out of valid range [0, 1]");
        return discount;
    }

    protected abstract decimal CalculateRawDiscount(
        Order order, Customer customer);
}

Why the fix works: The first fix is simple: Math.Clamp ensures the result never goes below 0 or above 1, no matter what the formula produces. The second fix is more robust: it creates a base class that enforces the postcondition for all implementations. Each subclass only needs to override CalculateRawDiscount, and the base class validates the result before returning it. If any implementation returns an out-of-range value, the system catches it immediately instead of silently charging customers the wrong price.

How to Spot This in Your Code

Look for interface methods with documented ranges (percentages, scores, ratings) and check whether every implementation actually honors those ranges for all possible inputs. Pay special attention to formulas with subtraction or division, which can produce unexpected negative or infinite values. If an interface says "returns 0 to 100," write a test that throws random data at every implementation and asserts the result is within bounds.

The Incident

High-traffic API service. The team used ILogger throughout their codebase, as every .NET application does. Originally they logged to the console, and everything was fast. Then a manager asked for logs to go to a database for easier querying. A developer created a DatabaseLogger that implemented ILogger and registered it in the DI container.

In development, the logger worked fine. Requests came in one at a time, each with maybe 5-10 log calls. The database inserts took 30-50ms each, but nobody noticed. Then they deployed to production, where the service handled hundreds of concurrent requests. Each request logged 20-30 messages throughout its lifecycle (entry, validation, business logic, exit).

Within an hour, the service ground to a halt. Response times shot from 200ms to 15 seconds. The database connection pool was exhausted. Timeouts cascaded through the system. The CPU was barely used, but the threads were all blocked, waiting for database round-trips to complete.

The ILogger.Log() method has an implicit performance contract: it should be fast and non-blocking. Logging is a cross-cutting concern that gets called everywhere, often in tight loops. The ConsoleLogger honored this (writing to stdout takes microseconds). The DatabaseLogger violated it by making a synchronous network call on every log invocation. Same interface, dramatically different performance characteristics. This is an LSP violation through implicit postconditions that aren't written in code but are essential to the system's behavior.

Implicit Contract: Log() must be fast ConsoleLogger 0.01ms DatabaseLogger 50ms (5000x slower!) conn.Open() ExecuteNonQuery() Hot path (30 logs) 30 x 50ms = 1500ms blocked per request!
Bug4-BlockingLogger.cs
// ConsoleLogger — honors the implicit performance contract
public class ConsoleLogger : ILogger
{
    public void Log<TState>(LogLevel level, EventId id, TState state,
        Exception? ex, Func<TState, Exception?, string> formatter)
    {
        Console.WriteLine(formatter(state, ex));  // ~0.01ms
    }
}

// ✗ DatabaseLogger — violates implicit performance postcondition
public class DatabaseLogger : ILogger
{
    public void Log<TState>(LogLevel level, EventId id, TState state,
        Exception? ex, Func<TState, Exception?, string> formatter)
    {
        using var conn = new SqlConnection(_connStr);
        conn.Open();                    // BLOCKING — 20-50ms
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "INSERT INTO Logs VALUES (@msg, @level, @time)";
        cmd.Parameters.AddWithValue("@msg", formatter(state, ex));
        cmd.Parameters.AddWithValue("@level", level.ToString());
        cmd.Parameters.AddWithValue("@time", DateTime.UtcNow);
        cmd.ExecuteNonQuery();          // Another 10-30ms — BLOCKING
    }
}

Walking through the buggy code: Look at the ConsoleLogger first. It calls Console.WriteLine and returns. Takes about 0.01 milliseconds. Code that uses ILogger assumes this kind of speed. Now look at DatabaseLogger. Every single log call opens a new database connection (20-50ms network round-trip), then executes an INSERT query (another 10-30ms). That's 30-80ms of blocking I/O on every log call. In a request handler that logs 30 times, that's up to 2.4 seconds spent just on logging. The method signature is identical, but the performance characteristics are completely different.

Bug4-Fix.cs
// ✅ BufferedDatabaseLogger — non-blocking write to a channel
public class BufferedDatabaseLogger : ILogger
{
    private readonly Channel<LogEntry> _channel =
        Channel.CreateBounded<LogEntry>(10_000);

    public void Log<TState>(LogLevel level, EventId id, TState state,
        Exception? ex, Func<TState, Exception?, string> formatter)
    {
        // Non-blocking — just drops the entry into an in-memory queue
        _channel.Writer.TryWrite(new LogEntry
        {
            Message = formatter(state, ex),
            Level = level,
            Timestamp = DateTime.UtcNow
        });  // ~0.001ms — as fast as ConsoleLogger
    }

    // A background task reads from the channel and batch-inserts
    public async Task ProcessLogsAsync(CancellationToken ct)
    {
        await foreach (var batch in _channel.Reader.ReadAllAsync(ct).Chunk(100))
        {
            await BulkInsertAsync(batch);  // One DB call per 100 logs
        }
    }
}

Why the fix works: The buffered logger separates the "accept the log entry" step from the "write it to the database" step. When your code calls Log(), all it does is drop a small object into an in-memory channel. That takes microseconds, just like the console logger. The expensive database work happens in a separate background task that reads from the channel and batch-inserts entries. This means the calling thread is never blocked, the performance contract is honored, and you still get database persistence.

How to Spot This in Your Code

Watch for interface implementations that add I/O operations (database calls, HTTP requests, file writes) where the original implementations were in-memory or trivially fast. If you're replacing a fast implementation with a slow one through DI, you've probably violated an implicit performance contract. Profile the hot paths and check if any "lightweight" interfaces suddenly have heavyweight implementations.

The Incident

Product catalog service. The team needed to sort products by price, but with a twist: products within $1 of each other should be considered "the same price" for display purposes. A developer wrote a custom IComparer<Product> that returned 0 (meaning "equal") when the price difference was less than $1.

In manual testing with small datasets, the sorting looked fine. But in production with thousands of products, the sort produced inconsistent orderings. Sometimes Product A appeared before Product B, and sometimes B appeared before A, depending on the initial order of the list. Occasionally, List.Sort() threw an InvalidOperationException with the message "Failed to compare two elements in the array."

The problem was a violation of transitivityTransitivity is a mathematical rule: if A equals B and B equals C, then A must equal C. Sorting algorithms rely on this rule to work correctly. If your comparer says $10 = $10.50 and $10.50 = $11 but $10 is NOT equal to $11, the algorithm gets confused because the "equals" relationship is contradictory.. The comparer said $10.00 was "equal" to $10.50, and $10.50 was "equal" to $11.00, but $10.00 was NOT equal to $11.00. This is like saying "Alice is the same height as Bob, and Bob is the same height as Carol, but Alice is shorter than Carol." That's a logical contradiction, and sorting algorithms break when they encounter it.

.NET's sorting algorithm (a modified QuickSort) assumes the comparer follows strict mathematical rules. When those rules are violated, the algorithm can enter inconsistent states: it might infinite-loop, produce wrong orderings, or throw an exception. The IComparer<T> contract demands transitivity, and the fuzzy comparer broke it.

Transitivity: if A = B and B = C, then A must = C A $10.00 B $10.50 C $11.00 = (diff $0.50) = (diff $0.50) A < C (diff $1.00) -- CONTRADICTION!
Bug5-FuzzyComparer.cs
// ✗ Violates IComparer<T> contract — not transitive
public class FuzzyPriceComparer : IComparer<Product>
{
    public int Compare(Product? x, Product? y)
    {
        if (x is null || y is null) return 0;
        var diff = x.Price - y.Price;
        if (Math.Abs(diff) < 1.0m) return 0;  // "Close enough" = equal
        return diff > 0 ? 1 : -1;
    }
}

// A=$10, B=$10.50, C=$11
// Compare(A,B) = 0 (equal — diff is $0.50)
// Compare(B,C) = 0 (equal — diff is $0.50)
// Compare(A,C) = -1 (A < C — diff is $1.00)
// INCONSISTENT! Sort() may crash or produce wrong results.

Walking through the buggy code: The comparer checks whether the price difference is less than $1.00. If so, it says "these two are equal." The problem is that "close enough" doesn't chain properly. Product A ($10.00) is close enough to B ($10.50), and B is close enough to C ($11.00), but A is NOT close enough to C. The sorting algorithm assumes that if it already determined A=B and B=C, it doesn't need to re-check A vs C. But when it eventually does compare A and C (because sorting involves many pair comparisons), it gets a contradictory answer. At that point, the algorithm's internal state is corrupted.

Bug5-Fix.cs
// ✅ FIX: Round to price buckets — transitive by design
public class BucketPriceComparer : IComparer<Product>
{
    public int Compare(Product? x, Product? y)
    {
        if (x is null && y is null) return 0;
        if (x is null) return -1;
        if (y is null) return 1;

        // Round to nearest dollar: $10.50 → $11, $10.49 → $10
        var bucketX = Math.Round(x.Price);
        var bucketY = Math.Round(y.Price);
        return bucketX.CompareTo(bucketY);  // Transitive!
    }
}

// Now: A=$10→bucket $10, B=$10.50→bucket $11, C=$11→bucket $11
// Compare(A,B) = -1 (A < B) — consistent
// Compare(B,C) = 0  (B = C) — consistent
// Compare(A,C) = -1 (A < C) — consistent!
// Transitivity is preserved.

Why the fix works: Instead of using fuzzy "close enough" comparison, the fix snaps each price to a bucket (the nearest whole dollar). Two items in the same bucket are truly equal, and items in different buckets have a clear ordering. This is transitive because bucket assignment is deterministic: $10.50 always rounds to $11, no matter what other items exist. There's no "sliding window" of closeness. The comparer now satisfies all three mathematical properties that IComparer<T> demands: reflexivity, antisymmetry, and transitivity.

How to Spot This in Your Code

Any custom IComparer<T> or IEqualityComparer<T> that uses a "tolerance" or "close enough" comparison is suspect. If your comparer returns 0 (equal) for items within some threshold, check whether three items at evenly spaced distances could break transitivity. The simplest test: pick three values A, B, C where A is "close to" B and B is "close to" C but A is not "close to" C. If such values exist, your comparer is broken.

The Incident

User management service. A developer needed to deduplicate users by email address. They overrode Equals() on the User class to compare by email instead of by the default reference equality. So two User objects with the same email would be considered "equal" even if they had different IDs.

The deduplication logic used a HashSet<User> to filter out duplicates. In testing with a small dataset, it seemed to work. But in production, duplicates kept slipping through. The HashSet was adding users with the same email as if they were different people. Worse, Dictionary<User, List<Order>> lookups were failing: the code would add a user as a key, then try to look them up with a different User object that had the same email but a different ID. The lookup returned nothing.

The developer had overridden Equals() to use email, but forgot to override GetHashCode(). The default GetHashCode() still used the object's ID (or memory reference). This violates a fundamental contract in .NET: if two objects are Equal, they must have the same hash code. Hash-based collections like HashSet and Dictionary check the hash code first to find the right "bucket," and only then call Equals() on items in that bucket. If two equal objects are in different buckets, they'll never be compared.

Think of it like a filing system. GetHashCode() decides which drawer to put the file in. Equals() checks if two files in the same drawer are the same document. If two copies of the same document end up in different drawers, the system will never realize they're duplicates because it only compares files within the same drawer.

HashSet Bucket Lookup: Hash Code first, then Equals Bucket #1 (hash=1) User A: Id=1, alice@ Bucket #2 (hash=2) User B: Id=2, alice@ set.Contains(userB) = FALSE! B's hash = 2, so it looks in Bucket #2. A is in Bucket #1. Never compared! A.Equals(B) = true (same email) but GetHashCode differs (1 vs 2)
Bug6-Equality.cs
public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = string.Empty;

    // ✗ Equals uses Email...
    public override bool Equals(object? obj) =>
        obj is User other &&
        Email.Equals(other.Email, StringComparison.OrdinalIgnoreCase);

    // ✗ ...but GetHashCode uses Id — INCONSISTENT!
    public override int GetHashCode() => Id.GetHashCode();
}

// User A: Id=1, Email="alice@test.com"
// User B: Id=2, Email="alice@test.com"
// A.Equals(B) = true  ← same email
// A.GetHashCode() = 1, B.GetHashCode() = 2  ← different buckets!

var set = new HashSet<User> { userA };
set.Contains(userB);  // FALSE! Same email, but different hash bucket

Walking through the buggy code: The Equals method compares by email, so User A (alice@test.com, Id=1) and User B (alice@test.com, Id=2) are considered equal. But GetHashCode still uses the Id field, so A's hash code is 1 and B's hash code is 2. When we add User A to the HashSet, it goes into bucket #1 (based on hash code 1). When we check set.Contains(userB), the set calculates B's hash code (2), looks in bucket #2, finds it empty, and immediately returns false. It never even calls Equals because the hash codes directed it to the wrong bucket.

Bug6-Fix.cs
// FIX 1: Override both to use the same fields
public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = string.Empty;

    public override bool Equals(object? obj) =>
        obj is User other &&
        Email.Equals(other.Email, StringComparison.OrdinalIgnoreCase);

    // ✅ Hash code now uses the same field as Equals
    public override int GetHashCode() =>
        Email.GetHashCode(StringComparison.OrdinalIgnoreCase);
}

// FIX 2 (best): Use a record — auto-generates correct equality
public record UserRecord(int Id, string Email);
// Records compare ALL properties and auto-generate matching GetHashCode

Why the fix works: The first fix changes GetHashCode to use the same field as Equals: the email. Now User A and User B both produce the same hash code because they have the same email. They land in the same bucket, Equals is called, and the set correctly identifies them as the same user. The second fix is even better: C# record types automatically generate Equals and GetHashCode from all properties, and they always match. You get correct equality behavior without writing it yourself.

How to Spot This in Your Code

The C# compiler actually warns about this (CS0659: "class overrides Equals but not GetHashCode"). Make sure this warning is not suppressed. Also search for any class that overrides Equals and check that GetHashCode uses the exact same set of fields. If Equals compares by email but GetHashCode uses Id (or the default), you have this bug. Using record types is the safest path because they make it impossible to get this wrong.

Section 13

Pitfalls

Why This Happens: Inheritance is tempting when you see a class that already has the methods you need. "Why rewrite it? I'll just extend it and get everything for free." That reasoning feels efficient, but it conflates code reuse with behavioral compatibility. Inheritance says "my class IS a kind of that class." If that's not genuinely true in terms of behavior, you'll inherit methods that don't make sense for your type.

The classic example: making a Stack inherit from List<T>. You get Push and Pop for free (sort of), but you also inherit Insert(), RemoveAt(), and Add(). Those operations let someone stick items in the middle of the stack or remove from arbitrary positions. That completely breaks the LIFO contractLast-In-First-Out: a stack only allows Push (add to top) and Pop (remove from top). Inheriting from List exposes Insert-at-index and Remove-at-index, which break the LIFO invariant. Java's Stack class actually makes this mistake — it extends Vector.. A user who treats your Stack as a List will use it in ways your Stack never intended.

Java made this exact mistake: java.util.Stack extends Vector. Any code that holds a Vector reference to a Stack can call insertElementAt() and break the LIFO ordering. This has been a well-known design flaw since the 1990s.

Inheritance (leaks methods) List<T> Add, Insert, RemoveAt... MyStack<T> Insert() exposed! LIFO broken Composition (controls API) MyStack<T> Push(), Pop(), Count private List<T> (hidden inside) LIFO enforced
BadInheritance.cs
// ❌ BAD: Stack inherits from List — exposes operations that break LIFO
public class MyStack<T> : List<T>
{
    public void Push(T item) => Add(item);
    public T Pop() { var item = this[^1]; RemoveAt(Count - 1); return item; }
}

var stack = new MyStack<int>();
stack.Push(1); stack.Push(2); stack.Push(3);
stack.Insert(0, 99);  // ← Inherited from List — breaks LIFO!
// Stack now has [99, 1, 2, 3] — not a valid stack state
GoodComposition.cs
// ✅ GOOD: Stack uses composition — only exposes Push and Pop
public class MyStack<T>
{
    private readonly List<T> _items = new();

    public void Push(T item) => _items.Add(item);
    public T Pop()
    {
        var item = _items[^1];
        _items.RemoveAt(_items.Count - 1);
        return item;
    }
    public int Count => _items.Count;
    // No Insert, no RemoveAt, no Add — LIFO is enforced
}

The difference is simple: with inheritance, you can't un-expose methods the parent class has. With composition, you control exactly which operations exist. The List is a private implementation detail that nobody outside the Stack can touch.

Why This Happens: You're building a class that needs to implement an interface, but one or two methods don't apply to your situation. The deadline is approaching, so you stub them out with throw new NotImplementedException() or throw new NotSupportedException(). Visual Studio even generates this as the default implementation when you auto-implement an interface. It feels safe because the code compiles, the other methods work, and "nobody calls that method anyway."

But here's the trap: the interface is a promise. When code accepts an IFileStorage, it trusts that every method works. If Delete() throws NotImplementedException, any code path that calls Delete() will crash at runtime. The compiler can't catch this because the method signature is satisfied; only the behavior is broken. This is LSP at its core: the subtype doesn't honor the contract the interface promises.

Fat interface, partial impl IFileStorage Read() | Write() | Delete() ReadOnlyStorage Read() Write() Delete() throw NotSupported! Split interfaces IFileReader IFileWriter IDeletor ReadOnlyStorage implements IFileReader only FullStorage all three interfaces
BadNotImpl.cs
// ❌ BAD: Implementing an interface but refusing to honor part of the contract
public class ReadOnlyStorage : IFileStorage
{
    public byte[] Read(string path) => File.ReadAllBytes(path);  // ✅ works
    public void Write(string path, byte[] data) =>
        throw new NotSupportedException("Read-only!");           // ❌ bomb
    public void Delete(string path) =>
        throw new NotSupportedException("Read-only!");           // ❌ bomb
}
GoodSplit.cs
// ✅ GOOD: Split the interface so each class only promises what it can deliver
public interface IFileReader { byte[] Read(string path); }
public interface IFileWriter { void Write(string path, byte[] data); }
public interface IFileDeletor { void Delete(string path); }

public class ReadOnlyStorage : IFileReader
{
    public byte[] Read(string path) => File.ReadAllBytes(path);  // ✅ only promises Read
}

public class FullStorage : IFileReader, IFileWriter, IFileDeletor
{
    // Implements all three — every method works as expected
}

By splitting the interface (this is the Interface Segregation Principle working hand-in-hand with LSP), each class only implements the methods it can actually honor. No more dead methods that explode at runtime.

Why This Happens: You receive an interface reference, but one particular implementation needs special handling. Maybe circles need to be drawn differently, or premium customers need an extra step. So you write if (shape is Circle) to check the actual type at runtime. It feels like a quick, practical fix.

But every is or as check is a red flag. It means the interface abstraction has leaked. If the caller needs to know the concrete type, then the types aren't truly interchangeable. Every time a new subtype is added, someone has to update every if-else chain that checks types. This defeats the entire purpose of using interfaces and polymorphism in the first place.

Think of it like a restaurant. The waiter shouldn't need to know which brand of oven the kitchen uses. They just say "make this order" and trust the kitchen to figure it out. If the waiter starts checking "is this a gas oven? Then I need to adjust the temperature myself" that's a sign the kitchen isn't doing its job.

Caller checks types Render(IShape) is Circle? is Rect? is ??? new shape? must edit caller every time Each shape renders itself shape.Render() Circle Rect Triangle add new shape = zero caller changes
BadTypeCast.cs
// ❌ BAD: Caller knows too much about the concrete type
public void Render(IShape shape)
{
    if (shape is Circle c)
        DrawCircle(c.Radius);       // Special path for Circle
    else if (shape is Rectangle r)
        DrawRect(r.Width, r.Height); // Special path for Rectangle
    // Every new shape = another "else if" here
}
GoodPolymorphism.cs
// ✅ GOOD: Each shape knows how to render itself
public interface IShape { void Render(ICanvas canvas); }

public class Circle : IShape
{
    public double Radius { get; }
    public void Render(ICanvas canvas) => canvas.DrawCircle(Radius);
}

public class Rectangle : IShape
{
    public double Width { get; }
    public double Height { get; }
    public void Render(ICanvas canvas) => canvas.DrawRect(Width, Height);
}

// Caller code — no type checking needed
public void Render(IShape shape) => shape.Render(_canvas);

In the fixed version, the caller doesn't care what kind of shape it has. It just calls shape.Render() and the right behavior happens automatically. Adding a new shape (like Triangle) requires zero changes to the caller code.

Why This Happens: In the real world, a square IS a rectangle (it's a rectangle with equal sides). So it feels natural to make Square extend Rectangle in code. The domain model matches the real world. What could go wrong?

The problem is that real-world categories are about what things are, but programming hierarchies are about how things behave. A Rectangle with mutable dimensions lets you set width and height independently. A Square can't allow that because both dimensions must always be equal. If Square overrides the width setter to also change the height, then code that says rect.Width = 5; rect.Height = 10; assert(rect.Area == 50) fails when rect is actually a Square (area becomes 100 instead of 50).

Square extends Rectangle Rectangle Width, Height (independent) Square Width = Height (coupled!) W=5, H=10 => Area = 100 not 50 Caller's assumption broken Separate types, shared interface IShape { Area } Rectangle(W, H) Area = W x H Square(Side) Area = S x S No shared mutable state, no surprises
BadSquare.cs
// ❌ BAD: Domain truth ("square is a rectangle") doesn't hold in code
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; } // Forces equality
    }
    public override int Height
    {
        set { base.Width = value; base.Height = value; } // Forces equality
    }
}

Rectangle rect = new Square();
rect.Width = 5;
rect.Height = 10;
// Expecting Area = 50, but it's 100 — Height setter overwrote Width!
GoodShapes.cs
// ✅ GOOD: Separate types behind a shared interface
public interface IShape { int Area { get; } }

public record Rectangle(int Width, int Height) : IShape
{
    public int Area => Width * Height;
}

public record Square(int Side) : IShape
{
    public int Area => Side * Side;
}
// No inheritance. No shared setters. No surprises.

The fix uses a shared IShape interface and immutable records. Rectangle and Square are separate, independent types. Neither one can break the other's behavior because they don't share any mutable state.

Why This Happens: An interface says GetByIdAsync returns an Order. The original SQL implementation always returns a valid order (throws if not found). Then someone writes a new implementation where sometimes the method silently returns null. Or a Save() method that does nothing because it's a test stub that accidentally leaked into production.

The developer thinks "I'm handling the edge case gracefully by returning null instead of throwing." But the caller was written to trust the original contract. It doesn't check for null. It chains method calls like order.Items.Count and gets a NullReferenceException three layers up the call stack, far from where the real problem is.

Promise says non-null Task<Order> GetByIdAsync() CachedRepo returns null! order.Items.Count NullReferenceException! Promise says nullable Task<Order?> GetByIdAsync() null? yes: handle no: use safely Compiler warns if null not handled
BadWeakPost.cs
// ❌ BAD: Silently returns null when base contract promised non-null
public class CachedRepo : IOrderRepository
{
    public async Task<Order> GetByIdAsync(int id)
    {
        if (_cache.TryGetValue(id, out var order))
            return order;
        return null!;  // ← "null-forgiving" hides the violation from the compiler
    }
}
// Caller: var total = (await repo.GetByIdAsync(42)).Items.Sum(i => i.Price);
// NullReferenceException if 42 isn't in cache!
GoodExplicitNull.cs
// ✅ GOOD: Make nullability explicit in the return type
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);  // ← "?" = might be null
}

// Now every caller MUST handle the null case
var order = await repo.GetByIdAsync(42);
if (order is null) { /* handle missing order */ }
var total = order.Items.Sum(i => i.Price);  // Compiler warns if null not handled

The fix is to make the postcondition honest. If some implementations might return null, make that part of the contract (Order? instead of Order). C# nullable reference typesC# 8+'s nullable reference types let you express "this returns null" (Task<Order?>) vs "this never returns null" (Task<Order>) at the type level. The compiler warns when a non-nullable return type might return null, catching potential NullReferenceExceptions at compile time. turn this from a runtime surprise into a compile-time warning.

Why This Happens: A developer overrides Equals() to compare by business key (email, SKU, etc.) but forgets to override GetHashCode() to match. Or they override both but base them on different fields. It's an easy mistake because the compiler only gives a warning (not an error), and the code seems to work in simple tests.

The Object class in .NET has a fundamental contract: if two objects are Equal, they must produce the same hash code. Hash-based collections (Dictionary, HashSet, LINQ's Distinct()) rely on this rule to work. They use the hash code to pick a bucket, then use Equals to compare within that bucket. If two "equal" objects land in different buckets, the collection will never realize they're the same.

Different fields = broken Equals() uses Email GetHashCode() uses Id Bucket #3 Bucket #7 "Equal" users in different buckets HashSet thinks they're different! Same field = correct Equals() uses Email GetHashCode() uses Email Same Bucket #3 "Equal" users in same bucket Collections work correctly
BadEquality.cs
// ❌ BAD: Equals uses Email, GetHashCode uses Id
public override bool Equals(object? obj) =>
    obj is User u && Email == u.Email;
public override int GetHashCode() => Id.GetHashCode(); // WRONG field!
GoodEquality.cs
// ✅ GOOD: Both methods use the same field(s)
public override bool Equals(object? obj) =>
    obj is User u && Email.Equals(u.Email, StringComparison.OrdinalIgnoreCase);
public override int GetHashCode() =>
    Email.GetHashCode(StringComparison.OrdinalIgnoreCase);

// ✅ BEST: Use a record — correct equality is automatic
public record User(int Id, string Email);

The safest approach: use C# record types. They auto-generate matching Equals and GetHashCode from all properties, making it impossible to get this wrong.

Why This Happens: A decorator or subtype seems like the perfect place to add caching, retry counts, or other stateful behavior. "I'll just wrap the original and cache the results for performance." But if the base type's contract implies that each method call is independent and stateless, introducing hidden state changes the observable behavior.

For example, a CachingDecorator around a stateless IProductService means the same call now returns different results depending on whether the cache is warm or cold. If another part of the system updates a product in the database, the cache still returns stale data. The original implementation always returned fresh data. That's a behavioral change the caller didn't ask for and doesn't know about.

Hidden cache state CachingProductService _cache{} hidden from caller Call 1: fresh Call 2: stale! DB update: still stale! Caller has no idea Explicit cache interface ICachedProductService GetById() InvalidateCache() InvalidateAll() Caller knows about caching No hidden surprises
BadMutableState.cs
// ❌ BAD: Introduces hidden state the base didn't have
public class CachingProductService : IProductService
{
    private readonly Dictionary<int, Product> _cache = new();  // hidden state!
    private readonly IProductService _inner;

    public Product GetById(int id) =>
        _cache.TryGetValue(id, out var p) ? p : (_cache[id] = _inner.GetById(id));
    // First call: fresh. Second call: stale. Third call after DB update: still stale!
}
GoodExplicitCache.cs
// ✅ GOOD: Make caching explicit in the interface
public interface ICachedProductService : IProductService
{
    void InvalidateCache(int id);
    void InvalidateAll();
}
// Now callers KNOW this service caches. They can invalidate when needed.
// Or use a separate IDistributedCache as a cross-cutting concern.

If you need caching, either make it explicit in the interface (so callers know about it and can manage it) or use a dedicated caching layer separate from the business logic interface.

Why This Happens: You want to change what a method does in a derived class, but the base class didn't mark it as virtual. C# lets you "hide" the base method with the new keyword. It compiles without errors. But it creates a trap: the method called depends on the reference type (how you hold the variable), not the object type (what the object actually is).

This means the exact same object can behave differently depending on whether you hold it as a Base reference or a Derived reference. That directly contradicts substitutability: the whole point of LSP is that the reference type shouldn't matter.

new hides (reference-based) Derived d d.Name() "Derived" (Base)d d.Name() "Base" !!! Same object, two answers override (object-based) Derived d d.Name() "Derived" (Base)d d.Name() "Derived" Same object, consistent answer
BadNewHiding.cs
// ❌ BAD: "new" hides the base method — behavior depends on reference type
public class Base { public virtual string Name() => "Base"; }
public class Derived : Base { public new string Name() => "Derived"; }

var d = new Derived();
Console.WriteLine(d.Name());           // "Derived"
Console.WriteLine(((Base)d).Name());   // "Base" — DIFFERENT behavior!
// Same object, two different answers. That's not substitutable.
GoodOverride.cs
// ✅ GOOD: "override" uses the runtime type — consistent behavior
public class Base { public virtual string Name() => "Base"; }
public class Derived : Base { public override string Name() => "Derived"; }

var d = new Derived();
Console.WriteLine(d.Name());           // "Derived"
Console.WriteLine(((Base)d).Name());   // "Derived" — SAME behavior!
// Doesn't matter how you hold the reference — the object type wins.

Use override instead of new. If the base method isn't virtual and you can't change it, that's a signal you shouldn't be inheriting from that class. Consider composition instead.

Why This Happens: A base method accepts any input (e.g., Process(Order order) works for any order). A developer creates a subtype that adds a business rule: "we only handle orders above $50." The subtype throws an exception or silently skips small orders. The caller had no idea about this restriction because the base type didn't have it.

This is called "strengthening the precondition." The base says "give me anything," but the subtype says "give me only this specific subset." That breaks substitutability: code that worked with the base type (passing any order) breaks with the subtype (which rejects small orders). LSP's rule is clear: subtypes can accept MORE inputs than the base (weaker preconditions), but never FEWER (stronger preconditions).

Subtype rejects valid input Base: Process(any order) Premium: only total >= $50 $30 order REJECTED! $60 order OK Capability check first IOrderProcessor CanProcess() Process() can? no: skip gracefully yes: process No surprise rejections
BadStrongerPre.cs
// ❌ BAD: Subtype demands MORE than the base — strengthened precondition
public class PremiumProcessor : OrderProcessor
{
    public override void Process(Order order)
    {
        if (order.Total < 50m)
            throw new ArgumentException("Order too small");  // Base didn't have this!
        base.Process(order);
    }
}
GoodCapability.cs
// ✅ GOOD: Capability checking instead of surprise rejection
public interface IOrderProcessor
{
    bool CanProcess(Order order);  // Let caller ask first
    void Process(Order order);
}

public class PremiumProcessor : IOrderProcessor
{
    public bool CanProcess(Order order) => order.Total >= 50m;
    public void Process(Order order) { /* ... */ }
}

The fix adds a CanProcess() method so callers can check before calling. This is the same pattern .NET's Stream uses with CanRead/CanWrite/CanSeek: capabilities are explicit, not surprise rejections.

Why This Happens: LSP is often taught using class inheritance examples (Rectangle/Square, Bird/Penguin), so many developers think it only matters when you write class Dog : Animal. But in modern C#, you rarely use class inheritance for polymorphism. You use interfaces. And LSP applies just as strongly to interface implementations.

Every time you register services.AddScoped<IRepository, SqlRepository>() in your DI container, you're creating a subtype relationship. SqlRepository is being substituted for IRepository. If SqlRepository behaves differently than what IRepository consumers expect (slower, throws different exceptions, returns null when others don't), that's an LSP violation. The same applies to decorators, adapters, mocks, and any wrapper that claims to implement an interface.

DI swaps in a no-op IEmailSender FakeEmailSender silently drops all emails Interface says "sends email" Implementation doesn't! Every impl honors contract IEmailSender SmtpSender SendGridSender Both actually send emails Postcondition honored
BadAssumption.cs
// ❌ BAD: LSP violation through DI — not inheritance
services.AddScoped<IEmailSender, FakeEmailSender>();  // For "testing"

public class FakeEmailSender : IEmailSender
{
    public Task SendAsync(string to, string body) =>
        Task.CompletedTask;  // Silently drops emails!
    // If this leaks to production, no emails go out.
    // LSP violation: interface says "sends email", implementation doesn't.
}
GoodDI.cs
// ✅ GOOD: Every DI registration honors the interface contract
services.AddScoped<IEmailSender, SmtpEmailSender>();    // Production
services.AddScoped<IEmailSender, SendGridSender>();     // Alternative

// Both actually send emails. Both honor the postcondition.
// For tests, use a real test framework mock that VERIFIES calls were made,
// not a silent no-op that hides bugs.

The takeaway: any time you swap one implementation for another (through DI, decorators, adapters, or test doubles), ask yourself: "Will every consumer still work correctly with this new implementation?" That question IS the Liskov Substitution Principle.

LSP Applies Everywhere, Not Just Inheritance DI Registration Decorators Adapters Test Doubles Mocks Every swap is a substitution. Will every consumer still work correctly?
Section 14

Testing Strategies

How to catch LSP violations before they reach production.

Strategy 1: Contract Tests (Abstract Test Classes)

Write a single test class that tests the interface contract, then run it against every implementation. If any implementation fails, it violates LSP.

ContractTests.cs
// Abstract test class — defines the CONTRACT
public abstract class IOrderRepositoryContractTests
{
    protected abstract IOrderRepository CreateSut();

    [Fact]
    public async Task GetByIdAsync_ExistingOrder_ReturnsOrder()
    {
        var sut = CreateSut();
        var order = new Order { Id = 1, Total = 100m };
        await sut.SaveAsync(order);

        var result = await sut.GetByIdAsync(1);

        Assert.NotNull(result);
        Assert.Equal(1, result.Id);
        Assert.Equal(100m, result.Total);
    }

    [Fact]
    public async Task GetByIdAsync_NonExistent_ReturnsNull()
    {
        var sut = CreateSut();
        var result = await sut.GetByIdAsync(999);
        Assert.Null(result);
    }

    [Fact]
    public async Task SaveAsync_ThenGet_ReturnsLatestVersion()
    {
        var sut = CreateSut();
        var order = new Order { Id = 1, Total = 100m };
        await sut.SaveAsync(order);

        order.Total = 200m;
        await sut.SaveAsync(order);

        var result = await sut.GetByIdAsync(1);
        Assert.Equal(200m, result!.Total);  // Must reflect the latest save
    }
}

// Run against EVERY implementation:
public class SqlOrderRepositoryTests : IOrderRepositoryContractTests
{
    protected override IOrderRepository CreateSut() =>
        new SqlOrderRepository(_testDbContext);
}

public class InMemoryOrderRepositoryTests : IOrderRepositoryContractTests
{
    protected override IOrderRepository CreateSut() =>
        new InMemoryOrderRepository();
}

public class CachedOrderRepositoryTests : IOrderRepositoryContractTests
{
    protected override IOrderRepository CreateSut() =>
        new CachedOrderRepository(new InMemoryOrderRepository());
}
Why This Works

Every implementation runs the exact same tests. If a caching decorator breaks the "save then get returns latest" test, you catch the LSP violation immediately. This is the most powerful LSP testing technique.

Generate random inputs and verify that invariantsProperties that must always be true, regardless of input. For a comparer: transitivity (if a≤b and b≤c then a≤c). For a discount calculator: result is between 0 and 1. Property-based testing generates hundreds of random inputs to find edge cases that violate these invariants. hold for every implementation. Perfect for catching edge cases that example-based tests miss.

PropertyTests.cs
// Using FsCheck with xUnit
[Property]
public Property Comparer_Must_Be_Transitive(int a, int b, int c)
{
    var comparer = CreateSut();  // Any IComparer<int> implementation
    var ab = comparer.Compare(a, b);
    var bc = comparer.Compare(b, c);
    var ac = comparer.Compare(a, c);

    // If a <= b and b <= c, then a <= c (transitivity)
    return (ab <= 0 && bc <= 0 ? ac <= 0 : true).ToProperty();
}

[Property]
public Property Discount_Must_Be_Between_Zero_And_One(
    PositiveInt orderTotal, PositiveInt memberYears)
{
    var calculator = CreateSut();  // Any IDiscountCalculator
    var order = new Order { Total = orderTotal.Get };
    var customer = new Customer { MemberYears = memberYears.Get };

    var discount = calculator.CalculateDiscount(order, customer);

    return (discount >= 0m && discount <= 1m).ToProperty()
        .Label($"Discount {discount} out of range [0, 1]");
}

Use NetArchTestA library that lets you write unit tests about your code's architecture. You can enforce rules like "classes implementing IRepository must be in the Data namespace" or "no class should throw NotImplementedException." These tests run in CI and catch structural violations automatically. to enforce structural rules that prevent common LSP violations.

ArchitectureTests.cs
public class LspArchitectureTests
{
    [Fact]
    public void No_Class_Should_Throw_NotImplementedException()
    {
        var result = Types.InCurrentDomain()
            .That().AreClasses()
            .ShouldNot().HaveDependencyOn("System.NotImplementedException")
            .GetResult();

        Assert.True(result.IsSuccessful,
            $"LSP violation: {string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>())} " +
            "throw NotImplementedException");
    }

    [Fact]
    public void No_Class_Should_Throw_NotSupportedException()
    {
        var result = Types.InCurrentDomain()
            .That().AreClasses()
            .And().ImplementInterface(typeof(IOrderRepository))
            .ShouldNot().HaveDependencyOn("System.NotSupportedException")
            .GetResult();

        Assert.True(result.IsSuccessful);
    }
}

Mutation testingA testing technique where small changes ("mutations") are made to your code — like changing + to -, flipping a boolean, removing a line — and your tests are re-run. If the tests still pass after a mutation, your tests are too weak to catch that kind of bug. Tools like Stryker.NET automate this. verifies that your contract tests actually catch violations. It modifies your implementations (mutants) and checks that your tests detect the change.

stryker-config.json
// dotnet tool install -g dotnet-stryker
// dotnet stryker

// Stryker will:
// 1. Change "return order.Total * 0.1m" to "return order.Total * 0.9m"
// 2. Run your contract tests
// 3. If tests pass → your contract tests are TOO WEAK (surviving mutant)
// 4. If tests fail → your contract tests correctly enforce the postcondition

// Target: 80%+ mutation kill rate for interface implementations
// This ensures your contract tests actually catch LSP violations
Section 15

Performance Considerations

LSP is primarily a correctness principle, not a performance one. But LSP violations can have severe performance implications.

ScenarioLSP ConnectionPerformance Impact
Decorator adds cachingMust honor "freshness" postconditionCache invalidation overhead; stale data = reprocessing cost
Subtype does more workMust not violate implicit performance postconditionBlocking I/O in a "fast" method → thread starvation
IQueryable vs IEnumerableSame interface, vastly different execution modelClient-side evaluation of large datasets → OOM
sealed classesNo subtypes = no LSP concernsJIT devirtualizationWhen the JIT compiler knows a class is sealed, it can replace virtual method calls with direct calls. This eliminates the vtable lookup overhead (~1-2ns per call) and enables further optimizations like inlining. More importantly, it communicates "no subtypes" as a design intent. → faster method calls
Collection type mismatchIReadOnlyList backed by LinkedListO(n) indexing instead of expected O(1)

The Hidden Performance LSP Violation

The algorithmic complexity contractWhen a method is expected to run in O(1) time (like List<T> indexing), but a subtype provides O(n) time (like LinkedList<T> indexing), it's an implicit LSP violation. The caller's performance assumptions are broken even though the return value is correct. is an implicit postcondition. If IReadOnlyList<T>[index] is expected to be O(1), but a LinkedList-backed implementation is O(n), callers with tight loops degrade from O(n) to O(n²).

PerformanceLSP.cs
// Caller assumes O(1) index access:
public decimal Sum(IReadOnlyList<decimal> values)
{
    decimal total = 0;
    for (int i = 0; i < values.Count; i++)
        total += values[i];  // Expected: O(1) per access → O(n) total
    return total;
}

// If values is backed by a LinkedList adapter:
// Actual: O(n) per access → O(n²) total!
// For 100,000 items: expected ~1ms, actual ~10 seconds

// FIX: Use IEnumerable<T> if you only need iteration (no index assumption):
public decimal Sum(IEnumerable<decimal> values) => values.Sum();
// Or accept IList<T> which genuinely promises O(1) index access
Performance tip: Mark classes as sealed when you don't intend subtypes. This communicates design intentA sealed class explicitly says "this is not meant to be subtyped." Without sealed, any class can be inherited, creating potential LSP violations you never intended. Sealing by default and only unsealing when subtyping is an explicit design goal is a defensive coding practice. AND enables JIT devirtualization — a rare win-win of correctness and performance.
Section 16

Interview Pitch

30-Second Version

"LSP says if you have code that works with a base type, every subtype must work without surprises. The classic example is Square inheriting from RectangleThe most-cited LSP example. Mathematically, a square is a rectangle. But in code, Rectangle promises independent Width and Height. Square breaks this promise by coupling them. The lesson: "is-a" in the real world doesn't always mean "is-a" in code. — mathematically correct, but in code, setting the width of a 'Rectangle' that's actually a Square also changes the height, breaking callers. In practice, I enforce LSP through interface contracts, the Result pattern instead of surprise exceptions, and contract tests — one test class that runs against every implementation to ensure they all honor the same behavior."

"LSP is about behavioral compatibility — subtypes must honor preconditions, postconditions, and invariants of the base type. In .NET, I see this everywhere: the collection hierarchy separates IReadOnlyList from IList so read-only consumers can't be surprised by write operations. The generic variance system — out T for covariance, in T for contravariance — is the compiler enforcing a subset of LSP at the type level.

In my projects, I use the Result pattern so payment processors return success/failure instead of throwing unexpected exceptions. I write abstract contract test classes that run against every implementation — if a CachedRepository or a MockRepository violates any postcondition, the test fails. And I mark classes as sealed by default, both to communicate design intent and to enable JIT devirtualization."

Section 17

Interview Q&As

Easy (Q1–Q7)

Think of a rental car service. You book a "sedan" and they give you a Toyota Camry. Next time, they give you a Honda Accord. Both are sedans, both drive the same way, and your trip works fine regardless of which specific car you get. That's LSP: any "sedan" should work wherever a "sedan" is expected.

In code terms: if your code works with a base type (like IRepository), it should continue to work correctly with ANY implementation of that type (SqlRepository, MongoRepository, InMemoryRepository). No surprises, no unexpected exceptions, no weaker guarantees. The formal definition from Barbara Liskov says: if S is a subtype of T, then objects of type T can be replaced with objects of type S without altering the correctness of the program. In everyday .NET, this means every class that implements an interface must fully honor what that interface promises.

The Square/Rectangle problem. In geometry class, every square IS a rectangle. So in code, it seems logical to make Square inherit from Rectangle. But here's the trap: a Rectangle lets you set width and height independently. If you set width to 5 and height to 10, you expect the area to be 50. A Square overrides the setters to keep both dimensions equal. So setting height to 10 also sets width to 10, and the area becomes 100 instead of 50.

Code that says rect.Width = 5; rect.Height = 10; assert(rect.Area == 50) works perfectly with a real Rectangle, but breaks with a Square pretending to be a Rectangle. The fix is simple: don't use inheritance here. Use a shared IShape interface with separate, independent Rectangle and Square types. Domain truth ("a square is a rectangle") doesn't always translate to code truth ("a Square can substitute for a Rectangle").

Think of them as a team. OCP (Open/Closed Principle) says "extend your system by adding new classes, not by modifying existing ones." LSP says "those new classes better behave correctly when they're swapped in." Without LSP, OCP is dangerous: you add a shiny new PremiumDiscountCalculator, plug it in through dependency injection, and everything breaks because it returns negative values the caller didn't expect.

LSP is the enforcement mechanism that makes OCP safe. OCP opens the door to new implementations; LSP ensures every new implementation that walks through that door plays by the same rules. Together, they let you extend a system confidently: new classes can be added, and you can trust they won't break existing code.

Imagine a restaurant. The precondition is what you need before ordering: you must be seated and have a menu. A subtype (like a fast-food counter) can WEAKEN this: no seating required, just walk up. But it can't STRENGTHEN it: "you also need a reservation and a dress code" would break customers who were fine at regular restaurants.

The postcondition is what you're guaranteed after ordering: you get food that matches your order. A subtype can STRENGTHEN this: "you also get a free dessert." But it can't WEAKEN it: "we might give you someone else's order" violates the promise. The invariant is what's always true: "the kitchen is clean." It must hold before, during, and after your meal, in every restaurant type.

In code: preconditions are input requirements (argument not null), postconditions are output guarantees (return value >= 0), and invariants are properties that always hold (account balance never negative). Subtypes must not demand MORE input, must not guarantee LESS output, and must preserve all invariants.

The most famous one lives right in .NET's core library: System.IO.Stream. The base class defines Read(), Write(), and Seek(). But NetworkStream throws NotSupportedException if you call Seek(), because you can't jump to an arbitrary position in a network stream the way you can in a file.

The .NET team acknowledged this LSP violation and added capability flags as a workaround: CanRead, CanWrite, CanSeek. Before calling Seek(), you check stream.CanSeek. It's a pragmatic compromise: rather than splitting Stream into 7 separate interfaces (which would make the API unusable), they document the violation and give callers a way to check capabilities. Not perfect, but practical.

Three main strategies, and you'll use different ones depending on the situation:

1. Extract interface (split the contract). If an implementation can only do part of what the interface promises, the interface is too broad. Split it into smaller, focused interfaces. A Robot that can't Eat() doesn't implement IFeedable; it only implements IWorkable. This is ISP working together with LSP.

2. Composition over inheritance. If a subclass can't truly substitute for its parent, don't use inheritance. Have the class contain the other as a private field and expose only the operations it can actually support. Stack has-a List rather than inherits from List.

3. Result pattern. If some implementations might fail where others succeed, make failure an explicit part of the contract. Return Result<T> with Success/Failure instead of throwing surprise exceptions. Now every caller handles failure uniformly.

Think of IReadOnlyList<T> like a one-way mirror. From your side (through the interface), you can only look, not touch. But someone on the other side (holding the original List<T> reference) can still rearrange, add, and remove items. You just can't see the mutation methods. The underlying collection IS still mutable.

ImmutableList<T> (from System.Collections.Immutable) is more like a photograph. The data is frozen in time. Nobody can change it, from any reference. If someone "adds" an item, they get a brand-new list; the original stays unchanged. For truly safe code, especially in multi-threaded scenarios, ImmutableList<T> provides the guarantee that IReadOnlyList<T> merely implies.

Medium (Q8–Q18)

Generic variance is the compiler's way of enforcing LSP at the type level. It answers the question: "If Dog is a subtype of Animal, is IEnumerable<Dog> a subtype of IEnumerable<Animal>?" The answer depends on how the generic type is used.

Covariance (the out keyword) means "this type parameter only comes OUT of the interface." IEnumerable<out T> only produces T values (via GetEnumerator). Since every Dog is an Animal, reading Dogs and treating them as Animals is always safe. So IEnumerable<Dog> can substitute for IEnumerable<Animal>.

Contravariance (the in keyword) means "this type parameter only goes IN to the interface." IComparer<in T> only consumes T values. A comparer that knows how to compare any Animal certainly knows how to compare Dogs. So IComparer<Animal> can substitute for IComparer<Dog>.

IList<T> is invariant because T goes both in and out. If you could treat IList<Dog> as IList<Animal>, someone could add a Cat to your list of Dogs. The compiler prevents this.

C# Generic Variance and LSP Covariant (out T) IEnumerable<Dog> as Animal Read-only = SAFE Contravariant (in T) IComparer<Animal> as Dog Write-only = SAFE Invariant (T) IList<Dog> NOT as Animal Read+Write = UNSAFE The compiler enforces LSP by only allowing variance when it's type-safe

Contract tests are the most practical way to enforce LSP in a real codebase. The idea is simple: write one set of tests that verifies the interface's promises, then run those exact same tests against every implementation.

You create an abstract test class with all the contract tests: "save then get returns the saved value," "get non-existent returns null," "delete removes the item," etc. Then for each implementation, you create a thin concrete test class that just provides the specific implementation to test. Every implementation runs the exact same test suite.

ContractTests.cs
// Abstract contract test — defines WHAT the interface must do
public abstract class IOrderRepositoryContractTests
{
    protected abstract IOrderRepository CreateSut();

    [Fact]
    public async Task SaveThenGet_ReturnsSavedOrder()
    {
        var sut = CreateSut();
        var order = new Order { Id = 1, Total = 99.99m };
        await sut.SaveAsync(order);
        var result = await sut.GetByIdAsync(1);
        Assert.Equal(order.Total, result!.Total);
    }

    [Fact]
    public async Task GetNonExistent_ReturnsNull()
    {
        var sut = CreateSut();
        var result = await sut.GetByIdAsync(999);
        Assert.Null(result);
    }
}

// Concrete tests — one per implementation
public class SqlRepoTests : IOrderRepositoryContractTests
{
    protected override IOrderRepository CreateSut() => new SqlOrderRepository(_db);
}

public class CachedRepoTests : IOrderRepositoryContractTests
{
    protected override IOrderRepository CreateSut() => new CachedOrderRepository(_inner);
}

If any implementation fails any contract test, it violates LSP. The beauty is that this catches violations automatically: when someone adds a new implementation, they just need to create one more concrete test class and all contract tests run against it.

Yes, in most cases it is. If an interface says "this method works," and your implementation says "nope, not supported," you've broken the contract. The caller expected the method to do something useful, and instead it blew up.

However, there's a pragmatic exception. If the interface contract explicitly says "this method MAY throw NotSupportedException, check the capability flag first," then the exception is part of the documented contract, not a surprise. .NET's Stream class does this: Seek() might throw, but the contract says "check CanSeek first." That's not ideal, but it's a managed violation with clear documentation.

The best fix is to split the interface. Instead of one fat IStream with Read/Write/Seek, create IReadableStream, IWritableStream, and ISeekableStream. Each implementation only promises what it can deliver. No exceptions needed.

The problem with exceptions is unpredictability. One implementation succeeds, another throws ArgumentException, and a third throws TimeoutException. The caller has no way to know what to expect because the contract doesn't mention exceptions. This is a postcondition violation: different implementations have different failure modes.

The Result pattern fixes this by making failure an explicit part of the return type. Instead of returning Order or throwing, every implementation returns Result<Order>. This type has two states: Success (with the order) or Failure (with an error message). Now every implementation honors the same contract: "I always return a Result, and you check IsSuccess."

ResultPattern.cs
// Contract: always returns Result, never throws
public interface IPaymentProcessor
{
    Task<Result<PaymentReceipt>> ChargeAsync(decimal amount);
}

// Both implementations honor the same contract
public class StripeProcessor : IPaymentProcessor
{
    public async Task<Result<PaymentReceipt>> ChargeAsync(decimal amount)
    {
        try { /* ... */ return Result.Ok(receipt); }
        catch (StripeException ex) { return Result.Fail(ex.Message); }
    }
}

// Caller — works identically for ALL implementations
var result = await processor.ChargeAsync(99.99m);
if (result.IsSuccess) ShowReceipt(result.Value);
else ShowError(result.Error);

No surprise exceptions, no special catch blocks for specific implementations. Every processor is truly interchangeable because the contract explicitly includes both success and failure.

IQueryable<T> inherits from IEnumerable<T>. Since it's a subtype, every LINQ method that works on IEnumerable should also work on IQueryable. But IQueryable translates LINQ expressions to SQL, and SQL doesn't support everything C# does.

For example, .Last() works fine on any IEnumerable (just iterate to the end). But EF Core's IQueryable can't translate .Last() to SQL because most databases don't have a "give me the last row without ordering" concept. Calling .Last() throws a runtime exception. Similarly, calling .Where(x => MyCustomMethod(x)) fails because EF Core can't translate your custom C# method into SQL.

IQueryable inherits IEnumerable but can't do everything IEnumerable<T> All LINQ works: Where, Select, Last, custom methods IQueryable<T> (EF Core) Only SQL-translatable LINQ works! inherits .Last() / .ToDictionary() / custom lambdas throw at runtime!

The fix: don't expose raw IQueryable from your repositories. Instead, use the Repository pattern with explicit methods like GetByIdAsync() and GetPagedAsync(). The LINQ translation happens inside the repository, where you control which operations are used.

Yes. A sealed class can't be inherited from, so it can't have LSP violations through class inheritance. But sealed doesn't prevent it from implementing interfaces incorrectly. If you write sealed class StubLogger : ILogger and the Log() method silently drops all messages, that violates the ILogger contract ("messages are logged").

The sealed keyword prevents one source of LSP issues (subclassing surprises) but not the other (interface contract violations). In fact, the .NET performance guidelines recommend making classes sealed by default because it enables JIT devirtualization and communicates "this class is not designed for inheritance." But you still need to make sure the sealed class honors every interface it claims to implement.

Immutability is the single most effective tool for preventing LSP violations. Think about it: the Square/Rectangle problem only exists because Rectangle has mutable setters that Square overrides with surprising behavior. The read-only list bug only exists because the underlying collection is mutable. The cached repository bug only exists because cached objects are mutable.

With C# records, you get immutability by default. record Rectangle(int Width, int Height) has no setters. "Resizing" creates a new instance with with { Width = 10 }, leaving the original unchanged. There's nothing to override, nothing to mutate, nothing to surprise the caller. If every type in your hierarchy is immutable, an entire category of LSP violations simply cannot exist.

ImmutableLSP.cs
// Immutable records — LSP violations through mutation are impossible
public record Rectangle(int Width, int Height)
{
    public int Area => Width * Height;
}

public record Square(int Side)
{
    public int Area => Side * Side;
}

// "Resizing" creates new instances — originals never change
var rect = new Rectangle(5, 10);
var taller = rect with { Height = 20 };  // New object, rect unchanged
// No setters to override, no mutation surprises

Method hiding with new creates a situation where the same object behaves differently depending on the reference type you use to hold it. If you hold a Derived reference, you call Derived.Method(). If you cast it to Base, you call Base.Method(). Same object, two different behaviors.

This isn't technically an LSP violation in the strictest sense (when you hold a Base reference, you DO get Base's behavior, which is correct for the Base type). But it defeats the purpose of polymorphism entirely. The whole point of LSP is that you shouldn't need to care about the actual type behind the reference. Method hiding makes the reference type matter, which creates confusing bugs where behavior changes depending on variable declarations.

The fix: use override instead of new. With override, the runtime type always wins, regardless of the reference type. If the base method isn't virtual and you can't change it, that's a strong signal you shouldn't be inheriting from it.

A decorator wraps another implementation of the same interface, adding behavior (logging, caching, retrying) without changing the contract. Since the decorator IS a subtype (it implements the same interface), it must honor the same promises.

A logging decorator should add log statements but return the exact same values as the wrapped instance. A caching decorator should return correct data and invalidate the cache when writes happen. A retry decorator should be transparent: if all retries fail, it throws the same exception type the original would have thrown.

Common mistakes that break LSP in decorators: (1) A caching decorator that returns stale data because it doesn't invalidate on writes. (2) A retry decorator that swallows exceptions and returns a default value. (3) A logging decorator that catches exceptions to log them but forgets to re-throw, silently swallowing errors.

LSPSafeDecorator.cs
public class LoggingOrderRepository : IOrderRepository
{
    private readonly IOrderRepository _inner;
    private readonly ILogger _logger;

    public async Task<Order?> GetByIdAsync(int id)
    {
        _logger.LogInformation("Getting order {Id}", id);
        var result = await _inner.GetByIdAsync(id);  // ✅ Same return value
        _logger.LogInformation("Got order {Id}: {Found}", id, result != null);
        return result;  // ✅ No transformation, no swallowing
    }
}

ISP (Interface Segregation Principle) is LSP's best friend. ISP says "don't force classes to implement methods they can't use." LSP says "every method you implement must work correctly." When an interface is too broad, implementations are forced to stub out methods with throw new NotSupportedException(), which violates LSP.

For example, an IWorker interface with Work() and Eat() forces a Robot class to implement Eat(). A robot can't eat, so it throws. That's an LSP violation. The ISP fix: split into IWorkable and IFeedable. Robot only implements IWorkable. Now there's no method it can't honor, and LSP is satisfied.

Think of ISP as a preventive measure: it eliminates the situations that would force LSP violations. If you're constantly finding LSP violations in your codebase, the root cause is often interfaces that are too fat (ISP violation).

The Object base class defines three mathematical properties for Equals and one critical rule linking it to GetHashCode. Testing all four is your way of ensuring LSP compliance:

1. Reflexive: x.Equals(x) must always be true. 2. Symmetric: if x.Equals(y) then y.Equals(x). 3. Transitive: if x.Equals(y) and y.Equals(z), then x.Equals(z). 4. Hash code consistency: if x.Equals(y), then x.GetHashCode() == y.GetHashCode().

EqualityTests.cs
[Theory, MemberData(nameof(EqualPairs))]
public void Equals_IsSymmetric(User x, User y)
{
    Assert.Equal(x.Equals(y), y.Equals(x));
}

[Theory, MemberData(nameof(EqualPairs))]
public void EqualObjects_HaveSameHashCode(User x, User y)
{
    if (x.Equals(y))
        Assert.Equal(x.GetHashCode(), y.GetHashCode());
}

// Best approach: property-based testing with FsCheck
[Property]
public Property HashCode_ConsistentWithEquals(User x, User y) =>
    (x.Equals(y) == false || x.GetHashCode() == y.GetHashCode()).ToProperty();

Property-based testing with FsCheckA .NET port of Haskell's QuickCheck library. Instead of writing individual test cases, you define properties that must always hold (e.g., "sorting is idempotent") and FsCheck generates hundreds of random inputs to try to find a counterexample. Excellent for testing mathematical contracts like equality and comparison. is ideal here because it generates hundreds of random inputs and checks that all properties hold, instead of relying on you to think of every edge case.

Hard (Q19–Q29)

Async methods add several hidden postconditions that implementations must honor. The return type is Task<T>, which carries expectations beyond just "eventually returns a value."

Rule 1: Don't block. If the interface returns Task<T>, callers expect to await it without blocking. An implementation that internally calls .Result or .Wait() can deadlock in ASP.NET or UI contexts. That's a postcondition violation: the caller expected a non-blocking operation and got a deadlock.

Rule 2: Exceptions go in the Task. Async methods should wrap exceptions inside the returned Task (which happens automatically with async/await). If an implementation throws synchronously before the first await, the exception escapes the Task and callers who try-catch the await won't catch it.

Rule 3: Honor cancellation tokens. If the contract includes CancellationToken ct, implementations must periodically check ct.IsCancellationRequested or pass the token to downstream calls. An implementation that ignores the token strengthens the precondition: "I only work without cancellation." Code that depends on timely cancellation (like HTTP request timeouts) will hang.

AsyncLSP.cs
public interface IDataFetcher
{
    Task<Data> FetchAsync(string url, CancellationToken ct);
}

// ❌ BAD: Blocks, ignores cancellation, throws synchronously
public class BadFetcher : IDataFetcher
{
    public Task<Data> FetchAsync(string url, CancellationToken ct)
    {
        if (url == null) throw new ArgumentNullException();  // ❌ Sync throw
        var result = _http.GetAsync(url).Result;             // ❌ Blocking
        return Task.FromResult(Parse(result));               // ❌ Ignores ct
    }
}

// ✅ GOOD: Truly async, honors cancellation, exception in Task
public class GoodFetcher : IDataFetcher
{
    public async Task<Data> FetchAsync(string url, CancellationToken ct)
    {
        var response = await _http.GetAsync(url, ct);  // ✅ Async + ct
        ct.ThrowIfCancellationRequested();              // ✅ Checks ct
        return Parse(await response.Content.ReadAsStringAsync(ct));
    }
}

When you register multiple implementations of the same interface (like IEnumerable<INotificationChannel>), each implementation is a subtype that must honor the interface contract. The system iterates through all of them, calling the same method on each. If any one behaves differently, the whole pipeline is affected.

The most common LSP violation here is performance asymmetry. If SmsChannel.SendAsync() takes 100ms but EmailChannel.SendAsync() takes 5 seconds, the overall pipeline is bound by the slowest channel. Code that loops through all channels and awaits each one will be unexpectedly slow.

Multi-registration: slowest channel = total time SMS: 100ms Fast Push: 200ms Fast Email: 5000ms Bottleneck! Total: 5300ms (not 100ms!) Fix: Define SLA postconditions ("SendAsync < 2s") and test each implementation Or: Use Task.WhenAll() with per-channel timeouts

The LSP-safe approach: define explicit SLA postconditions in the interface documentation ("SendAsync must complete within 2 seconds or return a failure result"). Then write contract tests that assert timing for each implementation. If Email is inherently slow, it should return a "queued" result immediately and process in the background.

Structs can't inherit from other structs, so the classic inheritance-based LSP violations don't apply. But structs CAN implement interfaces, and that's where a subtle trap lurks: boxing.

When a struct is cast to an interface, C# creates a boxed copy on the heap. If the struct implements IDisposable and you cast it to IDisposable before calling Dispose(), you're disposing the copy, not the original. The original struct's resources are never released. The interface contract says "calling Dispose releases resources," but boxing breaks this guarantee.

StructBoxing.cs
public struct FileHandle : IDisposable
{
    private IntPtr _handle;
    public void Dispose() => CloseHandle(_handle);
}

var handle = new FileHandle();
IDisposable disposable = handle;  // ← BOXING: creates a copy!
disposable.Dispose();              // Disposes the COPY
// Original 'handle' is NOT disposed — resource leak!

The fix: avoid implementing IDisposable on structs, or use the using statement directly with the struct variable (which avoids boxing in modern C#). This is why .NET's own ValueTask exists as a struct but uses special compiler support to avoid boxing issues.

Event handlers follow an implicit contract: receive the event, process it, and don't throw. If one handler in a multicast delegateIn C#, events use multicast delegates that can have multiple subscribers. When the event fires, each subscriber's handler is called in order. If any handler throws an exception, the remaining handlers in the chain never execute. throws an exception, all remaining handlers in the chain are skipped. This violates the implicit postcondition that "all subscribers are notified."

This is an LSP issue because each event handler is a "subtype" of the handler delegate contract. The contract says "handle this event." A handler that throws instead of handling breaks the contract and affects every other handler in the chain.

One throwing handler breaks the entire chain Handler A Handler B (throws!) Handler C (skipped) Handler D (skipped) X X Fix: Wrap each handler in try/catch, or use MediatR with pipeline error isolation

The fix: wrap each handler invocation in a try/catch so one failure doesn't cascade. Or better yet, use an event aggregator (like MediatR) that isolates handler failures. In MediatR, pipeline behaviors should catch and log exceptions from individual handlers without letting them crash the entire notification pipeline.

Designing for LSP compliance is about discipline at the beginning, not fixes at the end. Three rules will save you:

1. Contract first. Before writing any implementation, write down what the base type promises. What inputs does it accept? What does it guarantee about outputs? What invariants must always hold? Write these as XML doc comments or, better, as contract tests.

2. Tests before subtypes. Write the contract test suite (see Q9) before creating any subtype. The tests define the behavioral specification. Any future implementation that passes all tests is LSP-compliant by definition.

3. The type-check smell. If you find yourself writing if (x is SpecificSubtype) in caller code, that's a red flag. It means the subtype isn't truly substitutable and the caller needs special handling. Either fix the subtype's behavior or restructure the hierarchy.

ContractFirst.cs
/// <summary>Payment processor contract.</summary>
/// <remarks>
/// Preconditions: amount > 0, currency is valid ISO code
/// Postconditions: returns Result (never throws), completes within 5s
/// Invariants: idempotent — charging same orderId twice = one charge
/// </remarks>
public interface IPaymentProcessor
{
    Task<Result<Receipt>> ChargeAsync(
        decimal amount, string currency, string orderId,
        CancellationToken ct);
}

// Now write contract tests BEFORE any implementation:
[Fact] public async Task Charge_NegativeAmount_ReturnsFailure() { /* ... */ }
[Fact] public async Task Charge_SameOrderTwice_IsIdempotent() { /* ... */ }
[Fact] public async Task Charge_CompletesWithinFiveSeconds() { /* ... */ }

DIP and LSP are two halves of the same coin. DIP says "depend on abstractions (interfaces), not concrete classes." LSP says "every concrete class that implements that interface must actually behave the way the interface promises."

DIP without LSP is dangerous. You refactor your code to depend on IRepository instead of SqlRepository. Great, now you can swap implementations. But if the new MongoRepository doesn't honor the same contract (returns items in a different order, throws different exceptions, doesn't support transactions), your code breaks. You've got the abstraction layer that DIP wanted, but the implementations aren't trustworthy.

DIP + LSP = Safe dependency injection IRepository (abstraction) DIP: depend on this SqlRepository MongoRepository InMemoryRepo LSP: all honor it

Together, DIP gives you the abstraction layer, and LSP guarantees that swapping implementations doesn't break anything. This is the foundation of reliable dependency injection: you can confidently register services.AddScoped<IRepository, AnyRepo>() because LSP ensures AnyRepo plays by the rules.

API versioningThe practice of maintaining multiple versions of an API (v1, v2) to allow clients to migrate gradually. Each new version is essentially a "subtype" of the API contract — it must honor existing expectations (backward compatibility) while adding new capabilities. is LSP applied at the service boundary instead of the class boundary. A v2 API is essentially a "subtype" of the v1 API. Clients that worked with v1 should continue to work with v2 without changes. That's substitutability across HTTP.

The LSP rules map directly: a v2 endpoint that requires new mandatory parameters strengthens the precondition (clients must send more data). A v2 that returns fewer response fields weakens the postcondition (clients get less than they expected). Both are breaking changes that violate backward compatibility, which is LSP for APIs.

Safe API evolution follows LSP: add optional request fields (weaker preconditions, accepting more), add new response fields (stronger postconditions, giving more), add new endpoints (extending without modifying). Never remove response fields or make request fields mandatory in an existing version.

API Versioning = LSP for HTTP Safe (LSP-compliant) + Add optional request params (weaker pre) + Add new response fields (stronger post) + Add new endpoints (extension) Breaking (LSP violation) - New mandatory params (stronger pre) - Remove response fields (weaker post) - Change field types or meanings

Implicit postconditions are the sneaky ones. The interface doesn't say "must complete within 100ms," but every caller assumes it. When a new implementation takes 5 seconds instead of 5 milliseconds, the system breaks even though the return value is correct.

The most practical approach is to add performance assertions to your contract tests. Measure execution time with a Stopwatch and assert it's within acceptable bounds. For more rigorous testing, use BenchmarkDotNetA popular .NET benchmarking library that provides statistically rigorous performance measurements. It handles warm-up, multiple iterations, and statistical analysis automatically, giving you reliable baseline metrics for each implementation. to establish baseline metrics for each implementation and flag regressions in CI.

PerformanceContract.cs
// Add to your contract test suite
[Fact]
public async Task GetByIdAsync_CompletesWithinSLA()
{
    var sut = CreateSut();
    await sut.SaveAsync(new Order { Id = 1, Total = 99m });

    var sw = Stopwatch.StartNew();
    await sut.GetByIdAsync(1);
    sw.Stop();

    Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(100),
        $"GetByIdAsync took {sw.ElapsedMilliseconds}ms — SLA is 100ms");
}

If a new caching decorator or database migration makes the hot path 10x slower, this test catches it before production. Make implicit postconditions explicit by testing them.

These are two different ways to decide whether one type can substitute for another.

Structural subtyping (TypeScript's approach, often called "duck typing") only checks the shape: if an object has the right methods with the right signatures, it's a subtype. A class with a Sort(IList<T> items) method structurally matches ISortStrategy, even if the method arranges items randomly instead of sorting them.

Behavioral subtyping (LSP) goes deeper: it checks that the methods actually behave correctly. Having a Sort() method isn't enough; the method must actually sort. Having a Save() method isn't enough; the method must actually persist data.

C# uses nominal typing: you must explicitly say class MySort : ISortStrategy. This is stronger than structural typing (you can't accidentally satisfy an interface) but weaker than behavioral typing (the compiler can't check that Sort() actually sorts). LSP fills that gap: it's a design principle that says "make sure your implementations actually do what the interface promises," which no compiler can fully enforce.

Default interface methodsA C# 8+ feature that allows interfaces to provide method implementations. Existing classes that implement the interface don't need to change — they get the default behavior automatically. This is LSP-friendly because it adds capabilities without breaking existing subtypes. are inherently LSP-friendly. They let you add new methods to an interface without breaking existing implementations. Before C# 8, adding a method to IRepository would break every class that implements it. With default methods, existing classes get the default behavior automatically.

The LSP risk comes when an implementation overrides the default with different semantics. If the default BulkSaveAsync() saves items one by one (safe but slow), and an override saves them in a transaction (fast but different failure mode), callers who depend on the default's "one item fails, others succeed" behavior will get a surprise when the override rolls back everything.

DefaultMethods.cs
public interface IRepository<T>
{
    Task SaveAsync(T entity);

    // Default method: safe, LSP-friendly addition
    async Task BulkSaveAsync(IEnumerable<T> entities)
    {
        foreach (var entity in entities)
            await SaveAsync(entity);  // One at a time — partial success possible
    }
}

// Override must honor the SAME contract as the default
public class SqlRepo : IRepository<Order>
{
    public async Task BulkSaveAsync(IEnumerable<Order> entities)
    {
        // ⚠️ Wraps in a transaction — ALL or NOTHING
        // This changes the failure semantics! LSP risk.
        using var tx = await _db.BeginTransactionAsync();
        // ...
    }
}

The safest approach: run your contract tests against both the default behavior AND any overrides. If the override changes the observable behavior (not just performance), document the difference clearly.

Sometimes perfect LSP compliance costs more than it's worth. The .NET team knew that Stream violates LSP (NetworkStream can't Seek), but splitting Stream into 7 granular interfaces would make the API unusable. Instead, they chose a managed violation with capability flags.

Pragmatic LSP violations are acceptable when three conditions are met:

1. Documented. The violation is explicitly stated in documentation, XML comments, or capability flags. Nobody should be surprised.

2. Contained. All callers know about it and check for it. If CanSeek is false, nobody calls Seek(). The violation is isolated, not hidden.

3. Tested. There are tests that verify the capability flags are correct and that callers handle both cases (can and can't). If someone accidentally changes CanSeek to return true when it shouldn't, a test catches it.

The key distinction: accidental LSP violations are always bugs. Conscious, documented, tested LSP violations are engineering tradeoffs. The question isn't "is this a violation?" but "is this violation managed or wild?"

Section 18

Exercises

Exercise 1: Spot the Violation (Easy)

Identify the LSP violation in this code and explain why it's dangerous:

Exercise1.cs
public class Bird
{
    public virtual void Fly() => Console.WriteLine("Flying!");
}

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

public void MigrateSouth(IEnumerable<Bird> flock)
{
    foreach (var bird in flock)
        bird.Fly();  // What happens when a Penguin is in the flock?
}

Violation: Penguin throws NotSupportedException on Fly(), but Bird's contract implies all birds can fly. The MigrateSouth() method crashes when encountering a penguin.

Fix: Separate the hierarchy — not all birds fly. Use IFlyable for birds that can fly:

Exercise1-Solution.cs
public abstract class Bird { public abstract string Name { get; } }
public interface IFlyable { void Fly(); }

public class Eagle : Bird, IFlyable
{
    public override string Name => "Eagle";
    public void Fly() => Console.WriteLine("Eagle flying!");
}

public class Penguin : Bird  // No IFlyable — can't fly
{
    public override string Name => "Penguin";
    public void Swim() => Console.WriteLine("Penguin swimming!");
}

public void MigrateSouth(IEnumerable<IFlyable> flock)
{
    foreach (var flyer in flock) flyer.Fly();  // Only flyable birds — safe!
}

Given this interface, write an abstract contract test class that any implementation must pass:

Exercise2.cs
public interface ICache<TKey, TValue>
{
    void Set(TKey key, TValue value, TimeSpan? expiry = null);
    TValue? Get(TKey key);
    bool Remove(TKey key);
    bool ContainsKey(TKey key);
}
Exercise2-Solution.cs
public abstract class ICacheContractTests
{
    protected abstract ICache<string, int> CreateSut();

    [Fact]
    public void Set_ThenGet_ReturnsValue()
    {
        var cache = CreateSut();
        cache.Set("key1", 42);
        Assert.Equal(42, cache.Get("key1"));
    }

    [Fact]
    public void Get_NonExistent_ReturnsDefault()
    {
        var cache = CreateSut();
        Assert.Equal(default, cache.Get("missing"));
    }

    [Fact]
    public void Set_Override_ReturnsLatest()
    {
        var cache = CreateSut();
        cache.Set("key1", 1);
        cache.Set("key1", 2);
        Assert.Equal(2, cache.Get("key1"));
    }

    [Fact]
    public void Remove_Existing_ReturnsTrue()
    {
        var cache = CreateSut();
        cache.Set("key1", 42);
        Assert.True(cache.Remove("key1"));
        Assert.Equal(default, cache.Get("key1"));
    }

    [Fact]
    public void Remove_NonExistent_ReturnsFalse()
    {
        var cache = CreateSut();
        Assert.False(cache.Remove("missing"));
    }

    [Fact]
    public void ContainsKey_AfterSet_ReturnsTrue()
    {
        var cache = CreateSut();
        cache.Set("key1", 42);
        Assert.True(cache.ContainsKey("key1"));
    }

    [Fact]
    public void ContainsKey_AfterRemove_ReturnsFalse()
    {
        var cache = CreateSut();
        cache.Set("key1", 42);
        cache.Remove("key1");
        Assert.False(cache.ContainsKey("key1"));
    }
}

This caching decorator violates LSP. Identify the violations and fix them:

Exercise3.cs
public interface IUserRepository
{
    Task<User?> GetByIdAsync(int id);
    Task<User> CreateAsync(User user);
    Task UpdateAsync(User user);
    Task DeleteAsync(int id);
}

public class CachedUserRepository(IUserRepository inner) : IUserRepository
{
    private readonly Dictionary<int, User> _cache = new();

    public async Task<User?> GetByIdAsync(int id)
    {
        if (_cache.TryGetValue(id, out var user)) return user;
        user = await inner.GetByIdAsync(id);
        if (user != null) _cache[id] = user;
        return user;
    }

    public async Task<User> CreateAsync(User user)
    {
        var created = await inner.CreateAsync(user);
        _cache[created.Id] = created;
        return created;
    }

    public Task UpdateAsync(User user)
    {
        _cache[user.Id] = user;  // Bug: never calls inner!
        return Task.CompletedTask;
    }

    public async Task DeleteAsync(int id)
    {
        await inner.DeleteAsync(id);
        // Bug: never removes from cache!
    }
}

Violations: (1) UpdateAsync never persists to the inner repository — weakened postcondition ("data is saved"). (2) DeleteAsync never invalidates cache — GetByIdAsync returns a deleted user. (3) GetByIdAsync returns shared mutable references — modifications to the returned User affect cached data.

Exercise3-Solution.cs
public class CachedUserRepository(IUserRepository inner) : IUserRepository
{
    private readonly ConcurrentDictionary<int, User> _cache = new();

    public async Task<User?> GetByIdAsync(int id)
    {
        if (_cache.TryGetValue(id, out var user)) return user;
        user = await inner.GetByIdAsync(id);
        if (user != null) _cache[id] = user;
        return user;
    }

    public async Task<User> CreateAsync(User user)
    {
        var created = await inner.CreateAsync(user);
        _cache[created.Id] = created;
        return created;
    }

    public async Task UpdateAsync(User user)
    {
        await inner.UpdateAsync(user);     // Fix 1: persist FIRST
        _cache[user.Id] = user;            // Then update cache
    }

    public async Task DeleteAsync(int id)
    {
        await inner.DeleteAsync(id);
        _cache.TryRemove(id, out _);       // Fix 2: invalidate cache
    }
}

Design a payment system that supports: (1) Credit cards (always succeed unless declined), (2) Store credit (may have insufficient balance), (3) Gift cards (single-use, exact amount only), (4) Split payments (combine two processors). All must be substitutable. Write the interface, all four implementations, and contract tests.

  • Use the Result pattern — all processors return PaymentResult, never throw for business logic.
  • Include CanProcess(decimal amount) for precondition checking.
  • Gift cards need special handling: they follow a single-use patternOnce a gift card is redeemed, it can't be used again. This is a state constraint — the card transitions from "active" to "redeemed" and can't go back. The interface must accommodate this without forcing other processors to implement single-use logic. and only work for the exact amount. The interface must accommodate this without forcing other processors to implement single-use logic.
  • Split payment is a Composite pattern — it contains two processors and delegates to both.
Section 19

Cheat Sheet & Decision Framework

RuleWhat It MeansViolation Example
Don't strengthen preconditionsSubtype must accept at least what base acceptsBase: any amount. Subtype: amount ≤ $500
Don't weaken postconditionsSubtype must guarantee at least what base guaranteesBase: returns sorted list. Subtype: returns unsorted
Preserve invariantsProperties that are always true must stay trueBase: Width ≠ Height is allowed. Subtype: Width = Height always
No new exceptionsDon't throw types the base never mentionedBase: throws IOException. Subtype: throws AuthException
Honor the history constraintSubtype can't allow state changes the base preventsImmutable base → mutable subtype

Decision Tree: "Is My Subtype LSP-Safe?"

LSP-Decision-Tree.txt
Does subtype accept EVERY input the base type accepts?
├── NO → Strengthened precondition → LSP VIOLATION
└── YES ↓

Does subtype guarantee EVERY output the base type guarantees?
├── NO → Weakened postcondition → LSP VIOLATION
└── YES ↓

Does subtype preserve all invariants of the base type?
├── NO → Broken invariant → LSP VIOLATION
└── YES ↓

Does subtype avoid throwing new exception types?
├── NO → Surprise exception → LSP VIOLATION
└── YES ↓

Can callers use the subtype WITHOUT knowing it's a subtype?
├── NO → Behavioral difference → LSP VIOLATION
└── YES → ✅ LSP SAFE — substitution is transparent

Quick Reference: Fix Strategies

Violation TypeFix StrategyC# Mechanism
Mutation side effectsImmutabilityrecord, readonly struct, init properties
NotSupportedExceptionSplit interface (ISP)Separate IReadable / IWritable
Surprise exceptionsResult patternResult<T>, OneOf<T, Error>
Broken equalityUse recordsrecord auto-generates Equals/GetHashCode
Bad inheritanceCompositionHas-a with interfaces, no virtual/override
Performance surpriseExplicit contractsDocument SLAs, add perf contract tests
Section 20

Deep Dive

C# Variance Deep Dive: Covariance & Contravariance

The C# type system enforces a subset of LSP through generic varianceVariance determines when generic type parameters can be safely substituted. Covariance (out) allows substituting a more derived type for reading. Contravariance (in) allows substituting a less derived type for writing. Invariance allows neither — both reading and writing require exact types.. Understanding when variance is safe requires understanding LSP.

Variance-DeepDive.cs
// ============================
// COVARIANCE (out T) — "producers"
// ============================

// IEnumerable<out T> is covariant — safe to read Dogs as Animals
IEnumerable<Dog> dogs = new List<Dog> { new("Rex"), new("Buddy") };
IEnumerable<Animal> animals = dogs;  // ✓ Compiler allows this

// WHY it's safe: You only READ from IEnumerable. Reading a Dog as an Animal
// is always valid (Dog IS-A Animal). No LSP violation possible.

// WHY IList<T> is NOT covariant:
// IList<Dog> dogs = new List<Dog>();
// IList<Animal> animals = dogs;  // ✗ Compiler REJECTS this
// animals.Add(new Cat());         // Would put a Cat in a Dog list! Type safety broken.

// ============================
// CONTRAVARIANCE (in T) — "consumers"
// ============================

// IComparer<in T> is contravariant — safe to compare Dogs with an Animal comparer
IComparer<Animal> animalComparer = Comparer<Animal>.Create(
    (a, b) => a.Weight.CompareTo(b.Weight));
IComparer<Dog> dogComparer = animalComparer;  // ✓ Compiler allows this

// WHY it's safe: Comparing Dogs using an Animal-level comparison (weight)
// is always valid. Dogs ARE Animals, so they have Weight. No LSP violation.

// ============================
// CUSTOM VARIANCE INTERFACES
// ============================

// You can define your own variant interfaces:
public interface IProducer<out T>   // Covariant — T only in output positions
{
    T Produce();                     // ✓ T as return type
    // void Consume(T item);         // ✗ Compiler error: T in input position
}

public interface IConsumer<in T>    // Contravariant — T only in input positions
{
    void Consume(T item);            // ✓ T as parameter
    // T Produce();                  // ✗ Compiler error: T in output position
}

// Invariant — T in both positions (no variance possible)
public interface IRepository<T>
{
    T GetById(int id);               // T in output position
    void Save(T entity);             // T in input position
    // Cannot be covariant OR contravariant
}

While C# doesn't have Eiffel-style Design by ContractA programming methodology where you formally specify preconditions, postconditions, and invariants for every method. Eiffel built this into the language. C# has partial support through debug assertions, nullable references, and analyzers — but no built-in contract enforcement like Eiffel's require/ensure clauses., modern C# offers several tools to express and enforce contracts:

ContractEnforcement.cs
// 1. Nullable Reference Types — compiler-enforced null contracts
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);       // May return null — caller must check
    Task<Order> GetRequiredAsync(int id);    // Never returns null — throws if missing
    Task SaveAsync(Order order);              // order is non-null (compiler enforced)
}

// 2. ArgumentException helpers (.NET 6+) — precondition enforcement
public class OrderService
{
    public decimal CalculateDiscount(Order order, decimal rate)
    {
        ArgumentNullException.ThrowIfNull(order);
        ArgumentOutOfRangeException.ThrowIfNegative(rate);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(rate, 1.0m);
        return order.Total * rate;
    }
}

// 3. Debug.Assert — development-time invariant checks
public class Account
{
    public decimal Balance { get; private set; }

    public void Withdraw(decimal amount)
    {
        Debug.Assert(amount > 0, "Withdraw amount must be positive");
        Debug.Assert(amount <= Balance, "Cannot withdraw more than balance");

        Balance -= amount;

        Debug.Assert(Balance >= 0, "Invariant violated: Balance must be non-negative");
    }
}

// 4. Guard clauses with custom Result types
public record Result<T>
{
    public bool IsSuccess { get; init; }
    public T? Value { get; init; }
    public string? Error { get; init; }

    public static Result<T> Ok(T value) => new() { IsSuccess = true, Value = value };
    public static Result<T> Fail(string error) => new() { IsSuccess = false, Error = error };
}

The history constraintThe rule that subtypes can't allow state transitions the base type prevents. If the base type is immutable (no state changes after construction), a mutable subtype violates the history constraint — it allows state changes that the base type's "history" of states would never include. is the least-known part of LSP. It says: a subtype must not allow state changes that the base type's history wouldn't include.

HistoryConstraint.cs
// Base type — immutable point
public class ImmutablePoint
{
    public int X { get; }
    public int Y { get; }
    public ImmutablePoint(int x, int y) { X = x; Y = y; }
}

// ✗ VIOLATES history constraint — adds mutability the base didn't allow
public class MutablePoint : ImmutablePoint
{
    public new int X { get; set; }  // Adds setter — base is immutable!
    public new int Y { get; set; }
    public MutablePoint(int x, int y) : base(x, y) { }
}

// Code expecting ImmutablePoint can cache it, share it between threads,
// use it as a dictionary key — all safe because it's immutable.
// MutablePoint breaks ALL of these assumptions.

// ✓ Correct approach: separate types
public record Point(int X, int Y);                     // Immutable
public record MutablePoint(int X, int Y)               // If needed, separate hierarchy
{
    public int X { get; set; } = X;
    public int Y { get; set; } = Y;
}
Section 21

Mini-Project: Shape Area Calculator

Build a shape area calculator that demonstrates LSP. Three attempts: naive inheritance (broken), over-engineered (correct but complex), production-ready (simple and LSP-safe).

Attempt 1: Naive Inheritance (Broken)

Attempt1-Broken.cs
// "Everything is a shape, shapes have dimensions, let's use inheritance!"
public class Shape
{
    public virtual double Width { get; set; }
    public virtual double Height { get; set; }
    public virtual double Area() => Width * Height;
}

public class Rectangle : Shape { }  // Works fine

public class Square : Shape
{
    public override double Width
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; }
    }
    public override double Height
    {
        get => base.Height;
        set { base.Width = value; base.Height = value; }
    }
}

public class Circle : Shape
{
    public double Radius { get; set; }
    public override double Area() => Math.PI * Radius * Radius;
    // Width and Height are MEANINGLESS for a circle — LSP violation!
}

public class Triangle : Shape
{
    public override double Area() => Width * Height / 2;
    // Works, but Width/Height are misleading (base/height vs side/side)
}

// Test that PASSES for Rectangle, FAILS for Square:
void TestResize(Shape s)
{
    s.Width = 5; s.Height = 10;
    Debug.Assert(s.Area() == 50);  // Square: 100. Circle: Width*Height=garbage
}
What's Wrong

Shape assumes all shapes have independent Width/Height. Square violates independence. Circle makes Width/Height meaningless. The base class's contract doesn't match reality for most shapes.

Attempt2-OverEngineered.cs
// "Let's split everything into tiny interfaces!"
public interface IHasArea { double Area { get; } }
public interface IHasPerimeter { double Perimeter { get; } }
public interface IResizable { void Resize(double factor); }
public interface IHasDimensions { double Width { get; } double Height { get; } }
public interface IHasRadius { double Radius { get; } }
public interface IConvex : IHasArea { bool ContainsPoint(double x, double y); }

// Rectangle implements 5 interfaces:
public class Rectangle : IHasArea, IHasPerimeter, IResizable, IHasDimensions, IConvex
{
    public double Width { get; private set; }
    public double Height { get; private set; }
    public double Area => Width * Height;
    public double Perimeter => 2 * (Width + Height);
    public void Resize(double factor) { Width *= factor; Height *= factor; }
    public bool ContainsPoint(double x, double y) => x >= 0 && x <= Width && y >= 0 && y <= Height;
    public Rectangle(double w, double h) { Width = w; Height = h; }
}

// This is technically LSP-safe... but nobody wants to work with 6 interfaces
// for a shape calculator. The cure is worse than the disease.
Over-Engineering Alert

LSP-compliant but impractical. 6 interfaces for a shape calculator is interface explosion. Most callers just want Area.

IShape.cs
/// <summary>
/// A geometric shape that can report its area and perimeter.
/// Postconditions:
///   - Area >= 0 (always non-negative)
///   - Perimeter >= 0 (always non-negative)
///   - Both are deterministic (same shape → same values)
/// </summary>
public interface IShape
{
    double Area { get; }
    double Perimeter { get; }
    string Name { get; }
}
Shapes.cs
// Immutable records — no mutation, no LSP surprises
public record Circle(double Radius) : IShape
{
    public double Area => Math.PI * Radius * Radius;
    public double Perimeter => 2 * Math.PI * Radius;
    public string Name => "Circle";
}

public record Rectangle(double Width, double Height) : IShape
{
    public double Area => Width * Height;
    public double Perimeter => 2 * (Width + Height);
    public string Name => "Rectangle";
}

public record Square(double Side) : IShape  // NOT inheriting Rectangle!
{
    public double Area => Side * Side;
    public double Perimeter => 4 * Side;
    public string Name => "Square";
}

public record Triangle(double SideA, double SideB, double SideC) : IShape
{
    public double Perimeter => SideA + SideB + SideC;
    public double Area  // Heron's formula
    {
        get
        {
            var s = Perimeter / 2;
            return Math.Sqrt(s * (s - SideA) * (s - SideB) * (s - SideC));
        }
    }
    public string Name => "Triangle";
}
AreaCalculator.cs
// The calculator works with ANY IShape — fully LSP-safe
public class AreaCalculator
{
    public double TotalArea(IEnumerable<IShape> shapes) =>
        shapes.Sum(s => s.Area);

    public double TotalPerimeter(IEnumerable<IShape> shapes) =>
        shapes.Sum(s => s.Perimeter);

    public string Report(IEnumerable<IShape> shapes)
    {
        var sb = new StringBuilder();
        foreach (var shape in shapes)
            sb.AppendLine($"{shape.Name}: Area={shape.Area:F2}, Perimeter={shape.Perimeter:F2}");
        sb.AppendLine($"Total Area: {TotalArea(shapes):F2}");
        return sb.ToString();
    }
}

// Usage:
var shapes = new IShape[]
{
    new Circle(5),
    new Rectangle(10, 20),
    new Square(7),
    new Triangle(3, 4, 5)
};
var calc = new AreaCalculator();
Console.WriteLine(calc.Report(shapes));
// Every shape is substitutable — no special cases, no type checks
ShapeContractTests.cs
// Contract tests — run against EVERY IShape implementation
public abstract class IShapeContractTests
{
    protected abstract IShape CreateSut();

    [Fact]
    public void Area_Is_NonNegative()
    {
        var shape = CreateSut();
        Assert.True(shape.Area >= 0, $"{shape.Name} Area was {shape.Area}");
    }

    [Fact]
    public void Perimeter_Is_NonNegative()
    {
        var shape = CreateSut();
        Assert.True(shape.Perimeter >= 0, $"{shape.Name} Perimeter was {shape.Perimeter}");
    }

    [Fact]
    public void Area_Is_Deterministic()
    {
        var shape = CreateSut();
        Assert.Equal(shape.Area, shape.Area);  // Same value every time
    }

    [Fact]
    public void Name_Is_NotEmpty()
    {
        var shape = CreateSut();
        Assert.False(string.IsNullOrWhiteSpace(shape.Name));
    }
}

// Run for every shape:
public class CircleTests : IShapeContractTests
{
    protected override IShape CreateSut() => new Circle(5);
}
public class RectangleTests : IShapeContractTests
{
    protected override IShape CreateSut() => new Rectangle(10, 20);
}
public class SquareTests : IShapeContractTests
{
    protected override IShape CreateSut() => new Square(7);
}
public class TriangleTests : IShapeContractTests
{
    protected override IShape CreateSut() => new Triangle(3, 4, 5);
}
Production Ready

Why this works: (1) Immutable records eliminate mutation surprises. (2) No inheritance between shapes — Square is NOT a Rectangle subtype. (3) IShape only promises what ALL shapes can deliver (area + perimeter). (4) Contract tests verify every implementation. Adding a new shape = new record + new test class. Zero changes to existing code.

Section 22

Migration Guide

How to fix existing LSP violations without rewriting everything.

Step 1: Identify Violations

Scan your codebase for LSP red flagsCode patterns that strongly suggest an LSP violation. These aren't always violations — sometimes NotSupportedException is acceptable (like Stream.CanSeek) — but they warrant investigation. The presence of multiple red flags in the same hierarchy is a strong signal.:

LSP-Audit.txt
Search for these patterns in your codebase:

1. "throw new NotImplementedException"    → Interface too broad
2. "throw new NotSupportedException"      → Subtype can't fulfill contract
3. "is SomeConcreteType" / "as SomeType"  → Caller needs to know concrete type
4. "new" keyword hiding base members       → Polymorphism broken
5. Override that catches/rethrows differently → Exception contract changed
6. Virtual method that changes observable state differently → Invariant broken

// Quick grep commands:
grep -rn "NotImplementedException" --include="*.cs"
grep -rn "NotSupportedException" --include="*.cs"
grep -rn "is [A-Z].*\b" --include="*.cs" | grep -v "is null" | grep -v "is not"

Before fixing anything, write contract tests that define what the correct behavior should be. This ensures your fixes don't break other things.

  1. Create an abstract test class for each interface/base class.
  2. Define tests for every postcondition (return values, side effects, exceptions).
  3. Run tests against ALL existing implementations — the ones that fail are your violations.
  4. Fix one implementation at a time, running contract tests after each fix.
Violation FoundIncremental Fix
NotSupportedExceptionSplit the interface (ISP). Add the smaller interface first, migrate callers gradually.
Type checking (is/as)Move the special logic into a virtual/abstract method on the base type.
Surprise exceptionsIntroduce a Result type. Change one method at a time to return Result.
Mutation side effectsConvert to records or make properties init-only. One class at a time.
Broken Equals/GetHashCodeConvert to record type (auto-generates correct equality).
Bad inheritance hierarchyExtract interface, have both types implement it, remove the : base class relationship.
Golden rule: Fix one violation per PR. Run all contract tests after each change. Small, reviewable changes are safer than a big-bang refactorRewriting a large section of code all at once. High risk because you can't test incrementally — if something breaks, you don't know which change caused it. The incremental approach (one fix per PR) lets you verify each change in isolation and roll back easily..
Section 23

Code Review Checklist

#CheckWhat to Look For
1No NotImplementedExceptionEvery interface method has a real implementation. If you can't implement it, the interface is too broad.
2No NotSupportedExceptionSame as above — indicates the implementation can't honor the full contract.
3No type checking (is/as) in consumersCallers should never need to know the concrete type. If they do, the abstraction is leaky.
4Exceptions match the contractSubtypes only throw exception types the base type/interface documents. New exception types are wrapped.
5Equals/GetHashCode consistencyIf Equals() is overridden, GetHashCode() uses the same fields. Prefer record types.
6Immutability where appropriateValue objects and DTOs should be record types. Mutable subtypes of immutable bases = violation.
7Contract tests existEvery interface with 2+ implementations should have an abstract contract test class.
8Decorator honors the contractCaching decorators invalidate on writes. Logging decorators don't change return values. Retry decorators throw the same exception types.
Automate it: Use Roslyn analyzers to catch common violations: CA1065 (don't throw from unexpected places), CA1066 (implement IEquatable), CA2225 (operator overloads have named alternates). Add NetArchTestA .NET library for writing architecture tests. You can enforce rules like "no class in the Domain layer should depend on Infrastructure" or "all IRepository implementations must be in the Data namespace." These tests catch structural violations in CI. rules to your CI pipeline for structural enforcement.