27 March 2023

SOLID Principles in Action: Writing Clean and Maintainable Java Code

The SOLID principle is a collection of guidelines that can be used by developers to create object-oriented code that is maintainable and scalable over time. These principles assist developers in writing code that is more fluid, modular, and adjustable.

In this blog article, we will go over the SOLID concept in detail, focusing on each of its five components. We will describe each principle, review its advantages, and provide Java examples to demonstrate its application.

Single Responsibility Principle (SRP)

In accordance to the Single Responsibility Principle (SRP), a class should alter for only one cause. In other words, a class should only be responsible for one thing. This makes the code more flexible and manageable.

Take the following class as an illustration of one that breaks the SRP:

public class Employee {
  public void calculatePay() {
    // Calculate employee's pay
  }
  
  public void saveEmployee(Employee employee) {
    // Save employee's data to the database
  }
}

The Employee class in the code above is responsible for two tasks: figuring out an employee’s salary and storing the employee’s data in the database. As the class should only have one duty, this goes against the SRP.

We can build two different classes to address this violation:

public class Employee {
  public void calculatePay() {
    // Calculate employee's pay
  }
}

public class EmployeeRepository {
  public void saveEmployee(Employee employee) {
    // Save employee's data to the database
  }
}

The code above splits the responsibilities of calculating an employee’s pay and saving an employee’s data to the database into two distinct classes.

Open/Closed Principle (OCP)

Following to the Open/Closed Principle (OCP), classes should be available for additions but closed for changes. The functionality of a class should therefore be extendable without requiring you to change its source code.

Let’s look at an instance of a class that violates the OCP:

public class DiscountCalculator {
  public double calculateDiscount(double price, String discountType) {
    double discount = 0;
    if (discountType.equals("Christmas")) {
      discount = 0.2;
    } else if (discountType.equals("Easter")) {
      discount = 0.1;
    }
    return price * (1 - discount);
  }
}

The DiscountCalculator class in the code above has a conditional expression that verifies the discount category. It would be against the OCP to change the source code of the DiscountCalculator class in order to introduce a new discount category.

We can use strategy pattern to correct this violation:

public interface DiscountStrategy {
  double applyDiscount(double price);
}

class ChristmasDiscountStrategy implements DiscountStrategy {
  public double applyDiscount(double price) {
    return price * 0.8;
  }
}

class EasterDiscountStrategy implements DiscountStrategy {
  public double applyDiscount(double price) {
    return price * 0.9;
  }
}

class DiscountCalculator {
  private DiscountStrategy discountStrategy;
  
  public DiscountCalculator(DiscountStrategy discountStrategy) {
    this.discountStrategy = discountStrategy;
  }
  
  public double calculateDiscount(double price) {
    return discountStrategy.applyDiscount(price);
  }
}

ChristmasDiscountStrategy and EasterDiscountStrategy are two classes that implement the DiscountStrategy interface that was specified in the earlier code. The DiscountCalculator class has also been altered to support the DiscountStrategy protocol. Now, without altering the Discount source code, we can introduce a new discount group by developing a new class that makes use of the DiscountStrategy interface.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is a principle in object-oriented programming that states that if a program is using a base class, it should be able to use any of its derived classes without issues. This means that a derived class should not alter the behavior of the base class.

The Liskov replacement concept in Java is demonstrated in the following way:

Let’s say that the class Shape has a function named getArea() that identifies the shape’s area. Additionally, we have two derived classes that implement their own getArea() methods: Circle and Square, which are extended from the Shape class.

If we have a Shape object, we ought to be able to use getArea() without any issues on any associated classes (Circle, Square, etc.). This ensures that the behavior of the primary class is unaffected by the extended classes.

Here’s an example implementation that follows the Liskov Substitution Principle in Java:

class Shape {
    double getArea() {
        return 0;
    }
}

class Circle extends Shape {
    double radius;

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

    double getArea() {
        return Math.PI * radius * radius;
    }
}

class Square extends Shape {
    double side;

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

    double getArea() {
        return side * side;
    }
}

class AreaCalculator {
    double calculateArea(Shape shape) {
        return shape.getArea();
    }
}

In this implementation, the Shape class has a getArea() function that gives the shape’s area. We also have two derived classes, Circle and Square, which come from Shape and implement their own implementations of getArea().

