SOLID design principles are five design principles that intended to guide Object-Oriented Design developer in creating easily maintainable, flexible, and scalable software systems. The principles ensure the software is of high-quality. SOLID principles are also the underlying guidelines for software Design Patterns.

SOLID - Single Responsibility Principle (SRP)

Every class must perform only a single functionality. A class should have only one reason to change.

Approach

  1. Identify responsibility: Start by identifying if a class is currently performing various tasks and responsibilities. Look for different reasons reasons a class need to change in the future.
  2. Separate responsibility: For each identified responsibility, consider whether it can be logically separate into its own class or module. Aim to create smaller, more focused classes, each responsible for a specific aspect of functionality.
  3. Refactor existing code: Modify the existing code to use the newly created classes or modules.
  4. Extracting abstractions: Extract responsibilities into separate interfaces or abstract classes if necessary.

Advantage

  1. Enhance code readability: each class has only one responsibility, this helps developers to quickly understand the purpose of the class and its relationship with other parts of the system.
  2. Increase code maintainability: by breaking down complex system into smaller, more focused classes, SRP enables developers to make changes to the code easily without affecting other parts of the system.
  3. Facilitates code reuse: Code that adheres to SRP is often more modular and reusable.
  4. Improves system scalability: maintaining single responsibility for each class becomes increasingly important as the codebase grows. SRP ensures that the codebase can easily accommodate changes or new features without impacting the rest of the system.

Example

A banking system has a class called BankAccount that manages a bank account’s details and transactions. The BankAccount class has multiple responsibilities which violates the SRP.

public class BankAccount {
    private String accountNumber;
    private double balance;
 
    public BankAccount(String accountNumber, double initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }
 
    // Responsibility 1: Perform transactions (deposit and withdraw)
    public void deposit(double amount) {
        balance += amount;
    }
 
    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        } else {
            System.out.println("Insufficient balance in account");
        }
    }
 
    // Responsibility 2: Generate account statements
    public void generateAccountStatement() {
        System.out.println("Account Statement for account " + accountNumber);
        System.out.println("Balance: $" + balance);
    }
}
 

The BankAccount is responsible for manage transactions and generating account statements. This could result that the code is harder to maintain, extend, and when the system grows and more complex logics are added this could cause difficulties for code to be understand.

Resolution

The refactored code adhere to SRP, and separated the responsibilities into three classes AccountTransaction, AccountStatementGenerator, and BankAccount.

public class AccountTransaction {
    private String accountNumber;
    private double balance;
 
    public AccountTransaction(String accountNumber, double initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }
 
    public void deposit(double amount) {
        balance += amount;
    }
 
    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        } else {
            System.out.println("Insufficient balance in account");
        }
    }
 
    public double getBalance() {
        return balance;
    }
}
public class AccountStatementGenerator {
    public void generateAccountStatement(int accountNumber, double balance) {
        System.out.println("Account Statement for account " + accountNumber);
        System.out.println("Balance: $" + balance);
    }
}
public class BankAccount {
    private String accountNumber;
    private double balance;
 
    public BankAccount(String accountNumber, double initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }
 
    public void deposit(double amount) {
	    AccountTransaction transaction = new AccountTransaction(accountNumber, balance);
        transaction.deposit(amount);
        balance = transaction.getBalance();
    }
 
    public void withdraw(double amount) {
		AccountTransaction transaction = new AccountTransaction(accountNumber, balance);
        transaction.withdraw(amount);
        balance = transaction.getBalance();
    }
 
    public void generateAccountStatement() {
        AccountStatementGenerator statementGenerator = new AccountStatementGenerator();
        statementGenerator.generateAccountStatement(accountNumber, balance);
    }
}

This refactoring separates the responsibilities and brings enhanced maintainability that support changes and new features to be added more easily, and also supports ease of read, reusability and scalability.

SOLID - Open/Closed Principle (OCP)

When there is a change of requirement, the module should be open for extension but closed for modification. You should be able to add new classes (features) without modifying existing code.

Approach

  1. Abstraction: You can define a general interface or abstract class that specifies the common behaviour of a group of class.
  2. Polymorphism: You can have different subclasses or implementations that provide the specific behaviour of the interface or abstract class.

Advantage

  1. Adaptive to change: OCP promotes modularity and reduces the risk of introducing bugs when updating the codebase
  2. Loosed coupling: This reduces the coupling between components, as new functionality can be added without changing existing code. The separation of concerns leads to more modular and loosely coupled system.
  3. Promote SRP: OCP encourage developer to design classes that separates different concerns by creating specialised classes without adding new features to the existing classes. This avoids mixing responsibilities and enforces the SRP.

Example

Consider the below UML class diagram. The GraphicEditor is responsible for drawing different shapes. Every time a new shape is introduced, such like ellipse, polygon…, the GraphicEditor has to add new method drawEllipse(), drawPolygon()…, this modifies the existing code which violates the OCP.

Resolution

All shapes now implement a common interface, the GraphicEditor can now call the draw() method with polymorphism. When a new shape is introduced, the GraphicEditor does not need to add a new method to support the new shape. Remember, we want to draw more shapes without changing the existing classes.

SOLID - Liskov Substitution Principle (LSP)

Program should be able to replace objects of a parent class with objects of a subclass without breaking the application and maintain the expected behaviour.

Approach

  1. Preserve the behaviour contract: Subclasses must adhere to the contract established by the base class. This include method signatures and conditions. Avoid overriding methods in a way that contradicts the intended behaviour of the base class.

  2. Abstraction: You can define a general interface or abstract class that specifies the common behaviour of a group of class. Ensure the subclasses implement these interfaces or extend these abstract classes while maintaining the expected behaviour.

