Module 6 · Inheritance, Polymorphism & Design ⏱ 18 min

OOP Design Principles (SRP, OCP, LSP)

By the end of this lesson you will be able to:
  • Spot classes that violate the Single Responsibility Principle by mixing unrelated duties
  • Replace conditional type-check chains with polymorphism to satisfy the Open/Closed Principle
  • Recognise Liskov Substitution Principle violations from unexpected subclass behaviour

Most beginner programs grow until one class does everything: it stores data, formats reports, sends emails, and draws charts. It feels efficient because all the code is in one place, but it is a trap. Every time a requirement changes — a new output format, a different email provider — you edit the same file, risking bugs in unrelated features. The class becomes brittle not because any individual method is wrong, but because everything is welded together.

Design principles are not rules enforced by the compiler; they are heuristics that keep code maintainable as it grows. The three most influential are the SOLID trio: Single Responsibility, Open/Closed, and Liskov Substitution. They are simple to state and subtle to apply. A principle is not a law — it is a warning sign that a design is likely to cause pain later.

flowchart TD
  G["ReportGodClass<br/>generate, format, email, print"] -->|"split by responsibility"| F["ReportGenerator"]
  G --> M["ReportFormatter"]
  G --> S["ReportSender"]
  style G fill:#b45309,color:#fff
  style F fill:#3776ab,color:#fff
  style M fill:#3776ab,color:#fff
  style S fill:#3776ab,color:#fff
A God class mixes responsibilities; SRP splits them so each class has one reason to change.

The Single Responsibility Principle says a class should have only one reason to change. If your Invoice class both calculates totals and writes PDF files, a change to the PDF library forces you to retest the calculation logic. Split them: Invoice holds the data and rules; InvoicePdfWriter handles rendering; InvoiceEmailer handles delivery.

Responsibility is about actor, not size. A tiny class that mixes database access and currency conversion still violates SRP because two different business requirements could force it to change. The goal is not short classes; it is cohesive classes that answer to one boss. When you describe what a class does, the word 'and' is a warning signal.

Main.java — an Invoice class wearing too many hats. Real, compiling Java.
public class Main {
    static class Invoice {
        double amount;
        Invoice(double amount) { this.amount = amount; }
        double total() { return amount * 1.2; }
        void print() { System.out.println("Total: " + total()); }
        void saveToFile() { /* persistence logic */ }
    }

    public static void main(String[] args) {
        Invoice inv = new Invoice(100);
        inv.print();
    }
}

This class has at least three responsibilities: calculation, console output, and file persistence. A better design keeps total() inside Invoice because the total is part of what an invoice is, and moves print() to an InvoicePrinter and saveToFile() to an InvoiceRepository. Then a change to the printer — switching from console to HTML — never touches the calculation, and a bug in the printer cannot corrupt the invoice data.

The Open/Closed Principle says software entities should be open for extension but closed for modification. In practice, that means you should be able to add a new feature by writing new code, not by editing old code that already works. Every time you modify a working class you risk breaking every caller that depended on its old behaviour.

The most common violation is a long if-else or switch chain that checks a type flag. Add a new shape and you must open the area-calculator class and edit the existing method — risking a regression in every existing branch. The fix is polymorphism: each type implements its own behaviour, and the caller works with the abstraction. New shapes mean new files, not changed files.

flowchart TD
  A["area(shapes)<br/>if circle...<br/>if rectangle..."] -->|"refactor to"| B["Shape area()"]
  B --> C["Circle extends Shape"]
  B --> D["Rectangle extends Shape"]
  style A fill:#b45309,color:#fff
  style B fill:#3776ab,color:#fff
