Skip to content

// SOLID & Principles · 03

Liskov Substitution

Subtype swapped inleads toCallers still correct

A subtype must keep every promise its base type makes.

How it fits together

Liskov Substitution structureBeforeAfter«client»ReportQuery«interface»Repository<T>Add(item)Remove(id)Get(id)ReadOnlyRepositoryAdd(item) throwsRemove(id) throws«interface»ReadRepository<T>Get(id)List()«client»ReportQueryRepository<T>Get(id)Add(item)Remove(id)«interface»WriteRepository<T>Add(item)Remove(id)Add(item)throwsGet / List
uses / callsimplementsfails / error path
  • ReportQuery uses / calls Repository<T> (Add(item))
  • ReadOnlyRepository implements Repository<T>
  • ReadOnlyRepository fails / error path ReportQuery (throws)
  • Repository<T> implements ReadRepository<T>
  • Repository<T> implements WriteRepository<T>
  • ReportQuery uses / calls ReadRepository<T> (Get / List)

Spot it

  • Overrides that throw "not supported" errors
  • Callers check the concrete type before calling
  • A derived class quietly ignores a method

It costs

Honest hierarchies are often flatter; sometimes the fix is dropping inheritance entirely.

In code

public interface IRepository<T>{    Task AddAsync(Guid id, T item, CancellationToken ct);    Task<T?> GetAsync(Guid id, CancellationToken ct);}​// One contract every implementation must pass. A type that can't is not a subtype.public abstract class RepositoryContract{    protected abstract IRepository<Invoice> Create();​    [Fact]    public async Task GetAsync_AfterAdd_ReturnsTheItem()    {        // Arrange        var repository = Create();        var invoice = new InvoiceBuilder().Build();​        // Act        await repository.AddAsync(invoice.Id, invoice, CancellationToken.None);        var found = await repository.GetAsync(invoice.Id, CancellationToken.None);​        // Assert        found.ShouldBe(invoice);    }}​public sealed class SqlInvoiceRepositoryContract : RepositoryContract{    protected override IRepository<Invoice> Create() => new SqlInvoiceRepository(TestDb.Connect());}​public sealed class InMemoryInvoiceRepositoryContract : RepositoryContract{    protected override IRepository<Invoice> Create() => new InMemoryInvoiceRepository();}
Go deeperThe full explanation and what to ask for · 1 min readExpand

Barbara Liskov's rule is about behaviour, not signatures: anywhere the base type works, the subtype must work too, without the caller knowing which one it has.

The classic trap is Square inheriting Rectangle. It compiles, but setting the width of a square also changes its height, breaking what callers of Rectangle rely on. "Is-a" in the real world is not "is substitutable" in code.

A subtype may accept more and promise more, never require more or deliver less. When a type cannot honour the contract, it should not share it.

Ask for it

  • behavioural subtyping
  • preconditions
  • postconditions
  • invariants
  • contract tests

Check every implementation of the repository interface for Liskov violations: overrides that throw, strengthen preconditions, weaken postconditions or break invariants. Write one shared contract test suite all implementations must pass, and propose a split interface where one cannot.