Post

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:
    1. Storing employee information
    2. Calculating employee salary
    3. 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 class Addition that implements the interface and another class Subtraction that extends the interface, then a Calculator 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 a performOperation 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 the CalculatorOperations 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 the CalculatorOperations interface without modifying the Calculator 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 with width and height properties and a method getArea 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 the Rectangle 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 the Square 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 and Square:
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 and Square 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 and Square classes behave correctly, and we can replace objects of the superclass Rectangle with objects of its subclass Square without affecting the correctness of the program.

Another Example

  • Consider a public class CommonBankAccount with a protected balance property and a CommonBankAccount method , a public deposit method, a public cashOut method, a public getBalance method, and a public income 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 the CommonBankAccount class but does not have an income 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 a PayrollAccount is a CommonBankAccount but does not have an income method.
  • That means the subclass PayrollAccount does not behave like the superclass CommonBankAccount.

Solution

  • To resolve this, we can use the Composition design pattern to create an AccountManager class that has the balance property, the deposit method, the cashOut method, and the getBalance method, and the CommonBankAccount and PayrollAccount classes can have an AccountManager 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 and PayrollAccount with an AccountManager 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 the CommonBankAccount and PayrollAccount 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 methods work, eat, and sleep.
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 the Worker 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 the eat and sleep 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 class Switch that depends on the LightBulb 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 the LightBulb class directly.
  • This can lead to tight coupling between the Switch and LightBulb classes, making it difficult to change the implementation of the LightBulb class.

Solution

  • We can refactor the code to follow the Dependency Inversion Principle by using an interface Switchable that both the LightBulb and Switch classes depend on:
1
2
3
4
5
6
// Switchable interface

public interface Switchable {
    void turnOn();
    void turnOff();
}
  • Now, we can modify the LightBulb and Switch classes to depend on the Switchable 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 the Switchable interface, not the LightBulb class directly.
  • This way, we can easily change the implementation of the LightBulb class without affecting the Switch class.

Another Example

  • Consider a public class Project that depends on 2 classes FrontendDeveloper and BackendDeveloper.
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 the FrontendDeveloper and BackendDeveloper classes directly.
  • Imagine if we need to add a new class DatabaseDeveloper to the project, we would need to modify the Project class, which is not ideal.

Solution

  • We can refactor the code to follow the Dependency Inversion Principle by using an interface Developer that both the FrontendDeveloper and BackendDeveloper classes implement:
1
2
3
4
5
// Developer interface

public interface Developer {
    void develop();
}
  • Now, we can modify the FrontendDeveloper and BackendDeveloper classes to implement the Developer 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 the Developer 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 the Developer interface, not the FrontendDeveloper and BackendDeveloper classes directly.
  • This way, we can easily add new classes that implement the Developer interface without affecting the Project class.
This post is licensed under CC BY 4.0 by the author.