Software Engineer's Blog

Understanding LSP with the Rectangle and Square Example in Java

Understanding LSP with the Rectangle and Square Example in Java

Inheritance is one of the most familiar ideas in object-oriented programming. It allows us to reuse code and model relationships between classes.

But inheritance can also create subtle design problems.

One of the best-known examples is the relationship between Rectangle and Square.

Mathematically, a square is a rectangle.
But in object-oriented design, that does not always mean Square should inherit from Rectangle.

This is where the Liskov Substitution Principle, or LSP, becomes important.

What is LSP?

LSP stands for Liskov Substitution Principle.

It is one of the SOLID principles.

The basic idea is:

A subclass should be usable wherever its parent class is expected.

In simpler words:

If a piece of code works with a parent class, it should also work correctly with any subclass.

This is not only about syntax.

Java allows this:

Rectangle rectangle = new Square();

But LSP asks a deeper question:

Does Square actually behave correctly when used as a Rectangle?

If the answer is no, the inheritance relationship may be wrong.

The Rectangle Class

Let’s start with a simple Rectangle class.

class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

This class has two independent properties:

width
height

A client using this class can reasonably expect that changing the width does not change the height, and changing the height does not change the width.

For example:

Rectangle rectangle = new Rectangle();

rectangle.setWidth(5);
rectangle.setHeight(10);

System.out.println(rectangle.getArea()); // 50

This behavior is simple and predictable.

The width is 5, the height is 10, and the area is 50.

Making Square Extend Rectangle

Now let’s create a Square.

Since a square has equal width and height, we may try to model it as a subclass of Rectangle.

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

At first, this looks reasonable.

A square must keep its width and height equal. So when the width changes, the height also changes. When the height changes, the width also changes.

From the Square point of view, this makes sense.

But from the Rectangle point of view, the behavior has changed.

Where the Problem Appears

Now look at this code:

Rectangle rectangle = new Square();

rectangle.setWidth(5);
rectangle.setHeight(10);

System.out.println(rectangle.getArea());

The variable type is Rectangle.

So the code expects normal rectangle behavior:

width = 5
height = 10
area = 50

But the actual object is Square.

So this line:

rectangle.setWidth(5);

calls Square.setWidth(5).

The result becomes:

width = 5
height = 5

Then this line:

rectangle.setHeight(10);

calls Square.setHeight(10).

The result becomes:

width = 10
height = 10

So the final area is:

10 * 10 = 100

The program prints:

100

But the client code expected:

50

This is an LSP violation.

The subclass can be assigned to the parent type, but it does not behave like the parent type.

The Real Problem: Broken Expectations

The issue is not that Square is implemented incorrectly.

The issue is that Square and Rectangle have different behavior rules.

A mutable Rectangle allows width and height to change independently.

A mutable Square does not.

So when Square inherits from Rectangle, it changes the meaning of inherited methods like:

setWidth()
setHeight()

The parent class says:

Width and height can be changed independently.

The child class says:

Width and height must always be the same.

Those two rules conflict.

That is why this design breaks LSP.

Mathematical “is-a” Is Not Enough

This example is confusing because, in mathematics, a square is a rectangle.

But software design is not only about classification.

In object-oriented programming, inheritance should be based on behavior.

The better question is not:

Is a square a rectangle?

The better question is:

Can a square be used everywhere a rectangle is expected without surprising behavior?

For this mutable example, the answer is no.

That is the key lesson.

Inheritance should be based on behavior, not just real-world classification.

Mutability Makes the Problem Worse

The Rectangle/Square problem mainly appears because the objects are mutable.

The class allows its internal state to change after creation:

setWidth()
setHeight()

If the objects were immutable, the design would be simpler.

For example:

interface Shape {
    int getArea();
}

Then Rectangle can be written like this:

public final class Rectangle implements Shape {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("Width and height must be positive");
        }

        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

And Square can be written separately:

public final class Square implements Shape {
    private final int side;

