BackDev Patterns & Practices
Solid Principles//

Liskov Substitution Principle

Demonstrates Liskov Substitution with examples that ensure subclasses can safely replace base types without breaking system behavior.

Introduction

The Liskov Substitution Principle (LSP) states: subtypes must be substitutable for their base types without altering the correctness of the program. In simpler terms, if your code works with a base class, it should work identically when you swap in any subclass, no surprises, no broken contracts.

LSP is often misunderstood as “subclasses should inherit properly.” But it goes deeper: it defines a behavioral contract. A subclass that throws unexpected exceptions, ignores preconditions, or changes the meaning of a method violates LSP, even if it compiles perfectly.

This principle matters because OCP depends on it. If you design a system for extension (OCP), the new implementations must behave consistently with the expectations set by the base type. Otherwise, polymorphism becomes a trap: code that works with the base type silently breaks when handed a subtype.

In this article, we explore LSP through a real-world scenario: building a Community of Practice management system. You will see how Gathering's entity hierarchy and state machine design uphold LSP, and what happens when a naive subclass violates it.

The Problem: Subtypes That Break Expectations

A Simple Example: Shapes and Area Calculation

Before diving into the Gathering codebase, let's see the classic LSP violation: the Rectangle/Square problem. This illustrates the core principle without needing application context.

Without LSP: A Square That Breaks the Rectangle Contract

1public class Rectangle
2{
3    public virtual int Width { get; set; }
4    public virtual int Height { get; set; }
5
6    public int CalculateArea()
7    {
8        return Width * Height;
9    }
10}
11
12public class Square : Rectangle
13{
14    // A square forces Width and Height to be equal
15    public override int Width
16    {
17        get => base.Width;
18        set
19        {
20            base.Width = value;
21            base.Height = value; // Surprise! Setting Width also changes Height
22        }
23    }
24
25    public override int Height
26    {
27        get => base.Height;
28        set
29        {
30            base.Height = value;
31            base.Width = value; // Surprise! Setting Height also changes Width
32        }
33    }
34}
35
36// Client code that works with Rectangle
37public void ResizeAndCheck(Rectangle rect)
38{
39    rect.Width = 5;
40    rect.Height = 10;
41
42    // Expectation: Area should be 50
43    Console.WriteLine(rect.CalculateArea());
44    // With Rectangle: 50 ✓
45    // With Square:    100 ✗  (Width was overwritten to 10!)
46}

The problem: Square overrides the setter behavior, violating the contract that Width and Height are independent. Any code that sets them separately will produce incorrect results when given a Square. The subtype is not safely substitutable.

With LSP: Separate Types with a Shared Interface

1public interface IShape
2{
3    int CalculateArea();
4}
5
6public class Rectangle : IShape
7{
8    public int Width { get; }
9    public int Height { get; }
10
11    public Rectangle(int width, int height)
12    {
13        Width = width;
14        Height = height;
15    }
16
17    public int CalculateArea() => Width * Height;
18}
19
20public class Square : IShape
21{
22    public int Side { get; }
23
24    public Square(int side)
25    {
26        Side = side;
27    }
28
29    public int CalculateArea() => Side * Side;
30}
31
32// Client code works with IShape - no assumptions broken
33public void PrintArea(IShape shape)
34{
35    Console.WriteLine(shape.CalculateArea()); // Always correct
36}

Benefits of LSP Here:

  • ✓ No hidden side effects: Each shape calculates area correctly without unexpected property coupling.
  • ✓ Safe substitution: Any IShape can be used anywhere without breaking client code.
  • ✓ Clear contracts: Each type has its own appropriate properties (Width/Height vs Side).

This simple pattern (avoiding inheritance that changes the meaning of the base type) is the foundation of LSP. Now let's apply it to a larger, real-world system.

Case Study: Entity Hierarchy in Gathering

The Gathering application (a Community of Practice management system) uses a layered entity hierarchy: Entity AuditableEntity Session / Community. This hierarchy is carefully designed so every subtype is safely substitutable.

