Understanding and Implementing SOLID Principles in Software Development

Understanding and Implementing SOLID Principles in Software Development

A Comprehensive Guide to Writing Clean, Maintainable, and Scalable Code

Writing clean, maintainable, and scalable code is essential for building robust software. The SOLID principles-Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion-provide a foundation for achieving this. These principles help developers to create flexible, modular,and easy-to-maintain code.

In this article, we will explore each SOLID principle explain its significance and demonstrate how to apply it in real world scenarios, helping you improve your software design and development practices.

Basic Terminology

  • Tight Coupling : It refers to situation where classes(or components) are highly dependent on each other.When one class changes, it often requires changes to other classes that dependent on it .This strong interdependency makes the system difficult to maintain,extend and test.

  • Loose Coupling : It refers to a situation where classes (or components) are minimally dependent on each other. Changes in one class typically do not require changes in other classes that depend on it. This reduces interdependencies, making the system more flexible, maintainable, and easier to test.

  1. Single Responsibility Principle ( SRP ) :

    “A class or function should have one and only one reason to change, meaning it should have only one responsibility”

    why SRP is important?

    If a function or class does too many things, changes in one responsibility can accidentally break the other. Keeping responsibilities separate makes the code easier to maintain.

    Let’s take an example without SRP applied…

     // Imagine you are building a simple system to manage books in library .
     public class Book {
        private String title;
        private String author;
    
        public Book(String title ,String author) {
            this.title=title;
            this.author=author;
        }
     // method to get book details
        public String getDeatils(){
            retrun "Title:" + title + ", author:" + author;
        }
     // Method to save book details to a database
        public void saveToDatabase() {
             System.out.println("Saving book: " + title + " to the database.");
        }
    
     }
    
     // what's wrong in this code ?
     // the Book class is responsible for two things :
     //1) representing book data 2) saving the book to DB
    
     //if we want to change the how books are saved(moving saving to DB --> saving to File),
     //we have to modify the Book class..
    

    Solution with SRP

// Class to manage book data
public class Book {
    private String title;
    private String author;
    public Book(String title, String author) {
        this.title = title;
        this.author = author;
    }
    public String getDetails() {
        return "Title: " + title + ", Author: " + author;
    }
}
// Class to handle saving books to the database
public class BookDatabase {
    public void save(Book book) {
        System.out.println("Saving book: " + book.getDetails() + " to the database.");
    }
}
// Main class to use the classes
public class Main {
    public static void main(String[] args) {
        Book book = new Book("Java Programming", "Paradigm");
        BookDatabase bookDatabase = new BookDatabase();
        bookDatabase.save(book);
    }
}

//The Book class only manages book details (title and author).
//The BookDatabase class handles saving books to the database.
//If we change the way we save books (e.g. saving to a file or cloud storage), we only need to modify BookDatabase, 
//and the Book class remains untouched.

Benefits of SRP:

  • Easy to Maintain

  • Reusability

  • Scalable

  1. Open - Closed Principle ( OCP ):

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

    What does this mean?

    • Open for extension: You should be able to add new functionality to a class without changing its existing code.

    • Closed for modification: You should not have to modify a class’s existing code to add new behavior. Instead, you should use inheritance, interfaces, or other patterns to add new features.

let’s take an example without OCP

// Suppose we have a payment system that supports different payment methods like Credit Card and PayPal.
class PaymentProcessor {
    public void processPayment(String paymentType, double amount) {
        if (paymentType.equals("CreditCard")) {
            // Process Credit Card payment
            System.out.println("Processing Credit Card payment of $" + amount);
        } else if (paymentType.equals("PayPal")) {
            // Process PayPal payment
            System.out.println("Processing PayPal payment of $" + amount);
        } else {
            System.out.println("Unsupported payment method");
        }
    }
}
// problem with this code 
//In this design, the PaymentProcessor class is tightly coupled to the specific payment types (CreditCard, PayPal).
// Every time we add a new payment method (e.g., Bank Transfer or Bitcoin), 
//we will have to modify the PaymentProcessor class.

Solution with OCP :

// PaymentMethod interface
interface PaymentMethod {
    void processPayment(double amount);
}
// CreditCard class implements PaymentMethod
class CreditCard implements PaymentMethod {
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing Credit Card payment of $" + amount);
    }
}
// PayPal class implements PaymentMethod
class PayPal implements PaymentMethod {
   @Override
   public void processPayment(double amount) {
        System.out.println("Processing PayPal payment of $" + amount);
    }
}
// PaymentProcessor class is open for extension but closed for modification
class PaymentProcessor {
    public void processPayment(PaymentMethod paymentMethod, double amount) {
        paymentMethod.processPayment(amount);
    }
}

