SOLID Principles in Java

SOLID principles are essential design principles in object-oriented programming (OOP) that enhance the quality and maintainability of software applications. They are aimed at reducing complexity and encouraging best practices in software development.

SOLID Principles

The SOLID Principles:

  • S – Single Responsibility Principle: A class should have only one reason to change, meaning it should have only one job.
  • O – Open / Closed Principle: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
  • L – Liskov Substitution Principle: Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
  • I – Interface Segregation Principle: Clients should not be forced to depend on interfaces they do not use.
  • D – Dependency Inversion Principle: High-level modules should not depend on low-level modules. Both should depend on abstractions.

Help us to write better code:

  • Avoid duplicate code
  • Easy to maintain
  • Easy to understand
  • Flexible software
  • Reduce complexity

1. Single Responsibility Principle (SRP)

A Class should have only 1 reason to change.

According to the Single Responsibility Principle, a class should have only one reason to change, meaning it should be responsible for only one function. An example illustrates that if a class is responsible for both invoice calculations and printing, it violates SRP since changes in print logic could affect calculation logic.

The solution is to separate these responsibilities into different classes, thus making the code easier to maintain and understand.

Let’s understand about this with an example:


public class Student {
    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void saveToFile() {
        // Save student details to a file
        System.out.println("Saving student to file: " + name);
    }

    public void printDetails() {
        // Print student details
        System.out.println("Student: " + name + ", Age: " + age);
    }
}

Problems:

  • Multiple Responsibilities: It manages student details, handles file saving, and deals with printing logic.
  • Difficult Maintenance: If we want to change the file-saving logic, it may impact the unrelated printing functionality.

Following SRP (Single Responsibility Principle):

To fix this, split responsibilities into separate classes:

1. Class for Student Details:


public class Student {
    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

2. Class for File Saving:


public class StudentFileManager {
    public void saveToFile(Student student) {
        System.out.println("Saving student to file: " + student.getName());
    }
}

3. Class for Display Logic:


public class StudentPrinter {
    public void printDetails(Student student) {
        System.out.println("Student: " + student.getName() + ", Age: " + student.getAge());
    }
}

Benefits:

  • Separation of Concerns: Each class has a clear responsibility.
  • Easier Maintenance: Changes in file-saving or printing logic won’t affect student details.
  • Better Reusability: You can reuse the StudentFileManager or StudentPrinter for other similar tasks.

Open/Closed Principle (OCP)

Open for Extension but Closed for Modification.

The Open/Closed Principle states that classes should be open for extension but closed for modification, meaning existing code should not be altered for new functionalities. This principle encourages the creation of new classes that can extend existing functionality without modifying existing classes, ensuring stability and reliability.

Let’s understand about it with an example:


public class AreaCalculator {
    public double calculateArea(Object shape) {
        if (shape instanceof Circle) {
            Circle circle = (Circle) shape;
            return Math.PI * circle.getRadius() * circle.getRadius();
        } else if (shape instanceof Rectangle) {
            Rectangle rectangle = (Rectangle) shape;
            return rectangle.getLength() * rectangle.getBreadth();
        }
        return 0;
    }
}

Circle Class:


class Circle {
    private double radius;
  
    public Circle(double radius) {
        this.radius = radius;
    }
  
    public double getRadius() {
        return radius;
    }
}

Rectangle Class:


class Rectangle {
    private double length, breadth;
  
    public Rectangle(double length, double breadth) {
        this.length = length;
        this.breadth = breadth;
    }
  
    public double getLength() {
        return length;
    }
  
    public double getBreadth() {
        return breadth;
    }
}

Problems:

  • Violates OCP: To add a new shape (e.g., Triangle), you must modify the calculateArea method. This breaks the “closed for modification” rule.
  • Difficult Maintenance: Every time you add a new shape, you risk introducing bugs into the existing code.

Following OCP (Open/Closed Principle):

Define a Shape Interface: Every shape will implement this interface to calculate its area.


public interface Shape {
    double calculateArea();
}

Circle Implementation:


public class Circle implements Shape {
    private double radius;
  
    public Circle(double radius) {
        this.radius = radius;
    }
  
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

Rectangle Implementation:


public class Rectangle implements Shape {
    private double length, breadth;
  
