A solid guide to SOLID Principles

SOLID Principles is one of most popular set of principles among software developers community.

Acronym

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)

History

The SOLID principles were introduced by Robert C. Martin (also known as Uncle Bob) in his 2000 paper “Design Principles and Design Patterns.”

Uncle Bob developed the SOLID principles as a way to encapsulate best practices in object-oriented design and architecture. Since then, the SOLID principles have become widely adopted in the software development community and are considered essential knowledge for writing clean, maintainable, and scalable code.

Popularity

These principles are popular because they provide a clear and concise set of guidelines for designing software that is easy to maintain, extend, and scale. The principles help developers create software that is modular, flexible, and resilient to change, which is critical in today’s fast-paced and rapidly evolving technological landscape.

The SOLID principles also promote good coding practices and encourage developers to write code that is clean, well-organized, and easy to understand. By following the SOLID principles, developers can create code that is less error-prone, easier to test, and more reliable, which ultimately leads to higher-quality software.

Furthermore, the SOLID principles are technology-agnostic, meaning that they can be applied to any programming language or platform. This universality makes the SOLID principles valuable for developers working in a wide range of industries and domains.

SOLID Principles

Single Responsibility Principle

What?

The Single Responsibility Principle (SRP) states that a class should have only one reason to change. In other words, a class should have only one responsibility, and it should do it well. 

Why?

By following SRP, classes become easier to understand, maintain, and extend, which ultimately leads to higher-quality software.

How?

  • Apply Curly’s Law – Do one thing & Do it Well.
  • Identify Responsibilities: If a class or module is responsible for more than one task, consider refactoring it into multiple classes or modules.
  • Separation of Concerns: Each class or module should only deal with one concern, such as user authentication, data validation, or database access.
  • Cohesion: A class or module should have high cohesion, meaning that its methods and properties should be closely related to its responsibility. If a class or module has low cohesion, consider refactoring it into multiple classes or modules that each have a single responsibility.
  • Encapsulation: Encapsulation is the practice of hiding the implementation details of a class or module and exposing only its public interface. This helps enforce SRP by ensuring that each class or module is responsible for a single task and that its implementation details are not spread throughout the codebase.

For example, we might create a OrderTaker class responsible for taking orders from customers, a Cook class responsible for cooking food based on the order, a Waiter class responsible for serving food to the customer, and a Cashier class responsible for handling payments. Each of these classes would have only one responsibility and would do it well.

By separating these responsibilities into separate classes, we can ensure that each class is easy to understand, maintain, and extend. For instance, if we need to change the process of taking orders, we only need to modify the OrderTaker class, rather than making changes to the entire codebase. This results in more maintainable and scalable software.

Overall, SRP helps to create classes that are focused and cohesive, which leads to more modular and maintainable codebases. In the context of the restaurant example, it leads to a smoother and more efficient process of preparing and serving food to the customers.

// OrderTaker.java
public class OrderTaker {
    public void takeOrder(String order) {
        // logic for taking order from customer
    }
}
// Cook.java
public class Cook {
    public void cookOrder(String order) {
        // logic for cooking food based on order
    }
}
// Waiter.java
public class Waiter {
    public void serveOrder(String order) {
        // logic for serving food to customer
    }
}
// Cashier.java
public class Cashier {
    public void handlePayment(double amount) {
        // logic for handling payment from customer
    }
}
Java

In this implementation, we have created separate classes for each responsibility, such as OrderTaker for taking orders, Cook for cooking food, Waiter for serving food, and Cashier for handling payments. Each class has only one responsibility and does it well, adhering to the Single Responsibility Principle.

Open-Closed Principle

What?

The Open-Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, existing code should not be modified to add new functionality. Instead, new functionality should be added by creating new code that extends the existing codebase.

Why?

By following OCP, we can create software that is more flexible, reusable, and maintainable.

