SOLID Principles In Action with Java

SOLID Principles In Action with Java

Let’s use SOLID in day to day programming

Hi all! I hope you are safe and fine…If you are a good programming fellow, the content that I’m going to discuss should be a well known topic! So, without more talk, let’s dive into the topic.

SOLID principles were introduced by Robert C. Martin. They are design principles to create more maintainable, understandable, and flexible software code. These thumb rules help us to set up our code base with fewer complications. What are they?

  1. Single Responsibility Principle (SRP) [ S ]
  2. Open-Closed Principle (OCP) [ O ]
  3. Liskov Substitution Principle (LSP) [ L ]
  4. Interface Segregation Principle (ISP) [ I ]
  5. Dependency Inversion Principle (DIP) [ D ]

Let’s take one by one with practical examples.


Single Responsibility Principle (SRP)

This principle states that every Java class must perform a single functionality. Here, single functionality means: class has to perform actions that belong only to that class.

Let’s say we have a class called BankService. Before SRP is applied, it will be like this. All the actions including deposit, withdrawal, sending notifications, printing the passbook are done by BankService. This way, BankService class has multiple responsibilities which are not related to each other.

public class BankService {

    public void withdraw(double amount) {
        System.out.println("Withdraw money : " + amount);
    }

    public void deposit(double amount) {
        System.out.println("Deposit money : " + amount);
    }

    public String getLoanInfo(String loanType) {
        if (loanType.equals("professional")) {
            return "Professional Loan";
        } else if (loanType.equals("home")) {
            return "Home Loan";
        } else {
            return "Personal Loan";
        }
    }

    public void printPassbook() {
        System.out.println("Printing Book Details...");
    }

    public void sendOTP(String medium) {
        if (medium.equals("mobile")) {
            System.out.println("Sending OTP to mobile");
        } else if (medium.equals("email")) {
            System.out.println("Sending OTP to email");
        } else {
            System.out.println("Not a valid medium");
        }
    }

}

Let’s apply SRP to BankService. We can segregate the responsibilities into a set of services.

// BankService
public class BankService {
    public void withdraw(double amount) {
        System.out.println("Withdraw money : " + amount);
    }
    public void deposit(double amount) {
        System.out.println("Deposit money : " + amount);
    }
}
// LoanService
public class LoanService {
    public String getLoanInfo(String loanType) {
        if (loanType.equals("professional")) {
            return "Professional Loan";
        } else if (loanType.equals("home")) {
            return "Home Loan";
        } else {
            return "Personal Loan";
        }
    }
}
// PrinterService
public class PrinterService {
    public void printPassbook() {
        System.out.println("Printing Book Details...");
    }
}
// NotificationService
public class NotificationService {
    public void sendOTP(String medium) {
        if (medium.equals("mobile")) {
            System.out.println("Sending OTP to mobile");
        } else if (medium.equals("email")) {
            System.out.println("Sending OTP to email");
        } else {
            System.out.println("Not a valid medium");
        }
    }
}

Now you can see each class is performing its own actions. This way, the code looks more clear and understandable. That’s all about SRP!


Open-Closed Principle (OCP)

This principle states that according to new requirements the module should be open for extension but closed for modification. We should be able to add an extension to the existing code, without changing the original basic implementation which makes us easier to extend logic.

Let’s say we have a service to send notifications to various mediums named as NotificationService. You remember the previous example…Am I correct? So, look at that Service. There we had two ways to send OTP notifications. They were mobile and email. What happens if a new requirement comes to send OTP notifications through WhatsApp. Just imagine…What should we do? We have to modify the code original Service! This violates OCP!!!

Let’s apply OCP to this scenario. I’m going to re-implement this NotificationService class. It should be an interface actually. Then I will implement the interface into another 3 services called MobileNotificationService, EmailNotificationService, and WhatsAppNotificationService.

public interface NotificationService {
    void sendOTP(String medium);
    void sendTransactionHistory(String medium);
}
public class MobileNotificationService implements NotificationService {
    @Override
    public void sendOTP(String medium) {
        System.out.println("Sending OTP Number Message to: " + medium);
    }
    @Override
    public void sendTransactionHistory(String medium) {
        System.out.println("Sending Transactions Message to: " + medium);
    }
}
public class EmailNotificationService implements NotificationService {
    @Override
    public void sendOTP(String medium) {
        System.out.println("Sending OTP Number Email to: " + medium);
    }
    @Override
    public void sendTransactionHistory(String medium) {
        System.out.println("Sending Transactions Email to: " + medium);
    }
}
public class WhatsAppNotificationService implements NotificationService {
    @Override
    public void sendOTP(String medium) {
        System.out.println("Sending OTP Number to: " + medium);
    }
    @Override
    public void sendTransactionHistory(String medium) {
        System.out.println("Sending Transactions Details to: " + medium);
    }
}

If I want to send notifications from all 3 types, I can do it like this.

image.png

Result: image.png

