BackDev Patterns & Practices
Solid Principles//

Principio de Sustitución de Liskov

Demuestra la Sustitución de Liskov con ejemplos que aseguran que las subclases pueden reemplazar de forma segura los tipos base sin romper el comportamiento del sistema.

Introducción

El Principio de Sustitución de Liskov (LSP) establece: los subtipos deben ser sustituibles por sus tipos base sin alterar la correctitud del programa. En términos más simples, si tu código funciona con una clase base, debería funcionar de manera idéntica cuando sustituyes cualquier subclase, sin sorpresas, sin contratos rotos.

LSP a menudo se malinterpreta como “las subclases deben heredar correctamente.” Pero va más profundo: define un contrato de comportamiento. Una subclase que lanza excepciones inesperadas, ignora precondiciones o cambia el significado de un método viola LSP, incluso si compila perfectamente.

Este principio importa porque OCP depende de él. Si diseñas un sistema para extensión (OCP), las nuevas implementaciones deben comportarse consistentemente con las expectativas establecidas por el tipo base. De lo contrario, el polimorfismo se convierte en una trampa: código que funciona con el tipo base se rompe silenciosamente cuando recibe un subtipo.

En este artículo, exploramos LSP a través de un escenario del mundo real: construir un sistema de gestión de Comunidades de Práctica. Verás cómo la jerarquía de entidades y el diseño de máquina de estados de Gathering respetan LSP, y qué sucede cuando una subclase ingenua lo viola.

El Problema: Subtipos que Rompen Expectativas

Un Ejemplo Simple: Figuras y Cálculo de Área

Antes de sumergirnos en el código de Gathering, veamos la violación clásica de LSP: el problema Rectángulo/Cuadrado. Esto ilustra el principio fundamental sin necesitar contexto de aplicación.

Sin LSP: Un Cuadrado que Rompe el Contrato del Rectángulo

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    // Un cuadrado fuerza Width y Height a ser iguales
15    public override int Width
16    {
17        get => base.Width;
18        set
19        {
20            base.Width = value;
21            base.Height = value; // ¡Sorpresa! Establecer Width también cambia 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; // ¡Sorpresa! Establecer Height también cambia Width
32        }
33    }
34}
35
36// Código cliente que trabaja con Rectangle
37public void ResizeAndCheck(Rectangle rect)
38{
39    rect.Width = 5;
40    rect.Height = 10;
41
42    // Expectativa: El área debería ser 50
43    Console.WriteLine(rect.CalculateArea());
44    // Con Rectangle: 50 ✓
45    // Con Square:    100 ✗  (¡Width fue sobreescrito a 10!)
46}

El problema: Square sobreescribe el comportamiento del setter, violando el contrato de que Width y Height son independientes. Cualquier código que los establezca por separado producirá resultados incorrectos cuando reciba un Square. El subtipo no es sustituible de forma segura.

Con LSP: Tipos Separados con una Interfaz Compartida

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// El código cliente trabaja con IShape - sin suposiciones rotas
33public void PrintArea(IShape shape)
34{
35    Console.WriteLine(shape.CalculateArea()); // Siempre correcto
36}

Beneficios de LSP Aquí:

  • ✓ Sin efectos secundarios ocultos: Cada figura calcula el área correctamente sin acoplamiento inesperado de propiedades.
  • ✓ Sustitución segura: Cualquier IShape puede usarse en cualquier lugar sin romper el código cliente.
  • ✓ Contratos claros: Cada tipo tiene sus propias propiedades apropiadas (Width/Height vs Side).

Este patrón simple, evitar herencia que cambia el significado del tipo base, es la base de LSP. Ahora apliquémoslo a un sistema más grande del mundo real.

Caso de Estudio: Jerarquía de Entidades en Gathering

La aplicación Gathering (un sistema de gestión de Comunidades de Práctica) usa una jerarquía de entidades en capas: Entity AuditableEntity Session / Community. Esta jerarquía está cuidadosamente diseñada para que cada subtipo sea sustituible de forma segura.

La Base: Entity con Eventos de Dominio

1// De: 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 establece un contrato: cada entidad puede levantar y limpiar eventos de dominio. Este es el comportamiento base que todos los subtipos deben respetar.

La Extensión: AuditableEntity

1// De: 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 extiende sin violar el contrato base. Agrega seguimiento de auditoría (CreatedAt, UpdatedAt) pero no cambia el comportamiento de los eventos de dominio. Cualquier código que funcione con Entity sigue funcionando perfectamente con AuditableEntity.