How?

  • Abstraction: One of the best ways to enforce OCP is to use abstraction. Abstract classes and interfaces can be used to define a set of methods that all implementations must follow, without specifying how those methods should be implemented. This allows new functionality to be added by creating new classes that implement the required methods, without modifying the existing code.
  • Polymorphism: Polymorphism is another key feature that helps enforce the Open-Closed Principle. By using polymorphism, we can write code that works with objects of different types, without knowing the exact type at compile-time. This allows us to add new types of objects that implement the required interface or extend the required class, without modifying the existing code.
  • Design Patterns: Design patterns are reusable solutions to common software engineering problems. There are several design patterns that help enforce OCP, such as the Strategy Pattern, the Decorator Pattern, and the Factory Method Pattern. These patterns provide a framework for creating new functionality that follows the OCP.
  • Testing: Another way to enforce OCP is through testing. By writing tests that cover all the existing functionality, we can ensure that any new functionality we add does not break the existing code. This allows us to add new features with confidence, knowing that the existing codebase is still functioning correctly.

Let’s take a real-world example to illustrate OCP. Consider a library that manages various types of books, such as novels, biographies, and textbooks. Suppose we need to add a new type of book, such as a comic book, to the library. Following OCP, we would create a new class to represent the comic book, rather than modifying the existing code to handle the new type.

Here’s an example implementation of the book library scenario in Java, following the Open-Closed Principle:

// Book.java - abstract base class for all types of books
public abstract class Book {
    private String title;
    private String author;
    private double price;
    // constructors, getters, setters, and other methods
    public abstract String getType();
}
// Novel.java - concrete subclass for novels
public class Novel extends Book {
    public Novel(String title, String author, double price) {
        super(title, author, price);
    }
    @Override
    public String getType() {
        return "Novel";
    }
}
// Biography.java - concrete subclass for biographies
public class Biography extends Book {
    public Biography(String title, String author, double price) {
        super(title, author, price);
    }
    @Override
    public String getType() {
        return "Biography";
    }
}
// Textbook.java - concrete subclass for textbooks
public class Textbook extends Book {
    public Textbook(String title, String author, double price) {
        super(title, author, price);
    }
    @Override
    public String getType() {
        return "Textbook";
    }
}
// ComicBook.java - concrete subclass for comic books
public class ComicBook extends Book {
    public ComicBook(String title, String author, double price) {
        super(title, author, price);
    }
    @Override
    public String getType() {
        return "Comic Book";
    }
}
// Bookstore.java - example usage of the book classes
public class Bookstore {
    public static void main(String[] args) {
        Book novel = new Novel("The Great Gatsby", "F. Scott Fitzgerald", 10.99);
        Book biography = new Biography("Steve Jobs", "Walter Isaacson", 14.99);
        Book textbook = new Textbook("Introduction to Java Programming", "Y. Daniel Liang", 79.99);
        Book comicBook = new ComicBook("Watchmen", "Alan Moore", 15.99);
        // add the books to the library or perform other operations
    }
}
Java

In this implementation, we have created an abstract base class Book that represents all types of books in the library. We have also created concrete subclasses Novel, Biography, Textbook, and ComicBook that extend the Book class. Each subclass has its own implementation of the getType() method, which returns the type of the book.

By using inheritance and polymorphism, we have implemented the Open-Closed Principle in the book library scenario. We have created a flexible and extensible codebase that can handle new types of books in the future without modifying existing code.

Liskov Substitution Principle

What?

The Liskov Substitution Principle (LSP) is an important principle in object-oriented programming that ensures that objects of a derived class can be used in place of objects of a base class without causing unexpected results or breaking the code.

Why?

  • Improved modularity: By adhering to the LSP, we can break down a complex system into smaller, more manageable modules that can be tested and developed independently.
  • Increased reusability: By ensuring that objects of a derived class can be used in place of objects of a base class without causing unexpected results or breaking the code, we can reuse code more easily and reduce development time.
  • Reduced coupling: The LSP promotes loose coupling between objects, which makes it easier to modify and maintain the code.
  • Better testing: By adhering to the LSP, we can write more comprehensive and accurate tests that verify the behavior of the system as a whole, rather than just individual components.
  • Improved design: The LSP encourages developers to think more deeply about the relationships between objects and the contracts they adhere to, which can lead to more thoughtful and effective designs.

