Liskov Substitution Principle — Subtype Contracts
Apply the Liskov Substitution Principle in C#: what subtypes must guarantee, classic LSP violations (square-rectangle), precondition weakening and postcondition strengthening, and LSP in interface design.
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 typeClassic LSP Violation: Square-Rectangle
// 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
// 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
// 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
IAlertDeliveryChannelwith aSendAsync()method. One implementation,LegacyPagerChannel, threwNotImplementedExceptionfor theSendWithPriorityAsync()overload because the pager system didn't support priority routing. When a new high-priority alert feature was added, it calledSendWithPriorityAsync()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
NotSupportedExceptionor 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.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.