What happens if another type of medium comes into play? Then we just need to create another service, implement it from NotificationService and implement the logic related to the new medium!

That’s all guys! We have successfully applied OCP.


Liskov Substitution Principle (LSP)

This is said to be the toughest principle to understand by the majority of developers. This was introduced by Barbara Liskov. It applies to inheritance in such a way that the derived classes must be completely substitutable for their base classes.

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

Let’s understand this with an example…But let me tell you: this principle will make the article more lengthy.

Let’s assume we are going to manage multiple types of Social Media platforms. They are Facebook, Instagram, and WhatsApp.

image.png

So, SocialMedia class have 3 methods called chat(), publish() and groupCall(). I will first implement this without applying LSP.

public abstract class SocialMedia {
    abstract void chat(String user);
    abstract void publish(Object post);
    abstract void groupCall(String... users);
}
public class Facebook extends SocialMedia {
    @Override
    void chat(String user) {

    }
    @Override
    void publish(Object post) {

    }
    @Override
    void groupCall(String... users) {

    }
}
public class Instagram extends SocialMedia {
    @Override
    void chat(String user) {

    }
    @Override
    void publish(Object post) {

    }
    @Override
    void groupCall(String... users) {

    }
}
public class WhatsApp extends SocialMedia {
    @Override
    void chat(String user) {

    }
    @Override
    void publish(Object post) {

    }
    @Override
    void groupCall(String... users) {

    }
}

SocialMedia is an abstract class and all other platforms are its children. Expected logic is now implemented. So, what’s wrong here?

Did you notice that doing this way, some platforms had to maintain responsibilities even if that responsibility is not supported by the platform! Let me explain…

Facebook: support all actions

WhatsApp: does not support publish posts

Instagram: does not support groupCall

So, in this case, Instagram or WhatsApp can not be completely substitutable with SocialMedia class!

Let’s apply LSP here.

public interface SocialMedia {
     void chat(String user);
}
public interface PostManager {
    void publish(Object post);
}
public interface VideoCallManager {
    void groupCall(String... users);
}
public class Facebook implements SocialMedia, PostManager {
    @Override
    public void publish(Object post) {
        System.out.println("Publishing a post on Facebook: " + post);
    }
    @Override
    public void chat(String user) {
        System.out.println("Chatting on Facebook with: " + user);
    }
}
public class WhatsApp implements SocialMedia, VideoCallManager {
    @Override
    public void chat(String user) {
        System.out.println("Chatting on WhatsApp with: " + user);
    }
    @Override
    public void groupCall(String... users) {
        System.out.println("Taking a Group Call on WhatsApp with: " + Arrays.toString(users));
    }
}

You can see now SocialMedia is an interface with common responsibility called chat(). So, we have separate interfaces to manage video calls and publishing posts. This way all subclasses: Facebook and WhatsApp perform the actions that they can only do!

WhatsApp is a child to both SocialMedia and VideoCallManager Facebook is a child to both SocialMedia and PostManager Let’s check with the definition:

If class WhatsApp is a subtype of class SocialMedia, then we should be able to replace SocialMedia with WhatsApp without interrupting the behavior of the program. OK!!!

We are completely fine to replace SocialMedia with WhatsApp We are completely fine to replace SocialMedia with Facebook image.png

Result: image.png

This is all about LSP!!! We are done!


Interface Segregation Principle (ISP)

This principle states that the larger interfaces split into smaller ones. Because the implementation classes use only the methods that are required. We should not force the client to use the methods that they do not want to use.

This is somewhat similar to the Single Responsibility Principle also. Proper application design and correct abstraction is the key behind the Interface Segregation Principle.

Let’s take an example.

We have a Payment interface to represent all types of payments. BankPayment and LoanPayment are the implemented children from Payment.

public interface Payment {
    void init();
    Object status();
    List<Object> getPayments();
}
public class LoanPayment implements Payment {
    @Override
    public void init() {
        System.out.println("Initiate LoanPayment...");
    }
    @Override
    public Object status() {
        return "LoanPayment Status";
    }
    @Override
    public List<Object> getPayments() {
        return Arrays.asList("LoanPayment1", "LoanPayment2");
    }
}
public class BankPayment implements Payment {
    @Override
    public void init() {
        System.out.println("Initiate BankPayment...");
    }
    @Override
    public Object status() {
        return "BankPayment Status";
    }
    @Override
    public List<Object> getPayments() {
        return Arrays.asList("BankPayment1", "BankPayment2");
    }
}

Imagine we get a new requirement to add another method to the Payment interface. That is specific to LoanPayment class actually. Then we have to pollute the interface. Okay. But then we have to pollute the BankPayment class also since we are having interfaces here.

The new method will return payment time period: for Loan => 10 yrs