    public Square(int side) {
        if (side <= 0) {
            throw new IllegalArgumentException("Side must be positive");
        }

        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

Now both classes implement Shape.

They do not inherit from each other.

They only share the behavior that actually makes sense:

getArea()

This design is much safer.

A Better Design: Use a Common Interface

Instead of forcing Square to extend Rectangle, we can introduce a smaller abstraction.

public interface Shape {
    int getArea();
}

Then both classes can implement it.

public final class Rectangle implements Shape {
    private final int width;
    private final int height;

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

    @Override
    public int getArea() {
        return width * height;
    }
}
public final class Square implements Shape {
    private final int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

Now client code can work with the shared abstraction.

public class AreaPrinter {
    public void printArea(Shape shape) {
        System.out.println(shape.getArea());
    }
}

This avoids the broken inheritance relationship.

Rectangle has its own rules.
Square has its own rules.
Both can still be treated as shapes.

That is a cleaner model.

Another Option: Composition

Another way to avoid this problem is to prefer composition over inheritance.

Inheritance creates a strong relationship:

Square is a Rectangle

Composition allows us to reuse behavior without forcing an incorrect parent-child relationship.

For example, if we want to reuse dimension-related logic, we can create a separate class:

public final class Dimension {
    private final int width;
    private final int height;

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

    public int area() {
        return width * height;
    }
}

Then Rectangle can use it internally:

public final class Rectangle implements Shape {
    private final Dimension dimension;

    public Rectangle(int width, int height) {
        this.dimension = new Dimension(width, height);
    }

    @Override
    public int getArea() {
        return dimension.area();
    }
}

This approach gives us reuse without forcing Square to behave like a mutable Rectangle.

That is why developers often say:

Favor composition over inheritance.

It does not mean inheritance is always bad.

It means inheritance should be used only when the child really can behave like the parent.

Other Common LSP Violations

The Rectangle/Square example is classic, but the same issue appears in many real systems.

One common example is a bird hierarchy.

class Bird {
    void fly() {
        // flying logic
    }
}

Then we create:

class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly");
    }
}

A penguin is a bird in the real world.

But if Bird has a fly() method, the software model says:

All birds can fly.

That is not true.

So Penguin cannot safely replace Bird.

A better design is to separate the abilities:

interface Bird {
}
interface Flyable {
    void fly();
}

Then flying birds can implement Flyable.

class Eagle implements Bird, Flyable {
    @Override
    public void fly() {
        // flying logic
    }
}

And penguins do not need a fake fly() method.

class Penguin implements Bird {
    // no fly method
}

This design is more accurate and avoids unnecessary exceptions.

Another Example: Immutable List

Another common LSP issue appears when a subclass does not support behavior promised by the parent.

For example:

class MyList {
    void add(String item) {
        // add item
    }
}

Now imagine an immutable list extending it:

class ImmutableList extends MyList {
    @Override
    void add(String item) {
        throw new UnsupportedOperationException();
    }
}

The problem is that MyList promises that items can be added.

But ImmutableList breaks that promise.

If client code does this:

void addDefaultItem(MyList list) {
    list.add("default");
}

Then this code should work for any MyList.

But it fails with:

addDefaultItem(new ImmutableList());

A better design is to separate read-only behavior from mutable behavior.

interface ReadOnlyList {
    String get(int index);
    int size();
}
interface MutableList extends ReadOnlyList {
    void add(String item);
}

Now code that only reads from the list can depend on ReadOnlyList.

Code that needs to add items can depend on MutableList.

The contract becomes clearer.

How to Think About LSP in Practice

A simple way to check LSP is to ask:

Would the parent class tests still pass if I replaced the parent object with the child object?

For the rectangle example, this test makes sense:

@Test
void rectangleAreaShouldBeWidthTimesHeight() {
    Rectangle rectangle = new Rectangle();

    rectangle.setWidth(5);
    rectangle.setHeight(10);

    assertEquals(50, rectangle.getArea());
}

If Square is truly substitutable for Rectangle, the same expectation should still hold:

Rectangle rectangle = new Square();

rectangle.setWidth(5);
rectangle.setHeight(10);

assertEquals(50, rectangle.getArea());

But it does not.

That tells us the inheritance relationship is not safe.

LSP and Method Contracts

LSP is really about preserving contracts.

A subclass should not make the parent’s behavior weaker, narrower, or more surprising.

For example, a subclass should not suddenly reject inputs that the parent accepts.

class PaymentProcessor {
    void process(Payment payment) {
        // process payment
    }
}

If a subclass only accepts credit card payments, that can be a problem:

class CreditCardOnlyProcessor extends PaymentProcessor {
    @Override
    void process(Payment payment) {
        if (!(payment instanceof CreditCardPayment)) {
            throw new IllegalArgumentException("Only credit card is supported");
        }

        // process credit card
    }
}

The parent type suggests it can process a general Payment.

The child narrows that behavior and rejects other payments.

This can break code that expects the parent behavior.

Checked Exceptions and LSP

Java also reflects part of this idea through checked exceptions.

A subclass cannot override a method and throw a broader checked exception than the parent method.

For example:

class FileProcessor {
    void process() throws IOException {
        // process file
    }
}

This is not allowed:

class CustomProcessor extends FileProcessor {
    @Override
    void process() throws Exception {
        // compile error
    }
}

Why?

Because client code using FileProcessor only expects to handle IOException.

If a subclass could suddenly throw a broader Exception, existing client code could break.

A more specific exception is allowed:

class CustomProcessor extends FileProcessor {
    @Override
    void process() throws FileNotFoundException {
        // OK
    }
}

FileNotFoundException is a subtype of IOException, so it does not break the original contract.

Covariant Return Type Is Okay

Returning a more specific type from an overridden method is not an LSP violation.

For example:

class Animal {
}
class Dog extends Animal {
}
class AnimalFactory {
    Animal create() {
        return new Animal();
    }
}

A subclass can return Dog:

class DogFactory extends AnimalFactory {
    @Override
    Dog create() {
        return new Dog();
    }
}

This is safe because Dog is still an Animal.

Client code expecting an Animal still works:

AnimalFactory factory = new DogFactory();
Animal animal = factory.create();

The contract is still preserved.

Final Thoughts

The Liskov Substitution Principle helps us use inheritance more carefully.

The Rectangle/Square example shows that a relationship that looks correct in the real world may not be correct in code.

A square is mathematically a rectangle, but a mutable Square does not behave like a mutable Rectangle.

That difference matters.

LSP reminds us that inheritance should be based on behavior, not just classification.

A good rule of thumb is:

If a subclass cannot fully behave like its parent, do not force inheritance.

In many cases, a smaller interface or composition gives a cleaner design.

For the Rectangle/Square problem, this is usually better:

interface Shape {
    int getArea();
}

Then Rectangle and Square can each implement Shape in their own way.

The result is simpler, safer, and easier to maintain.