The Base: Entity with Domain Events

1// From: Gathering.SharedKernel/Entity.cs
2public abstract class Entity
3{
4    private readonly List<IDomainEvent> _domainEvents = [];
5
6    public IReadOnlyCollection<IDomainEvent> DomainEvents => 
7        _domainEvents.AsReadOnly();
8
9    public void ClearDomainEvents()
10    {
11        _domainEvents.Clear();
12    }
13
14    protected void Raise(IDomainEvent domainEvent)
15    {
16        _domainEvents.Add(domainEvent);
17    }
18}

Entity establishes a contract: every entity can raise and clear domain events. This is the base behavior that all subtypes must respect.

The Extension: AuditableEntity

1// From: Gathering.SharedKernel/AuditableEntity.cs
2public abstract class AuditableEntity : Entity, IAuditable
3{
4    public DateTimeOffset CreatedAt { get; private set; }
5
6    public DateTimeOffset? UpdatedAt { get; private set; }
7}

AuditableEntity extends without violating the base contract. It adds audit tracking (CreatedAt, UpdatedAt) but does not change the behavior of domain events. Any code that works with Entity still works perfectly with AuditableEntity.

The Concrete Types: Session and Community

1// From: Gathering.Domain/Sessions/Session.cs
2public sealed partial class Session : AuditableEntity
3{
4    public Guid Id { get; private set; }
5    public Guid CommunityId { get; private set; } = Guid.Empty;
6    public string Title { get; private set; } = string.Empty;
7    public string Speaker { get; private set; } = string.Empty;
8    public DateTimeOffset ScheduledAt { get; private set; }
9    public SessionStatus Status { get; private set; }
10
11    public static Result<Session> Create(
12        Guid communityId,
13        string title,
14        string speaker,
15        DateTimeOffset scheduledAt,
16        string? description = null,
17        string? image = null)
18    {
19        // Validation...
20        var session = new Session
21        {
22            Id = Guid.NewGuid(),
23            CommunityId = communityId,
24            Title = title,
25            Speaker = speaker,
26            ScheduledAt = scheduledAt,
27            Status = SessionStatus.Scheduled
28        };
29
30        session.Raise(new SessionCreatedDomainEvent(session.Id));
31        return Result.Success(session);
32    }
33}
34
35// From: Gathering.Domain/Communities/Community.cs
36public sealed class Community : AuditableEntity
37{
38    public Guid Id { get; private set; }
39    public string Name { get; private set; } = string.Empty;
40    public string Description { get; private set; } = string.Empty;
41
42    public static Result<Community> Create(
43        string name, string description, string? image = null)
44    {
45        // Validation...
46        var community = new Community
47        {
48            Id = Guid.NewGuid(),
49            Name = name,
50            Description = description,
51        };
52
53        community.Raise(new CommunityCreatedDomainEvent(community.Id));
54        return Result.Success(community);
55    }
56}

Why This Hierarchy Respects LSP:

  • Entity contract preserved: Both Session and Community use Raise() and ClearDomainEvents() exactly as Entity defines. No overrides, no surprises.
  • AuditableEntity adds, never changes: Audit fields are additive. Existing behavior is untouched.
  • Sealed classes prevent violations: Session and Community are sealed, preventing subtypes from breaking their contracts.
  • Generic repository works: IRepository<T> where T : Entity works identically for Session, Community, or any future entity.

Case Study: Session State Machine

A more subtle LSP scenario involves Gathering's session status transitions. The Session entity manages a state machine: Scheduled Completed / Canceled. This state machine defines a behavioral contract that must be respected.

The Real Code: State Transitions with Validation

1// From: Gathering.Domain/Sessions/SessionStatus.cs
2public enum SessionStatus
3{
4    Scheduled = 0,
5    Completed = 1,
6    Canceled = 2
7}
8
9// From: Gathering.Domain/Sessions/Session.cs
10public Result UpdateStatus(SessionStatus newStatus)
11{
12    if (Status == SessionStatus.Canceled && 
13        newStatus == SessionStatus.Scheduled)
14    {
15        return Result.Failure(SessionError.InvalidStatusTransition);
16    }
17
18    if (Status == SessionStatus.Completed && 
19        newStatus == SessionStatus.Scheduled)
20    {
21        return Result.Failure(SessionError.InvalidStatusTransition);
22    }
23
24    Status = newStatus;
25    return Result.Success();
26}

