In object-oriented programming, classes are the fundamental building blocks of any application. When we design these classes poorly, the entire codebase becomes difficult to maintain, extend, and debug over time. SOLID principles give us a structured way to avoid these problems from the start.
The term SOLID was coined by Michael Feathers, building on the work of Robert C. Martin (widely known as Uncle Bob), who introduced these concepts in his 2000 paper Design Principles and Design Patterns. Over the past two decades, these five principles have become the cornerstone of clean, maintainable object-oriented design.
SOLID stands for:
- 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)
When we follow these principles carefully, we write code that is easier to understand, extend, and refactor. We also reduce technical debt and minimize the number of bugs that creep in as requirements change.
SOLID principles are not strict rules we must always follow to the letter. They are guiding principles that help us make better design decisions, especially as our application grows in size and complexity.

1. Single Responsibility Principle (SRP)
“One class should have one and only one responsibility”
This means every class we write should be responsible for exactly one piece of functionality within the application. When a class takes on multiple responsibilities, any change to one responsibility risks breaking the other. This makes our code fragile and tightly coupled.
Imagine a class that handles user authentication, sends email notifications, and also writes logs to a file. If the logging mechanism changes, we are forced to modify a class that is also responsible for authentication logic. This violates SRP and creates unnecessary risk.
1.1. SRP Violation Example
In the following example, the Employee class above handles domain logic, email communication, and database persistence all in one place. Each of these is a separate responsibility that should live in its own class.
// BAD: This class has too many responsibilities
public class Employee {
public String getDesignation(int employeeId) {
// Fetch designation from DB
return "Software Engineer";
}
public void updateSalary(int employeeId, double salary) {
// Update salary in DB
}
public void sendPayslipEmail(String email) {
// Send email - NOT the Employee class's responsibility
}
public void saveToDatabase() {
// Persistence logic mixed into a domain class
}
}
1.2. SRP Correct Implementation
// GOOD: Each class has a single responsibility
public class Employee {
private int id;
private String name;
private String designation;
private double salary;
// Getters and setters only
}
public class EmployeeRepository {
public void save(Employee employee) {
// Handles DB persistence
}
public Employee findById(int id) {
return new Employee();
}
}
public class NotificationService {
public void sendPayslipEmail(String email, Employee employee) {
// Handles email communication
}
}
Now each class has exactly one reason to change. If the email provider changes, we only touch NotificationService. If the database schema changes, only EmployeeRepository is affected.
We can find plenty of classes in all popular Java libraries which follow the single responsibility principle. For example, in Log4j2, we have different classes with logging methods, different classes are logging levels and so on.
2. Open-Closed Principle (OCP)
The open/closed principle is the second principle we should consider while designing our application. It states:
“Software components should be open for extension, but closed for modification”
It means that the application classes should be designed in such a way that whenever fellow developers want to change the flow of control in specific conditions in the application, all they need to do is extend the class and override some functions, and that’s it.
Modifying existing, tested code to accommodate new features introduces regression risk. OCP guides us to design our classes so that new functionality can be added by writing new code rather than changing existing code.
If we take a look into any good framework like struts or spring, we will see that we cannot change their core logic and request processing. Still, we modify the desired application flow by extending some classes and plugin them in configuration files.
For example, spring framework has class DispatcherServlet. This class acts as a front controller for Spring-based web applications. To use this class, we are not required to modify this class. All we need is to pass initialization parameters, and we can extend its functionality the way we want.
Please note that apart from passing initialization parameters during application startup, we can also override methods to modify the behavior of the target class by extending the classes. For example, struts Action classes are extended to override the request processing logic.
2.1. OCP Violation Example
// BAD: Every new shape requires modifying this class
public class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.getRadius() * c.getRadius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.getWidth() * r.getHeight();
}
// Adding a new shape forces us to come back and edit this method
return 0;
}
}
In the above example, every time we add a new shape, we must open this class and modify it. This creates a maintenance burden and increases the chance of introducing bugs in existing, working logic.
2.2. OCP Correct Implementation
// GOOD: Use abstraction to allow extension without modification
public interface Shape {
double calculateArea();
}
public class Circle implements Shape {
private double radius;
public Circle(double radius) { this.radius = radius; }
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
public class Rectangle implements Shape {
private double width, height;
public Rectangle(double w, double h) { this.width = w; this.height = h; }
@Override
public double calculateArea() {
return width * height;
}
}
public class Triangle implements Shape {
private double base, height;
public Triangle(double base, double height) {
this.base = base; this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
Now we can add as many shapes as needed without ever touching the AreaCalculator class. This is the power of designing your classes around abstractions.
3. Liskov Substitution Principle (LSP)
LSP is a variation of the previously discussed open-closed principle. It says:
“Objects of a subclass should be replaceable with objects of their superclass without breaking the correctness of the program.”
This means if class B is a subtype of class A, we should be able to use B wherever A is expected.
LSP guides that the classes, fellow developers created by extending our class, should be able to fit in the application without fail. This is important when we resort to polymorphic behavior through inheritance.
LSP is essentially about designing inheritance hierarchies correctly. When a subclass overrides methods of a parent class in ways that break the expected behavior, we violate LSP and introduce subtle, hard-to-debug errors.
3.1. LSP Violation Example
// BAD: Square violates LSP when extending Rectangle
public class Rectangle {
protected double width;
protected double height;
public void setWidth(double width) { this.width = width; }
public void setHeight(double height) { this.height = height; }
public double getArea() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(double width) {
this.width = width;
this.height = width; // Forces both sides to be equal
}
@Override
public void setHeight(double height) {
this.width = height; // Unexpected behavior for a Rectangle consumer
this.height = height;
}
}
// This method breaks when called with a Square
public void testRectangle(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Expects 50, but gets 100 if r is a Square
assert r.getArea() == 50;
}
In above example, although a square is mathematically a rectangle, inheriting from Rectangle in code creates unexpected behavior. A consumer of Rectangle has no reason to expect that setting the width would also change the height.
3.2. LSP Correct Implementation
// GOOD: Model the hierarchy around a common abstraction
public interface Shape {
double getArea();
}
public class Rectangle implements Shape {
private double width, height;
public Rectangle(double w, double h) { this.width = w; this.height = h; }
public double getArea() { return width * height; }
}
public class Square implements Shape {
private double side;
public Square(double side) { this.side = side; }
public double getArea() { return side * side; }
}
Both Rectangle and Square now implement a shared abstraction without one extending the other. Any method that expects a Shape will work correctly with either type.
4. Interface Segregation Principle (ISP)
This principle is my favorite one. ISP is applicable to interfaces as a single responsibility principle holds to classes. ISP says:
“Clients should not be forced to implement unnecessary methods which they will not use”
Take an example. Developer Alex created an interface Reportable and added two methods generateExcel() and generatedPdf(). Now client ‘A’ wants to use this interface but he intends to use reports only in PDF format and not in excel. Will he be able to use the functionality easily?
NO. He will have to implement both methods, out of which one is an extra burden put on him by the designer of the software. Either he will implement another method or leave it blank. This is not a good design. Large, bloated interfaces force implementing classes to define methods that are meaningless in their context. This leads to empty implementations, thrown exceptions, and confusion for anyone reading the code.
So what is the solution? The solution is to create two interfaces by breaking the existing one. They should be like PdfReportable and ExcelReportable. This will give the flexibility to users to use only the required functionality only.
4.1. ISP Violation Example
// BAD: One large interface forced on all implementing classes
public interface Worker {
void work();
void eat();
void sleep();
}
// A robot does not eat or sleep!
public class Robot implements Worker {
public void work() { System.out.println("Robot working"); }
public void eat() { /* Robots do not eat - forced empty impl */ }
public void sleep() { /* Robots do not sleep - forced empty impl */ }
}
4.2. ISP Correct Implementation
The best place to look for IPS examples is Java AWT event handlers for handling GUI events fired from keyboard and mouse. It has different listener classes for each kind of event. We only need to write handlers for events, we wish to handle. Nothing is mandatory.
Some of the listeners are –
- FocusListener
- KeyListener
- MouseMotionListener
- MouseWheelListener
- TextListener
- WindowFocusListener
By splitting the interface, we give each implementing class the freedom to pick only what it needs. We keep our interfaces focused and our implementations honest.
public class MouseMotionListenerImpl implements MouseMotionListener
{
@Override
public void mouseDragged(MouseEvent e) {
//handler code
}
@Override
public void mouseMoved(MouseEvent e) {
//handler code
}
}
A good rule of thumb: if we find ourselves writing empty method bodies or throwing UnsupportedOperationException to satisfy an interface, that interface probably needs to be split.
5. Dependency Inversion Principle (DIP)
Most of us are already familiar with the words used in the principle name. DI principle says:
“High-level modules should not depend on low-level modules. Both should depend on abstractions.“
Additionally, abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
In other words. we should design our software so that various modules can be separated from each other using an abstract layer to bind them together.
5.1. DIP Violation Example
In the following example, if we need to switch from MySQL to PostgreSQL, we must modify UserRepository. It also becomes impossible to write unit tests without spinning up a real database connection.
// BAD: High-level class directly depends on a concrete low-level class
public class MySQLConnection {
public void connect() {
System.out.println("Connecting to MySQL database...");
}
}
public class UserRepository {
private MySQLConnection connection;
public UserRepository() {
// Hard dependency on a concrete implementation
this.connection = new MySQLConnection();
}
public void saveUser(String user) {
connection.connect();
System.out.println("Saving: " + user);
}
}
5.2. DIP Correct Implementation
In the correct implementation below, UserRepository depends only on the DatabaseConnection abstraction. We can swap the database implementation at any time without changing the repository logic at all. We can also inject a mock in tests.
// GOOD: Depend on an abstraction, not a concrete class
public interface DatabaseConnection {
void connect();
}
public class MySQLConnection implements DatabaseConnection {
public void connect() {
System.out.println("Connecting to MySQL...");
}
}
public class PostgreSQLConnection implements DatabaseConnection {
public void connect() {
System.out.println("Connecting to PostgreSQL...");
}
}
public class UserRepository {
private DatabaseConnection connection;
// Dependency is injected through the constructor
public UserRepository(DatabaseConnection connection) {
this.connection = connection;
}
public void saveUser(String user) {
connection.connect();
System.out.println("Saving: " + user);
}
}
// Usage
DatabaseConnection db = new PostgreSQLConnection();
UserRepository repo = new UserRepository(db);
repo.saveUser("Alice");
...
...
DIP is the foundation of Dependency Injection (DI) frameworks like Spring. When we use @Autowired or constructor injection in Spring Boot, we are applying this principle in practice every day.
These separate components are so well closed in their boundaries that we can use them in other software modules apart from spring with the same ease. This has been achieved by dependency inversion and open-closed principles. All modules expose only abstraction, which is useful in extending the functionality or plug-in in another module.
6. Benefits of Following SOLID Principles in Java
After we apply these principles consistently across our codebase, we start noticing several tangible improvements in our development workflow:
- Easier unit testing because classes have focused responsibilities and dependencies are injected rather than hardcoded.
- Reduced risk of regression, since changes to one part of the system are less likely to break unrelated parts.
- Faster onboarding for new team members, because well-structured code is far easier to read and reason about.
- Better scalability, as extending the application requires writing new classes rather than refactoring existing ones.
- Lower technical debt over time, reducing maintenance cost across the entire software lifecycle.
7. Frequently Asked Questions
7.1. Are SOLID principles only for Java?
No. While the examples in this guide are in Java, SOLID principles apply to any object-oriented language including C#, Python, Kotlin, and TypeScript. The underlying design thinking is universal.
7.2. Do we need to follow all five principles in every project?
Not necessarily. These are guidelines, not mandates. In smaller scripts or throwaway code, strict adherence may add unnecessary complexity. The value of SOLID becomes most apparent in medium-to-large applications that evolve over time.
7.3. How does SOLID relate to Design Patterns?
SOLID principles and design patterns complement each other. Many classic design patterns from the Gang of Four, such as Strategy, Factory, and Decorator, are essentially concrete implementations of one or more SOLID principles. Understanding SOLID makes it much easier to understand why those patterns work the way they do.
7.4. What is the relationship between DIP and Spring Dependency Injection?
Spring’s dependency injection framework is a direct application of the Dependency Inversion Principle. When we wire beans through constructor injection or @Autowired, Spring manages the creation of concrete objects and injects them through abstractions, exactly as DIP recommends.
8. Conclusion
These were 5 class design principles, also known as SOLID principles, which make the best practices to be followed to design our application classes and interfaces. When followed carefully, our systems design is easy to maintain and change overtime.
Happy Learning !!
Comments