How?

  • Use interfaces or abstract classes to define contracts that derived classes must implement, rather than concrete classes.
  • Do not throw new or broader exceptions than the base class. If the base class throws a certain exception, the derived class should maintain that same exception or a narrower one.
  • Do not change the type of any parameter or return value in the derived class. If the base class takes a certain type of parameter or returns a certain type of value, the derived class should maintain that same type.
  • Ensure that the derived class maintains the behavior of the base class, including preconditions, postconditions, and invariants.
  • Do not weaken the preconditions of the methods in the derived class. If the base class requires a certain condition to be true before a method is called, the derived class should maintain that same condition.
  • Do not strengthen the postconditions of the methods in the derived class. If the base class specifies a certain behavior or return value, the derived class should maintain that same behavior or return value.

One real-world example of the Liskov Substitution Principle is the concept of a square and a rectangle. A square is a special type of rectangle where all sides are equal. In code, we might have a Rectangle class with properties for height and width, and a Square class that extends Rectangle and sets the height and width to be the same.

Here’s an example Java code that violates the LSP:

class Rectangle {
    protected int height;
    protected int width;
 
    public void setHeight(int height) {
        this.height = height;
    }
 
    public void setWidth(int width) {
        this.width = width;
    }
 
    public int getArea() {
        return height * width;
    }
}
 
class Square extends Rectangle {
    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height;
    }
 
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }
}
public class LiskovViolation {
    public static void main(String[] args) {
        Rectangle rectangle = new Square();
        rectangle.setHeight(5);
        rectangle.setWidth(10);
        System.out.println(rectangle.getArea()); // Output: 50 instead of 100
    }
}
Java

In this code, the Square class extends the Rectangle class, but violates the LSP because it changes the behavior of the Rectangle class. Specifically, the Square class sets the height and width to be the same, which works for a Square, but causes unexpected results when a Square is treated as a Rectangle.

Here’s an example Java code that follows the LSP:

interface Shape {
    int getArea();
}
 
class Rectangle implements Shape {
    protected int height;
    protected int width;
 
    public Rectangle(int height, int width) {
        this.height = height;
        this.width = width;
    }
 
    @Override
    public int getArea() {
        return height * width;
    }
}
 
class Square implements Shape {
    private int side;
 
    public Square(int side) {
        this.side = side;
    }
 
    @Override
    public int getArea() {
        return side * side;
    }
}
public class LiskovCompliant {
    public static void main(String[] args) {
        Shape rectangle = new Rectangle(5, 10);
        Shape square = new Square(5);
        System.out.println(rectangle.getArea()); // Output: 50
        System.out.println(square.getArea()); // Output: 25
    }
}
Java

In this code, we have an interface called Shape that both the Rectangle and Square classes implement. The Shape interface specifies a getArea() method that both classes implement in their own way. By using an interface and ensuring that the derived classes implement the same method with the same signature, we can ensure that objects of the derived classes can be used in place of the base class without causing unexpected results or breaking the code.

Interface Segregation Principle

What?

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use.

Why?

This principle is aimed at avoiding unnecessary coupling between components by breaking down interfaces into smaller, more focused ones. This results in more cohesive interfaces, which are easier to understand, implement and maintain.

How?

  • Avoid fat interfaces: Avoid creating large, monolithic interfaces that contain a lot of methods. Instead, break them down into smaller, more focused interfaces that contain only the methods relevant to the component.
  • Identify and separate concerns: Identify the different concerns that your code needs to address and separate them into smaller, more focused interfaces. This will help you to achieve better modularity and avoid unnecessary coupling.
  • Use abstraction: Use abstract classes or interfaces to define common behavior and create specialized interfaces that implement only the specific functionality needed by each component.
  • Test for compliance: Test your code to ensure that it adheres to the ISP. This may involve writing unit tests that check that each component depends only on the interfaces it needs.

Here’s an example in Java:

Suppose we have an interface Vehicle with methods startEngine(), stopEngine(), drive(), and park(). However, not all vehicles have the same features. For example, an airplane can’t simply “drive” like a car. In such a case, we can break down the Vehicle interface into smaller, more specialized interfaces:

public interface Driveable {
    void drive();
}
public interface Parkable {
    void park();
}
public interface Flyable {
    void takeoff();
    void fly();
    void land();
}
Java

Now, the Car class can implement Driveable and Parkable, while the Airplane class can implement Flyable. This way, each class only depends on the interfaces it needs, and we avoid unnecessary coupling between the classes.

Here’s the Java code for the Car class:

public class Car implements Driveable, Parkable {
    public void drive() {
        // logic for driving a car
    }
    public void park() {
        // logic for parking a car
    }
}
Java

And the Airplane class:

public class Airplane implements Flyable {
    public void takeoff() {
        // logic for taking off an airplane
    }
    public void fly() {
        // logic for flying an airplane
    }
    public void land() {
        // logic for landing an airplane
    }
}
Java

By breaking down the Vehicle interface into smaller interfaces, we adhere to the ISP and create more focused and cohesive interfaces. This leads to better code organization, easier maintenance, and reduced coupling between components.

Dependency Inversion Principle

What?

The Dependency Inversion Principle (DIP) is a principle of object-oriented design that states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Additionally, abstractions should not depend on details, but details should depend on abstractions.

Why?

This allows for a more flexible and maintainable codebase, as changes in low-level modules do not affect high-level modules and vice versa.

How?

  1. Use interfaces and abstractions: Use interfaces and abstractions to define high-level modules and their dependencies on lower-level modules. This makes it easy to swap out different implementations of a module without affecting the high-level module.
  2. Invert the control flow: Instead of creating an instance of a lower-level module within a high-level module, invert the control flow and pass in the lower-level module as a dependency. This allows for easier testing and better flexibility.
  3. Use dependency injection: Use dependency injection frameworks or libraries to inject dependencies into a class at runtime, instead of hardcoding dependencies within the class itself.
  4. Use factories: Use factories to create instances of lower-level modules and pass them in as dependencies to the high-level module. This allows for more flexibility and easier testing.

Here’s an example to illustrate the DIP in Java:

Suppose we have a high-level module called OrderProcessor that processes orders by calling a PaymentProcessor to charge a customer’s credit card. The PaymentProcessor is a low-level module that communicates with an external payment gateway.

Without the DIP, the OrderProcessor might have a direct dependency on the PaymentProcessor, like this:

public class OrderProcessor {
  private PaymentProcessor paymentProcessor;
  public OrderProcessor() {
    this.paymentProcessor = new PaymentProcessor();
  }
  public void processOrder(Order order) {
    // Process order logic
    paymentProcessor.chargeCreditCard(order, order.getCustomer());
  }
}
Java

This creates a tight coupling between the high-level OrderProcessor module and the low-level PaymentProcessor module. If the payment gateway changes or if we want to use a different payment processor, we would need to modify the OrderProcessor class.

To apply the DIP, we can introduce an abstraction layer that both modules depend on. For example, we can create an interface called PaymentProcessorInterface:

public interface PaymentProcessorInterface {
  void chargeCreditCard(Order order, Customer customer);
}
Java

Then, we modify the OrderProcessor to depend on the PaymentProcessorInterface abstraction, instead of the PaymentProcessor implementation:

public class OrderProcessor {
  private PaymentProcessorInterface paymentProcessor;
  public OrderProcessor(PaymentProcessorInterface paymentProcessor) {
    this.paymentProcessor = paymentProcessor;
  }
  public void processOrder(Order order) {
    // Process order logic
    paymentProcessor.chargeCreditCard(order, order.getCustomer());
  }
}
Java

Now, the OrderProcessor module only depends on the PaymentProcessorInterface, and not on the PaymentProcessor implementation details. We can create different implementations of the PaymentProcessorInterface, such as StripePaymentProcessor or PayPalPaymentProcessor, and pass them to the OrderProcessor constructor. This makes the OrderProcessor module more flexible and decoupled from the low-level PaymentProcessor module.

Software Attributes & SOLID Principles

According to a research paper published, here’s how SOLID principles help achieve software goals

AttributePrinciple
ReusabilityS, I
ExtendabilityO, L, I, D
MaintainabilityS, O, L, I, D
ScalabilityS, I

Design Pattern & SOLID Principles

The same paper also talks about preferred design pattern for each of the SOLID principle

Design PatternPrinciple
Dependency InjectionI, D
FacadeI, D
Chain of ResponsibilityS, O, L, I, D
DecoratorS, O, L, I, D
FactoryS, O, I, D
BuilderS, I, D
AdapterS, I, D

Wiki: https://en.wikipedia.org/wiki/SOLID

ResearchGate Publication: https://www.researchgate.net/publication/332397035_A_Reference_Model_for_Low-Level_Design