Understanding SOLID Programming Principles - A Guide to Writing Maintainable Code.

SOLID Programming Principles are guidelines to write clean, maintainable and extensible code. In this article we will go into the depth of each one of these principles.

In the world of software development, the job of Software Developers is not just to write working code. That is just 1% of what Software Engineers do. The remaining 99% goes into maintaining and extending the already written code.

Since we as software developers spend most of our time maintaining and extending code, we need to ensure that this process is as simple as possible. This is where SOLID programming principles come into play.

SOLID Principles are programming principles which were introduced by Robert C Martin and others and provide a set of guidelines for writing clean, maintainable and extensible code.

In this article we will go through each one of these five principles in depth, explaining their importance and how they can be applied to improve our code quality.

1. Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change. In other words, each class should have a single responsibility or job. When a class has multiple responsibilities, it becomes harder to maintain, understand, and modify.

When you are creating a class, think to yourself that is this class doing. If you have multiple answers to that question, its better to split that class into smaller classes.

Benefits of SRP:

  • Improved code maintainability: Each class does one thing and does it well, making it easier to locate and fix bugs or add new features.
  • Enhanced code reusability: Single-responsibility classes are often more modular, which allows you to reuse them in different parts of your application.
// SRP violated
// Here the Employee class holds logic to calculate employee salary and how to generate report as well
class Employee {
    void calculateSalary() {
        // Calculate employee's salary
    }

    void generateReport() {
        // Generate employee's performance report
    }
}

// SRP adhered
// The employee class know how to generate salary only
class Employee {
    void calculateSalary() {
        // Calculate employee's salary
    }
}

// This class know how to render report for an Employee
class ReportGenerator {
    void generateReport(Employee employee) {
        // Generate employee's performance report
    }
}

2. Open-Closed Principle (OCP)

The Open-Closed Principle suggests that your classes should be open for extension but closed for modification. This means that you should be able to extend the functionality of a class without changing its existing code.

Benefits:

  • Code stability: Extending a class without modifying its existing code reduces the risk of introducing new bugs. Since, you are only extending the code the bug-free base code remains as is.
  • Easier maintenance: You can introduce new features or behaviours by adding new classes or components rather than altering existing ones.
// OCP violated
class Rectangle {
    double width;
    double height;
}

// The problem here is - If tomorrow you want to calculate area for circle, you will have to modify this class
// and add another method for it.
class AreaCalculator {
    double calculateArea(Rectangle rectangle) {
        return rectangle.width * rectangle.height;
    }
}

// OCP adhered
// With the below approach you can add as many shapes you want, each one holding logic to calculate its own area.
interface Shape {
    double calculateArea();
}

class Rectangle implements Shape {
    double width;
    double height;

    @Override
    public double calculateArea() {
        return width * height;
    }
}

class Circle implements Shape {
    double radius;

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

3. Liskov Substitution Principle (LSP)

The Liskov Substitution Principle emphasizes that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. In other words, if a class is a subtype of another class, it should be usable in place of its parent class.

If I have to put this simply, this principle states that you should be able to replace an object of parent class with a child class without breaking the system.

Benefits:

  • Improved code correctness: When you follow LSP, you can confidently use derived classes without worrying about breaking the code's expected behaviour.
  • Simplified code maintenance: When you have the flexibility to replacing child classes and parent class with one another, you can seamlessly updates and improve the codebase.

Additional Read: Understanding Liskov Substitution Principle With Code Examples

class Shape {
    double area() {
        return 0.0;
    }
}

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

    void setWidth(double width) {
        this.width = width
    }

    void setHeight(double height) {
        this.height = height
    }

    @Override
    double area() {
        return width * height;
    }
}

// This is a violation of LSP, as Square is a specialization of Rectangle
// If you set width or height, the order side should also change
// So, this is a poor abstraction
class Square extends Rectangle {
    @Override
    double area() {
        return width * width;  // Incorrectly uses width instead of width * height
    }
}

// LSP Adhered
class Square extends Shape {
    private double side;

    void setSide(double side) {
        this.side = side
    }

    @Override
    double area() {
        return side * side;
    }
}

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle encourages the creation of specific, client-focused interfaces rather than large, all-encompassing ones. Clients should not be forced to depend on interfaces they don't use.

In simple words: We should always create smaller interfaces. A class can choose to implement multiple interfaces.

Benefits:

  • Reduces coupling: By creating smaller, more specialized interfaces, you minimize the dependencies between classes.
  • Enhances code flexibility: Clients can implement only the interfaces they require, leading to more adaptable and extensible code.
// ISP violated
interface Worker {
    void work();
    void eat();
}

class Engineer implements Worker {
    void work() {
        // Engineer's work
    }

    void eat() {
        // Engineer's lunch break
    }
}

// ISP adhered
interface Worker {
    void work();
}

interface Eater {
    void eat();
}

class Engineer implements Worker, Eater {
    void work() {
        // Engineer's work
    }

    void eat() {
        // Engineer's lunch break
    }
}

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle advocates that high-level modules should not depend on low-level modules but both should depend on abstractions. It promotes the use of interfaces or abstract classes to decouple components.

Simply it means, that classes should depend on other classes via interfaces and not concrete implementations.

Benefits:

  • Improved maintainability: Decoupling high-level and low-level modules makes replacing or upgrading components easier without affecting the entire system.
  • Easier testing: Abstractions enable the use of mock objects or stubs, simplifying unit testing.
// DIP violated
class LightBulb {
    void turnOn() {
        // Turn on the light bulb
    }
}

class Switch {
    private LightBulb bulb;

    Switch() {
        bulb = new LightBulb();
    }

    void operate() {
        bulb.turnOn();
    }
}

// DIP adhered
interface Switchable {
    void turnOn();
}

class LightBulb implements Switchable {
    void turnOn() {
        // Turn on the light bulb
    }
}

class Fan implements Switchable {
    void turnOn() {
        // Turn on the fan
    }
}

class Switch {
    private Switchable device;

    // Since, we are injecting the Switchable instance here
    // We can easily have a switch created for Fan or LightBulb
    Switch(Switchable device) {
        this.device = device;
    }

    void operate() {
        device.turnOn();
    }
}

So, this was the explanation for the SOLID Programming principles. Hope, it makes sense and can help you to write better object Oriented Code.