Skip to main content
CodeOath
← All posts

Architecture & Patterns110 min total · 26 parts

ACID, SOLID, and Design Patterns: A Complete Software Design Reference

Part 9 of 26 · ~2 min

Liskov Substitution Principle

Swap a base-type reference for any of its subtypes, and calling code should never notice — it shouldn't have to change its own behavior, or even know, to stay correct. Merely compiling isn't the bar here. A conforming subtype can't tighten what it demands from callers beyond what the base type asked for, loosen what it promises back below what the base type guaranteed, or quietly invalidate an assumption callers were entitled to make from the base type's contract alone.

ShiftDesk lets a coordinator bump a shift's start time — useful right up until a volunteer has already been texted a confirmed time and the twenty-four-hour "lock window" kicks in, after which a reschedule needs an actual phone call, not a silent database update:

public class Shift
{
    public int Id { get; set; }
    public DateTime Start { get; set; }
    public virtual void Reschedule(DateTime newStart) => Start = newStart;
}

// Looks reasonable in isolation — a locked shift IS-A shift, mechanically.
public class LockedShift : Shift
{
    public override void Reschedule(DateTime newStart) =>
        throw new InvalidOperationException("This shift is inside the lock window — call the volunteer instead.");
}

// Written against the Shift contract, and completely reasonable to write...
public void BumpAllMorningShifts(List<Shift> shifts, TimeSpan delay)
{
    foreach (var shift in shifts)
    {
        shift.Reschedule(shift.Start + delay); // works for any real Shift...
        // ...but THROWS the moment it reaches a LockedShift, halfway through
        // the list — some shifts in this batch are now rescheduled, the
        // rest aren't, and the sweep never finishes.
    }
}

Nothing about LockedShift fails to compile, and it is, technically, a Shift. The break only shows up later, at runtime, against any caller that took Shift at its word and assumed Reschedule was always safe to invoke. Patching around the exception inside Reschedule itself would just be treating the symptom — the actual problem is that the inheritance relationship is asserting something false. Some shifts genuinely can't be rescheduled, so that capability has no business being part of what every Shift guarantees in the first place:

public interface IShiftInfo
{
    int Id { get; }
    DateTime Start { get; }
}

public interface IReschedulable
{
    void Reschedule(DateTime newStart);
}

public class RegularShift : IShiftInfo, IReschedulable
{
    public int Id { get; set; }
    public DateTime Start { get; set; }
    public void Reschedule(DateTime newStart) => Start = newStart;
}

public class LockedShift : IShiftInfo // deliberately does NOT implement IReschedulable
{
    public int Id { get; set; }
    public DateTime Start { get; set; }
}

// BumpAllMorningShifts now only accepts what can genuinely be bumped —
// a LockedShift simply can't be passed to it. The compiler enforces the
// rule that used to be a runtime surprise.
public void BumpAllMorningShifts(List<IReschedulable> shifts, TimeSpan delay) { /* ... */ }