DEV Community

Cover image for SOLID Design Patterns for Spring Boot Developers
Rajdip Bhattacharya
Rajdip Bhattacharya

Posted on

SOLID Design Patterns for Spring Boot Developers

Hello geeks!

Welcome to yet another new blog on the tidbits of software development. In my previous blogs, I have touched upon topics such as Docker and System Design. While these topics are no doubt a good to have skills, what is more important is how you write your code. Imagine this scenario where you have a top-class secure pipeline set up for your application, all ready to go into deployment, your manager is super happy with you, and he/she hires more people to assist you in your work. But now, they are introduced with a nightmare since your code isn't clean. No, I'm not saying that you don't know how to code, all I'm saying is, even if you do, you might not be following the world-class SOLID Principles!

This is the core motive of this blog, to introduce you to this magical concept of clean coding and make you stand apart in the crowd! So, let's get started without further ado.

A note: Most of the references in this article has been taken from the book Clean Architecture by Robert C. Martin.

What is SOLID?

Before we start, make a note of this: The essence of design and architecture is that, it never gave us anything new, but always took from us what we already had.
Let me simplify this. We live in a society where we are bound by some laws enforced upon us by our government. Violating any such law would impose a penalty upon us. What laws essentially are: barriers. Laws draw the line between the dos and don'ts of our society. This is absolutely necessary because us humans have the capability to do absolute wonders in the planet. To maintain the stability of our society, laws are our only hopes.
Similarly, to maintain the sustainability and developability of code, coding best practices are the only way forward.

Now, to begin with SOLID, it focuses on arranging data and functions into classes. Note that in this context, class doesn't necessarily mean the class we know from OOP, but rather containers that allow us to containerize our code.

Goal of SOLID: Creating mid-level software programs that:

  1. Tolerate change
  2. Are easy to understand
  3. Can be easily used in many software systems.

The term mid-level signifies that we use SOLID principles while working at module level. To further simplify, we use solid principles while developing, for example, business logic and APIs for our software.

As you might have already guessed, SOLID is a short form. It consists of:

  1. (S)ingle Responsibility Principle (SRP)
  2. (O)pen Closed Principle (OCP)
  3. (L)iskov Substitution Principle (LSP)
  4. (I)nterface Segregation Principle (ISP)
  5. (D)ependency Inversion Principle (DIP)

We will look at each of the constituents and analyze them in the following sections.

Single Responsibility Principle (SRP)

First, let's see what we do in general. Consider that we have a class named UserService. It does the following things:

  • Register a user
  • Send promotional mails

Upon registration, a user will receive a mail from us denoting their successful registration, followed by a promotional email.

While this might look ok, it isn't optimal. We can clearly spot two different functionalities stashed inside the same class. What this will do is, any changes in the registerUser() function will also recompile the promotionalMail() function. Plus, if there were two teams working on the two different functions, a merge conflict is inevitable.

For this very reason, we would like to use two separate classes for this: UserService and EmailService. This obeys the following principle: A module should be responsible to one, and only one, actor. Actor, here, means the changing factor. In simple words, the reason for which a class needs to change. The lesser the changing factors for a class, the more stability it gains.

The main takeaway of this principle is: make sure that you segregate your code based on the factors that changes them.

Open Closed Principle (OCP)

While the name might sound intuitive, what it really means is, we want to develop code where in to make changes to our code, we would essentially extend the code rather than updating the existing code.

Consider the following code:

@Service
public class ShoppingCartService {

    public double calculateTotalPrice(List<Item> items) {
        double total = 0.0;
        for (Item item : items) {
            total += item.getPrice();
        }
        return total;
    }
}
Enter fullscreen mode Exit fullscreen mode

The calculateTotalPrice() calculates the total price of our order based upon the cost of each item. Later, let's say we need to implement a discount mechanism in the code where the discount percentage is variable.

One way to do this is to directly insert the math in the same function. While we can give ourselves a pat on the back for doing this so simply, we might be digging our own graves in the longer run.

The optimal way to solve this problem is by creation another service class that will calculate the price for us based upon the discount. We can then segregate our code based on the SRP, which narrates us to separate our code based upon the actors affecting it.

We are going to do the following:

  • Create a new interface named DiscountStratergy with a single method named applyDiscount
public interface DiscountStrategy {
    double applyDiscount(double totalPrice);
}
Enter fullscreen mode Exit fullscreen mode
  • Create an implementation of this interface named PercentageDiscount
@Component
public class PercentageDiscount implements DiscountStrategy {