Advantage:

  • Open for Extension: We can now easily add a new payment method by simply creating a new class that implements the PaymentMethod interface. For example, we can add a Bitcoin payment method without changing the PaymentProcessor class.

  • Closed for Modification: The PaymentProcessor class doesn't need any changes, regardless of how many new payment methods we add. It remains closed for modification but is open for extension.

  1. Liskov Substitution Principle ( LSP ) :

    “Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.”

    What does this mean ?

    This principle ensures that any class that is the child of a parent class should be usable in place of its parent without any unexpected behavior.

let’s take a scenario : A Vehicle Rental Service

We have a base class Vehicle, and we want to represent different types of vehicles, such as Car and Bicycle. Here's the process:

  1. Initial Design: We create a Vehicle base class with a method startEngine(), assuming all vehicles have engines.

  2. Subclasses: We create Car and Bicycle classes that inherit from Vehicle.

    here is the example which Violets LSP

     // Base class
     public class Vehicle {
         public void startEngine() {
             System.out.println("Starting the engine...");
         }
         public void drive() {
             System.out.println("Driving the vehicle...");
         }
     }
     // Subclass: Car (inherits from Vehicle)
     public class Car extends Vehicle {
         @Override
         public void startEngine() {
             System.out.println("Car engine started.");
         }
     }
     // Subclass: Bicycle (inherits from Vehicle)
     public class Bicycle extends Vehicle {
         @Override
         public void startEngine() {
             throw new UnsupportedOperationException("Bicycles don't have an engine!");
         }
         @Override
         public void drive() {
             System.out.println("Pedaling the bicycle...");
         }
     }
     // Main class
     public class Main {
         public static void main(String[] args) {
             Vehicle car = new Car();
             Vehicle bicycle = new Bicycle();
             // Both should behave as a Vehicle
             car.startEngine();    // Works fine
             bicycle.startEngine(); // Throws an exception
         }
     }
     // what's wrong here 
     //Not all vehicles have engines, so startEngine() should not be part of the general Vehicle class. 
     //The design assumes all vehicles share the same characteristics, which isn’t true(Case of bicycle).
    

    Fixing the Violation

    To follow Liskov Substitution Principle, we need to:

    1. Avoid putting startEngine() in the Vehicle base class.

    2. Introduce a new interface or abstract class for vehicles that have engines.

       // Base class: Vehicle (generalized for all vehicles)
       public abstract class Vehicle {
           public abstract void drive(); // All vehicles can drive (general behavior)
       }
       // Interface: EnginePowered (specific for vehicles with engines)
       public interface EnginePowered {
           void startEngine();
       }
       // Subclass: Car (a vehicle with an engine)
       public class Car extends Vehicle implements EnginePowered {
           @Override
           public void startEngine() {
               System.out.println("Car engine started.");
           }
           @Override
           public void drive() {
               System.out.println("Driving the car...");
           }
       }
       // Subclass: Bicycle (a vehicle without an engine)
       public class Bicycle extends Vehicle {
           @Override
           public void drive() {
               System.out.println("Pedaling the bicycle...");
           }
       }
       // Main class
       public class Main {
           public static void main(String[] args) {
               Vehicle car = new Car();
               Vehicle bicycle = new Bicycle();
       // Driving both vehicles works as expected
               car.drive();      // Outputs: Driving the car...
               bicycle.drive();  // Outputs: Pedaling the bicycle...
       // Start engine only for vehicles that support it
               if (car instanceof EnginePowered) {
                   ((EnginePowered) car).startEngine(); // Outputs: Car engine started.
               }
               if (bicycle instanceof EnginePowered) {
                   ((EnginePowered) bicycle).startEngine(); // No operation, as Bicycle doesn't have an engine
               }
           }
       }
      
  3. Interface Segregation Principle ( ISP ) :

    “Classes that implement interfaces, should not be forced to implement methods they do not use.”

    What Does This Mean?

    1. Smaller Interfaces:
      If a class only needs certain methods from an interface, we shouldn’t force it to implement unnecessary methods. A class should only implement the methods that are relevant to it.

Scenario : Waiter and Restaurant

Imagine we have a restaurant system where the Waiter is responsible for taking orders and serving food.

code for Violation of ISP

