Liskov Substitution Principle (LSP)

intermediate 2-4 YOE lld solid lsp clean-code

The Liskov Substitution Principle says: if class B is a subclass of class A, we should be able to use B wherever we use A without anything breaking.

In simple language, if our code works with an Animal object, it should also work perfectly with a Dog object (since Dog extends Animal). If swapping in the subclass causes surprises — we’ve violated LSP.

Or as the joke goes: “If it looks like a duck, quacks like a duck, but needs batteries — we probably have the wrong abstraction.”

The Classic Violation: Rectangle and Square

This is the most famous LSP example. Mathematically, a square IS a rectangle. So it makes sense to write:

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    def set_width(self, width):
        self._width = width

    def set_height(self, height):
        self._height = height

    def area(self):
        return self._width * self._height

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    def set_width(self, width):
        # A square must keep sides equal -- SURPRISE!
        self._width = width
        self._height = width  # also changes height

    def set_height(self, height):
        self._width = height  # also changes width
        self._height = height

# This function expects Rectangle behavior
def test_rectangle(rect):
    rect.set_width(5)
    rect.set_height(10)
    assert rect.area() == 50, f"Expected 50, got {rect.area()}"

test_rectangle(Rectangle(2, 3))  # PASSES -- area is 50
test_rectangle(Square(2))        # FAILS -- area is 100!
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(w) {
    this.width = w;
  }

  setHeight(h) {
    this.height = h;
  }

  area() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side) {
    super(side, side);
  }

  setWidth(w) {
    this.width = w;
    this.height = w; // SURPRISE -- changes height too
  }

  setHeight(h) {
    this.width = h; // SURPRISE -- changes width too
    this.height = h;
  }
}

function testRectangle(rect) {
  rect.setWidth(5);
  rect.setHeight(10);
  console.assert(rect.area() === 50, `Expected 50, got ${rect.area()}`);
}

testRectangle(new Rectangle(2, 3)); // PASSES
testRectangle(new Square(2));       // FAILS -- area is 100
class Rectangle {
    protected int width, height;

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    void setWidth(int w) { this.width = w; }
    void setHeight(int h) { this.height = h; }
    int area() { return width * height; }
}

class Square extends Rectangle {
    Square(int side) { super(side, side); }

    void setWidth(int w) {
        this.width = w;
        this.height = w; // SURPRISE
    }

    void setHeight(int h) {
        this.width = h; // SURPRISE
        this.height = h;
    }
}

// testRectangle(new Square(2)) would fail -- area is 100, not 50

The problem: Square overrides setWidth() and setHeight() in a way that breaks the expected behavior of Rectangle. We can’t substitute a Square where a Rectangle is expected. LSP violated.

The Fix

Don’t force Square to extend Rectangle. Instead, use a common interface:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side

# Now they're siblings, not parent-child. No LSP issues.
shapes = [Rectangle(5, 10), Square(7)]
for s in shapes:
    print(s.area())  # 50, 49 -- both work as expected
class Shape {
  area() {
    throw new Error("Must implement area()");
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(side) {
    super();
    this.side = side;
  }

  area() {
    return this.side * this.side;
  }
}

// Siblings, not parent-child. Both are Shapes.
[new Rectangle(5, 10), new Square(7)].forEach((s) =>
  console.log(s.area())
); // 50, 49
interface Shape {
    int area();
}

class Rectangle implements Shape {
    int width, height;
    Rectangle(int w, int h) { width = w; height = h; }
    public int area() { return width * height; }
}

class Square implements Shape {
    int side;
    Square(int s) { side = s; }
    public int area() { return side * side; }
}

// Both implement Shape. No parent-child weirdness.

Another Example: Birds That Can’t Fly

# BAD -- Penguin violates LSP
class Bird:
    def fly(self):
        print("Flying high!")

class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins can't fly!")  # SURPRISE!

# GOOD -- separate the ability to fly
class Bird:
    def eat(self):
        print("Eating...")

class FlyingBird(Bird):
    def fly(self):
        print("Flying high!")

class Penguin(Bird):
    def swim(self):
        print("Swimming!")

# Now nobody expects Penguin to fly
// BAD -- Penguin violates LSP
class Bird {
  fly() {
    console.log("Flying high!");
  }
}

class Penguin extends Bird {
  fly() {
    throw new Error("Penguins can't fly!"); // SURPRISE!
  }
}

// GOOD -- separate the ability to fly
class Bird {
  eat() {
    console.log("Eating...");
  }
}

class FlyingBird extends Bird {
  fly() {
    console.log("Flying high!");
  }
}

class Penguin extends Bird {
  swim() {
    console.log("Swimming!");
  }
}
// BAD -- Penguin violates LSP
class Bird {
    void fly() { System.out.println("Flying high!"); }
}
class Penguin extends Bird {
    void fly() { throw new RuntimeException("Can't fly!"); }
}

// GOOD -- separate the concerns
class Bird {
    void eat() { System.out.println("Eating..."); }
}
class FlyingBird extends Bird {
    void fly() { System.out.println("Flying high!"); }
}
class Penguin extends Bird {
    void swim() { System.out.println("Swimming!"); }
}

How to Detect LSP Violations

Watch out for these code smells:

  1. Subclass throws exceptions the parent doesn’tfly() throws “can’t fly”
  2. Subclass does nothing in an inherited method — empty override of save()
  3. Subclass changes the expected behavior — setting width also sets height
  4. We check the type before calling a methodif isinstance(bird, Penguin): skip fly()

If we find ourselves writing instanceof or typeof checks to handle subclass differences, that’s a strong hint our hierarchy is wrong.

The Simple Rule

Every subclass should strengthen the parent’s promises, never weaken them. If the parent says “I can fly,” every child better be able to fly. If not, we need a different abstraction.