This is a clear contract: once a session is Canceled or Completed, it cannot go back to Scheduled. Any consumer of Session can rely on this invariant.

The Violation: A Subtype That Ignores the State Machine

Imagine a developer needs a “recurring session” feature. Instead of extending the domain properly, they create a subclass that overrides the state machine rules:

1// ✗ VIOLATION: This subtype breaks the Session contractantcurringSession example)
2public class RecurringSession : Session  // Note: Session is sealed in Gathering,
3{                                        // but imagine it wasn't
4    public DayOfWeek RecurrenceDay { get; set; }
5
6    // Violates LSP: removes the restriction that prevents
7    // Canceled → Scheduled transitions
8    public new Result UpdateStatus(SessionStatus newStatus)
9    {
10        // "Recurring sessions can always be rescheduled"
11        // This BREAKS the contract established by Session
12        Status = newStatus;
13        return Result.Success();
14    }
15}
16
17// Client code that relies on the Session contract
18public class SessionDashboardService
19{
20    private readonly ISessionRepository _repository;
21
22    public async Task<IReadOnlyList<Session>> GetReschedulableSessions(
23        CancellationToken cancellationToken)
24    {
25        var sessions = await _repository.GetAllAsync(cancellationToken);
26
27        // This code TRUSTS the Session contract:
28        // "Canceled sessions cannot be rescheduled"
29        return sessions
30            .Where(s => s.Status == SessionStatus.Scheduled)
31            .ToList()
32            .AsReadOnly();
33    }
34}
35
36// The problem: if RecurringSession is in the list,
37// a canceled session could silently become "Scheduled" again,
38// sending notifications to attendees for a session that was canceled.
39// The UI shows it as reschedulable. Attendees get confused.
40// The contract is broken.

Why This Violates LSP:

  • Weakened postcondition: Session guarantees that Canceled → Scheduled fails. RecurringSession removes this guarantee.
  • Broken client expectations: Any code filtering by Status trusts the state machine. A recurring session that bypasses rules produces invalid states.
  • Silent data corruption: No exception is thrown. The system quietly enters an inconsistent state.
  • Method hiding (not overriding): Using new instead of override means behavior depends on the reference type, not the actual object, a dangerous trap.

The Right Approach: Composition Over Inheritance

Gathering wisely avoids this problem entirely. Session is sealed, preventing subclasses from breaking its contract. For recurring sessions, the correct approach uses composition:

1// ✓ CORRECT: Composition preserves the Session contract
2public sealed class RecurrenceSchedule : AuditableEntity
3{
4    public Guid Id { get; private set; }
5    public Guid CommunityId { get; private set; }
6    public DayOfWeek RecurrenceDay { get; private set; }
7    public TimeOnly RecurrenceTime { get; private set; }
8    public string Title { get; private set; } = string.Empty;
9    public string Speaker { get; private set; } = string.Empty;
10
11    // Creates a NEW session for each occurrence
12    // The Session's state machine remains intact
13    public Result<Session> CreateNextOccurrence()
14    {
15        var nextDate = CalculateNextDate();
16
17        // Uses the REAL Session.Create() factory method
18        // All validations and state machine rules apply
19        return Session.Create(
20            CommunityId,
21            Title,
22            Speaker,
23            nextDate);
24    }
25
26    private DateTimeOffset CalculateNextDate()
27    {
28        var today = DateTimeOffset.UtcNow;
29        var daysUntilNext = ((int)RecurrenceDay - (int)today.DayOfWeek + 7) % 7;
30        if (daysUntilNext == 0) daysUntilNext = 7;
31
32        return today.AddDays(daysUntilNext)
33            .Date
34            .Add(RecurrenceTime.ToTimeSpan())
35            .ToDateTimeOffset();
36    }
37}
38
39// Now recurring sessions create proper Session instances
40// Each session has its own independent state machine
41// Canceling one occurrence doesn't affect others

