DEV Community

Amrishkhan Sheik Abdullah
Amrishkhan Sheik Abdullah

Posted on

Demystifying SOLID: 5 Principles for Building Robust and Maintainable Software πŸš€

In the world of software development, some principles stand the test of time. Among the most influential are the SOLID principles, a set of five design guidelines that empower developers to create software that is more understandable, flexible, and maintainable. Coined by Robert C. Martin (Uncle Bob), SOLID isn't a silver bullet, but rather a powerful framework for thinking about object-oriented design.

Let's break down each principle with practical examples.


S - Single Responsibility Principle (SRP)

"A class should have only one reason to change."

This principle advocates for a class to have a single, well-defined purpose. If a class has multiple responsibilities, changes to one responsibility might inadvertently affect others, leading to unexpected bugs and a tangled codebase.

Bad Example (Violates SRP)

Imagine a Report class that's responsible for both generating the report data and printing it.

  • Python 🐍

    class Report:
        def __init__(self, data):
            self.data = data
    
        def generate_report(self):
            # Logic to process report data
            return f"Report Data: {self.data}"
    
        def print_report(self):
            report_content = self.generate_report()
            print(f"Printing: {report_content}")
    
  • JavaScript πŸ“œ

    class Report {
        constructor(data) {
            this.data = data;
        }
    
        generateReport() {
            // Logic to process report data
            return `Report Data: ${this.data}`;
        }
    
        printReport() {
            const reportContent = this.generateReport();
            console.log(`Printing: ${reportContent}`);
        }
    }
    

Why it's bad: This class has two reasons to change: a change in the data generation logic or a change in the printing mechanism (e.g., printing to a file, sending an email).

Good Example (Adheres to SRP)

We split these responsibilities into separate classes.

  • Python 🐍

    class ReportGenerator:
        def generate(self, data):
            return f"Report Data: {data}"
    
    class ReportPrinter:
        def print(self, report_content):
            print(f"Printing: {report_content}")
    
    # Usage
    generator = ReportGenerator()
    printer = ReportPrinter()
    report_data = generator.generate("Sales Figures")
    printer.print(report_data)
    
  • JavaScript πŸ“œ

    class ReportGenerator {
        generate(data) {
            return `Report Data: ${data}`;
        }
    }
    
    class ReportPrinter {
        print(reportContent) {
            console.log(`Printing: ${reportContent}`);
        }
    }
    
    // Usage
    const generator = new ReportGenerator();
    const printer = new ReportPrinter();
    const reportData = generator.generate("Sales Figures");
    printer.print(reportData);
    

Now, ReportGenerator only cares about generating data, and ReportPrinter only cares about printing. Each has one, and only one, reason to change.


O - Open/Closed Principle (OCP)

"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification."

This principle suggests that you should be able to add new functionality without altering existing, working code. This is typically achieved through abstraction and polymorphism.

Bad Example (Violates OCP)

Consider a function to calculate the total area of shapes, but it has to know about every concrete shape type.

  • Python 🐍

    # Violates OCP
    def calculate_total_area(shapes):
        total_area = 0
        for shape in shapes:
            if isinstance(shape, Circle):
                total_area += 3.14 * shape.radius ** 2
            elif isinstance(shape, Rectangle):
                total_area += shape.width * shape.height
            # To add a Triangle, we must modify this function!
        return total_area
    
  • JavaScript πŸ“œ

    // Violates OCP
    function calculateTotalArea(shapes) {
        let totalArea = 0;
        for (const shape of shapes) {
            if (shape.type === 'circle') {
                totalArea += Math.PI * shape.radius ** 2;
            } else if (shape.type === 'rectangle') {
                totalArea += shape.width * shape.height;
            }
            // To add a Triangle, we must modify this function!
        }
        return totalArea;
    }
    

Why it's bad: Every time a new shape is introduced, the calculate_total_area function needs to be modified. It's not closed for modification.

Good Example (Adheres to OCP)

We use polymorphism. Each shape knows how to calculate its own area.

  • Python 🐍

    class Shape:
        def calculate_area(self):
            raise NotImplementedError
    
    class Circle(Shape):
        def __init__(self, radius): self.radius = radius
        def calculate_area(self): return 3.14 * self.radius ** 2
    
    class Rectangle(Shape):
        def __init__(self, width, height): self.width, self.height = width, height
        def calculate_area(self): return self.width * self.height
    
    # We can add new shapes without changing calculate_total_area
    class Triangle(Shape):
        def __init__(self, base, height): self.base, self.height = base, height
        def calculate_area(self): return 0.5 * self.base * self.height
    
    def calculate_total_area(shapes):
        return sum(shape.calculate_area() for shape in shapes)
    
  • JavaScript πŸ“œ

    class Circle {
        constructor(radius) { this.radius = radius; }
        calculateArea() { return Math.PI * this.radius ** 2; }
    }
    
    class Rectangle {
        constructor(width, height) { this.width = width; this.height = height; }
        calculateArea() { return this.width * this.height; }
    }
    // We can add new shapes without changing calculateTotalArea
    class Triangle {
        constructor(base, height) { this.base = base; this.height = height; }
        calculateArea() { return 0.5 * this.base * this.height; }
    }
    
    function calculateTotalArea(shapes) {
        return shapes.reduce((sum, shape) => sum + shape.calculateArea(), 0);
    }
    

