DEV Community

Cover image for S.O.L.I.D Principles Explained.
Clifford Silla
Clifford Silla

Posted on • Originally published at Medium

S.O.L.I.D Principles Explained.

S.O.L.I.D principles explained.

SOLID principles are the design principles that enable us to manage several software design problems. They were introduced by Robert C. Martin in his 2000 paper “Design Principles and Design Patterns”. SOLID is an acronym that stands for:

  • Single Responsibility Principle: A class should have one and only one reason to change, meaning that a class should have only one job.

  • Open/Closed Principle: Objects or entities should be open for extension but closed for modification. This means that a class should be easily extendable without modifying the class itself.

  • Liskov Substitution Principle: A subclass should be substitutable for its superclass. This means that a subclass should not break the functionality of a superclass or its clients.

  • Interface Segregation Principle: Clients should not be forced to depend on methods that they do not use. This means that interfaces should be small and focused, and classes should implement only the interfaces that they need.

  • Dependency Inversion Principle: High-level modules should not depend on low-level modules; both should depend on abstractions. This means that classes should depend on interfaces or abstract classes instead of concrete implementations.

  • These principles provide us with ways to move from tightly coupled code and little encapsulation to the desired results of loosely coupled and encapsulated real business needs properly. They also help us to create more maintainable, understandable, and flexible software.

Single Responsibility Principle (SRP)

A class should have only one reason to change. In other words, a class should have a single responsibility or purpose. This principle promotes separation of concerns and helps in creating smaller, focused classes that are easier to understand, test, and maintain.

Let’s consider an example of a class called Employee that represents an employee in a company. According to the Single Responsibility Principle (SRP), the Employee class should have only one reason to change, meaning it should have a single responsibility.

In its current state, the Employee class might have multiple responsibilities, such as managing employee data and calculating payroll. To adhere to SRP, we can split these responsibilities into separate classes.

Here’s an example implementation:

    // Employee class with a single responsibility of managing employee data
    class Employee {
        private String name;
        private String employeeId;
        private double salary;

        // constructor, getters, and setters

        // Other methods specific to employee data management
        public void saveEmployeeData() {
            // Code to save employee data to the database
        }

        public void updateEmployeeData() {
            // Code to update employee data in the database
        }

        // ... other methods related to employee data management
    }

    // PayrollCalculator class with a single responsibility of calculating payroll
    class PayrollCalculator {
        public double calculateSalary(Employee employee) {
            // Code to calculate employee salary based on some logic
            // and return the calculated salary
        }

        // Other methods related to payroll calculations
        public void generatePayrollReport(Employee employee) {
            // Code to generate a payroll report for the employee
        }

        // ... other methods related to payroll calculations
    }
Enter fullscreen mode Exit fullscreen mode

In this example, we have separated the responsibilities of managing employee data and calculating payroll into separate classes: Employee and PayrollCalculator. The Employee class is now responsible only for managing employee data, such as storing and updating employee information in the database. The PayrollCalculator class, on the other hand, focuses solely on calculating employee salaries and generating payroll reports.

By adhering to the Single Responsibility Principle, we have created two classes, each with a single responsibility. This separation of concerns makes the codebase more maintainable, as any changes related to employee data management will be isolated to the Employee class, and any changes related to payroll calculations will be isolated to the PayrollCalculator class. This approach promotes code reusability, testability, and overall code organization.

Open/Closed Principle (OCP):

Software entities (classes, modules, functions) should be open for extension but closed for modification. This principle encourages designing code that can be easily extended without modifying existing code. By using abstractions, interfaces, and inheritance, new functionality can be added without changing the existing codebase.

Suppose we have a Shape class hierarchy that represents different shapes, and we want to calculate the area of each shape. Initially, we have two shapes: Circle and Rectangle. However, we anticipate that new shapes will be added in the future. We want to design our code in a way that allows for easy extension without modifying the existing code.

Here’s an example implementation:

    abstract class Shape {
        public abstract double calculateArea();
    }

    class Circle extends Shape {
        private double radius;

        public Circle(double radius) {
            this.radius = radius;
        }

        public double calculateArea() {
            return Math.PI * radius * radius;
        }
    }

    class Rectangle extends Shape {
        private double width;
        private double height;

        public Rectangle(double width, double height) {
            this.width = width;
            this.height = height;
        }

        public double calculateArea() {
            return width * height;
        }
    }
Enter fullscreen mode Exit fullscreen mode

In this example, we have an abstract Shape class that defines a contract for calculating the area of a shape through the calculateArea method. The Circle and Rectangle classes extend the Shape class and provide their own implementations of the calculateArea method.

