SOLID Principle in software design
Introduction
- SOLID is an acronym for the first five object-oriented design (OOD) principles by Robert C. Martin. These principles intend to make software designs more understandable, flexible, and maintainable.
- The SOLID principles are:
- S - Single Responsibility Principle
- O - Open/Closed Principle
- L - Liskov Substitution Principle
- I - Interface Segregation Principle
- D - Dependency Inversion Principle
- SOLID principles are guidelines that can be applied while designing software to reduce code smells, bugs, and maintenance costs.
Single Responsibility Principle (SRP)
- The Single Responsibility Principle states that a class should have only one reason to change, meaning that a class should have only one job.
- If a class has more than one responsibility, it becomes coupled, and changes to one responsibility may affect the other.
- This principle helps in reducing the complexity of the code and makes it easier to understand and maintain.
Example
- Consider a class
Employeethat has three responsibilities:- Storing employee information
- Calculating employee salary
- Printing employee details
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Employee class violating SRP
public class Employee {
private Integer id;
private String name;
private Double salary;
private Connection dbConnection;
public Double calculateSalary() {
// Calculate salary
return salary;
}
public void row() throws SQLException {
this.dbConnection = DriverManager.getConnection("jdbc:mysql://localhost:3306/employees");
// Insert row into the database
Statement stmt = dbConnection.createStatement();
String sql = "INSERT INTO employees (id, name, salary) VALUES (this.id, this.name, this.salary)";
stmt.executeUpdate(sql);
}
public void printEmployeeDetails() {
// Print employee details
}
}
- The above class violates the SRP as it has multiple responsibilities.
- What if we need to change the database connection or the way we calculate the salary? We would need to modify the
Employeeclass, which is not ideal.
Solution
- We can refactor the
Employeeclass to follow the Single Responsibility Principle by creating separate classes for each responsibility:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// Employee class following SRP
public class Employee {
private Integer id;
private String name;
private Double salary;
public Employee(Integer id, String name, Double salary) {
this.id = id;
this.name = name;
this.salary = salary;
}
}
// connection class for database operations
class Connection {
public Connection getConnection() {
return DriverManager.getConnection("jdbc:mysql://localhost:3306/employees");
}
}
//class to save employee details
class EmployeeRepository {
private Connection dbConnection;
public EmployeeRepository() {
this.dbConnection = new Connection();
}
public void saveEmployee(Employee employee) throws SQLException {
Connection connection = dbConnection.getConnection();
Statement stmt = connection.createStatement();
String sql = "INSERT INTO employees (id, name, salary) VALUES (" + employee.getId() + ", '" + employee.getName() + "', " + employee.getSalary() + ")";
stmt.executeUpdate(sql);
}
}
// EmployeeService class for business logic (salary calculation, printing details)
public class EmployeeService {
public Double calculateSalary(Employee employee) {
// Calculate salary
return employee.getSalary();
}
public void printEmployeeDetails(Employee employee) {
// Print employee details
}
}
- Now, each class has a single responsibility, making the code more maintainable and easier to understand.
Open/Closed Principle (OCP) - Open for Extension, Closed for Modification
- The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
- This principle allows us to add new functionality to an existing module without altering its source code.
Example
- Consider a public interface
CalculatorOperationswith a public classAdditionthat implements the interface and another classSubtractionthat extends the interface, then aCalculatorclass that performs the operations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// CalculatorOperations interface
public interface CalculatorOperations {}
// Addition class implementing CalculatorOperations
public class Addition implements CalculatorOperations {
private double left;
private double right;
private double result = 0.0;
public Addition(double left, double right) {
this.left = left;
this.right = right;
}
// getter and setter methods
}
// Subtraction class extending CalculatorOperations
public class Subtraction implements CalculatorOperations {
private double left;
private double right;
private double result = 0.0;
public Subtraction(double left, double right) {
this.left = left;
this.right = right;
}
// getter and setter methods
}
// Calculator class
public class Calculator {
public double calculate(CalculatorOperations operation) {
if (operation instanceof Addition) {
Addition add = (Addition) operation;
return add.getLeft() + add.getRight();
} else if (operation instanceof Subtraction) {
Subtraction sub = (Subtraction) operation;
return sub.getLeft() - sub.getRight();
}
return 0.0;
}
}
- The above code violates the Open/Closed Principle because every time we add a new operation (e.g., multiplication, division), we need to modify the
Calculatorclass.- Also say we need to add new operations like multiplication, division, etc., we would need to modify the
Calculatorclass, which is not ideal.
Solution
- We can refactor the code to follow the Open/Closed Principle by using the Strategy design pattern:
- We can create a
CalculatorOperationsinterface with aperformOperationmethod and have separate classes for each operation (e.g.,Addition,Subtraction,Multiplication,Division) that implement the interface.
1
2
3
4
// CalculatorOperations interface
public interface CalculatorOperations {
void performOperation();
}
- Now, we can create separate classes for each operation that implements the
CalculatorOperationsinterface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Addition class implementing CalculatorOperations
public class Addition implements CalculatorOperations {
private double left;
private double right;
private double result = 0.0;
public Addition(double left, double right) {
this.left = left;
this.right = right;
}
@Override
public void performOperation() {
result = left + right;
}
// getter and setter methods
}
// Subtraction class implementing CalculatorOperations
public class Subtraction implements CalculatorOperations {
private double left;
private double right;
private double result = 0.0;
public Subtraction(double left, double right) {
this.left = left;
this.right = right;
}
@Override
public void performOperation() {
result = left - right;
}
// getter and setter methods
}
- finally, we can modify the
Calculatorclass to use theCalculatorOperationsinterface:
1
2
3
4
5
6
7
// Calculator class
public class Calculator {
public double calculate(CalculatorOperations operation) {
operation.performOperation();
return operation.getResult();
}
}
- Now, we can add new operations (e.g.,
Multiplication,Division) by creating new classes that implement theCalculatorOperationsinterface without modifying theCalculatorclass.- This way, the
Calculatorclass is open for extension but closed for modification.
Liskov Substitution Principle (LSP)
- The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
- In other words, a subclass should override the methods of the superclass in such a way that the behavior of the superclass is preserved in the subclass.
Example
- Consider a class
Rectanglewithwidthandheightproperties and a methodgetAreato calculate the area of the rectangle.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Rectangle class
public class Rectangle {
protected int width;
protected int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getArea() {
return width * height;
}
}
- Now, letβs create a subclass
Squarethat extends theRectangleclass:
1
2
3
4
5
6
7
// Square class
public class Square extends Rectangle {
public Square(int side) {
super(side, side);
}
}
- The
Squareclass violates the Liskov Substitution Principle because a square is a rectangle with equal width and height, but theSquareclass does not behave like a rectangle.
Solution
- We can refactor the code to follow the Liskov Substitution Principle by using a factory method to create instances of
RectangleandSquare:
1
2
3
4
5
6
7
8
9
10
11
// ShapeFactory class
public class ShapeFactory {
public static Rectangle createRectangle(int width, int height) {
return new Rectangle(width, height);
}
public static Rectangle createSquare(int side) {
return new Rectangle(side, side);
}
}
- Now, we can create instances of
RectangleandSquareusing the factory methods:
1
2
3
4
5
6
7
8
9
10
11
// Main class
public class Main {
public static void main(String[] args) {
Rectangle rectangle = ShapeFactory.createRectangle(5, 10);
System.out.println("Rectangle Area: " + rectangle.getArea());
Rectangle square = ShapeFactory.createSquare(5);
System.out.println("Square Area: " + square.getArea());
}
}
- Now, the
RectangleandSquareclasses behave correctly, and we can replace objects of the superclassRectanglewith objects of its subclassSquarewithout affecting the correctness of the program.
Another Example
- Consider a public class
CommonBankAccountwith a protected balance property and aCommonBankAccountmethod , a publicdepositmethod, a publiccashOutmethod, a publicgetBalancemethod, and a publicincomemethod.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// CommonBankAccount class
public class CommonBankAccount {
protected double balance;
public CommonBankAccount(double balance) {
this.balance = balance;
}
public void deposit(double amount) {
this.balance += amount;
}
public void cashOut(double amount) {
this.balance -= amount;
}
public double getBalance() {
return balance;
}
public void income(double percent) {
this.balance += balance * percent / 100;
}
}
- Now, letβs create a subclass
PayrollAccountthat extends theCommonBankAccountclass but does not have anincomemethod:
1
2
3
4
5
6
7
8
// PayrollAccount class
public class PayrollAccount extends CommonBankAccount {
public void income(){
throw new Exception("This account does not have income");
}
}
- The
PayrollAccountclass violates the Liskov Substitution Principle because aPayrollAccountis aCommonBankAccountbut does not have anincomemethod.- That means the subclass
PayrollAccountdoes not behave like the superclassCommonBankAccount.
Solution
- To resolve this, we can use the
Compositiondesign pattern to create anAccountManagerclass that has thebalanceproperty, thedepositmethod, thecashOutmethod, and thegetBalancemethod, and theCommonBankAccountandPayrollAccountclasses can have anAccountManagerobject as a property.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// AccountManager class
public class AccountManager {
private double balance;
public void deposit(double amount) {
this.balance += amount;
}
public void cashOut(double amount) {
this.balance -= amount;
}
public double getBalance() {
return balance;
}
public void income(double percent) {
this.balance += balance * percent / 100;
}
}
- Now, we can create instances of
CommonBankAccountandPayrollAccountwith anAccountManagerobject as a property:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// CommonBankAccount class
public class CommonBankAccount {
private AccountManager accountManager;
public CommonBankAccount(double balance) {
this.accountManager = new AccountManager();
}
public void deposit(double amount) {
this.accountManager.deposit(amount);
}
public void cashOut(double amount) {
this.accountManager.cashOut(amount);
}
public double getBalance() {
return accountManager.getBalance();
}
public void income(double percent) {
this.accountManager.income(percent);
}
}
// PayrollAccount class
public class PayrollAccount {
private AccountManager accountManager;
public PayrollAccount(double balance) {
this.accountManager = new AccountManager();
}
public void deposit(double amount) {
this.accountManager.deposit(amount);
}
public void cashOut(double amount) {
this.accountManager.cashOut(amount);
}
public double getBalance() {
return accountManager.getBalance();
}
}
- By using the
Compositiondesign pattern, we can ensure that theCommonBankAccountandPayrollAccountclasses behave correctly and follow the Liskov Substitution Principle.
Interface Segregation Principle (ISP)
- The Interface Segregation Principle states that a client should not be forced to implement an interface that it does not use.
- In other words, a class should not be forced to implement methods it does not need.
Example
- Consider a public interface
Workerwith methodswork,eat, andsleep.
1
2
3
4
5
6
7
// Worker interface
public interface Worker {
void work();
void eat();
void sleep();
}
- Now, letβs create a class
Robotthat implements theWorkerinterface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Robot class
public class Robot implements Worker {
public void work() {
System.out.println("Robot is working");
}
public void eat() {
// Robot does not eat
}
public void sleep() {
// Robot does not sleep
}
}
- The
Robotclass violates the Interface Segregation Principle because it is forced to implement theeatandsleepmethods even though it does not need them.- This can lead to unnecessary code in the
Robotclass.
Solution
- We can refactor the code to follow the Interface Segregation Principle by creating separate interfaces for each behavior:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Workable interface
public interface Workable {
void work();
}
// Eatable interface
public interface Eatable {
void eat();
}
// Sleepable interface
public interface Sleepable {
void sleep();
}
- Now, we can create classes that implement only the interfaces they need:
1
2
3
4
5
6
7
// Robot class
public class Robot implements Workable {
public void work() {
System.out.println("Robot is working");
}
}
- By using separate interfaces for each behavior, we can ensure that classes implement only the methods they need, making the code cleaner and more maintainable.
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.
- Abstractions should not depend on details. Details should depend on abstractions.
Example
- Consider a public class
LightBulband a public classSwitchthat depends on theLightBulbclass:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// LightBulb class
public class LightBulb {
public void turnOn() {
System.out.println("LightBulb: Bulb turned on...");
}
public void turnOff() {
System.out.println("LightBulb: Bulb turned off...");
}
}
// Switch class
public class Switch {
private LightBulb lightBulb;
public Switch() {
this.lightBulb = new LightBulb();
}
public void turnOn() {
lightBulb.turnOn();
}
public void turnOff() {
lightBulb.turnOff();
}
}
- The
Switchclass violates the Dependency Inversion Principle because it depends on theLightBulbclass directly.- This can lead to tight coupling between the
SwitchandLightBulbclasses, making it difficult to change the implementation of theLightBulbclass.
Solution
- We can refactor the code to follow the Dependency Inversion Principle by using an interface
Switchablethat both theLightBulbandSwitchclasses depend on:
1
2
3
4
5
6
// Switchable interface
public interface Switchable {
void turnOn();
void turnOff();
}
- Now, we can modify the
LightBulbandSwitchclasses to depend on theSwitchableinterface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// LightBulb class
public class LightBulb implements Switchable {
public void turnOn() {
System.out.println("LightBulb: Bulb turned on...");
}
public void turnOff() {
System.out.println("LightBulb: Bulb turned off...");
}
}
// Switch class
public class Switch {
private Switchable switchable;
public Switch(Switchable switchable) {
this.switchable = switchable;
}
public void turnOn() {
switchable.turnOn();
}
public void turnOff() {
switchable.turnOff();
}
}
- Now, the
Switchclass depends on theSwitchableinterface, not theLightBulbclass directly.- This way, we can easily change the implementation of the
LightBulbclass without affecting theSwitchclass.
Another Example
- Consider a public class
Projectthat depends on 2 classesFrontendDeveloperandBackendDeveloper.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// FrontendDeveloper class
public class FrontendDeveloper {
public void writeJavascript() {
System.out.println("Frontend Developer: Writing frontend code...");
}
}
// BackendDeveloper class
public class BackendDeveloper {
public void writeJava() {
System.out.println("Backend Developer: Writing backend code...");
}
}
// Project class
public class Project {
private FrontendDeveloper frontendDeveloper = new FrontendDeveloper();
private BackendDeveloper backendDeveloper = new BackendDeveloper();
public void implement() {
frontendDeveloper.writeJavascript();
backendDeveloper.writeJava();
}
}
- The
Projectclass violates the Dependency Inversion Principle because it depends on theFrontendDeveloperandBackendDeveloperclasses directly.- Imagine if we need to add a new class
DatabaseDeveloperto the project, we would need to modify theProjectclass, which is not ideal.
Solution
- We can refactor the code to follow the Dependency Inversion Principle by using an interface
Developerthat both theFrontendDeveloperandBackendDeveloperclasses implement:
1
2
3
4
5
// Developer interface
public interface Developer {
void develop();
}
- Now, we can modify the
FrontendDeveloperandBackendDeveloperclasses to implement theDeveloperinterface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// FrontendDeveloper class
public class FrontendDeveloper implements Developer {
@Override
public void develop() {
writeJavascript();
}
public void writeJavascript() {
System.out.println("Frontend Developer: Writing frontend code...");
}
}
// BackendDeveloper class
public class BackendDeveloper implements Developer {
@Override
public void develop() {
writeJava();
}
public void writeJava() {
System.out.println("Backend Developer: Writing backend code...");
}
}
- Now, we can modify the
Projectclass to depend on theDeveloperinterface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Project class
public class Project {
private List<Developer> developers;
public Project(List<Developer> developers) {
this.developers = developers;
}
public void implement() {
for (Developer developer : developers) {
developer.develop();
}
}
public void addDeveloper(Developer developer) {
developers.add(developer);
}
public void removeDeveloper(Developer developer) {
developers.remove(developer);
}
}
- Now, the
Projectclass depends on theDeveloperinterface, not theFrontendDeveloperandBackendDeveloperclasses directly.- This way, we can easily add new classes that implement the
Developerinterface without affecting theProjectclass.
This post is licensed under CC BY 4.0 by the author.