Mastering the Art of Software Design: Unlocking the Power of SOLID Principles for Robust Code

The SOLID principles are essential guidelines in software development that help create maintainable, flexible, and scalable systems. By applying these principles, developers can enhance code quality and reduce technical debt. Below, we explore each of the five SOLID principles with real-world examples to illustrate their significance.

Single Responsibility Principle (SRP)

A class should have only one reason to change, meaning it should encapsulate a single responsibility.

Lets understand with help of an example of a bakery, If a baker is responsible for baking bread, managing inventory, and serving customers, this violates the SRP. Instead, different roles should be assigned: one person for baking, another for inventory management, and so on. In code, this translates to creating separate classes for each responsibility.

class BreadBaker {
    public void bakeBread() {
        // Baking logic
    }
}

class InventoryManager {
    public void manageInventory() {
        // Inventory logic
    }
}

Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification. This means you can add new functionality without changing existing code.

Imagine a payment processing system that initially supports credit card payments. If you want to add PayPal support, instead of modifying the existing PaymentProcessor class, you would create a new class that extends its functionality.

class PaymentProcessor {
    void processPayment(PaymentMethod method) {
        // Process payment
    }
}

class PayPalPayment extends PaymentProcessor {
    void processPayment(PayPalMethod method) {
        // PayPal processing logic
    }
}

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering the correctness of the program.

Consider a Rectangle class and a Square subclass. If a method expects a Rectangle, passing a Square should not cause issues. However, if the Square overrides methods in such a way that it breaks the expected behavior of Rectangle, it violates LSP.

public class Rectangle {
    private int width;
    private int height;

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

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

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

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }
}

public class Square {
    private Rectangle rectangle;

    public Square(int side) {
        this.rectangle = new Rectangle(side, side);
    }

    public void setSide(int side) {
        rectangle.setWidth(side);
        rectangle.setHeight(side);
    }

    public int getSide() {
        return rectangle.getWidth(); // since width = height in a square
    }

    public int getWidth() {
        return rectangle.getWidth();
    }

    public int getHeight() {
        return rectangle.getHeight();
    }
}

Interface Segregation Principle (ISP)

No client should be forced to depend on methods it does not use. This principle encourages the creation of smaller, more specific interface.

Think of a multi-functional printer. Instead of having one large interface for all functions (printing, scanning, faxing), you could have separate interfaces for each function:

interface Printer {
    void print();
}

interface Scanner {
    void scan();
}

interface Fax {
    void fax();
}

Dependency Inversion Principle (DIP)

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

Consider an electrical appliance that connects to a power source. The appliance does not care whether the power source is from aluminum or copper wires; it only needs an interface to connect to the power supply.

public interface PowerSupply {
    void providePower();
}

public class ACPowerSupply implements PowerSupply {
    @Override
    public void providePower() {
        System.out.println("Providing AC power to the kettle.");
    }
}

public class BatteryPowerSupply implements PowerSupply {
    @Override
    public void providePower() {
        System.out.println("Providing battery power to the kettle.");
    }
}

public class ElectricKettle {
    private PowerSupply powerSupply;

    // Constructor injection
    public ElectricKettle(PowerSupply powerSupply) {
        this.powerSupply = powerSupply;
    }

    public void boilWater() {
        powerSupply.providePower();
        System.out.println("Boiling water...");
    }
}

public class Main {
    public static void main(String[] args) {
        // Use ACPowerSupply
        PowerSupply acPower = new ACPowerSupply();
        ElectricKettle kettleWithACPower = new ElectricKettle(acPower);
        kettleWithACPower.boilWater();

        System.out.println("----");

        // Use BatteryPowerSupply
        PowerSupply batteryPower = new BatteryPowerSupply();
        ElectricKettle kettleWithBatteryPower = new ElectricKettle(batteryPower);
        kettleWithBatteryPower.boilWater();
    }
}

Incorporating SOLID principles into software development is essential for creating high-quality applications that are easy to understand, maintain, and extend. While initially challenging to implement, these principles ultimately lead to better coding practices and more robust software systems. By fostering an environment where developers prioritize design quality through SOLID principles, organizations can ensure their software remains adaptable in an ever-evolving technological landscape.