Los Tipos Concretos: Session y Community

1// De: 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        // Validación...
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// De: 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        // Validación...
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}

Por Qué Esta Jerarquía Respeta LSP:

  • Contrato de Entity preservado: Tanto Session como Community usan Raise() y ClearDomainEvents() exactamente como Entity los define. Sin overrides, sin sorpresas.
  • AuditableEntity agrega, nunca cambia: Los campos de auditoría son aditivos. El comportamiento existente no se toca.
  • Clases selladas previenen violaciones: Session y Community son sealed, previniendo que subtipos rompan sus contratos.
  • El repositorio genérico funciona: IRepository<T> where T : Entity funciona idénticamente para Session, Community o cualquier entidad futura.

Caso de Estudio: Máquina de Estados de Session

Un escenario LSP más sutil involucra las transiciones de estado de sesión de Gathering. La entidad Session gestiona una máquina de estados: Scheduled Completed / Canceled. Esta máquina de estados define un contrato de comportamiento que debe respetarse.

El Código Real: Transiciones de Estado con Validación

1// De: Gathering.Domain/Sessions/SessionStatus.cs
2public enum SessionStatus
3{
4    Scheduled = 0,
5    Completed = 1,
6    Canceled = 2
7}
8
9// De: 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}

Este es un contrato claro: una vez que una sesión está Cancelada o Completada, no puede volver a Programada. Cualquier consumidor de Session puede confiar en este invariante.

La Violación: Un Subtipo que Ignora la Máquina de Estados

Imagina que un desarrollador necesita una funcionalidad de “sesión recurrente”. En lugar de extender el dominio correctamente, crea una subclase que sobreescribe las reglas de la máquina de estados:

1// ✗ VIOLACIÓN: Este subtipo rompe el contrato de Session
2public class RecurringSession : Session  // Nota: Session es sealed en Gathering,
3{                                        // pero imagina que no lo fuera
4    public DayOfWeek RecurrenceDay { get; set; }
5
6    // Viola LSP: elimina la restricción que previene
7    // transiciones Canceled → Scheduled
8    public new Result UpdateStatus(SessionStatus newStatus)
9    {
10        // "Las sesiones recurrentes siempre pueden reprogramarse"
11        // Esto ROMPE el contrato establecido por Session
12        Status = newStatus;
13        return Result.Success();
14    }
15}
16
17// Código cliente que confía en el contrato de Session
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        // Este código CONFÍA en el contrato de Session:
28        // "Las sesiones canceladas no pueden reprogramarse"
29        return sessions
30            .Where(s => s.Status == SessionStatus.Scheduled)
31            .ToList()
32            .AsReadOnly();
33    }
34}
35
36// El problema: si RecurringSession está en la lista,
37// una sesión cancelada podría silenciosamente volver a "Scheduled",
38// enviando notificaciones a asistentes por una sesión cancelada.
39// La UI la muestra como reprogramable. Los asistentes se confunden.
40// El contrato está roto.

Por Qué Esto Viola LSP:

  • Postcondición debilitada: Session garantiza que Canceled → Scheduled falla. RecurringSession elimina esta garantía.
  • Expectativas del cliente rotas: Cualquier código que filtre por Status confía en la máquina de estados. Una sesión recurrente que elude las reglas produce estados inválidos.
  • Corrupción silenciosa de datos: No se lanza ninguna excepción. El sistema entra silenciosamente en un estado inconsistente.
  • Ocultamiento de método (no sobreescritura): Usar new en vez de override significa que el comportamiento depende del tipo de referencia, no del objeto real, una trampa peligrosa.

El Enfoque Correcto: Composición sobre Herencia

Gathering sabiamente evita este problema por completo. Session es sealed, previniendo que subclases rompan su contrato. Para sesiones recurrentes, el enfoque correcto usa composición:

1// ✓ CORRECTO: La composición preserva el contrato de Session
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    // Crea una NUEVA sesión para cada ocurrencia
12    // La máquina de estados de Session permanece intacta
13    public Result<Session> CreateNextOccurrence()
14    {
15        var nextDate = CalculateNextDate();
16
17        // Usa el método factory REAL Session.Create()
18        // Todas las validaciones y reglas de máquina de estados aplican
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// Ahora las sesiones recurrentes crean instancias propias de Session
40// Cada sesión tiene su propia máquina de estados independiente
41// Cancelar una ocurrencia no afecta a las demás

Por Qué la Composición Respeta LSP:

  • ✓ Contrato de Session intacto: Cada sesión pasa por Session.Create() y sigue las mismas reglas de máquina de estados.
  • ✓ Sin overrides ocultos: RecurrenceSchedule no pretende ser un Session. Crea Sessions.
  • ✓ Ciclos de vida independientes: Cada ocurrencia es un Session completo con su propio estado. Cancelar uno no afecta a otros.
  • ✓ Compatibilidad con el repositorio: Todas las sesiones funcionan con ISessionRepository sin manejo especial.

Cómo el Repositorio Genérico Depende de LSP

El patrón repositorio de Gathering es un ejemplo práctico de LSP en acción. La interfaz genérica IRepository<T> funciona con cualquier subtipo de Entity porque la jerarquía respeta la sustituibilidad.

1// De: 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// Los repositorios especializados extienden sin romper el contrato
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 se necesitan métodos adicionales - el contrato base es suficiente
30}

Esto funciona gracias a LSP: cada subtipo de Entity se comporta consistentemente. El repositorio genérico puede Add, Update, Remove y consultar cualquier entidad sin preocuparse de que los subtipos rompan el comportamiento esperado.

1// De: 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    // Los métodos base de IRepository<Session> funcionan porque
12    // Session es un subtipo apropiado de 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    // Métodos extendidos específicos de 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}

Entendiendo los Contratos LSP

LSP define tres reglas que los subtipos deben seguir. Veamos cómo Gathering respeta cada una:

1. Las Precondiciones No Pueden Fortalecerse

Un subtipo no puede exigir más que su tipo base. Si Entity acepta cualquier evento de dominio, AuditableEntity también debe aceptar cualquier evento de dominio. No puede agregar restricciones como “solo acepta eventos de auditoría.”

1// ✓ Gathering: AuditableEntity no restringe Raise()
2public abstract class AuditableEntity : Entity, IAuditable
3{
4    // Raise() funciona exactamente como Entity lo define
5    // No se agregan precondiciones extra
6}
7
8// ✗ Violación: Fortaleciendo precondiciones
9public class RestrictedEntity : Entity
10{
11    protected new void Raise(IDomainEvent domainEvent)
12    {
13        // VIOLACIÓN: Ahora requiere un tipo de evento específico
14        if (domainEvent is not AuditEvent)
15            throw new ArgumentException("Solo eventos de auditoría permitidos");
16
17        base.Raise(domainEvent);
18    }
19}

2. Las Postcondiciones No Pueden Debilitarse

Un subtipo debe garantizar al menos tanto como su tipo base. Si Session.UpdateStatus() garantiza que las transiciones inválidas retornan un resultado Failure, un subtipo no puede tener éxito silenciosamente en su lugar.

1// ✓ Gathering: Session garantiza que las transiciones inválidas fallan
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// ✗ Violación: Debilitando postcondiciones (el ejemplo de RecurringSession)
15// Un subtipo que siempre retorna Success elimina la garantía
16// de que las transiciones inválidas son rechazadas

3. Los Invariantes Deben Preservarse

Un subtipo debe mantener todos los invariantes de su tipo base. Si Entity garantiza que DomainEvents nunca es null, cada subtipo debe mantener esto.

1// ✓ Gathering: Entity inicializa _domainEvents en la declaración
2private readonly List<IDomainEvent> _domainEvents = [];
3
4// Esto garantiza que DomainEvents nunca es null
5// Cada subtipo hereda este invariante automáticamente
6
7// ✗ Violación: Un subtipo que rompe el invariante
8public class BrokenEntity : Entity
9{
10    public BrokenEntity()
11    {
12        // Llamar ClearDomainEvents() en el constructor está bien...
13        // Pero si de alguna manera nullificaras _domainEvents,
14        // cualquier código que llame entity.DomainEvents fallaría
15    }
16}

LSP en TypeScript

Los mismos principios aplican en TypeScript. Aquí está el patrón de jerarquía de entidades traducido a un contexto Node.js/Express:

1// Entidad base con eventos de dominio
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// Extensión auditable - agrega sin cambiar comportamiento base
19abstract class AuditableEntity extends Entity {
20  readonly createdAt: Date;
21  updatedAt?: Date;
22
23  constructor() {
24    super(); // Contrato de Entity preservado
25    this.createdAt = new Date();
26  }
27}
28
29// Session con máquina de estados
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("El título es obligatorio");
47    }
48
49    if (scheduledAt <= new Date()) {
50      return failure("La fecha debe ser en el futuro");
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("No se puede reprogramar una sesión cancelada");
66    }
67
68    if (this._status === "completed" && newStatus === "scheduled") {
69      return failure("No se puede reprogramar una sesión completada");
70    }
71
72    this._status = newStatus;
73    return success(undefined);
74  }
75}
76
77// ✗ Violación de LSP en TypeScript
78class RecurringSession extends Session {
79  // Sobreescribe la máquina de estados - rompe el contrato
80  updateStatus(newStatus: SessionStatus): Result<void> {
81    // Permite silenciosamente TODAS las transiciones
82    this._status = newStatus;
83    return success(undefined);
84  }
85}
86
87// ✓ Compatible con LSP: Usar composición
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    // Delega a Session.create() - todas las reglas aplican
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}

Beneficios de Aplicar LSP

1. Polimorfismo en el que Puedes Confiar

Cuando los subtipos honran el contrato base, puedes usar polimorfismo sin miedo. IRepository<T> funciona con cualquier entidad porque cada entidad se comporta como Entity promete.

2. El Código Genérico Simplemente Funciona

El repositorio genérico de Gathering, el middleware que procesa eventos de dominio y las configuraciones de EF Core todo funciona porque cada entidad es un subtipo apropiado de Entity. Sin casos especiales. Sin verificaciones de tipo.

3. Extensión Segura

Agregar un nuevo tipo de entidad (ej., una entidad “Resource”) es seguro. Extiende AuditableEntity, implementa las propiedades requeridas, y todo, repositorios, procesamiento de eventos, auditoría, funciona automáticamente.

4. Depuración Más Fácil

Cuando los subtipos respetan los contratos, los bugs están localizados. Si GetAllAsync() retorna datos inesperados, sabes que el problema está en la lógica de consulta, no en un subtipo que cambió secretamente cómo funciona Add().

5. OCP se Vuelve Confiable

OCP dice “extiende a través de nuevas implementaciones.” Pero la extensión solo funciona si las nuevas implementaciones honran el contrato. LSP es lo que hace que OCP sea seguro.

Trampas del Mundo Real

Trampa 1: Heredar para Reutilizar Código, No Comportamiento

La herencia debe modelar relaciones “es-un”, no “tiene-código-que-quiero.” Un RecurringSession no es un Session con reglas diferentes, es un schedule que crea sessions. Usa composición cuando quieras reutilizar código sin sustituibilidad de comportamiento.

Trampa 2: Lanzar NotImplementedException

Si un subtipo lanza NotImplementedException para un método definido en el tipo base, eso es una violación de LSP. El tipo base promete que el método funciona; el subtipo rompe esa promesa.

Mejor: Si un método no aplica, el tipo probablemente no debería heredar de esa base.

Trampa 3: Usar Verificaciones de Tipo en Vez de Polimorfismo

Si te encuentras escribiendo if (entity is RecurringSession), es una señal de que el subtipo no es sustituible apropiadamente. El verdadero polimorfismo significa que nunca necesitas verificar el tipo concreto.

Trampa 4: Ignorar Clases Selladas

Gathering marca las entidades concretas como sealed. Esto es intencional: Session y Community tienen contratos específicos (máquinas de estado, reglas de validación) que las subclases podrían romper fácilmente. Si una clase tiene invariantes de comportamiento, considera sellarla y usar composición para extensión.

Checklist: Detectando Violaciones de LSP

Si respondes “sí” a alguna de estas, LSP probablemente está siendo violado.

Conclusión

El Principio de Sustitución de Liskov asegura que las jerarquías de herencia son confiables. Cuando los subtipos honran los contratos establecidos por sus tipos base, el polimorfismo funciona de manera confiable, el código genérico se mantiene genérico, y la extensión a través de nuevos tipos es segura.

Gathering demuestra esto a través de su jerarquía de entidades: Entity → AuditableEntity → Session/Community. Cada nivel agrega comportamiento sin romper el contrato superior. El repositorio genérico funciona con cualquier entidad. La máquina de estados en Session impone reglas que ningún subtipo puede eludir silenciosamente, porque la clase es sealed.

La idea clave: la herencia no se trata de reutilización de código, se trata de compatibilidad de comportamiento. Si un subtipo no puede honrar cada promesa de su tipo base, usa composición en su lugar.

En el próximo artículo, exploramos el Principio de Segregación de Interfaces, que asegura que las interfaces son lo suficientemente enfocadas para que ninguna implementación se vea forzada a depender de métodos que no usa.

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.