Now, if we want to add a new shape, such as a Triangle, we can create a new class that extends Shape and implement the calculateArea method specifically for triangles, without modifying the existing code:

    class Triangle extends Shape {
        private double base;
        private double height;

        public Triangle(double base, double height) {
            this.base = base;
            this.height = height;
        }

        public double calculateArea() {
            return 0.5 * base * height;
        }
    }
Enter fullscreen mode Exit fullscreen mode

By following the Open/Closed Principle, our code remains closed for modification. We can easily introduce new shapes by extending the Shape class and implementing the calculateArea method specific to each shape. This way, we are extending the behavior of the code without modifying the existing Shape class or any other classes that depend on it. This promotes code maintainability, reusability, and minimizes the risk of introducing bugs in the existing codebase when extending functionality.

Liskov Substitution Principle (LSP):

Subtypes must be substitutable for their base types. This principle emphasizes that objects of a superclass should be replaceable with objects of its subclass without affecting the correctness of the program. In other words, subclasses should adhere to the contract defined by the superclass and not introduce new behaviors that could break the code.

Suppose we have a Rectangle class that represents a rectangle shape, and we use it in various parts of our codebase. According to the LSP, we should be able to substitute a Rectangle object with an object of any of its subclasses (e.g., Square) without causing any issues.

Heres an example implementation:

    class Rectangle {
        protected int width;
        protected int height;

        public Rectangle(int width, int height) {
            this.width = width;
            this.height = height;
        }

        public int getWidth() {
            return width;
        }

        public void setWidth(int width) {
            this.width = width;
        }

        public int getHeight() {
            return height;
        }

        public void setHeight(int height) {
            this.height = height;
        }

        public int calculateArea() {
            return width * height;
        }
    }

    class Square extends Rectangle {
        public Square(int sideLength) {
            super(sideLength, sideLength);
        }

        public void setWidth(int sideLength) {
            super.setWidth(sideLength);
            super.setHeight(sideLength);
        }

        public void setHeight(int sideLength) {
            super.setWidth(sideLength);
            super.setHeight(sideLength);
        }
    }
Enter fullscreen mode Exit fullscreen mode

In this example, we have a Rectangle class with a width and height, and a calculateArea method that returns the area of the rectangle. We then introduce a Square class that extends Rectangle. Since a square is a special type of rectangle where all sides are equal, we override the setWidth and setHeight methods to ensure that both sides are always set to the same value.

Now, let’s examine how LSP is demonstrated in this example:

    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle(3, 4);
        processShape(rectangle);

        Square square = new Square(5);
        processShape(square);
    }

    public static void processShape(Rectangle shape) {
        shape.setWidth(10);
        shape.setHeight(5);

        int area = shape.calculateArea();
        System.out.println("Area: " + area);
    }
Enter fullscreen mode Exit fullscreen mode

In the main method, we create a Rectangle object and a Square object. Both objects are passed to the processShape method, which expects a Rectangle parameter. According to LSP, the Square object should be substitutable for the Rectangle object.

When we execute the code, we see that the calculateArea method correctly calculates the area for both the Rectangle and Square objects. This demonstrates the substitutability of the Square object for its superclass Rectangle, without altering the expected behavior of the program.

By adhering to the Liskov Substitution Principle, we ensure that subclasses can be used interchangeably with their superclasses, which promotes code reuse, polymorphism, and flexibility in object-oriented design.

Interface Segregation Principle (ISP):

Clients should not be forced to depend on interfaces they do not use. This principle encourages creating fine-grained interfaces that are specific to the needs of clients, rather than having a large, monolithic interface. It helps in preventing the coupling of unrelated code and avoids the burden of implementing unnecessary methods.

To explain the ISP using a Java example, let’s consider a scenario where we have an interface called Printer that provides various printing-related methods. However, different types of clients may only need a subset of these methods. Applying the ISP, we should split the monolithic Printer interface into smaller, more focused interfaces that cater to the specific needs of each client.

Here’s an example implementation:

    // Monolithic interface
    interface Printer {
        void print();
        void scan();
        void fax();
    }

    // Fine-grained interfaces
    interface Printer {
        void print();
    }

    interface Scanner {
        void scan();
    }

    interface FaxMachine {
        void fax();
    }
Enter fullscreen mode Exit fullscreen mode

In this example, we start with a monolithic Printer interface that includes three methods: print(), scan(), and fax(). However, following the ISP, we split this interface into three smaller interfaces: Printer, Scanner, and FaxMachine, each focusing on a specific functionality.

