SwadhyAI Notes

All Low-Level Design notes

SOLID · L

Liskov Substitution

Liskov Substitution says a subtype must be usable wherever its parent type is expected. Callers should not need to know which concrete child they got. If the subtype breaks the parent’s promises — what it allows, what it forbids, and what it returns — substitution has failed.

The classic teaching example is Rectangle and Square. Rectangle promised that setWidth and setHeight could move independently. Square overrode both so they always matched. A helper that doubled width and checked area silently failed. The subtype broke the parent’s contract. That is a Liskov Substitution failure, not a clever trick.

The promise

If I hand this subtype to code that only knows the parent, does the promise still hold?

A subtype must keep the parent’s contract. Callers should not need to know which child they got. If they start writing if (spot instanceof HandicappedSpot), the contract already leaked. The promise includes what the parent allows, what it forbids, and what it returns.

Parking spots have a contract, not a family tree

ParkingSpot.occupy(vehicle) either takes the vehicle or explains why not. An EvSpot that throws for bikes is fine if the parent already said “may reject.” An EvSpot that no-ops occupy() and leaves the spot free is a lie. Callers of ParkingSpot did not expect occupy() to be a landmine or a silent no-op.

Inheritance is for genuine substitutability. If a subclass has to empty-implement half the parent methods, you picked the wrong parent. Prefer a smaller interface, or compose a role instead of extending a taxonomy. That is why Interface Segregation and Liskov travel together: when occupy() is not on the report port, nobody has to throw UnsupportedOperationException to “implement” it.

Test a subtype through the parent type. If the test has to downcast, the substitution already failed. Write the test against ParkingSpot or Occupiable, pass CompactSpot, EvSpot, and HandicappedSpot, and assert the same occupancy invariant. Geometry-class “is-a” is not the test. Behavior is.

Java: a subtype that turns occupy into a landmine

Display-only spots exist in some lots — a sign with a number, not a place a car may sit. Modeling that as a ParkingSpot that throws is how Liskov fails. Callers that loop “occupy every free spot” now explode. The English sentence “a display spot is a parking spot” was a taxonomy lie.

java
class ParkingSpot {
  private Vehicle vehicle;

  boolean isFree() { return vehicle == null; }

  void occupy(Vehicle v) {
    if (!isFree()) throw new IllegalStateException("occupied");
    if (!fits(v)) throw new IllegalArgumentException("too large");
    this.vehicle = v;
  }

  void vacate() { this.vehicle = null; }

  boolean fits(Vehicle v) { return true; }
}

class ReadOnlySpot extends ParkingSpot {
  @Override
  void occupy(Vehicle v) {
    throw new UnsupportedOperationException("display only");
  }
}

// Allocation that only knows ParkingSpot:
for (ParkingSpot spot : floor.spots()) {
  if (spot.isFree()) spot.occupy(vehicle); // landmine
}

A role, not a lying subtype

Occupiable is a role. DisplaySpot never claimed it. CompactSpot, EvSpot, and HandicappedSpot all honor occupy: they may reject a vehicle that does not fit, which the parent already allowed. Rejection is in the contract. Pretending to occupy, or throwing a surprise exception, is not.

java
interface Occupiable {
  boolean isFree();
  void occupy(Vehicle v);
  void vacate();
}

class ParkingSpot implements Occupiable {
  private Vehicle vehicle;
  private final SpotSize size;

  public boolean isFree() { return vehicle == null; }

  public void occupy(Vehicle v) {
    if (!isFree()) throw new IllegalStateException("occupied");
    if (!size.fits(v.size())) throw new IllegalArgumentException("too large");
    this.vehicle = v;
  }

  public void vacate() { this.vehicle = null; }
}

class EvSpot extends ParkingSpot {
  private final boolean chargerFree;

  @Override
  public void occupy(Vehicle v) {
    if (v.needsCharger() && !chargerFree) {
      throw new IllegalStateException("charger in use");
    }
    super.occupy(v);
  }
}

class DisplaySpot {
  private final String label;
  String label() { return label; }
  // not Occupiable — nothing promises occupy()
}

The Square / Rectangle lesson

A square “is a” rectangle in geometry class. In code, a Square that breaks Rectangle callers is not a Rectangle. If setWidth is allowed to leave height unchanged on the parent, a child that secretly writes height has lied. Model the contract, not the textbook sentence. Prefer composition: Shape with area(), or a Size value object, rather than a fragile inheritance tree.

What the parent actually promised

  • Preconditions must not get stronger. If ParkingSpot.occupy accepts any fitting vehicle, EvSpot must not suddenly require a membership card the parent never mentioned — unless the parent already allowed extra rejection reasons.
  • Postconditions must not get weaker. After occupy returns, the spot is occupied. A child that returns while still free has weakened the postcondition.
  • Invariants must hold. A free spot has no vehicle. A child that stores a vehicle while reporting isFree() is not a ParkingSpot.

