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
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.
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
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
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());
}
}
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());
}
}
The parameter type is
Animal, but the runtime type isDog.Dynamic dispatch calls the overridden method on the actual object.
A class named UserManager contains methods for authentication, password hashing, database queries, and sending welcome emails. What is the main design problem?
SRP says a class should have one reason to change. Authentication rules, database schemas, and email templates change for different reasons, so they belong in separate classes.
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());
}
}
Squareoverrides both setters to keep width and height equal.After
setHeight(4), width becomes 4 as well, so the area is 4 * 4 = 16.
You need to add a Triangle to an existing area calculator. Which approach follows OCP?
OCP says extend behaviour by adding new code, not by modifying old code. A new Triangle subclass adds behaviour without touching the existing calculator or any other shape.
A Penguin class cannot fly, but you want it to share code with Bird. Which design respects LSP?
If a subclass cannot honour a parent's contract, inheritance is the wrong tool. Composition lets both classes share common behaviour without forcing Penguin to pretend it can fly.
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.