Learnixo

SOLID Principles in C# · Lesson 3 of 6

Liskov Substitution Principle — Subtype Contracts

What LSP Means

Liskov Substitution Principle (Barbara Liskov, 1987):
  "If S is a subtype of T, then objects of type T may be replaced
   with objects of type S without altering the correctness of the program."

In plain English:
  If your code works with INotificationSender,
  it must work correctly with ANY implementation of INotificationSender —
  SmsNotificationSender, EmailNotificationSender, SilentNotificationSender.

LSP violations happen when a subtype:
  ✗ Throws NotSupportedException for methods of the base type
  ✗ Ignores parameters that the contract says it should use
  ✗ Returns data that violates the base contract's guarantees
  ✗ Requires stronger preconditions than the base type

Classic LSP Violation: Square-Rectangle

C#
// Looks correct — Square is a Rectangle, right?
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; }  // keeps square invariant
    }
    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

// LSP violation:
void DoubleWidth(Rectangle r) { r.Width *= 2; }

var rect   = new Rectangle { Width = 4, Height = 3 };
var square = new Square    { Width = 4 };

DoubleWidth(rect);   // rect.Area = 8 × 3 = 24 ✓
DoubleWidth(square); // square.Area = 8 × 8 = 64 — not 24 as expected ✗

// Square cannot be substituted for Rectangle without breaking the caller.
// Fix: don't make Square inherit Rectangle. Use composition or separate types.

LSP in Interface Design

C#
// Contract: INotificationSender sends notifications
public interface INotificationSender
{
    // Contract implies: the notification IS sent, or an exception is thrown
    // Callers assume: if no exception, the notification was sent
    Task SendAsync(PatientAlert alert, CancellationToken ct);
}

// LSP violation: NullNotificationSender ignores the contract silently
public sealed class NullNotificationSender : INotificationSender
{
    public Task SendAsync(PatientAlert alert, CancellationToken ct)
        => Task.CompletedTask;  // silently does nothing — is this a bug or by design?
}

// Better: explicit NullObject pattern with clear semantics
public sealed class DevNullNotificationSender : INotificationSender
{
    private readonly ILogger<DevNullNotificationSender> _logger;

    public Task SendAsync(PatientAlert alert, CancellationToken ct)
    {
        _logger.LogDebug("Null notification sender: alert {AlertType} suppressed", alert.Type);
        return Task.CompletedTask;
    }
}

// The LSP question: is suppressing the notification "correct behavior" for the caller?
// If the caller needs to know if the notification was actually sent,
// the null object violates LSP.
// If the caller just needs "best-effort" notification, it is fine.

Precondition and Postcondition Rules

LSP and contracts:
  Base type:    precondition = "alert must not be null"
  Subtype:      CANNOT strengthen precondition to "alert must have a phone number"
                That would break callers who don't provide a phone number.

  Base type:    postcondition = "notification is sent, or exception thrown"
  Subtype:      CANNOT weaken postcondition to "notification might not be sent"
                Callers rely on the notification being sent.

Weaken preconditions (accept more): safe
Strengthen postconditions (guarantee more): safe

Example:
  IRepository.GetById(Guid id) — postcondition: returns entity or null
  ConcreteRepository.GetById() — CANNOT guarantee "never returns null" if callers are written
    to handle null (they will get NullReferenceException if the contract is unexpectedly tightened)

Clinical Example: Reporting

C#
// Base contract: generate a report from clinical data
public interface IReportExporter
{
    Task<byte[]> ExportAsync(IEnumerable<PatientRecord> records, CancellationToken ct);
}

// PdfExporter: satisfies the contract
public sealed class PdfExporter : IReportExporter
{
    public async Task<byte[]> ExportAsync(IEnumerable<PatientRecord> records, CancellationToken ct)
        => await _pdfLib.GenerateAsync(records, ct);
}

// LSP violation: CsvExporter throws for large record sets
public sealed class BadCsvExporter : IReportExporter
{
    public Task<byte[]> ExportAsync(IEnumerable<PatientRecord> records, CancellationToken ct)
    {
        if (records.Count() > 1000)
            throw new NotSupportedException("CSV export only supports up to 1000 records.");
        // Callers using IReportExporter assume ANY exporter handles any record count
        // This breaks LSP — the subtype adds a precondition the caller doesn't know about
        return Task.FromResult(GenerateCsv(records));
    }
}

// Fix: change the contract to express the limitation honestly
public interface IReportExporter
{
    int? MaxRecords { get; }  // null = no limit; callers can check
    Task<byte[]> ExportAsync(IEnumerable<PatientRecord> records, CancellationToken ct);
}

Production issue I've seen: A team had IAlertDeliveryChannel with a SendAsync() method. One implementation, LegacyPagerChannel, threw NotImplementedException for the SendWithPriorityAsync() overload because the pager system didn't support priority routing. When a new high-priority alert feature was added, it called SendWithPriorityAsync() on all channels. The pager channel threw — silently swallowed by a catch block — and no high-priority alerts reached pager-using staff for 3 hours. LSP: if a subtype can't fulfill the contract, it shouldn't implement the interface.


Key Takeaway

LSP means every implementation of an interface must honor the full contract — not just some methods. If a subtype throws NotSupportedException or silently ignores operations that callers depend on, it violates LSP. Fix LSP violations by narrowing the interface (ISP), changing the base contract, or creating a separate type hierarchy. Don't inherit just because two things seem related — inherit only when full behavioral compatibility is guaranteed.