SOLID Principles Demystified: A Developer's Roadmap to Clean Code

A Developer's Guide to Write Elegant and Maintainable Code Using SOLID Principles

In the ever-evolving landscape of software development, writing code that is not only functional but also readable, maintainable and adaptable is the main goal. This is where SOLID principles come into play—a set of five design principles that serve as a guiding light for developers striving to create robust and scalable software.

SOLID is an acronym that stands for five fundamental principles of object-oriented programming and design, introduced by Robert C. Martin and widely adopted across the software development industry. The 5 SOLID principles are:

  1. Single Responsibility Principle

  2. Open/Closed Principle

  3. Liskov Substitution Principle

  4. Interface Segregation Principle

  5. Dependency Inversion Principle

Let us delve into the heart of SOLID principles and understand each of them for their significance in building a clean readable, maintainable and adaptable code.

Single Responsibility

The Single Responsibility Principle states that a class should always have only one responsibility and there should only be one reason to modify it.
At the core, the principle says that each class should deal with a specific concern and only one concern so that if there is any situation to modify the code you know where exactly it has to be done, minimizing the risk of unintended consequences.

Bad Implementation:

public class Product {
    private int productId;
    private String productName;
    private double productPrice;

    // Getters and Setters

    // *** Business Logic ***
    public void saveProduct(){ /*buisness logic here*/ }
    public void getProductById(){/*buisness logic here*/ }

    // *** Persistence Logic ***
    public void saveProductToDatabase(Product product){/*persistence logic here*/}
    public void fetchProductById(int id){/*persistence logic here*/}
}

Issue: In the above example, the Product class is dealing with multiple aspects(entity definition, business logic and persistence logic). This code can further get complex and there are multiple reasons to modify the class.

Good Implementation:

// Create a Product class that defines product attributes.
public class Product {
    private int productId;
    private String productName;
    private double productPrice;

    // Getters and Setters
}
/*---------------------------------------------------------------------------
Later, create a service class that performs a business logic.*/
public class ProductService {
    // Business Logic
    public void saveProduct(){ /* ... */ }
    public void getProductById(){/* ... */ }
}
/*---------------------------------------------------------------------------
And, have a Dao class that interacts with the database (persistence logic).*/
public class ProductDao {
    // Persistence Logic
    public void saveProductToDatabase(Product product){/* ... */}
    public void fetchProductById(int id){/* ... */}
}

The above example contains three different classes dealing with multiple aspects of the same resource (Product). The Product class defines the entity, the ProductService class deals with the business logic and ProductDao deals with the persistence logic.
Here each class has its reason and only reason for getting modified.

The Single Responsibility Principle stands as the most widely followed guideline among all SOLID principles. By advocating for a single responsibility per class, it promotes code that is not only more readable and maintainable but also highly adaptable to diverse projects.

Open/Closed

The Open/Closed Principle states that "a class should be open for Extension but closed for modification". In other words, once a class is written and tested, it should not be altered to accommodate new features. Instead, it should be open to accepting new functionality through extension, without requiring changes to its existing codebase.

Bad Implementation:

public class Payment {

   public void doPay(String paymentType){
       if(paymentType.equals("CreditCard")){
           // logic to perform Credit Card Payment
       }else if(paymentType.equals("DebitCard")) {
           // logic to perform Debit Card Payment
       }
   }
}

Issue: In the above example, the Payment class performs a payment in two ways by Credit Card and Debit Card. If in future we need to add another payment mode, we may have to alter the existing codebase. As the Open/Closed principle states the existing codebase should never be altered, so how do we resolve this issue? Let us look into the good Implementation.

Good Implementation:

public interface Payment {
   public void doPay(String paymentType);
}

Since we have introduced an interface Payment, it can later be implemented by desired implementing classes allowing different payment modes such as Credit Card, Debit Card, etc.,

public class CreditCardPayment implements Payment{
    public void doPay(String paymentType) {
        // logic to perform Credit Card Payment
    }
}

public class DebitCardPayment implements Payment{
    public void doPay(String paymentType) {
        // logic to perform Debit Card Payment
    }
}

Later in future if there has to be a new payment method introduced, it can be achieved by implementing the Payment Interface and we need not have to alter the existing code base. for example, if we need to add a UPI Payment mode to the list the Payment modes we can just implement the Payment interface with UPI Payment logic.

public class UpiPayment implements Payment{
    public void doPay(String paymentType) {
        // logic to perform UPI Payment
    }
}

In the real world, we can take the List interface and its implementing classes ArrayList, Vector and LinkedList from the Collection Framework as an example.

Here in future, it is possible to easily create a new List implementing class with a new way of storing and processing the elements without the need of altering the existing codebase.

Liskov Substitution

The Liskov Substitution Principle states that the objects of parent class should replaceable by the objects of child classes without affecting the correctness of the program.
In other words, if we have a parent class A and child class B, we should be able to replace of object of class A with the object of class B.

Bad Implementation

public interface Car{
    public void move();
    public void color();
    public void fuel();
}

public class HondaCar implements Car{
    @Override
    public void move(){ /* implementation */}
     @Override
    public void color(){ /* implementation */}
     @Override
    public void fuel(){ /* implementation */}
}

public class TeslaCar implements Car{
    @Override
    public void move(){ /* implementation */}
     @Override
    public void color(){ /* implementation */}
     @Override
    public void fuel(){
         // does not support
    }
    // requires a different method to support the propultion type
    public void battery(){ /* implemention */ }
}