    private final double discountRate;

    @Autowired
    public PercentageDiscount(@Value("${app.discountRate}") double discountRate) {
        this.discountRate = discountRate;
    }

    @Override
    public double applyDiscount(double totalPrice) {
        return totalPrice * (1 - discountRate);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Hook up this interface to do the calculation for us in ShoppingCartService
@Service
public class ShoppingCartService {

    private final DiscountStrategy discountStrategy;

    @Autowired
    public ShoppingCartService(DiscountStrategy discountStrategy) {
        this.discountStrategy = discountStrategy;
    }

    public double calculateTotalPrice(List<Item> items) {
        double total = 0.0;
        for (Item item : items) {
            total += item.getPrice();
        }
        return discountStrategy.applyDiscount(total);
    }
}
Enter fullscreen mode Exit fullscreen mode

Wallah! We now have SRP, OCP and DIP & LSP (coming soon) in place. Now, let's say we choose to move from percentage based discount to loyalty based discount. All we need to do is create another implementation of this interface and comment out the @Component tag in the current implementation. This approach also makes sure that, for any change in the discount implementation, there aren't any changes in the ShoppingCartService class.

Liskov Substitution Principle (LSP)

We have already covered the usage of LSP unknowingly, but let's look at it properly in this section.

Let's take an example of the Linux kernel. Don't worry, you don't need to be a Linux geek to understand this example! Linux uses files to represent everything, be it a device or an actual file. This enables the kernel to use uniformity across its code for doing I/O based operations. Namely, there are 4 functions that Linux use to operate on file or I/O devices: open(), read(), write(), close(). What this means is, any I/O device that is attached to the Linux kernel, must provide their own implementations for these methods. The kernel doesn't bother itself with the details of the implementation. This is what LSP enforces.
LSP promotes the use of interfaces instead of concrete definitions. This allows relaxation of code and lesser dependency.

Here is an example of the interface promoted by the Linux kernel.

import java.io.IOException;

public interface FileDevice {
    void open(String filename, String mode) throws IOException;
    int read(byte[] buffer) throws IOException;
    void write(byte[] data) throws IOException;
    void close() throws IOException;
}
Enter fullscreen mode Exit fullscreen mode

Any device that wants to talk to Linux should implement these methods (It is the device drivers that do this and not the device. Kept it simple for ease of understanding).

Interface Segregation Principle (ISP)

The name already tells us what it does. ISP tells us to not couple classes together that are not used.

ISP

In the above diagram, we can see that the 3 User classes directly depend on the OPS class. Note that User1 uses ops1 method, and the same goes for the rest 3. As you can see, it makes a clutter if stash unrelated code. We solve this issue by using interfaces to segregate the use.

ISP

This diagram demonstrates how we can change the hierarchy of classes and interfaces so that each module talks to another module that is made for just a specific use case. The green boxes are interfaces.

Dependency Inversion Principle (DIP)

The last and perhaps one of the most tricky principles to understand. Let's expand on the previous examples of using interfaces in place of classes. Following this approach helps us to not depend on concretions, but rather, abstractions. If we depend on concrete classes, any change in the class would also mean recompilation of dependent classes. Not only that, this approach makes the code highly dependent and reduces code reusability. This is what DIP puts emphasis on.

DIP dictates that the most flexible systems are those in which source code dependencies refer only to abstractions, not to concretions. Abstraction, in the context of OOP, can mean interfaces or abstract classes.

These abstractions are of classes that are highly volatile. A point to note is that, it is perfectly normal to depend directly on classes that do not change often or at all (eg. String in Java).

The bottom line of this principle is, never ever depend directly on volatile classes.

Let's take the example of JPA repository.

DIP

This is the dependency graph we follow when we are creating a simple CRUD application using springboot.

  • The Service class contains business related logic that uses the JPA Repository to do its work.
  • The JPA repository provides an interface of the functions that might be used by its consumers
  • The DB provides drivers that implements these methods to connect all the dots

Here is a simple java code for the same:

public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // Rest of the code
}
Enter fullscreen mode Exit fullscreen mode

Note that the userRepository object is injected by SpringBoot at runtime.

Conclusion

With this, we come to the end of this blog. I'm sure if you have read so far, you would have found it really helpful. As developers, it's our responsibility to maintain the software we develop. There is no better way to do this than starting from such a granular level.

Once again, in case you feel I have missed something, please do let me know.
P.S. You should definitely get Clean Architecture if you liked this article :D!

Top comments (0)