We have an AreaCalculator class that takes a Shape object as an argument to its calculateArea() method. The method calls the getArea() method on the Shape object to calculate its area.

With this implementation, we can call the calculateArea() method with a Circle or Square object, and the method will return the correct area for each object. This demonstrates the Liskov Substitution Principle, as the derived classes (Circle and Square) do not break the behavior of the base class (Shape).

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is a principle in object-oriented programming that states that a client should not be forced to implement interfaces it does not use.

The principle is designed to help keep interfaces focused and small, and to avoid the unnecessary coupling of classes.

Here’s an illustration of an ISP in Java:

Assume we have a Printer interface with two methods: printDocument() and scanDocument(). We also have two classes that implement the Printer interface: SimplePrinter and SmartPrinter.

The SimplePrinter class only needs to implement the printDocument() method, but since it’s implementing the Printer interface, it’s forced to implement the scanDocument() method as well, even though it doesn’t use it. This violates the ISP.

Here’s an example implementation that follows the ISP in Java:

interface Printer {
    void printDocument();
}

interface Scanner {
    void scanDocument();
}

class SimplePrinter implements Printer {
    public void printDocument() {
        // implementation
    }
}

class SmartPrinter implements Printer, Scanner {
    public void printDocument() {
        // implementation
    }

    public void scanDocument() {
        // implementation
    }
}

In this implementation, we have two separate interfaces, Printer and Scanner, each with their own methods. We also have two classes, SimplePrinter and SmartPrinter, that implement the appropriate interface(s).

The SimplePrinter class only implements the Printer interface, and the SmartPrinter class implements both the Printer and Scanner interfaces. This follows the ISP, as each class is only implementing the interfaces it needs, without being forced to implement methods it doesn’t use.

Dependency Inversion Principle (DIP)

According to the Dependency Inversion Principle (DIP), high-level modules should not rely on low-level modules in object-oriented programming. They both ought to rely on ideas.

The idea is to make code more adaptable and straightforward to manage over time.

Here’s a Java example to illustrate the DIP:

Imagine that the TemperatureSensor class is a dependency of the WeatherTracker class, which uses it to calculate the temperature. The getTemperature() function on the TemperatureSensor is invoked by the WeatherTracker class’s getCurrentTemperature() method.

We would also need to modify the WeatherTracker class if we wanted to switch the TemperatureSensor to a different kind of monitor. The DIP is broken by this.

interface Sensor {
    double getTemperature();
}

class TemperatureSensor implements Sensor {
    public double getTemperature() {
        // implementation
    }
}

class WeatherTracker {
    private Sensor sensor;

    public WeatherTracker(Sensor sensor) {
        this.sensor = sensor;
    }

    public double getCurrentTemperature() {
        return sensor.getTemperature();
    }
}

In this implementation, we have an abstract Sensor interface that defines a getTemperature() method. We also have a TemperatureSensor class that implements the Sensor interface.

The WeatherTracker class no longer depends on the concrete TemperatureSensor class, but instead depends on the Sensor interface. This allows us to swap out the TemperatureSensor for a different type of sensor that implements the Sensor interface without changing the WeatherTracker class. This follows the DIP, as the high-level WeatherTracker class no longer depends on the low-level TemperatureSensor class directly.

Conclusion

The SOLID principle is a set of guidelines that help developers create more maintainable code. By adhering to the five concepts of Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion, developers can produce software that is more adaptable, understandable, and simple to alter over time.

When writing code, keep in mind these ideas as they can improve the effectiveness and efficiency of software development. By employing SOLID principles, developers can create code that is less prone to mistakes, easier to handle, and more flexible to changing requirements.

Therefore, if you’re a developer, take some time to familiarize yourself with SOLID principles and begin incorporating them into your own writing procedures. You will be well on your way to creating superior, more stable software if you do this.

Resources


Arapsih Güngör

Arapsih Güngör is a Senior Software Developer at OpenValue with expertise in multiple programming languages and frameworks. He is dedicated to producing high-quality work, enjoys problem-solving, and is passionate about writing clean, efficient code. Outside of work, Arapsih enjoys badminton, tennis, dancing, and lifting weights.