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
Employee
that 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
Employee
class, which is not ideal.
Solution
- We can refactor the
Employee
class 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
CalculatorOperations
with a public classAddition
that implements the interface and another classSubtraction
that extends the interface, then aCalculator
class 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
Calculator
class.- Also say we need to add new operations like multiplication, division, etc., we would need to modify the
Calculator
class, 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
CalculatorOperations
interface with aperformOperation
method 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
CalculatorOperations
interface:
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
Calculator
class to use theCalculatorOperations
interface:
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 theCalculatorOperations
interface without modifying theCalculator
class.- This way, the
Calculator
class 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
Rectangle
withwidth
andheight
properties and a methodgetArea
to 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
Square
that extends theRectangle
class:
1
2
3
4
5
6
7
// Square class
public class Square extends Rectangle {
public Square(int side) {
super(side, side);
}
}
- The
Square
class violates the Liskov Substitution Principle because a square is a rectangle with equal width and height, but theSquare
class 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
Rectangle
andSquare
:
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
Rectangle
andSquare
using 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
Rectangle
andSquare
classes behave correctly, and we can replace objects of the superclassRectangle
with objects of its subclassSquare
without affecting the correctness of the program.
Another Example
- Consider a public class
CommonBankAccount
with a protected balance property and aCommonBankAccount
method , a publicdeposit
method, a publiccashOut
method, a publicgetBalance
method, and a publicincome
method.
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
PayrollAccount
that extends theCommonBankAccount
class but does not have anincome
method:
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
PayrollAccount
class violates the Liskov Substitution Principle because aPayrollAccount
is aCommonBankAccount
but does not have anincome
method.- That means the subclass
PayrollAccount
does not behave like the superclassCommonBankAccount
.
Solution
- To resolve this, we can use the
Composition
design pattern to create anAccountManager
class that has thebalance
property, thedeposit
method, thecashOut
method, and thegetBalance
method, and theCommonBankAccount
andPayrollAccount
classes can have anAccountManager
object 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
CommonBankAccount
andPayrollAccount
with anAccountManager
object 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
Composition
design pattern, we can ensure that theCommonBankAccount
andPayrollAccount
classes 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
Worker
with 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
Robot
that implements theWorker
interface:
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
Robot
class violates the Interface Segregation Principle because it is forced to implement theeat
andsleep
methods even though it does not need them.- This can lead to unnecessary code in the
Robot
class.
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
LightBulb
and a public classSwitch
that depends on theLightBulb
class:
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
Switch
class violates the Dependency Inversion Principle because it depends on theLightBulb
class directly.- This can lead to tight coupling between the
Switch
andLightBulb
classes, making it difficult to change the implementation of theLightBulb
class.
Solution
- We can refactor the code to follow the Dependency Inversion Principle by using an interface
Switchable
that both theLightBulb
andSwitch
classes depend on:
1
2
3
4
5
6
// Switchable interface
public interface Switchable {
void turnOn();
void turnOff();
}
- Now, we can modify the
LightBulb
andSwitch
classes to depend on theSwitchable
interface:
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
Switch
class depends on theSwitchable
interface, not theLightBulb
class directly.- This way, we can easily change the implementation of the
LightBulb
class without affecting theSwitch
class.
Another Example
- Consider a public class
Project
that depends on 2 classesFrontendDeveloper
andBackendDeveloper
.
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
Project
class violates the Dependency Inversion Principle because it depends on theFrontendDeveloper
andBackendDeveloper
classes directly.- Imagine if we need to add a new class
DatabaseDeveloper
to the project, we would need to modify theProject
class, which is not ideal.
Solution
- We can refactor the code to follow the Dependency Inversion Principle by using an interface
Developer
that both theFrontendDeveloper
andBackendDeveloper
classes implement:
1
2
3
4
5
// Developer interface
public interface Developer {
void develop();
}
- Now, we can modify the
FrontendDeveloper
andBackendDeveloper
classes to implement theDeveloper
interface:
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
Project
class to depend on theDeveloper
interface:
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
Project
class depends on theDeveloper
interface, not theFrontendDeveloper
andBackendDeveloper
classes directly.- This way, we can easily add new classes that implement the
Developer
interface without affecting theProject
class.
This post is licensed under CC BY 4.0 by the author.