// interface that has methods unrelated to Waiter
interface RestaurantEmployee {
    void takeOrder(); // task of waiter 
    void cookFood();  // NOT a waiter's task
    void serveFood(); // task of waiter 
}
// Waiter class implementing RestaurantEmployee interface
class Waiter implements RestaurantEmployee {
    @Override
    public void takeOrder() {
        System.out.println("Taking order from customer");
    }
    @Override
    public void cookFood() {
        // Waiter doesn't cook food, this is a violation of ISP!
        System.out.println("I am a waiter. I don't cook food.");
    }
    @Override
    public void serveFood() {
        System.out.println("Serving food to the customer");
    }
}

Problem:

  • The Waiter class is forced to implement the cookFood method, even though it doesn't cook food. This violates the Interface Segregation Principle because the waiter is being forced to implement methods it doesn't use.

Fixed Code :

//  specific interfaces
interface OrderTaker {
    void takeOrder();
}
interface FoodServer {
    void serveFood();
}
interface Chef {
    void cookFood();
}
// Waiter class implements only the relevant interfaces
class Waiter implements OrderTaker, FoodServer {
    @Override
    public void takeOrder() {
        System.out.println("Taking order from customer");
    }
    @Override
    public void serveFood() {
        System.out.println("Serving food to the customer");
    }
}
// Chef class implements Chef interface
class Chef implements Chef {
    @Override
    public void cookFood() {
        System.out.println("Cooking food");
    }
}
  1. Dependency Inversion Principle ( DIP ) :

    “High-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details. Details should depend on abstractions.”

    In simpler terms:

    • High-level modules: The core functionality of your application.

    • Low-level modules: Specific implementations or details.

    • Abstractions: Interfaces or abstract classes that define the contract between components.

The goal of DIP is to reduce the dependency of high-level components on low-level components by introducing abstractions. This makes the system more flexible and easier to change.

Scenario:

We have a system where the Computer can connect to either a Wired or Bluetooth Keyboard.

Violated code :

    // Low-level module: WiredKeyboard
    class WiredKeyboard {
        public void connect() {
            System.out.println("Wired keyboard connected");
        }
    }
    // Low-level module: BluetoothKeyboard
    class BluetoothKeyboard {
        public void connect() {
            System.out.println("Bluetooth keyboard connected");
        }
    }
    // High-level module: Computer
    class Computer {
        private WiredKeyboard wiredKeyboard;
        // Direct dependency on concrete classes (Violation of DIP)
        public Computer() {
            wiredKeyboard = new WiredKeyboard();  // Directly creating specific device
        }
        public void connectDevice() {
            wiredKeyboard.connect();
        }
    }

Problem:

  • The Computer class is tightly coupled to the WiredKeyboard.

  • If we want to switch to a BluetoothKeyboard, we must modify the Computer class. This violates the Dependency Inversion Principle.

Corrected Code :

    // Abstraction: Keyboard
    interface Keyboard {
        void connect();
    }
    // Low-level module: WiredKeyboard implements Keyboard
    class WiredKeyboard implements Keyboard {
        @Override
        public void connect() {
            System.out.println("Wired keyboard connected");
        }
    }
    // Low-level module: BluetoothKeyboard implements Keyboard
    class BluetoothKeyboard implements Keyboard {
        @Override
        public void connect() {
            System.out.println("Bluetooth keyboard connected");
        }
    }
    // High-level module: Computer
    class Computer {
        private Keyboard keyboard;
    // Constructor injection: Dependency is passed from outside
        public Computer(Keyboard keyboard) {
            this.keyboard = keyboard;
        }
        public void connectDevice() {
            keyboard.connect();
        }
    }
    public class Main {
        public static void main(String[] args) {
            // Example 1: Using Bluetooth keyboard with Computer
            Keyboard bluetoothKeyboard = new BluetoothKeyboard();
            Computer computer = new Computer(bluetoothKeyboard);
            computer.connectDevice();  // Outputs: Bluetooth keyboard connected
        }
    }

This implementation follows the Dependency Inversion Principle (DIP), ensuring loose coupling and making the system more flexible and maintainable.

Remember, the journey doesn't end here**.** Web development is a dynamic field, and as you continue to master the art of applying the SOLID principles, you'll find endless opportunities to improve your codebase and create more efficient, scalable software.

Thank you for exploring the SOLID principles with us**.** As you integrate these practices into your projects, may your architecture be clean, your code be maintainable, and your applications be robust. Keep coding, keep refining, and keep building towards exceptional software. Happy coding! 👨‍💻