Why Composition Respects LSP:

  • ✓ Session contract intact: Every session goes through Session.Create() and follows the same state machine rules.
  • ✓ No hidden overrides: RecurrenceSchedule doesn't pretend to be a Session. It creates Sessions.
  • ✓ Independent lifecycles: Each occurrence is a full Session with its own status. Canceling one doesn't affect others.
  • ✓ Repository compatibility: All sessions work with ISessionRepository without special handling.

How the Generic Repository Relies on LSP

Gathering's repository pattern is a practical example of LSP in action. The generic IRepository<T> interface works with any Entity subtype because the hierarchy respects substitutability.

1// From: Gathering.Domain/Abstractions/IRepository.cs
2public interface IRepository<T> where T : Entity
3{
4    Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
5    Task<IReadOnlyList<T>> GetAllAsync(CancellationToken cancellationToken = default);
6    Task<IReadOnlyList<T>> FindAsync(
7        Expression<Func<T, bool>> predicate,
8        CancellationToken cancellationToken = default);
9    Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default);
10
11    void Add(T entity);
12    void Update(T entity);
13    void Remove(T entity);
14}
15
16// Specialized repositories extend without breaking the contract
17public interface ISessionRepository : IRepository<Session>
18{
19    Task<IReadOnlyList<Session>> GetByCommunityIdAsync(
20        Guid communityId,
21        CancellationToken cancellationToken = default);
22
23    Task<IReadOnlyList<Session>> GetActiveSessionsAsync(
24        CancellationToken cancellationToken = default);
25}
26
27public interface ICommunityRepository : IRepository<Community>
28{
29    // No additional methods needed - the base contract is sufficient
30}

This works because of LSP: every Entity subtype behaves consistently. The generic repository can Add, Update, Remove, and query any entity without worrying about subtypes breaking expected behavior.

1// From: Gathering.Infrastructure/Repositories/SessionRepository.cs
2internal sealed class SessionRepository : ISessionRepository
3{
4    private readonly ApplicationDbContext _dbContext;
5
6    public SessionRepository(ApplicationDbContext dbContext)
7    {
8        _dbContext = dbContext;
9    }
10
11    // Base IRepository<Session> methods work because
12    // Session is a proper subtype of Entity
13    public void Add(Session entity)
14    {
15        _dbContext.Sessions.Add(entity);
16    }
17
18    public async Task<Session?> GetByIdAsync(
19        Guid id, CancellationToken cancellationToken = default)
20    {
21        return await _dbContext.Sessions
22            .Include(s => s.Resources)
23            .FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
24    }
25
26    // Extended methods specific to Session
27    public async Task<IReadOnlyList<Session>> GetByCommunityIdAsync(
28        Guid communityId, CancellationToken cancellationToken = default)
29    {
30        var sessions = await _dbContext.Sessions
31            .Where(s => s.CommunityId == communityId)
32            .ToListAsync(cancellationToken);
33
34        return sessions.AsReadOnly();
35    }
36
37    public async Task<IReadOnlyList<Session>> GetActiveSessionsAsync(
38        CancellationToken cancellationToken = default)
39    {
40        var sessions = await _dbContext.Sessions
41            .Where(s => s.Status == SessionStatus.Scheduled)
42            .ToListAsync(cancellationToken);
43
44        return sessions.AsReadOnly();
45    }
46}

Understanding LSP Contracts

LSP defines three rules that subtypes must follow. Let's see how Gathering respects each one:

1. Preconditions Cannot Be Strengthened

A subtype cannot demand more than its base type. If Entity accepts any domain event, AuditableEntity must also accept any domain event. It cannot add restrictions like “only accept audit events.”

