Артыкул апублікаваны на англійскай мове.
SOLID Principles With Code: Violations and Fixes for Each Rule
A practical walk through the five SOLID principles in low-level design, with a bad and a good code example for SRP, OCP, LSP, ISP and DIP, plus a quick recap.
Low-level design is the stage where requirements turn into classes, interfaces and relationships. Among the ideas that guide it, the SOLID principles come up more than any other.
SOLID stands for five rules of thumb that keep object-oriented code clean, flexible and easy to maintain:
- S: Single Responsibility Principle (SRP)
- O: Open/Closed Principle (OCP)
- L: Liskov Substitution Principle (LSP)
- I: Interface Segregation Principle (ISP)
- D: Dependency Inversion Principle (DIP)
Every principle below comes with two versions of the same code, one that breaks the rule and one that follows it, so the difference is easy to see.
S: Single Responsibility
A class should have only one reason to change. Put another way, it should do one job.
Take a Car class. Anything about cars belongs in it, and a feature that has nothing to do with cars belongs somewhere else.
First, the version that breaks the rule:
package SingleResponsibilityWithViolation;
// Violates SRP: This class has multiple responsibilities
// (salary calculation & report generation)
class Employee {
private String name;
private double salary; public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
} public double calculateSalary() {
return salary * 1.2; // Example salary calculation
} public void generateReport() {
System.out.println("Generating employee report...");
}
}public class SRPViolation {
public static void main(String[] args) {
Employee emp = new Employee("John", 50000);
System.out.println("Salary: " + emp.calculateSalary());
emp.generateReport();
}
}
Here the Employee class juggles three jobs: it holds employee data, computes salary and produces reports. That gives it three reasons to change. A new salary formula, a new report layout or a change to the employee fields all force edits to the same class. As time passes, the class swells and becomes risky to touch, since a tweak to reporting could quietly break salary logic.
Now the cleaner version:
package SingleResponsibilityWithoutViolation;
// Now, each class has a single responsibility// Responsible only for holding employee details
class Employee {
private String name;
private double salary; public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
} public String getName() {
return name;
} public double getSalary() {
return salary;
}
}// Responsible only for salary calculation
class SalaryCalculator {
public double calculateSalary(Employee employee) {
return employee.getSalary() * 1.2;
}
}// Responsible only for report generation
class ReportGenerator {
public void generateReport(Employee employee) {
System.out.println("Generating employee report for " + employee.getName() + "...");
}
}public class SRPExample {
public static void main(String[] args) {
Employee emp = new Employee("John", 10000);
SalaryCalculator calculator = new SalaryCalculator();
ReportGenerator reportGenerator = new ReportGenerator(); reportGenerator.generateReport(emp);
System.out.println("Salary: " + calculator.calculateSalary(emp));
}
}
Each class now has a single reason to change. Employee only stores data, SalaryCalculator only computes pay, and ReportGenerator only builds reports. If the salary formula changes tomorrow, only SalaryCalculator is touched and nothing else is at risk. Each class is also much easier to test on its own.
O: Open/Closed
Software entities should be open for extension but closed for modification. In practice, new behaviour should arrive without editing code that already works. Each edit to stable code risks breaking something that was fine, so new features should come from extending what exists.
The violation:
package OpenClosedWithViolation;
// Violates OCP: Every time a new shape is added,
// we need to modify the existing class.
class AreaCalculator {
public double calculateArea(String shape, double radius, double length, double breadth) {
if (shape.equals("circle")) {
return Math.PI * radius * radius;
} else if (shape.equals("rectangle")) {
return length * breadth;
}
return 0;
}
}public class OCPViolation {
public static void main(String[] args) {
AreaCalculator calculator = new AreaCalculator();
System.out.println("Circle Area: " + calculator.calculateArea("circle", 5, 0, 0));
}
}
To support another shape, whether a triangle, a square or a hexagon, someone has to return to AreaCalculator and add one more else-if branch. The class is never finished. The method signature is awkward too: radius, length and breadth must all be passed every time, even when most are irrelevant, which is why the call contains those zeros. Every new shape makes the method longer, harder to read and harder to test.
The fix:
package OpenClosedWithoutViolation;
// Now, new shapes can be added without modifying the existing code
interface Shape {
double calculateArea();
}class Circle implements Shape {
private double radius; public Circle(double radius) {
this.radius = radius;
} public double calculateArea() {
return Math.PI * radius * radius;
}
}class Rectangle implements Shape {
private double length, breadth; public Rectangle(double length, double breadth) {
this.length = length;
this.breadth = breadth;
} public double calculateArea() {
return length * breadth;
}
}class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}public class OCPExample {
public static void main(String[] args) {
Shape circle = new Circle(5);
Shape rectangle = new Rectangle(4, 6);
AreaCalculator calculator = new AreaCalculator(); System.out.println("Circle Area: " + calculator.calculateArea(circle));
System.out.println("Rectangle Area: " + calculator.calculateArea(rectangle));
}
}
Now each shape knows how to compute its own area, and AreaCalculator doesn't care which shape it gets. It just calls calculateArea(). Adding a Triangle means writing a new class that implements Shape, with no changes to AreaCalculator, Circle or Rectangle. The design is open to extension, because new shapes can be added freely, and closed to modification, because existing classes stay as they are.
L: Liskov Substitution
Objects of a superclass should be replaceable by objects of a subclass without breaking the program's correctness.
This one depends on inheritance. A parent class has several child classes that inherit its features. The principle says that wherever code expects the parent type, any child type should work correctly in its place.
A violating example:
package LiskovSubstitutionWithViolation;
// Violates LSP: Penguin cannot fly, but it inherits a fly() method.
class Bird {
public void fly() {
System.out.println("Flying...");
}
}class Sparrow extends Bird {
@Override
public void fly() {
System.out.println("Sparrow can fly");
}
}class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly!");
}
}public class LSPViolation {
public static void main(String[] args) {
Bird penguin = new Penguin();
Bird sparrow = new Sparrow();
sparrow.fly();
penguin.fly(); // This will break at runtime
}
}
The Bird base class assumes every bird flies, so fly() sits right on it. Penguins can't fly, which leaves Penguin overriding fly() just to throw an exception. Any code that takes a Bird and calls fly(), as main() does here, crashes at runtime when it receives a Penguin. The subclass can no longer safely stand in for its parent, and that's precisely what LSP prohibits. The root cause is poor inheritance design: flying was built into the base class as if every bird did it.
The corrected design:
package LiskovSubstitutionWithoutViolation;
abstract class Bird {
abstract void makeSound();
}interface FlyingBird {
void fly();
}class Sparrow extends Bird implements FlyingBird {
public void makeSound() {
System.out.println("Chirp Chirp!");
} public void fly() {
System.out.println("Sparrow is flying...");
}
}class Penguin extends Bird {
public void makeSound() {
System.out.println("Penguin sound...");
}
}public class LSPExample {
public static void main(String[] args) {
Bird sparrow = new Sparrow();
Bird penguin = new Penguin(); sparrow.makeSound();
penguin.makeSound(); FlyingBird flyingBird = new Sparrow();
flyingBird.fly(); // Works without breaking substitution
}
}
Flying moved out of the base class and into its own FlyingBird interface. Bird now guarantees only what every bird can really do, which is make a sound, and the species that fly opt in by implementing FlyingBird. Penguin remains a legitimate Bird, usable anywhere a Bird is expected, with no crash waiting at runtime. The design now matches reality instead of fighting it.
I: Interface Segregation
Clients shouldn't be forced to depend on interfaces they don't use. The principle favours smaller, more specific interfaces over one big general-purpose one.
An example of the problem:
package InterfaceSegregationWithViolation;
// Violates ISP: Robot is forced to implement eat()
// even though it doesn't need it.
interface Worker {
void work();
void eat();
}class Human implements Worker {
public void work() {
System.out.println("Human working...");
} public void eat() {
System.out.println("Human eating...");
}
}class Robot implements Worker {
public void work() {
System.out.println("Robot working...");
} public void eat() {
throw new UnsupportedOperationException("Robots do not eat!");
}
}public class ISPViolation {
public static void main(String[] args) {
Human human = new Human();
human.work();
human.eat(); Robot robot = new Robot();
robot.work();
robot.eat(); // Causes error
}
}
The Worker interface packs two unrelated abilities, working and eating, into one contract. A robot can work but can't eat, yet it still has to supply eat(). Throwing an exception is the only way out, and that makes the class misreport its capabilities. Calling eat() on anything typed as Worker may then crash if the object turns out to be a Robot. For some implementers, this interface asks for too much.
The split version:
package InterfaceSegregationWithoutViolation;
interface Worker {
void work();
}interface Eatable {
void eat();
}class Human implements Worker, Eatable {
public void work() {
System.out.println("Developer is coding...");
} public void eat() {
System.out.println("Developer is eating...");
}
}class Robot implements Worker {
public void work() {
System.out.println("Robot is working...");
}
}public class ISPExample {
public static void main(String[] args) {
Human human = new Human();
human.work();
human.eat(); Robot robot = new Robot();
robot.work();
}
}
The fat interface became two focused ones, Worker and Eatable. Each class implements only what it can truly do: Human implements both, while Robot implements just Worker. No unsupported methods, no exceptions to throw and no misleading contracts remain. Every class honestly reflects its own abilities.
D: Dependency Inversion
High-level modules shouldn't depend on low-level modules. Both should depend on abstractions rather than on concrete implementations.
Here is the version that breaks it:
package DependencyInversionWithViolation;
class WiredKeyboard {
public void connect() {
System.out.println("Wired keyboard connected");
}
}// Violates DIP: Computer is tightly coupled to WiredKeyboard.
class Computer {
private WiredKeyboard keyboard = new WiredKeyboard(); // Tight coupling public void start() {
keyboard.connect();
}
}public class DIPViolation {
public static void main(String[] args) {
Computer pc = new Computer();
pc.start();
}
}
In this setup Computer sits at the high level and WiredKeyboard is a low-level detail. Since Computer builds a WiredKeyboard itself and keeps it in a field, it's permanently tied to that one keyboard type. Supporting a wireless keyboard would mean editing Computer itself. Unit testing is nearly impossible as well, since no fake or mock keyboard can be swapped in when the dependency is built internally instead of being passed in.
And here is the version that follows it:
package DependencyInversionWithoutViolation;
interface Keyboard {
void connect();
}class WiredKeyboard implements Keyboard {
public void connect() {
System.out.println("Wired keyboard connected");
}
}class WirelessKeyboard implements Keyboard {
public void connect() {
System.out.println("Wireless keyboard connected");
}
}class Computer {
private Keyboard keyboard; public Computer(Keyboard keyboard) {
this.keyboard = keyboard;
} public void start() {
keyboard.connect();
}
}public class DIPExample {
public static void main(String[] args) {
Keyboard wired = new WiredKeyboard();
Computer pc1 = new Computer(wired);
pc1.start(); Keyboard wireless = new WirelessKeyboard();
Computer pc2 = new Computer(wireless);
pc2.start();
}
}
Computer now depends on the Keyboard interface, an abstraction, instead of any particular keyboard class. The actual keyboard arrives from outside through the constructor, a technique called dependency injection. A wired keyboard, a wireless one or any future type can be plugged in without ever modifying Computer. Testing gets easy too, because a mock keyboard can be injected.
The five at a glance
- SRP: give each class a single job and a single reason to change.
- OCP: add behaviour by extending, leaving working code untouched.
- LSP: a child class has to work anywhere its parent does.
- ISP: prefer several slim interfaces over one bloated one.
- DIP: lean on abstractions, never on concrete classes.
A common thread runs through all five examples: abstraction and separation. Nearly every violation traced back to a class that did too many things or was welded to a concrete type, and nearly every fix split duties apart and coded against an interface.
Getting comfortable with these five principles already makes code noticeably cleaner and easier to maintain, and it's good preparation for LLD interview rounds. Design patterns are the natural next topic.