Now, the calculate_total_area function is closed for modification. To add new shapes, we simply extend our system with new classes.


L - Liskov Substitution Principle (LSP) Good Example

The best way to adhere to LSP in the shape example is to avoid making Square a subtype of Rectangle. Instead, both should inherit from a common abstraction like Shape that doesn't define setters that could be misused, ensuring the behavior of the derived classes is consistent with what the client expects from the base class.

Good Example (Adheres to LSP)

Both Circle and Rectangle (and Square, if needed) are substitutes for the general Shape base type, as neither breaks the contract defined by Shape's calculate_area method.

Python 🐍

class Shape:
    # Base class defining the expected contract: calculation of area
    def calculate_area(self):
        raise NotImplementedError

class Circle(Shape):
    def __init__(self, radius): self.radius = radius
    # Behavior is extended, not broken
    def calculate_area(self): return 3.14 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, width, height): self.width, self.height = width, height
    # Behavior is extended, not broken
    def calculate_area(self): return self.width * self.height

# A function that expects a Shape will work correctly with any subtype (LSP is maintained)
def check_area(shape: Shape):
    # The client only calls a method that is correctly implemented by all subtypes
    print(f"Calculated area: {shape.calculate_area()}")

# Usage: Both Circle and Rectangle are substitutable for Shape
circle = Circle(5)
rectangle = Rectangle(4, 6)
check_area(circle)
check_area(rectangle)
Enter fullscreen mode Exit fullscreen mode

JavaScript πŸ“œ

// A common base class/interface is used.
class Shape {
    calculateArea() {
        throw new Error("calculateArea must be implemented");
    }
}

class Circle extends Shape {
    constructor(radius) { super(); this.radius = radius; }
    // Behavior is extended, not broken
    calculateArea() { return Math.PI * this.radius ** 2; }
}

class Rectangle extends Shape {
    constructor(width, height) { super(); this.width = width; this.height = height; }
    // Behavior is extended, not broken
    calculateArea() { return this.width * this.height; }
}

// A function that expects a Shape (i.e., an object with a calculateArea method)
function checkArea(shape) {
    // The client only calls a method that is correctly implemented by all subtypes
    console.log(`Calculated area: ${shape.calculateArea()}`);
}

// Usage: Both Circle and Rectangle are substitutable for the abstract Shape contract
const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);
checkArea(circle);
checkArea(rectangle);
Enter fullscreen mode Exit fullscreen mode

By ensuring that derived classes don't alter the assumptions made by the client code when dealing with the base class (by using immutable properties or constructors), we uphold the Liskov Substitution Principle.


I - Interface Segregation Principle (ISP)

"Clients should not be forced to depend on interfaces they do not use."

This principle suggests that large, monolithic interfaces should be broken down into smaller, more specific ones. This way, classes only need to implement methods that are relevant to their functionality.

Bad Example (Violates ISP)

Imagine a single, "fat" Worker interface.

  • Python 🐍

    class Worker:
        def work(self): raise NotImplementedError
        def eat(self): raise NotImplementedError
    
    class Human(Worker):
        def work(self): print("Human working")
        def eat(self): print("Human eating")
    
    class Robot(Worker):
        def work(self): print("Robot working")
        def eat(self): pass # Robots don't eat, but must implement the method.
    
  • JavaScript πŸ“œ

    class Worker {
        work() { throw new Error("Method not implemented!"); }
        eat() { throw new Error("Method not implemented!"); }
    }
    class Human extends Worker {
        work() { console.log("Human working"); }
        eat() { console.log("Human eating"); }
    }
    class Robot extends Worker {
        work() { console.log("Robot working"); }
        eat() { /* Robots don't eat, but must implement the method. */ }
    }
    

Why it's bad: The Robot class is forced to implement an eat method it doesn't need. This creates an unnatural dependency.

Good Example (Adheres to ISP)

