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:
- Tolerate change
- Are easy to understand
- 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:
- (S)ingle Responsibility Principle (SRP)
- (O)pen Closed Principle (OCP)
- (L)iskov Substitution Principle (LSP)
- (I)nterface Segregation Principle (ISP)
- (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;
}
}
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 namedapplyDiscount
```java
public interface DiscountStrategy {
double applyDiscount(double totalPrice);
}
- Create an implementation of this interface named `PercentageDiscount`
```java
@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);
}
}
- Hook up this interface to do the calculation for us in
ShoppingCartService
```java
@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);
}
}
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.
```java
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;
}
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.
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.
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.
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
}
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)