In the above given example the HondaCar class object can completely replace the Car type with same behaviours, but TeslaCar class object cannot completely replace the behaviours of Car type. This is because the TeslaCar doesn't support fuel() and has an additional behaviour battery().
When we try to assign the object of TeslaCar to the reference of Car (Car car = new TeslaCar()), TeslaCar loses its battery() behaviour. In other words, the battery method cannot be accessed with the reference of the Car Type.

Good Implementation

public interface Car{
    public void move();
    public void color();
}

public class HondaCar implements Car{
    @Override
    public void move(){ /* implementation */}
     @Override
    public void color(){ /* implementation */}
}

public class TeslaCar implements Car{
    @Override
    public void move(){ /* implementation */}
     @Override
    public void color(){ /* implementation */}
}

The above example perfectly adheres to the Liskov Substitution Principle, both the TeslaCar object and the HondaCar object can be assigned to the Car reference without losing any behaviours of any classes.

But, few behaviours are subjective to TeslaCar and HondaCar such as fuel() and battery(). These behaviours can be mandatory as well. These issues can further be solved using the next principle of the SOLID principles i.e., the Interface Segregation principle.

Interface Segregation

The Interface Segregation Principle is the fourth principle of the SOLID principles, it states that " An Interface should only have those methods that apply to all its child classes, A child class should never be forced to implement the method that does not apply to its behaviour".

Considering the previous example taken for the Liskov Substitution Principle, we can see that the interface has only those methods that apply to all its child classes. So hereby we are not forcing our child classes (HondaCar & TeslaCar) to implement unnecessary methods. But further, HondaCar and TeslaCar have their subjective behaviours right? So how do we achieve having them?

Solution:

public interface Car{
    public void move();
    public void color();
}

public interface FuelEngineCar{
    public void fuel();
}

public interface BatteryCar{
    public void battery();
}
// --------------------------------------------------------------------------
// implementing classes

public class HondaCar implements Car, FuelEngineCar {
    @Override
    public void move(){ /* implementation */}
     @Override
    public void color(){ /* implementation */}
    @Override
    public void fuel(){ /* implementation */}
}

public class TeslaCar implements Car, BatteryCar {
    @Override
    public void move(){ /* implementation */}
     @Override
    public void color(){ /* implementation */}
     @Override
    public void battery(){ /* implementation */}
}

In the above program, we have created two different interfaces FuelEngineCar and BatteryCar both have specific methods for marking specific behaviours. so by implementing FuelEngineCar interface we can have a car with fuel() method and by implementing BatteryCar interface we can have a car with the battery() method.

This is how we can segregate the unrelated behavioural methods into different interfaces and achieve different behaviours.

In real-time, we can again consider the List interface and its implementing classes ArrayList, Vector and LinkedList of collection framework as an example.

Here the LinkedList class implements both List and Deque marking the behaviour of both the interfaces together.

Dependency Inversion

The Dependency Inversion Principle states that "High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions".

In a classic scenario where high-level modules directly depend on low-level modules. If changes are made to low-level modules, it can have a cascading impact on higher-level modules, leading to fragility in the system. The Dependency Inversion Principle addresses this issue by introducing abstractions, typically in the form of interfaces or abstract classes, that act as mediators between high-level and low-level components.

// Abstraction
interface SwitchableDevice {
    void turnOn();
    void turnOff();
}

Here we have an Interface SwitchableDevice, having methods such as turnOn() and turnOff(). This interface can be used by a high-level module without worrying about the implementation.


// High-level module depending on abstractions
class Switch {
    private SwitchableDevice device;

    public Switch(SwitchableDevice device) {
        this.device = device;
    }
    public void press() {
        device.turnOn();
    }
}

The above Switch class represents a high-level module, where it is not directly dependent on the low-level module (classes with concrete methods), rather it depends on an abstracted method. Therefore any changes to the implementing class of SwitchableDevice will not affect the code on Switch class.

// Low-level module
class LightBulb implements SwitchableDevice {
    @Override
    public void turnOn() {
        System.out.println("LightBulb: ON");
    }
    @Override
    public void turnOff() {
        System.out.println("LightBulb: OFF");
    }
}

// Another low-level module
class Fan implements SwitchableDevice {
    @Override
    public void turnOn() {
        System.out.println("Fan: ON");
    }
    @Override
    public void turnOff() {
        System.out.println("Fan: OFF");
    }
}

Here we have two low-level modules, LightBulb and Fan implementing the SwitchableDevice interface. So now we can either assign an object of LightBulb of Fan to the SwitchableDevice reference present in the Switch class (high-level module).
This promotes the code reusability.


public class Test {
    public static void main(String[] args) {
        SwitchableDevice light = new LightBulb();
        SwitchableDevice fan = new Fan();

        Switch lightSwitch = new Switch(light);
        Switch fanSwitch = new Switch(fan);

        lightSwitch.press(); // Output: LightBulb: ON
        fanSwitch.press();   // Output: Fan: ON
    }
}

In the real world, the Dependency Inversion Principle is again one of the widely followed principles among the SOLID principles.
Examples:

  1. Java-JDBC-Connector

  2. Java-JPA-Hibernate

Conclusion

In wrapping up our exploration of the SOLID principles, think of them as the superheroes of software design, each playing a crucial role in crafting code that's not just functional but also flexible and easy to manage.

These principles create a league of extraordinary coding practices. They may sound fancy, but at the heart of it, they're simple tools helping us build software that's powerful, adaptable, and ready for whatever challenges come our way. So, let's put on our coding capes and continue crafting our software! If there are any queries feel free to leave a comment.