1// ✓ Gathering: AuditableEntity does not restrict Raise()
2public abstract class AuditableEntity : Entity, IAuditable
3{
4    // Raise() works exactly as Entity defines it
5    // No extra preconditions are added
6}
7
8// ✗ Violation: Strengthening preconditions
9public class RestrictedEntity : Entity
10{
11    protected new void Raise(IDomainEvent domainEvent)
12    {
13        // VIOLATION: Now requires a specific event type
14        if (domainEvent is not AuditEvent)
15            throw new ArgumentException("Only audit events allowed");
16
17        base.Raise(domainEvent);
18    }
19}

2. Postconditions Cannot Be Weakened

A subtype must guarantee at least as much as its base type. If Session.UpdateStatus() guarantees that invalid transitions return a Failure result, a subtype cannot silently succeed instead.

1// ✓ Gathering: Session guarantees invalid transitions fail
2public Result UpdateStatus(SessionStatus newStatus)
3{
4    if (Status == SessionStatus.Canceled && 
5        newStatus == SessionStatus.Scheduled)
6    {
7        return Result.Failure(SessionError.InvalidStatusTransition);
8    }
9
10    Status = newStatus;
11    return Result.Success();
12}
13
14// ✗ Violation: Weakening postconditions (the RecurringSession example)
15// A subtype that always returns Success removes the guarantee
16// that invalid transitions are rejected

3. Invariants Must Be Preserved

A subtype must maintain all invariants of its base type. If Entity guarantees that DomainEvents is never null, every subtype must maintain this.

1// ✓ Gathering: Entity initializes _domainEvents in declaration
2private readonly List<IDomainEvent> _domainEvents = [];
3
4// This guarantees DomainEvents is never null
5// Every subtype inherits this invariant automatically
6
7// ✗ Violation: A subtype that breaks the invariant
8public class BrokenEntity : Entity
9{
10    public BrokenEntity()
11    {
12        // Calling ClearDomainEvents() in constructor is fine...
13        // But if you somehow nullified _domainEvents,
14        // any code calling entity.DomainEvents would crash
15    }
16}

LSP in TypeScript

The same principles apply in TypeScript. Here is the entity hierarchy pattern translated to a Node.js/Express context:

1// Base entity with domain events
2abstract class Entity {
3  private readonly domainEvents: DomainEvent[] = [];
4
5  get events(): ReadonlyArray<DomainEvent> {
6    return [...this.domainEvents];
7  }
8
9  clearDomainEvents(): void {
10    this.domainEvents.length = 0;
11  }
12
13  protected raise(event: DomainEvent): void {
14    this.domainEvents.push(event);
15  }
16}
17
18// Auditable extension - adds without changing base behavior
19abstract class AuditableEntity extends Entity {
20  readonly createdAt: Date;
21  updatedAt?: Date;
22
23  constructor() {
24    super(); // Entity contract preserved
25    this.createdAt = new Date();
26  }
27}
28
29// Session with state machine
30type SessionStatus = "scheduled" | "completed" | "canceled";
31
32class Session extends AuditableEntity {
33  private _status: SessionStatus = "scheduled";
34
35  get status(): SessionStatus {
36    return this._status;
37  }
38
39  static create(
40    communityId: string,
41    title: string,
42    speaker: string,
43    scheduledAt: Date
44  ): Result<Session> {
45    if (!title.trim()) {
46      return failure("Title is required");
47    }
48
49    if (scheduledAt <= new Date()) {
50      return failure("Schedule must be in the future");
51    }
52
53    const session = new Session();
54    session.communityId = communityId;
55    session.title = title;
56    session.speaker = speaker;
57    session.scheduledAt = scheduledAt;
58
59    session.raise({ type: "SessionCreated", sessionId: session.id });
60    return success(session);
61  }
62
63  updateStatus(newStatus: SessionStatus): Result<void> {
64    if (this._status === "canceled" && newStatus === "scheduled") {
65      return failure("Cannot reschedule a canceled session");
66    }
67
68    if (this._status === "completed" && newStatus === "scheduled") {
69      return failure("Cannot reschedule a completed session");
70    }
71
72    this._status = newStatus;
73    return success(undefined);
74  }
75}
76
77// ✗ LSP Violation in TypeScript
78class RecurringSession extends Session {
79  // Overrides state machine - breaks the contract
80  updateStatus(newStatus: SessionStatus): Result<void> {
81    // Silently allows ALL transitions
82    this._status = newStatus; // Would need to access private field
83    return success(undefined);
84  }
85}
86
87// ✓ LSP-Compliant: Use composition
88class RecurrenceSchedule {
89  constructor(
90    private communityId: string,
91    private title: string,
92    private speaker: string,
93    private recurrenceDay: number
94  ) {}
95
96  createNextOccurrence(): Result<Session> {
97    const nextDate = this.calculateNextDate();
98    // Delegates to Session.create() - all rules apply
99    return Session.create(
100      this.communityId,
101      this.title,
102      this.speaker,
103      nextDate
104    );
105  }
106
107  private calculateNextDate(): Date {
108    const today = new Date();
109    const daysUntil = (this.recurrenceDay - today.getDay() + 7) % 7 || 7;
110    return new Date(today.getTime() + daysUntil * 86400000);
111  }
112}