Test through the parent type

The cheapest Liskov check in a machine-coding round is a parameterized test. You do not need a framework speech. You need one method that accepts Occupiable and a vehicle that should fit, then asserts occupied. Run it on every real spot type. If a type cannot pass, it should not extend or implement the parent.

java
void assertOccupyHonorsContract(Occupiable spot, Vehicle fitting) {
  assertTrue(spot.isFree());
  spot.occupy(fitting);
  assertFalse(spot.isFree());
  spot.vacate();
  assertTrue(spot.isFree());
}

// CompactSpot, EvSpot, HandicappedSpot all go through this.
// DisplaySpot never enters the list.

Two inheritance smells

Is-a in English, not in behavior. StaffVehicle “is a” Vehicle, and that usually holds because park() still occupies a spot. ReadOnlySpot “is a” ParkingSpot only in a diagram. If you need instanceof to stay safe, the hierarchy is already wrong. See composition versus inheritance.

Throwing to disable a parent method. If the child cannot honor the method, it should not extend the parent. Split the interface so the child never had that method to begin with. Empty methods to satisfy a fat interface are the same lie with a quieter compiler. That is also an ISP failure.

Fees and substitution

PricingStrategy has a Liskov angle too. Every implementation must return a money amount for a closed ticket, or fail in a way the interface documented. A strategy that returns null, or that throws because “staff tickets should not go through fee(),” will blow up ExitService. Staff pricing that returns zero is substitutable. Staff pricing that refuses to be called is not.

The same rule applies to TicketRepository. An in-memory map and a JDBC adapter must both honor save and find. An adapter that silently drops saves is not a repository. Tests that run against the interface catch this without knowing about Postgres.

Handicapped spots without instanceof

A common follow-up: only permit-holding vehicles may use handicapped spots. The wrong move is if (spot instanceof HandicappedSpot) inside the lot. That leaks the subtype to every caller. The right move is to keep rejection inside occupy or fits: the spot already knows it requires a permit. Allocation asks spot.fits(vehicle). Callers stay on the parent type. Substitution holds because every Occupiable still occupy-or-explains.

java
class HandicappedSpot extends ParkingSpot {
  @Override
  public void occupy(Vehicle v) {
    if (!v.hasHandicapPermit()) {
      throw new IllegalArgumentException("permit required");
    }
    super.occupy(v);
  }
}

// Lot code never names HandicappedSpot.
Optional<ParkingSpot> choose(List<ParkingSpot> free, Vehicle vehicle) {
  return free.stream().filter(s -> s.fits(vehicle)).findFirst();
}

If fits() on the parent already meant “this vehicle may sit here,” requiring a permit is a specialization of rejection, not a new surprise. Document that occupy/fits may reject. Then every subtype that rejects for a documented reason is still a ParkingSpot. A subtype that accepts and then fails to occupy is not.

Null and surprise exceptions also break substitution

If PricingStrategy.fee is documented to return Money, an implementation that returns null is not substitutable. ExitService will NPE. Return Money.zero() for staff, or fail with a documented domain error if the ticket is still open. The parent promise includes “returns a money amount for a closed ticket.” Honor that in every child, including the fake you use in tests. A test double that returns null to “skip pricing” will hide production bugs and is itself a Liskov failure.

Related

In-memory repository must still persist

A fake that forgets saves is not a TicketRepository. Tests that pass with a no-op save will fail in production when JDBC is wired. Honor save and find in every implementation, including the HashMap. That is Liskov on the persistence abstraction: the parent promised durability for the lifetime of the object. An in-memory map is durable until the process dies; it is not allowed to drop the put.

java
class InMemoryTicketRepository implements TicketRepository {
  private final Map<TicketId, Ticket> store = new HashMap<>();
  public void save(Ticket ticket) {
    store.put(ticket.id(), ticket); // must actually store
  }
  public Optional<Ticket> find(TicketId id) {
    return Optional.ofNullable(store.get(id));
  }
}

Zero is a legal fee; null is not

StaffPricing that returns Money.zero() honors fee(). StaffPricing that returns null, or that throws because “staff should skip checkout,” does not. Callers of PricingStrategy must be able to add the result. Substitution is a numeric contract here, not a social one.

Takeaway

A subtype must keep the parent’s promise: same allowances, same forbiddings, same postconditions. Test children through the parent type. If you need instanceof, throws-to-disable, or empty methods, you picked the wrong parent — split the role instead of lying in the family tree.