Now, let’s consider two different types of clients: a basic printer client that only needs printing functionality, and an advanced office equipment client that requires scanning and faxing capabilities.

    class BasicPrinterClient implements Printer {
        public void print() {
            // Implementation for basic printing
        }
    }

    class AdvancedOfficeEquipmentClient implements Printer, Scanner, FaxMachine {
        public void print() {
            // Implementation for printing
        }

        public void scan() {
            // Implementation for scanning
        }

        public void fax() {
            // Implementation for faxing
        }
    }
Enter fullscreen mode Exit fullscreen mode

In the above code, the BasicPrinterClient only implements the Printer interface because it only requires printing functionality.

On the other hand, the AdvancedOfficeEquipmentClient implements all three interfaces: Printer, Scanner, and FaxMachine, as it needs all these functionalities.

By adhering to the Interface Segregation Principle, we ensure that clients depend only on the interfaces they actually need. The BasicPrinterClient only knows about printing, while the AdvancedOfficeEquipmentClient is aware of printing, scanning, and faxing capabilities. This approach prevents clients from being burdened with unnecessary methods, reduces coupling, and allows for cleaner, more maintainable code.

Additionally, if a new type of client requires a different combination of functionalities, we can easily create a new interface and implement it in the appropriate client class, without impacting existing clients or modifying the existing codebase.

Dependency Inversion Principle (DIP):

High-level modules should not depend on low-level modules. Both should depend on abstractions. This principle promotes loose coupling and decoupling of modules by introducing abstractions (e.g., interfaces or abstract classes) that define the dependencies between modules. By depending on abstractions, the code becomes more flexible, testable, and easier to modify.

To explain the DIP using a Java example, let’s consider a scenario where we have a high-level class called BusinessLogic that depends on a low-level class called DatabaseService for data persistence. However, applying the DIP, we should introduce an abstraction and have both the high-level and low-level classes depend on that abstraction.

Here’s an example implementation:

    // Abstraction
    interface PersistenceService {
        void saveData(String data);
    }

    // Low-level class
    class DatabaseService implements PersistenceService {
        public void saveData(String data) {
            // Code to save data to a database
        }
    }

    // High-level class
    class BusinessLogic {
        private PersistenceService persistenceService;

        public BusinessLogic(PersistenceService persistenceService) {
            this.persistenceService = persistenceService;
        }

        public void processData(String data) {
            // Perform business logic operations
            persistenceService.saveData(data);
        }
    }
Enter fullscreen mode Exit fullscreen mode

In this example, we introduce the PersistenceService interface as an abstraction that defines the saveData method. The DatabaseService class, which previously represented the low-level module, now implements the PersistenceService interface.

The BusinessLogic class, representing the high-level module, depends on the PersistenceService interface through its constructor. This allows us to inject any implementation of PersistenceService, including the DatabaseService or any other class that implements the PersistenceService interface.

By following the Dependency Inversion Principle, we have inverted the dependency direction. The BusinessLogic class now depends on the abstraction (PersistenceService) rather than the concrete implementation (DatabaseService). This decouples the high-level module from the low-level module, making the code more flexible, testable, and easier to modify.

Here’s an example of how we can use the classes:

    public static void main(String[] args) {
        PersistenceService persistenceService = new DatabaseService();
        BusinessLogic businessLogic = new BusinessLogic(persistenceService);

        String data = "Some data";
        businessLogic.processData(data);
    }
Enter fullscreen mode Exit fullscreen mode

In the main method, we create an instance of DatabaseService and pass it as a parameter to the BusinessLogic constructor. This way, the BusinessLogic class can utilize the PersistenceService abstraction without directly depending on the DatabaseService implementation.

The Dependency Inversion Principle allows us to decouple modules, promote interchangeable components, and facilitate easier testing and maintainability. By depending on abstractions rather than concrete implementations, we gain flexibility and can easily switch or extend the underlying implementations without affecting the high-level module.

In conclusion, the SOLID principles provide a set of guidelines for writing clean, maintainable, and flexible software code.

By adhering to these SOLID principles, software developers can achieve code that is modular, flexible, and easier to maintain. These principles encourage good design practices, reduce code complexity, promote reusability, and make the codebase more adaptable to future changes. Applying the SOLID principles leads to improved code quality, better software architecture, and increased productivity for software development teams.

Top comments (2)

Collapse
 
michaeltharrington profile image
Michael Tharrington

Great explanation, Clifford! Thanks for sharing this one with us.

Collapse
 
kurealnum profile image
Oscar

Solid (pun intended) explanation and article!