Advantage

  1. Adaptive to change: LSP encourages modular design by ensuring that subclasses can be added or modified without introducing incoherent behaviour.
  2. Enhanced abstraction: LSP promotes the definition of interfaces and abstract classes by preserving the contract, this leads to more reliable software.

Example

class Shape {
    public double calculateArea() {
        return 0.0; // Default implementation
    }
}
 
class Rectangle extends Shape {
    protected double length;
    protected double width;
 
    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }
 
    public double calculateArea() {
        return length * width;
    }
}
 
class Square extends Shape {
    protected double side;
 
    public Square(double side) {
        this.side = side;
    }
 
    public double calculateArea() {
        return side * side;
    }
}

Now we substitute superclass Square with subclassRectangle.

public class LSPViolationExample {
    public static void main(String[] args) {
        Rectangle rectangle = new Square(5); // A square is treated as a rectangle
        rectangle.setLength(4); // Attempting to modify the length
        rectangle.setWidth(6);  // Attempting to modify the width
 
        double area = rectangle.calculateArea();
        System.out.println("Area: " + area); // Incorrect area calculation for Square
    }
}

The violation occurs when Square is treated as Rectangle. This program produces anomalous output.

Resolution

class LSPCompliantExample {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle(4, 6);
        double area = rectangle.calculateArea();
        System.out.println("Area: " + area); // Correct area calculation for Rectangle
 
        Square square = new Square(5);
        area = square.calculateArea();
        System.out.println("Area: " + area); // Correct area calculation for Square
    }
}

In this resolution, the Square and Rectangle classes maintain their distinct behaviours, and there is no attempt to treat a Square as a Rectangle which not violates the LSP.

SOLID - Interface Segregation Principle (ISP)

Interfaces should not force classes to implement what they can’t do.

Approach

  1. Focused interface: Define interfaces that represent well-defined behaviour or role. Avoid creating interfaces that encompass too many unrelated methods.
  2. Implement multiple interfaces: large interfaces should divided into small ones for better focus.

Advantage

  1. Loosed coupling: Smaller, more specialised interfaces reduce coupling between classes. Classes only depend on the methods they require.
  2. Adaptive to change: Allows for easier addition or removal of features without impacting other parts of the system.

Example

Consider a scenario where an application involves different types of document processors. Some processors can read documents, some can write documents, and some can do both.

public interface DocumentProcessor {
    void readDocument();
    void writeDocument();
}
class ReadProcessor implements DocumentProcessor {
	public void readDocument(){ ... };
	// Oops, I can't perform this task 
	public void writeDocument(){}; 
}

This might violate the ISP because classes implementing this interface might not need both reading and writing capabilities. For example, a class that only needs to read documents would still be forced to implement the writeDocument() method.

Resolution

public interface ReadableDocumentProcessor {
    void readDocument();
}
 
public interface WritableDocumentProcessor {
    void writeDocument();
}
class SuperProcessor implements ReadableDocumentProcessor, WritableDocumentProcessor {
	public void readDocument(){ ... };
	public void writeDocument() { ... };
}
class ReadProcessor implements ReadableDocumentProcessor {
	public void readDocument(){ ... };
}

A class that only needs to read documents can implement ReadableDocumentProcessor, while a class that only needs to write documents can implement WritableDocumentProcessor.

SOLID - Dependency Inversion Principle (DIP)

Components should depend on abstractions not on concretions. Abstraction should not have implementation details, rather details should depend on abstractions.

We are prone to set high-level component to depend on low-level component where high-level often refers to policy maker for low-level to carry out the actual operation, however this brings a tightly coupled system.

Approach

  1. Abstractions over concretions: Both high-level and low-level components should depend on abstractions (interfaces or abstract classes).
  2. Abstract layers: All relationships should involve abstract classes of interfaces that forms a layered abstraction.

It is sometimes called, coding to interface principle. Specifically, in Java it is a good practice to write List<Integer> myList = new ArrayList<>() other than ArrayList<Integer> myList = new ArrayList<>() to adhere to coding to interfaces rather than implementation, we are implementing the List abstraction using ArrayList.

Advantage

  1. Loosed coupling: High-level components are not directly depend on low-level components which reduce tight coupling of the system.
  2. Promote OCP: New implementations can be added without changing existing code, leading to higher resilience of the system.

Example 1

The click() method has internal logic to call the turnTVOn() or turnTVOff() method respectively. In this case, the high-level component RemoteControl is depending directly on a low-level concrete Television class. This causes difficulties when we want to control another type of device in the future that also has an on-off function as we might modify the existing remote control to support that device.

Resolution

To prevent the remote control depend on a concrete television class, we would like to introduce an abstraction that allows turn on or off operations. Now we compose the remote control with an on-off device. Here the OnOffDevice interface does not depend on the low-level detail of Television, it is the Television depends on the abstraction which are the on and off methods in the interface.

Example 2

This example is from the previous OCP, the actual implementation of the code also adhere to DIP, the code allowing the GraphicEditor class to depend on an abstraction (Shape) rather than on concrete implementations (Rectangle, Circle).

public class GraphicEditor {
	private Shape shape;
	public GraphicEditor(Shape shape) {
		this.shape = shape;
	}
	
	public void drawShape() {
		shape.draw();
	}
}
public class Client {
	public static void main(String[] args) {
		Rectangle rect = new Rectangle();
		GraphicEditor myEditor = new GraphicEditor(rect);
		// we draw the rectangle
		myEditor.drawShape();
	}
}

Back to parent page: Web and Application Development

Design_PrincipleDesign_PatternDependencyMaintainabilityReusabilityScalabilityLoose_CouplingSingle_Responsibility_Principle_SRPClosed_Principle_OCPLiskov_Substitution_Principle_LSPInterface_Segregation_Principle_ISPDependency_Inversion_Principle_DIPSOFT2201