Benefits of Applying LSP

1. Polymorphism You Can Trust

When subtypes honor the base contract, you can use polymorphism fearlessly. IRepository<T> works with any entity because every entity behaves as Entity promises.

2. Generic Code Just Works

Gathering's generic repository, middleware that processes domain events, and EF Core configurations all work because every entity is a proper subtype of Entity. No special cases. No type checks.

3. Safe Extension

Adding a new entity type (e.g., a “Resource” entity) is safe. Extend AuditableEntity, implement the required properties, and everything (repositories, event processing, auditing) works automatically.

4. Easier Debugging

When subtypes respect contracts, bugs are localized. If GetAllAsync() returns unexpected data, you know the issue is in the query logic, not in a subtype that secretly changed how Add() works.

5. OCP Becomes Reliable

OCP says “extend through new implementations.” But extension only works if new implementations honor the contract. LSP is what makes OCP safe.

Real-World Pitfalls

Pitfall 1: Inheriting for Code Reuse, Not Behavior

Inheritance should model “is-a” relationships, not “has-some-code-I-want.” A RecurringSession is not a Session with different rules, it is a schedule that creates sessions. Use composition when you want code reuse without behavioral substitutability.

Pitfall 2: Throwing NotImplementedException

If a subtype throws NotImplementedException for a method defined in the base type, that is an LSP violation. The base type promises the method works; the subtype breaks that promise.

Better: If a method does not apply, the type probably should not inherit from that base.

Pitfall 3: Using Type Checks Instead of Polymorphism

If you find yourself writing if (entity is RecurringSession), it is a sign that the subtype is not properly substitutable. True polymorphism means you never need to check the concrete type.

Pitfall 4: Ignoring Sealed Classes

Gathering marks concrete entities as sealed. This is intentional: Session and Community have specific contracts (state machines, validation rules) that subclasses could easily break. If a class has behavioral invariants, consider sealing it and using composition for extension.

Checklist: Detecting LSP Violations

If you answer “yes” to any of these, LSP is likely being violated.

Conclusion

The Liskov Substitution Principle ensures that inheritance hierarchies are trustworthy. When subtypes honor the contracts established by their base types, polymorphism works reliably, generic code stays generic, and extension through new types is safe.

Gathering demonstrates this through its entity hierarchy: Entity → AuditableEntity → Session/Community. Each level adds behavior without breaking the contract above it. The generic repository works with any entity. The state machine in Session enforces rules that no subtype can silently bypass, because the class is sealed.

The key insight: inheritance is not about code reuse, it is about behavioral compatibility. If a subtype cannot honor every promise of its base type, use composition instead.

In the next article, we explore the Interface Segregation Principle, which ensures that interfaces are focused enough that no implementation is forced to depend on methods it does not use.

About

Dev Patterns & Practices is a space for long-form thinking on design, technology, and craft. Every piece is written with care and the belief that the best ideas deserve room to breathe.