SOLID Principles ๐ฆ

Software Engineer | Java | Spring | MSc. in Big Data Analytics | Salesforce IAM Architect | IAM Expert | AWS SAA | AWS DVA | Hashicorp Terraform Certified
๐งฌ Introduction
When building performant and versatile software, it is essential to follow sound design principles. One of the most widely recognized sets of guidelines in the software world is SOLID.
The SOLID principles, introduced by Robert C. Martin (commonly known as Uncle Bob), are foundational concepts in Object-Oriented Design (OOD). The term SOLID is an acronym representing five key principles:
Single Responsibility Principle
Open/Closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle
In this article, we will explore each of these principles in detail and discuss how applying them can help us write more maintainable, scalable, and high-performance code.
๐ Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. In other words, each class should focus on a single responsibility or purpose, rather than being overloaded with multiple, unrelated functionalities.
Letโs look at an example. Suppose we have a class AreaCalculator that calculates the area of different shapes. Initially, it seems fine. But if we also add methods for printing the result as JSON or CSV, the class now has multiple responsibilities: calculation and presentation. This violates SRP.
public class Rectangle {
public int length;
public int width;
public Rectangle(int length, int width) {
this.length = length;
this.width = width;
}
}
public class Circle {
public int radius;
public Circle(int radius) {
this.radius = radius;
}
}
import java.util.List;
public class AreaCalculator {
private List<Object> shapes;
public AreaCalculator(List<Object> shapes) {
this.shapes = shapes;
}
// Responsible for calculating area
public int sum() {
int sum = 0;
for (Object shape : shapes) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
sum += rectangle.length * rectangle.width;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
sum += Math.PI * Math.pow(circle.radius, 2);
}
}
return sum;
}
// Responsible for formatting the output
public String outputAsJson() {
return "{ \"sum\" : " + sum() + " }";
}
public String outputAsCsv() {
return "sum," + sum();
}
}
Here, AreaCalculator is doing more than just area calculation. It also handles output formatting, which is a separate responsibility.
We can fix this by splitting the responsibilities into different classes.
AreaCalculatorโ only responsible for calculating the area.AreaPrinterโ responsible for formatting and output.
But to incorporate this, we need to create a new interface named Shape as well. This interface comes handy in the second SOLID principle, Open/Closed Principle as well.
public interface Shape {
double area();
}
public class Rectangle implements Shape {
private int length;
private int width;
public Rectangle(int length, int width) {
this.length = length;
this.width = width;
}
@Override
public double area() {
return length * width;
}
}
public class Circle implements Shape {
private int radius;
public Circle(int radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * Math.pow(radius, 2);
}
}
import java.util.List;
public class AreaCalculator {
private List<Shape> shapes;
public AreaCalculator(List<Shape> shapes) {
this.shapes = shapes;
}
public double sum() {
return shapes.stream().mapToDouble(Shape::area).sum();
}
}
public class AreaPrinter {
private AreaCalculator calculator;
public AreaPrinter(AreaCalculator calculator) {
this.calculator = calculator;
}
public String toJson() {
return "{ \"sum\" : " + calculator.sum() + " }";
}
public String toCsv() {
return "sum," + calculator.sum();
}
}
Now we can use AreaCalculator and AreaPrinter as shown below.
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
Shape rectangle = new Rectangle(5, 10);
Shape circle = new Circle(7);
AreaCalculator calculator = new AreaCalculator(Arrays.asList(rectangle, circle));
AreaPrinter printer = new AreaPrinter(calculator);
System.out.println("JSON Output: " + printer.toJson());
System.out.println("CSV Output: " + printer.toCsv());
}
}
๐ Open/Closed Principle (OCP)
The Open/Closed Principle stats that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In simple terms, you should be able to add new functionality to a class without modifying its existing code. This makes the system easier to extend, reduces the risk of breaking existing features, and encourages flexibility.
Letโs extend our earlier AreaCalculator example. Suppose we have shapes like Square and Circle. To calculate the area, we write a method that checks each type with instanceof.
public class Square {
int side;
public Square(int side) {
this.side = side;
}
}
public class Circle {
int radius;
public Circle(int radius) {
this.radius = radius;
}
}
public class AreaCalculator {
public double findArea(Object shape) {
if (shape instanceof Square) {
Square square = (Square) shape;
return square.side * square.side;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * Math.pow(circle.radius, 2);
}
return 0;
}
}
At first, this works. But if we want to add a new shape, say Triangle, weโll have to go back and modify the findArea() method to handle it.
...
else if (shape instanceof Triangle) {
Triangle triangle = (Triangle) shape;
return 0.5 * triangle.base * triangle.height;
}
...
This approach violates OCP because every time we add a new shape, we must edit the AreaCalculator class.
We can fix this by introducing an abstraction. Instead of relying on instanceof, we define a Shape interface with an area() method. Each shape will implement its own area calculation, and AreaCalculator will no longer need to change when new shapes are added.
public interface Shape {
double area();
}
public class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
public class Circle implements Shape {
private int radius;
public Circle(int radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * Math.pow(radius, 2);
}
}
public class Triangle implements Shape {
private int base;
private int height;
public Triangle(int base, int height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return 0.5 * base * height;
}
}
public class AreaCalculator {
public double findArea(Shape shape) {
return shape.area();
}
}
๐ Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that, subtypes must be substitutable for their base types. In other words, if class B is a subclass (or implementation) of class A, then objects of type A should be replaceable with objects of type B without breaking the programโs correctness. This principle ensures that inheritance and polymorphism are used properly.
Weโve already defined a Shape interface with an area() method. Any class that implements Shape should be usable wherever a Shape is expected.
public class Main {
public static void main(String[] args) {
Shape circle = new Circle(5); // Circle as Shape
Shape rectangle = new Rectangle(4, 6); // Rectangle as Shape
printArea(circle);
printArea(rectangle);
}
public static void printArea(Shape shape) {
System.out.println("Area: " + shape.area());
}
}
๐ Interface Segregation Principle (ISP)
The Interface Segregation Principle states that, no client should be forced to depend on methods it does not use. In other words, large, โfatโ interfaces should be split into smaller, more specific ones. Classes should only implement the methods that are relevant to them. This principle is like applying the Single Responsibility Principle (SRP) to interfaces.
To outline this, letโs think of the following scenario. What will happen to our Shape interface, if we were to add a volume() method because we want to support 3D shapes like a Cube.
public interface Shape {
double area();
double volume();
}
This will definitely violate the Interface Segregation Principle (ISP) as the Circle does not have a way to define its volume.
public class Circle implements Shape {
private int radius;
public Circle(int radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * Math.pow(radius, 2);
}
@Override
public double volume() {
// Circle does not have a volume
throw new UnsupportedOperationException("Circle has no volume");
}
}
Therefore, instead of piling up all the methods into one interface, we can split the responsibilities into smaller interfaces.
public interface Shape2D {
double area();
}
public interface Shape3D {
double volume();
}
Now, 2D and 3D shapes can implement only what they need.
public class Circle implements Shape2D {
private int radius;
public Circle(int radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * Math.pow(radius, 2);
}
}
public class Cube implements Shape2D, Shape3D {
private int side;
public Cube(int side) {
this.side = side;
}
@Override
public double area() {
// Surface area of a cube = 6 * side^2
return 6 * side * side;
}
@Override
public double volume() {
return Math.pow(side, 3);
}
}
๐ Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that, high-level modules should not depend on low-level modules. Both should depend on abstractions. This means classes should depend on interfaces (abstractions) rather than concrete implementations. By doing this, we make our code more flexible, testable, and maintainable.
Suppose we have a ReportService that directly creates and depends on a concrete AreaCalculator.
public class ReportService {
private AreaCalculator areaCalculator = new AreaCalculator();
public void generateReport(int radius) {
double area = areaCalculator.calculateCircleArea(radius);
System.out.println("Circle area: " + area);
}
}
Here, ReportService is tightly coupled to AreaCalculator. If we wanted to switch to a different calculator (say, an AdvancedAreaCalculator), weโd have to modify ReportService violating the OCP and DIP.
We can fix this by introducing an abstraction (IAreaCalculator) and using constructor injection.
public interface IAreaCalculator {
double calculateCircleArea(int radius);
}
public class AreaCalculator implements IAreaCalculator {
@Override
public double calculateCircleArea(int radius) {
return Math.PI * Math.pow(radius, 2);
}
}
public class AdvancedAreaCalculator implements IAreaCalculator {
@Override
public double calculateCircleArea(int radius) {
// some more optimized or approximate formula
return 3.14 * radius * radius;
}
}
public class ReportService {
private final IAreaCalculator areaCalculator;
// Constructor injection of abstraction
public ReportService(IAreaCalculator areaCalculator) {
this.areaCalculator = areaCalculator;
}
public void generateReport(int radius) {
double area = areaCalculator.calculateCircleArea(radius);
System.out.println("Circle area: " + area);
}
}
๐ Conclusion
So, in this article we have discussed the SOLID principles and how they should be used to create performant code. To get additional info on SOLID principles you can try reading the following books.
Solid Principles Succinctly by Gaurav Arora
Clean Code by Robert C. Martin
Clean Code in Python, Develop Maintainable and Efficient Code by Mariano Anaya