OCP replaces a growing conditional chain with new subclasses that add behaviour without touching old code.
Main.java — polymorphic shapes keep the calculator closed for modification. Real, compiling Java.
public class Main {
    static abstract class Shape {
        abstract double area();
    }
    static class Circle extends Shape {
        double radius;
        Circle(double r) { this.radius = r; }
        double area() { return Math.PI * radius * radius; }
    }
    static class Rectangle extends Shape {
        double width, height;
        Rectangle(double w, double h) { this.width = w; this.height = h; }
        double area() { return width * height; }
    }
    static double totalArea(Shape[] shapes) {
        double sum = 0;
        for (Shape s : shapes) {
            sum += s.area();
        }
        return sum;
    }
    public static void main(String[] args) {
        Shape[] shapes = { new Circle(1), new Rectangle(2, 3) };
        System.out.println(totalArea(shapes));
    }
}

The Liskov Substitution Principle says that if B is a subclass of A, then objects of type A should be replaceable with objects of type B without altering the correctness of the program. In simpler terms: a subclass must keep the promises made by its parent.

The classic classroom violation is Square extends Rectangle. A Rectangle promises that setting the width leaves the height unchanged. Square breaks that promise by coupling the two fields. Pass a Square into a method written for Rectangle — resizeToWidthFiveThenPrintArea — and the result is silently wrong. The bug is not in the method; it is in the inheritance hierarchy. The method trusted the Rectangle contract, and Square lied.

flowchart LR
  R["Rectangle<br/>setWidth w<br/>height unchanged"] -->|"breaks contract"| S["Square<br/>setWidth w<br/>height = w"]
  S -->|"surprising result"| B["area() != expected"]
  style R fill:#3776ab,color:#fff
  style S fill:#b45309,color:#fff
Square breaks Rectangle's contract: setting height should not change width, yet it does.
Main.java — Square violates Rectangle's contract and silently breaks a caller. Real, compiling Java.
public class Main {
    static class Rectangle {
        int width, height;
        void setWidth(int w) { width = w; }
        void setHeight(int h) { height = h; }
        int area() { return width * height; }
    }
    static class Square extends Rectangle {
        void setWidth(int w) { width = w; height = w; }
        void setHeight(int h) { width = h; height = h; }
    }
    static void resizeAndPrint(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        System.out.println(r.area());
    }
    public static void main(String[] args) {
        resizeAndPrint(new Square());
    }
}
Exercise

What does this program print? Read it carefully and type the exact output (one line).

public class Main {
    static class Animal {
        void speak() { System.out.println("?"); }
    }
    static class Dog extends Animal {
        void speak() { System.out.println("Woof"); }
    }
    static void talk(Animal a) {
        a.speak();
    }
    public static void main(String[] args) {
        talk(new Dog());
    }
}
Exercise

A class named UserManager contains methods for authentication, password hashing, database queries, and sending welcome emails. What is the main design problem?

Exercise

What does this program print? Read it carefully and type the exact output (one line).

public class Main {
    static class Rectangle {
        int width, height;
        void setWidth(int w) { width = w; }
        void setHeight(int h) { height = h; }
        int area() { return width * height; }
    }
    static class Square extends Rectangle {
        void setWidth(int w) { width = w; height = w; }
        void setHeight(int h) { width = h; height = h; }
    }
    static void resizeAndPrint(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        System.out.println(r.area());
    }
    public static void main(String[] args) {
        resizeAndPrint(new Square());
    }
}
Exercise

You need to add a Triangle to an existing area calculator. Which approach follows OCP?

Exercise

A Penguin class cannot fly, but you want it to share code with Bird. Which design respects LSP?

Recap

  • SRP — one class, one reason to change. Split classes that mix unrelated duties; the word 'and' in a class description is a warning.
  • OCP — extend behaviour with new subclasses, not by editing old conditional chains. Add files, do not modify files.
  • LSP — a subclass must keep every promise its parent makes. 'Is-a' in English is not always 'is-a' in code.
  • When inheritance breaks a promise, prefer composition — shared behaviour through delegates, not forced hierarchy.
  • These principles work together: SRP splits classes, OCP replaces conditionals with polymorphism, and LSP keeps that polymorphism honest.

Checkpoint quiz

Why is a method full of if (shape.type == "circle") considered a violation of OCP?

A Square subclass overrides setWidth so that it also changes height. What is the consequence?

Go deeper — technical resources