Segregate the interfaces into smaller, role-specific ones.

  • Python 🐍

    class Workable:
        def work(self): raise NotImplementedError
    
    class Eatable:
        def eat(self): raise NotImplementedError
    
    class Human(Workable, Eatable):
        def work(self): print("Human working")
        def eat(self): print("Human eating")
    
    class Robot(Workable): # Only implements what it needs
        def work(self): print("Robot working")
    
  • JavaScript πŸ“œ

    // In JS, this is often done with composition or smaller classes.
    const workable = {
        work() { console.log(`${this.name} working`); }
    };
    const eatable = {
        eat() { console.log(`${this.name} eating`); }
    };
    
    class Human {
        constructor(name) { this.name = name; }
    }
    Object.assign(Human.prototype, workable, eatable);
    
    class Robot {
        constructor(name) { this.name = name; }
    }
    Object.assign(Robot.prototype, workable); // Only implements what it needs
    
    const human = new Human("Alice");
    const robot = new Robot("C-3PO");
    human.work(); // Alice working
    human.eat();  // Alice eating
    robot.work(); // C-3PO working
    // robot.eat(); // TypeError: robot.eat is not a function
    

Now, Robot only depends on the Workable behavior. Clients can depend on smaller, more focused abstractions.


D - Dependency Inversion Principle (DIP)

"1. High-level modules should not depend on low-level modules. Both should depend on abstractions."
"2. Abstractions should not depend on details. Details should depend on abstractions."

This principle is about decoupling. Instead of high-level logic depending directly on concrete low-level implementations, both should depend on an abstraction (like an interface). This is often achieved through Dependency Injection.

Bad Example (Violates DIP)

A PasswordReminder (high-level) module is directly coupled to a MySQLDatabase (low-level) module.

  • Python 🐍

    class MySQLDatabase:
        def connect(self): print("Connecting to MySQL...")
        def get_user_data(self): return "User data from MySQL"
    
    class PasswordReminder:
        def __init__(self):
            self.db_connection = MySQLDatabase() # <-- Direct dependency!
    
        def get_user(self):
            self.db_connection.connect()
            return self.db_connection.get_user_data()
    
  • JavaScript πŸ“œ

    class MySQLDatabase {
        connect() { console.log("Connecting to MySQL..."); }
        getUserData() { return "User data from MySQL"; }
    }
    
    class PasswordReminder {
        constructor() {
            this.dbConnection = new MySQLDatabase(); // <-- Direct dependency!
        }
        getUser() {
            this.dbConnection.connect();
            return this.dbConnection.getUserData();
        }
    }
    

Why it's bad: The PasswordReminder is completely tied to MySQLDatabase. If we want to switch to a PostgreSQL database or use a mock database for testing, we have to change the PasswordReminder class.

Good Example (Adheres to DIP)

We introduce an abstraction that both modules can depend on. The dependency is "injected" into the high-level module.

  • Python 🐍

    class DatabaseConnection: # The Abstraction
        def connect(self): raise NotImplementedError
        def get_user_data(self): raise NotImplementedError
    
    class MySQLDatabase(DatabaseConnection): # A Detail
        def connect(self): print("Connecting to MySQL...")
        def get_user_data(self): return "User data from MySQL"
    
    class PostgreSQLDatabase(DatabaseConnection): # Another Detail
        def connect(self): print("Connecting to PostgreSQL...")
        def get_user_data(self): return "User data from PostgreSQL"
    
    class PasswordReminder: # High-level module
        def __init__(self, db_connection: DatabaseConnection): # Depends on abstraction
            self.db_connection = db_connection
    
        def get_user(self):
            self.db_connection.connect()
            return self.db_connection.get_user_data()
    
    # We can now 'inject' any database that follows the contract.
    mysql = MySQLDatabase()
    reminder = PasswordReminder(mysql)
    
  • JavaScript πŸ“œ

    // In JS, the abstraction is often an implicit contract (duck typing).
    // We expect any database object to have `connect` and `getUserData` methods.
    
    class MySQLDatabase { // A Detail
        connect() { console.log("Connecting to MySQL..."); }
        getUserData() { return "User data from MySQL"; }
    }
    
    class PostgreSQLDatabase { // Another Detail
        connect() { console.log("Connecting to PostgreSQL..."); }
        getUserData() { return "User data from PostgreSQL"; }
    }
    
    class PasswordReminder { // High-level module
        constructor(dbConnection) { // Depends on the abstraction (the contract)
            this.dbConnection = dbConnection;
        }
        getUser() {
            this.dbConnection.connect();
            return this.dbConnection.getUserData();
        }
    }
    
    // We can now 'inject' any database that follows the contract.
    const mysql = new MySQLDatabase();
    const reminder = new PasswordReminder(mysql);
    console.log(reminder.getUser());
    

By depending on an abstraction, our PasswordReminder is now decoupled from the specific database implementation, making it more flexible, reusable, and testable.


Conclusion

Adopting the SOLID principles is an investment in the long-term health of your codebase. It might seem like more work upfront, but it leads to software that is easier to scale, refactor, and understand. By building systems with single responsibilities, that are open to extension, with substitutable parts, lean interfaces, and inverted dependencies, you create a foundation for truly robust and elegant software. Happy coding!

Top comments (0)