SOLID Principles Understanding
Hi Everyone, Today we are going to look at some of the principles or statements that are advised to follow in your Object Oriented Programming world, which can turn your code into more readable, and extendible code.
SOLID is an acronym where each character stands for an individual Principle. These principles were presented by Robert C. Martin aka Uncle Bob in the year 2000 essay named “Design Principles and Design Patterns”.
This Design Principles are just some statements that are not restricted to be used during your Code design/writing. These principles are neither laws nor the truth which everyone needs to follow. These are just some statements/advice that can turn a bad behavior of code into good behavior, Explained by Uncle Bob in one of his posts Getting a SOLID Start.
The SOLID acronym is divided into five principles as follows
- S:- Single Responsibility Principle.
- O:- Open-Closed Principle.
- L:- Liskov Substitution Principle.
- I:- Interface Segregation Principle.
- D:- Dependency Inversion Principle.
Single Responsibility Principle
The Principle States that class/module/package should have a single responsibility, It means it should have a single reason to be changed.
For suppose, you have a payment system class that makes payments, but it also sends email invoices to the Payer’s email ID (As shown in the below code).
class Payment {
public void makePayment(String paymentMode, Double rupees){
System.out.println("Payment is done through " + paymentMode + " for rupees: " + rupees);
emailInvoice();
}
private void emailInvoice(){
System.out.println("Invoice is sent..");
}
}
public class SRP_Violation {
public static void main(String[] args) {
Payment1 payment = new Payment1();
payment.makePayment("UPI", 13.90);
}
}ja
Here, the Payment class has two reasons to be changed.
- Logic change in payment/transaction
- In the future, we can change the logic of the sendInvoiceToEmail method.
Thus, It violates the principle of Single Responsibility principle.
To get rid of the above issue, we can create another class that is only responsible for sending emails and attachments. Thus, we can follow the Single responsibility principle. So, as shown in the below code, Each class has one responsibility and only one reason to be changed.
// Each class can be considered as different .java file
// They are combined just for the understanding purpose.
class EmailSender{
public void sendInvoiceToEmail(String to, String anyAttachment){
System.out.println("Invoice attachment is sent to: " + to);
}
}
class Payment{
EmailSender emailSender = new EmailSender();
public void makePayment(String paymentMode, Double rupees){
System.out.println("Payment is done through " + paymentMode + " for rupees: " + rupees);
emailSender.sendInvoiceToEmail("abc@gmail.com", "emem");
}
}
public class SRP_Follows {
public static void main(String[] args) {
Payment payment = new Payment();
payment.makePayment("UPI", 13.90);
}
}
Pros of Using the Single Responsibility Principle
- This principle lets us focus on assigning only one responsibility to a class. Suppose, your class is assigned multiple responsibilities, but in the future change in one responsibility might break the entire class functionality.
- Using the principle in code can make it more readable, easily testable, and extendable.
Cons of Using the Single Responsibility Principle
- Creating a class for each of the responsibilities can lead creation of a lot of small classes in the project. If the project is large, it becomes a headache to manage all the classes with their test code.
Open-Closed Principle
The Principle States that a class/module/package/method should be open for extension, not for modification.
Let’s say, we have a requirement to design a Payment System that supports UPI, and Card Payment Mode, and based on the user’s selected payment mode, the Transaction should be completed.
So, first, you can design code in the below way, where Both the Single Responsibility Principle and Open Closed principle are breaking.
class Payment{
public void makePayment(String paymentMode, Double amount){
if(paymentMode.equals("UPI")){
upiPayment(amount);
} else if (paymentMode.equals("CARD")) {
cardPayment(amount);
}
}
private void upiPayment(Double amount){
System.out.println("Upi Payment done for rupees: " + amount);
}
private void cardPayment(Double amount){
System.out.println("Card Payment done for rupees: " + amount);
}
}
public class OCP_Violate {
public static void main(String[] args) {
Payment payment = new Payment();
payment.makePayment("UPI", 90.89);
}
}
So, The above code violates the Single Responsibility principle because the Payment class has two modes to handle UPI and Card. It is also breaking the OCP if some new payment mode requirements come. The class code needs to be modified.
Abstraction plays a key role in reforming the code and making it follow Both of the above principles. Basically, we talk to Different Payment modes through abstractions (as shown below in the code).
// Transaction interface through which we talk with different payment modes.
interface Transaction{
void makeTransaction(Double amount);
}
class UPITransaction implements Transaction{
@Override
public void makeTransaction(Double amount) {
System.out.println("Upi Payment done for rupees: " + amount);
}
}
class CARDTransaction implements Transaction{
@Override
public void makeTransaction(Double amount) {
System.out.println("Card Payment done for rupees: " + amount);
}
}
class EMITransaction implements Transaction{
@Override
public void makeTransaction(Double amount) {
System.out.println("EMI Payment done for rupees: " + amount);
}
}
// Factory Pattern to select the required Payment mode instance as per the
// Payment mode selected by Payer.
class Factory{
private static Transaction cardTransaction = new CARDTransaction();
private static Transaction upiTransaction = new UPITransaction();
private static Transaction emiTransaction = new EMITransaction();
public static Transaction getTransactionType(String paymentMode){
if(paymentMode.equals("UPI")){
return upiTransaction;
}
if (paymentMode.equals("CARD")){
return cardTransaction;
}
if (paymentMode.equals("EMI")){
return emiTransaction;
}
return null;
}
}
class Payment1{
public void makePayment(String paymentMode, Double amount){
// Use factory pattern to find Transaction type object, Using
Factory.getTransactionType(paymentMode).makeTransaction(amount);
}
}
public class OCP_Follows {
public static void main(String[] args) {
Payment1 payment1 = new Payment1();
payment1.makePayment("EMI", 45.67);
}
}
As shown in the above code, It is obviously seen that we are doing below things
- Creating a Class for each payment mode as each one of them has different rules to complete the transaction, and each class follows a Single Responsibility.
- Using Interface/abstraction through which client talks with our payment modes.
- We have used Factory Pattern to select the needed instance of the selected payment mode at runtime. For the Learning Factory Pattern, take a look at it here (Although, I will be covering it in future articles).
Pros of Using the Open-Closed Principle
- As per the principle, no modification should happen in existing code, so it leads to fewer bugs/errors in existing code.
- Code becomes more extendable for future purposes.
Cons of Using the Open-Closed Principle
- This principle comes with the introduction of an Abstraction Layer which becomes a little tough at the beginner level.
Liskov Substitution Principle
The Principle states “In the code, if S is subtype of T, then object of T should be replaced with S without breaking the functionality of Parent T.”
To understand the principle, please take a look at the code snippet.
/**
* Animal class
* Subclasses, Pigeon, Cow.
*
* Animal has two methods getNumberOfLegs(), getFlyingSpeed().
*/
class Animal{
public Integer getNumberOfLegs(){
return 2;
}
public Integer getFlyingSpeed(){
return 20;
}
}
class Pigeon extends Animal{
}
class Cow extends Animal{
public Integer getFlyingSpeed(){
return null;
}
}
public class LSP_Violation {
public static void main(String[] args) {
Animal pigeon = new Pigeon();
Animal cow = new Cow();
List<Animal> animals = Arrays.asList(pigeon, cow);
for(Animal animal: animals){
System.out.println("Speed: " + animal.getFlyingSpeed().toString());
}
// Getting error for cow, as it is not a flying animal
}
}
/**
Animal
/ \
/ \
Pigeon Cow
*/
As shown in the above code, Pigeon and Cow are generalized as Animal, and animal has two methods getNumberOfLegs(), and getFlyingSpeed().
A pigeon is an animal that has legs and it is also a flying animal, but on the other hand, Cow is not a flying animal. So, the Animal instance can’t be replaced with the Cow instance which is a violation of the Liskov Substitution principle.
So, to overcome the above situation, we can divide the animals also in more categories as follows.
Animal
/ \
/ \
FlyingAnimal EarthAnimal
/ \
/ \
Pigeon,Eagle Cow
Keep the most generic methods in the Animal class only which are available to all animals, like getNumberOfLegs() now, keep the getFlyingSpeed() method only in the FlyingAnimal class, not in the Animal class Suppose, we have the same above list,
import java.util.Arrays;
import java.util.List;
class Animal_ {
public Integer getNumberOfLegs() {
return 2;
}
}
class FlyingAnimal extends Animal_ {
public Integer getFlyingSpeed() {
return 20;
}
}
class EarthAnimal extends Animal_ {
public Integer getWalkingSpeed() {
return 5;
}
}
class Pigeon_ extends FlyingAnimal {
}
class Cow_ extends EarthAnimal {
}
public class LSP_Follows {
public static void main(String[] args) {
Animal_ pigeon = new Pigeon_();
Animal_ cow = new Cow_();
List<Animal_> animal_s = Arrays.asList(pigeon, cow);
for (Animal_ animal : animal_s) {
System.out.println(animal.getNumberOfLegs());
// System.out.println(animal.getFlyingSpeed()); // not accessible, as this two methods are not part of Animal
// System.out.println(animal.getWalkingSpeed()); // not accessible, as this two methods are not part of Animal
}
FlyingAnimal flyingAnimal = new Pigeon_();
// FlyingAnimal flyingAnimal1 = new Cow_(); // not allowed
// EarthAnimal earthAnimal = new Pigeon_(); // not allowed
}
}
now we can’t do like list.getFlyingSpeed() for cow, as this method does not belong to Animal class anymore, it belongs to FlyingAnimal class and cow does not belong to FlyingAnimal class anymore.
Pros of Using the Liskov Substitution Principle
- This principle helps to create a better structure of classes (When inheritance among classes is happening) and puts only needed methods in a class that are suitable for that subsystem if the class is the root class of that subsystem.
Cons of Using the Liskov Substitution Principle
- Sometimes this principle can lead to a complex hierarchy of classes, which may increase the complexity of code.
Interface Segregation Principle
The Principle states “Interfaces should be so grained that client should not be forced to implement interface, even client does not want to use it.”
Let’s understand the problem that we face before using this principle.
Suppose, You have a project that is responsible for making shapes on the screen, whatever shape you give as input.
Interface IShape has four methods => drawCircle(), drawSquare(), drawOval() etc.
interface IShape{
void drawCircle();
void drawSquare();
}
class Circle implements IShape{
@Override
public void drawCircle() {
System.out.println("Drawing Circle...");
}
@Override
public void drawSquare() {
}
}
class Square implements IShape{
@Override
public void drawCircle() {
}
@Override
public void drawSquare() {
System.out.println("Drawing Square...");
}
}
public class IS_Violation {
public static void main(String[] args) {
IShape circle = new Circle();
IShape square = new Square();
circle.drawCircle();
square.drawSquare();
// The above breaking SR principle as circle class has another respons. of drawing Aquare.
// breaks rule of Interface Segregation principle.
}
}
Problem: Now, the Circle class implements the above interface, which only wants to implement drawCircle(), but needs to implement two extra methods drawSquare, and drawOval.
Similarly, goes for Square class, and Oval class.
Here, In the future, if we introduce a new feature drawTriangle() in the main interface, the client unnecessarily needs to implement this method in each of the classes where this interface is implemented.
So, as per the principle, we will create an interface for each shape which will be implemented by their respective classes.
- ICircleShape
- ISuareShape
- IOvalShape
- ITriangleShape
Look at the below code.
interface ICircleShape{
void drawCircle();
}
interface ISquareShape{
void drawSquare();
}
interface ITriangleShape{
void drawTriangle();
}
class Circle_ implements ICircleShape{
@Override
public void drawCircle() {
System.out.println("Drawing Circle...");
}
}
class Square_ implements ISquareShape{
@Override
public void drawSquare() {
System.out.println("Drawing Square...");
}
}
class Triangle_ implements ITriangleShape{
@Override
public void drawTriangle() {
System.out.println("Drawing Triangle...");
}
}
public class IS_Follows {
public static void main(String[] args) {
ICircleShape circle = new Circle_();
ISquareShape square = new Square_();
ITriangleShape triangle = new Triangle_();
circle.drawCircle();
square.drawSquare();
triangle.drawTriangle();
// Follows SR principle, Open-Closed Principle,
// and Interface segregation principle.
}
}
Pros of Using the Interface Segregation Principle
- Using this principle, the code structure becomes modular where behavior/operations are categorized with the help of an interface and further implemented by their own classes.
- It reduces coupling as a client does need not to implement unwanted methods in the implementation classes.
Cons of Using the Interface Segregation Principle
- Strict following of this principle can lead to a large number of small interfaces that are more complex to maintain.
Dependency Inversion Principle
The Principle states “High Level modules should not depend on low level modules, it should depend on abstraction.”
There should be a layer of abstraction between high level modules and low level modules.
Suppose, you are working on a class which is responsible for saving some text either on DB or on some text file, then below code shown violation of this principle.
class DBMode{
public void save(String content){
System.out.println("Saving content: '" + content + "' into db..." );
}
}
class FileMode{
public void save(String content){
System.out.println("Saving content: '" + content + "' into File..." );
}
}
class FileSave1{
private DBMode dbMode = new DBMode();
private FileMode fileMode = new FileMode();
public void saveContent(String content){
dbMode.save(content);
fileMode.save(content);
}
}
public class DIP_Violates {
public static void main(String[] args) {
FileSave1 fileSave1 = new FileSave1();
fileSave1.saveContent("Hello");
}
}
Problem: We have a high level module/class “FileSave” which is responsible for calling either db save or file save.
Now, suppose in future some other Storage options comes into picture, then we will have to make change in FileSave class.
It violates the Dependency Inversion principle.
So, the principle asks us to introduce an abstraction layer as follows:
import java.util.Arrays;
import java.util.List;
/**
* Each Mode DBMode, FileMode, LocalStorage are different classes which enables
* code to follow SRP, Open-Closed Principle, Dependency Inversion Principle.
*
*/
interface Modes{
void save(String content);
}
class DBMode_ implements Modes{
public void save(String content){
System.out.println("Saving content: '" + content + "' into db..." );
}
}
class FileMode_ implements Modes{
public void save(String content){
System.out.println("Saving content: '" + content + "' into File..." );
}
}
class LocalStorageMode_ implements Modes{
public void save(String content){
System.out.println("Saving content: '" + content + "' into LocalStorage..." );
}
}
class FileSave2{
private List<Modes> savingModes = Arrays.asList(new DBMode_(), new FileMode_(), new LocalStorageMode_());
public void saveContent(String content){
for(Modes mode: savingModes){
mode.save("Hello");
}
}
}
public class DIP_Follows {
public static void main(String[] args) {
FileSave2 fileSave2 = new FileSave2();
fileSave2.saveContent("Hello");
}
}
Pros of Using the Dependency Inversion Principle
- Reduces coupling among High Level and Low Level modules.
- The code is easy to reuse, maintain and test. High-level modules depend on abstractions and interfaces, making it easier to substitute different implementations of low-level components.
Cons of Using the Dependency Inversion Principle
- Understanding and applying DIP can be challenging, and a little complex.
For code used in this article, take a look at the repo here.
For More understanding of the SOLID principles, Please go through the below references
- https://blog.bitsrc.io/solid-principles-every-developer-should-know-b3bfa96bb688
- https://medium.com/backticks-tildes/the-s-o-l-i-d-principles-in-pictures-b34ce2f1e898
Thanks, Open for more suggestions.