SOLID Principles Made Simple

SOLID Principles Made Simple

This post is for beginners in software development who have some prior experience but haven't explored the potential of SOLID principles. If you've heard about SOLID but aren't sure how to leverage its power or if you're unfamiliar with these principles, this guide is designed to simplify and demonstrate their significance in an easily understandable way.

What is SOLID?

SOLID is an acronym for five design principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.These principles aim to create better, more maintainable software by promoting clean code, flexibility, and easier future modifications.

Benefits of SOLID

  1. Maintainability: Easier to understand, modify, and extend code.

  2. Flexibility: Allows for adapting to changes without major rewrites.

  3. Scalability: Facilitates the growth of software systems without adding complexity.

  4. Reusability: Promotes reusable, modular code components.

  5. Testability: Simplifies testing and debugging processes.

Now that we've seen why SOLID principles matter, let's explore each one in detail.

Single Responsibility Principle (SRP)

This principle states that a class should only have one responsibility.

For example, Let's consider a class that handles both user authentication and sending emails. This violates SRP because it has multiple responsibilities.

// Problematic class violating SRP
class UserManagement {
    public boolean authenticateUser(String username, String password) {
        // Code for user authentication
        return true;
    }

    public void sendEmail(String email, String message) {
        // Code for sending email
    }
}

Separate concerns into different classes.

// SRP-compliant solution
class Authenticator {
    public boolean authenticateUser(String username, String password) {
        // Code for user authentication
        return true;
    }
}

class EmailService {
    public void sendEmail(String email, String message) {
        // Code for sending email
    }
}

Open-Closed Principle (OCP)

This principle suggests that classes should be open for extension but closed for modification.

For example, A class that calculates area for different shapes but needs modification whenever a new shape is added.

// Problematic class violating OCP
class AreaCalculator {
    public double calculateArea(Shape shape) {
        if (shape instanceof Circle) {
            // Calculate area for Circle
        } else if (shape instanceof Square) {
            // Calculate area for Square
        }
        // More shapes lead to more modifications
        return 0.0;
    }
}

Design classes that are open for extension but closed for modification. One way to achieve this is by creating an abstract Shape class and extending it to accommodate new shapes without altering the core class.

// OCP-compliant solution
abstract class Shape {
    public abstract double calculateArea();
}

class Circle extends Shape {
    private double radius;

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

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

class Square extends Shape {
    private double side;

    public Square(double side) {
        this.side = side;
    }

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

Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without breaking the system.

For example, Subclass behavior does not match superclass behavior, causing unexpected results.

/ Problematic scenario violating LSP
class Rectangle {
    protected int width;
    protected int height;

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

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

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

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Setting both width and height for a square
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height; // Setting both height and width for a square
    }
}

Ensure subclasses can be substituted for their base classes without altering program correctness.

// Applying LSP-compliant solution (by not using inheritance)
class Shape {
    public int calculateArea() {
        return 0; // Default area for a shape
    }
}

class Rectangle extends Shape {
    protected int width;
    protected int height;

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

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

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

class Square extends Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int calculateArea() {
        return side * side;
    }
}

Interface Segregation Principle (ISP)

Larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.

For example, An interface contains methods that are not relevant to all implementing classes.

// Problematic interface violating ISP
interface Worker {
    void work();
    void eat();
}

class Programmer implements Worker {
    @Override
    public void work() {
        // Code for programming
    }

    @Override
    public void eat() {
        // Code for eating
    }
}

class Janitor implements Worker {
    @Override
    public void work() {
        // Code for cleaning
    }

    @Override
    public void eat() {
        // Code for eating
    }
}

Split interfaces into smaller, specific ones.

// ISP-compliant solution
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Programmer implements Workable, Eatable {
    @Override
    public void work() {
        // Code for programming
    }

    @Override
    public void eat() {
        // Code for eating
    }
}

class Janitor implements Workable {
    @Override
    public void work() {
        // Code for cleaning
    }
}

Workable and Eatable Interfaces: These interfaces are specific and only contain methods relevant to their purpose. Programmer and Janitor implement these interfaces, providing their respective implementations for work and eat behavior.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules, both should depend on abstractions.

Here, OrderService class directly depends on the concrete implementation of the MySQLDatabase. This tight coupling makes it difficult to switch databases or test the OrderService class independently.

// Problematic scenario violating DIP
class OrderService {
    private MySQLDatabase database;

    public OrderService() {
        this.database = new MySQLDatabase(); // Direct dependency on MySQL
    }

    public void saveOrder(Order order) {
        database.save(order);
    }
}

Depend on abstractions rather than concrete implementations.

// DIP-compliant solution
interface Database {
    void save(Object object);
}

class MySQLDatabase implements Database {
    @Override
    public void save(Object object) {
        // Code to save to MySQL database
    }
}

class OracleDatabase implements Database {
    @Override
    public void save(Object object) {
        // Code to save to Oracle database
    }
}

class OrderService {
    private Database database;

    public OrderService(Database database) {
        this.database = database; // Depends on Database interface
    }

    public void saveOrder(Order order) {
        database.save(order);
    }
}

This implementation adheres to the DIP, allowing high-level modules (like OrderService) to depend on abstractions (Database interface) rather than concrete implementations, enabling better flexibility and testability.

Conclusion

The "SOLID Principles Made Simple" blog is a beginner-friendly guide explaining five important rules for writing better code. These rules—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—help make code easier to understand and change.

The blog shows how following these rules can make code more flexible and easy to work with. It uses examples, like splitting tasks into smaller classes or using interfaces smartly, to explain these ideas in a way that's easy to grasp for newcomers to coding. Overall, it's a helpful guide for beginners to learn how to write strong and adaptable code.