public interface Payment {
    void init();
    Object status();
    List<Object> getPayments();
    int getTimePeriod();
}
public class LoanPayment implements Payment {
    @Override
    public void init() {
        System.out.println("Initiate LoanPayment...");
    }
    @Override
    public Object status() {
        return "LoanPayment Status";
    }
    @Override
    public List<Object> getPayments() {
        return Arrays.asList("LoanPayment1", "LoanPayment2");
    }
    public int getTimePeriod() {
        return 10;
    }
}
public class BankPayment implements Payment {
    @Override
    public void init() {
        System.out.println("Initiate BankPayment...");
    }
    @Override
    public Object status() {
        return "BankPayment Status";
    }
    @Override
    public List<Object> getPayments() {
        return Arrays.asList("BankPayment1", "BankPayment2");
    }
    // not needed for BankPayment but we have to override
    public int getTimePeriod() {
        retun 0;
    }
}

This is not recommended. In this case, design matters guys! Let’s try to apply ISP here…

public interface Payment {
    void init();
    Object status();
    List<Object> getPayments();
}
public interface Loan extends Payment {
    int getTimePeriod();
}
public interface Bank extends Payment {
    int getOutstandingBalance();
}
public class LoanPayment implements Loan {
    @Override
    public int getTimePeriod() {
        return 10;
    }
    @Override
    public void init() {
        System.out.println("Initiate LoanPayment...");
    }
    @Override
    public Object status() {
        return "LoanPayment Status";
    }
    @Override
    public List<Object> getPayments() {
        return Arrays.asList("LoanPayment1", "LoanPayment2");
    }
}
public class BankPayment implements Bank {
    @Override
    public int getOutstandingBalance() {
        return 1000;
    }
    @Override
    public void init() {
        System.out.println("Initiate BankPayment...");
    }
    @Override
    public Object status() {
        return "BankPayment Status";
    }
    @Override
    public List<Object> getPayments() {
        return Arrays.asList("BankPayment1", "BankPayment2");
    }
}

This is how design makes our code much better! We have totally done with ISP. What we have done??? We used more interfaces and segregated the responsibilities in a comprehensive manner.

What happens if BankPayment related new requirement comes?

We just need to pollute the Bank interface and override it in BankPayment class. Any of the other classes are not touched even!

That’s all…


Dependency Inversion Principle (DIP)

This principle states that we must use abstraction (abstract classes and interfaces) instead of concrete implementations. High-level modules should not depend on the low-level module but both should depend on the abstraction.

Let me explain with another example here…

Assume we are going to create a Shopping scenario. So, we need a credit card or debit card to purchase items. Let’s create them and do a purchase!

public class CreditCard {
    public void doTransaction(double amount) {
        System.out.println("Transaction with CreditCard: " + amount);
    }
}
public class DebitCard {
    public void doTransaction(double amount) {
        System.out.println("Transaction with DebitCard: " + amount);
    }
}

Shopping Mall scenario

public class ShoppingMall {

    private DebitCard debitCard;

    public ShoppingMall(DebitCard debitCard) {
        this.debitCard = debitCard;
    }

    public void purchase(double amount) {
        this.debitCard.doTransaction(amount);
    }

    public static void main(String[] args) {
        DebitCard debitCard = new DebitCard();
        ShoppingMall shoppingMall = new ShoppingMall(debitCard);
        shoppingMall.purchase(10000);
    }
}

You can see here, DebitCard is tightly coupled as a dependency to ShoppingMall class. We need that to perform a purchase. So what happens if someone IS NOT HAVING a DebitCard? But he/she has a CreditCard! Now again we have to change ShoppingMall class and bind a CreditCard instead of a DebitCard. Design matters a lot!

Let’s apply DIP to the shopping mall…

public interface BankCard {
    void doTransaction(double amount);
}
public class DebitCard implements BankCard {
    @Override
    public void doTransaction(double amount) {
        System.out.println("Transaction with DebitCard: " + amount);
    }
}
public class CreditCard implements BankCard {
    @Override
    public void doTransaction(double amount) {
        System.out.println("Transaction with CreditCard: " + amount);
    }
}

We have introduced an interface as the Parent for both cards. It’s BankCard. Our new shopping mall will be now able to use any BankCard and not tightly coupled with a specific card type! See the below client code.

public class ShoppingMall {

    private BankCard bankCard;

    public ShoppingMall(BankCard bankCard) {
        this.bankCard = bankCard;
    }

    public void purchase(double amount) {
        this.bankCard.doTransaction(amount);
    }

    public static void main(String[] args) {
        BankCard bankCard1 = new DebitCard();
        BankCard bankCard2 = new CreditCard();
        ShoppingMall shoppingMall = new ShoppingMall(bankCard1);
        // ShoppingMall shoppingMall = new ShoppingMall(bankCard2);
        shoppingMall.purchase(10000);
    }
}

ShoppingMall now accepts any card as its dependency. That’s all! We implemented the last SOLID principle also…


I hope the examples I used are very close and relatable. I tried to make the flow with familiar scenarios. The first time I read about SOLID, it was like greek for me also. but gradually I understood them. The same way I understood the concepts are written here. So, this article is having much content. If you have any thoughts, the comment section is totally yours :D

Good Bye! Stay safe!