    public Rectangle(double length, double breadth) {
        this.length = length;
        this.breadth = breadth;
    }
  
    @Override
    public double calculateArea() {
        return length * breadth;
    }
}

Area Calculator Class:


public class AreaCalculator {
    public double calculateTotalArea(Shape[] shapes) {
        double totalArea = 0;
        for (Shape shape : shapes) {
            totalArea += shape.calculateArea();
        }
        return totalArea;
    }
}

Adding a New Shape (e.g., Triangle):


public class Triangle implements Shape {
    private double base, height;

    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public double calculateArea() {
        return 0.5 * base * height;
    }
}

Benefits:

  • Closed for Modification: The AreaCalculator class does not need to be modified when adding new shapes.
  • Open for Extension: Adding a new shape (e.g., Triangle) requires only creating a new class, without affecting existing code.
  • Easier Maintenance: Changes in one shape’s logic do not affect the others.

Liskov Substitution Principle (LSP)

If Class B is a subtype of Class A, then we should be able to replace the object of A with B without breaking the behavior of the program.

The Liskov Substitution Principle asserts that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the programs. This promotes code that is flexible and can integrate new implementations without breaking existing functionality, maintaining behavioral integrity.

Example Without LSP (Violation):


class Bird {
    public void fly() {
        System.out.println("I am flying!");
    }
}

class Sparrow extends Bird {
    @Override
    public void fly() {
        System.out.println("Sparrow is flying!");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}

Problems:

  • Violates LSP: The Penguin class does not fulfill the promise of the fly() method in the Bird superclass. Any code relying on Bird’s fly() method will break when working with a Penguin instance.
  • Unexpected Behavior: Users of the Bird class expect all birds to be able to fly.

Following LSP (Liskov Substitution Principle):

To fix this, we should rethink our design. We can use an interface or abstract class to separate flying birds from non-flying birds.


interface Flyable {
    void fly();
}

abstract class Bird {
    public abstract void eat();
}

class Sparrow extends Bird implements Flyable {
    @Override
    public void eat() {
        System.out.println("Sparrow is eating!");
    }

    @Override
    public void fly() {
        System.out.println("Sparrow is flying!");
    }
}

class Penguin extends Bird {
    @Override
    public void eat() {
        System.out.println("Penguin is eating!");
    }
}

Benefits:

  • Honors LSP: Each class now adheres to the behavior expected of it. Flyable separates the concern of flying, so Penguin does not misrepresent its capabilities.
  • Extensible: You can add more flying or non-flying birds without breaking the existing design.
  • No Unexpected Exceptions: Methods work as intended, with no risk of unsupported operations.

Interface Segregation Principle (ISP)

Interfaces should be such that clients should not implement unnecessary functions they do not need.

The Interface Segregation Principle highlights the importance of creating smaller, specific interfaces rather than large, general-purpose interfaces. This prevents classes from being forced to implement functionalities they do not require, thereby maintaining cleaner and more efficient code designs.

Example Without ISP (Violation):


interface Vehicle {
    void drive();   // For cars
    void fly();     // For airplanes
    void sail();    // For boats
}

class Car implements Vehicle {
    @Override
    public void drive() {
        System.out.println("Car is driving on the road!");
    }

    @Override
    public void fly() {
        // Car can't fly, but it is forced to implement this method.
        throw new UnsupportedOperationException("Car can't fly!");
    }

    @Override
    public void sail() {
        // Car can't sail either.
        throw new UnsupportedOperationException("Car can't sail!");
    }
}

class Airplane implements Vehicle {
    @Override
    public void drive() {
        // Airplanes don't drive on roads.
        throw new UnsupportedOperationException("Airplane can't drive!");
    }

    @Override
    public void fly() {
        System.out.println("Airplane is flying in the sky!");
    }

    @Override
    public void sail() {
        // Airplanes don't sail.
        throw new UnsupportedOperationException("Airplane can't sail!");
    }
}

Problems in This Design:

  • Unnecessary Methods: Classes like Car and Airplane are forced to implement methods (fly and sail) that they don’t need.
  • UnsupportedOperationException: Developers using this interface may accidentally call unsupported methods, leading to runtime errors.
  • Hard to Maintain: Adding a new method to the Vehicle interface would force all implementing classes to update, even if they don’t need that method.

Following ISP (Interface Segregation Principle):

We can split the Vehicle interface into smaller, more specific interfaces:


interface Drivable {
    void drive();
}

interface Flyable {
    void fly();
}

interface Sailable {
    void sail();
}

class Car implements Drivable {
    @Override
    public void drive() {
        System.out.println("Car is driving on the road!");
    }
}

class Airplane implements Flyable {
    @Override
    public void fly() {
        System.out.println("Airplane is flying in the sky!");
    }
}

class Boat implements Sailable {
    @Override
    public void sail() {
        System.out.println("Boat is sailing on the water!");
    }
}

Benefits :

  • No Unused Methods: Classes only implement what they need. A Car doesn’t need to know about flying or sailing.
  • Better Flexibility: Adding new types of vehicles or behaviors (e.g., Hoverable) won’t affect existing classes.
  • Easier to Understand and Use: Each interface has a clear purpose, making it easier to understand and implement.

Dependency Inversion Principle (DIP)

A class should depend on interfaces rather than concrete classes.

The Dependency Inversion Principle advocates for the design that depends on abstractions (interfaces) rather than concrete implementations. This principle allows for flexibility in the system because high-level modules can operate independently of the low-level modules, provided they adhere to the agreed interfaces.

Example Without DIP (Violation):


class CustomerRepository {
    public void saveCustomer(Customer customer) {
        System.out.println("Customer saved in the database.");
    }
}

class CustomerService {
    private CustomerRepository customerRepository;

    public CustomerService() {
        customerRepository = new CustomerRepository();  // Tight coupling
    }

    public void addCustomer(Customer customer) {
        customerRepository.saveCustomer(customer);
    }
}

Problems:

  • Tight Coupling: CustomerService is tightly coupled with CustomerRepository. If you want to change the CustomerRepository implementation (e.g., switch to a different database), you’ll need to modify the CustomerService class.
  • Hard to Test: To test CustomerService, you must instantiate a CustomerRepository which makes unit testing harder.

Refactored Example Following DIP:

We can introduce abstraction by using an interface for the CustomerRepository. This way, CustomerService will depend on the abstraction (interface), not the concrete class.


interface CustomerRepository {
    void saveCustomer(Customer customer);
}

class MySQLCustomerRepository implements CustomerRepository {
    @Override
    public void saveCustomer(Customer customer) {
        System.out.println("Customer saved in MySQL database.");
    }
}

class MongoDBCustomerRepository implements CustomerRepository {
    @Override
    public void saveCustomer(Customer customer) {
        System.out.println("Customer saved in MongoDB database.");
    }
}

class CustomerService {
    private CustomerRepository customerRepository;

    // Dependency Injection via Constructor
    public CustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    public void addCustomer(Customer customer) {
        customerRepository.saveCustomer(customer);
    }
}

Benefits:

  • Flexibility: You can swap out the implementation of CustomerRepository without modifying the CustomerService. You can switch to different database technologies (e.g., MySQL, MongoDB) without changing the service logic.
  • Easier Testing: With Dependency Injection, you can easily mock the CustomerRepository when testing CustomerService.
  • Loose Coupling: The high-level modules and low-level modules are loosely coupled, which makes your code easier to maintain and extend.

Conclusion:

Understanding and applying SOLID principles can significantly improve code quality, making it easier to maintain and extend, which is critical in modern software developments.

Youtube Video –

Explore our more articles

What is SOLID Principles in Java

SOLID principles are essential design principles in object-oriented programming (OOP) that enhance the quality and maintainability of software applications. They are aimed at reducing complexity and encouraging best practices in software development.

1. S – Single Responsibility Principle: A class should have only one reason to change, meaning it should have only one job.
2. O – Open / Closed Principle: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
3. L – Liskov Substitution Principle: Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
4. I – Interface Segregation Principle: Clients should not be forced to depend on interfaces they do not use.
5. D – Dependency Inversion Principle: High-level modules should not depend on low-level modules. Both should depend on abstractions.

What is Benefits of using SOLID Principles in Software Development

SOLID Principles Help us to write better code like

– Avoid duplicate code
– Easy to maintain
– Easy to understand
– Flexible software
– Reduce complexity

Leave a Comment