paint-brush
SOLID Principles In Java: A Beginner's Guideby@ps06756
15,002 reads
15,002 reads

SOLID Principles In Java: A Beginner's Guide

by Pratik SinghalMarch 22nd, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

SOLID Principles are the principles of object oriented programming essential to develop scalable softwares. The principles are : S: Single Responsibility Principle O: Open / Closed Principle L: Liskov's Substitution Principle I: Interface Segregation Principle D: Dependency Inversion Principle
featured image - SOLID Principles In Java: A Beginner's Guide
Pratik Singhal HackerNoon profile picture

SOLID design principles are the most important design principles you need to know to write clean code. Having a solid command over SOLID principles is an indispensable skill for any programmer. They are the foundation on which other design patterns are developed.


In this article, we will tackle the SOLID design principles using some real-life examples and understand their importance.


Together with Polymorphism, Abstraction, and Inheritance, SOLID Principles are really important to be good at objective-oriented programming.

Why Are SOLID Principles Important?

SOLID principles are important for multiple reasons:


  • SOLID principles allow us to write clean & maintainable code: When we start a new project, initially the code quality is good because we have a limited set of features that we implement. However, as we incorporate more features the code starts becoming cluttered.


  • SOLID Principles build upon the foundations of Abstraction, Polymorphism, and Inheritance and lead to design patterns for common use cases. Understanding these design patterns helps to implement common use cases in programming.


  • SOLID Principles help us write clean code which improves the testability of the code. This is because the code is modular and loosely coupled. Each module can be developed independently and tested independently.


Let’s now explore each of the SOLID principles in detail with real-world examples.

1. Single Responsibility Principle

S in SOLID principles stands for Single Responsibility Principle. The Single Responsibility Principle states that a class should have only a single reason to change. This limits the number of places we need to make changes when incorporating additional requirements in our project.

Each class should have exactly one reason to change.

For example, let’s say we are designing a banking application in Java where we have a SavingsAccount class that allows basic operations like debit, credit, and sendUpdates. The sendUpdate method takes an enum called NotificationMedium (like Email, SMS, etc.) and sends the update with the appropriate medium. We will write the code for that as shown below.


public class SavingsAccount {
    public int balance;
    public String name;

    public SavingsAccount(int initialBalance, String name) {
        this.balance = initialBalance;
        this.name = name;
        System.out.println("Created a savings account with balance = " + initialBalance);
    }

    public void debit(int amountToDebit) {
        // debit business logic
    }

    public void credit(int amountToCredit) {
        // credit business logic
    }  

    public void sendNotification(NotificationMedium medium) {
        if (medium == NotificationMedium.SMS) {
            // Send SMS here
        }
        else if (medium == NotificationMedium.EMAIL) {
           // Send Email here
        }
    }
}


public enum NotificationMedium {
    SMS,
    EMAIL
}


Now, if you look at the SavingsAccount class above, it can change due to multiple reasons:


  1. If there is any change in the core logic of SavingsAccount class (like debit, credit, etc.).


  2. If the bank decides to introduce a new Notification medium (let’s say WhatsApp).


This is a violation of the Single Responsibility Principle in SOLID Principles. To fix, it we will make a separate class that sends the notification.


Let’s refactor the code above according to the SOLID Principles


public class SavingsAccount {
    public int balance;
    public String name;

    public SavingsAccount(int initialBalance, String name) {
        this.balance = initialBalance;
        this.name = name;
        System.out.println("Created a savings account with balance = " + initialBalance);
    }

    public void debit(int amountToDebit) {
        // debit business logic
    }

    public void credit(int amountToCredit) {
        // credit business logic
    }

    public void printBalance() {
        System.out.println("Name: " + name+ " Account Balance: " + balance);
    }

    public void sendNotification(Medium medium) { 
        Sender.sendNotification(medium, this);
    }
}


public enum NotificationMedium {
    SMS,
    EMAIL
}


public class Sender {
    public static void sendNotification(NotificationMedium medium, SavingsAccount account) {
        // extract account data from the account object
        if (medium == NotificationMedium.SMS) {
            //logic to send SMS here
        }
        else if (medium == NotificationMedium.EMAIL) {
            // logic to send Email here
        }
    }
}


Now, since we have refactored the code, if there is any change in the NotificationMedium or the format, we will change the Sender class. However, if there is a change in the core logic of SavingsAccount, there will be changes in the SavingsAccount class.


This fixes the violation that we observed in the first example.

2. Open/Close Principle

The Open Close principle states that we should design the classes in such a way so that they are open for extension (in case of adding additional features) but closed for modification. Being closed for modification helps us in two ways:


  • Many times, the original class source may not be even available. It could be a dependency consumed by your project.


  • Keeping the original class unchanged reduces the chances of bugs. Since there might be other classes that are dependent on the class that we want to modify.


To see an example of the Open Close Principle, let’s take a look at the example of a shopping cart (such as the one implemented on e-commerce websites).


I am going to create a class called as Cart which will contain a list of Item(s) that you can add to it. Depending on the type of item, and the taxation on it we want to create a method that calculates the total cart value inside the Cart class.


Classes should be open for extension and closed for modification


import java.util.ArrayList;
import java.util.List;

public class Cart {
    private List<Item> items;
 

    public Cart() {
        this.items = new ArrayList<>();
    }

    public void addToCart(Item item) {
        items.add(item);
    }

    public double calculateCartValue() {
        double value = 0.0;
        for(Item item: items) {
            if (item.getItemType() == GIFT) {
                // 8% tax on gift + 2% gift wrap cost
                value += (item.getValue()*1.08) + item.getValue()*0.02
            }
            else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) {
                value += (item.getValue()*1.11);
            }
            else {
                value += item.getValue()*1.10;  
            }
        }
        return value;
    }
}


@Getter
@Setter
public abstract class Item {
    protected double price;
    private ItemType itemType;    
    
    public double getValue() {
        return price;
    }
}


public enum ItemType {
    ELECTRONIC,
    GIFT
}


In the above example, calculateCartValue method calculates the cart value by iterating over all the items inside the cart and invoking logic based on the type of item.


Although this code looks correct, it violates the SOLID Principles.


Let’s say we need to add a new rule for a different type of item (say Grocery) while calculating cart value. In that case, we would have to modify the original class Cart and write another else if condition inside it which checks for items of type Grocery.


However, with little refactoring, we can make the code adhere to the open/close principle. Let’s see how.


First, we will make the Item class abstract and make concrete classes for different types of Item(s) as shown below.


public abstract class Item {
    protected double price;
    
    
    public double getValue() {
        return price;
    }
}


public class ElectronicItem extends Item {

    public ElectronicItem(double price) {
        super.price = price;
    }
    @Override
    public double getValue() {
        return super.getValue()*1.11;
    }
}


public class GiftItem extends Item {
    public GiftItem(double price) {
        super.price = price;
    }

    @Override
    public double getValue() {
        return super.getValue()*1.08 + super.getValue()*0.02;
    }
}


public class GroceryItem extends Item {

    public GroceryItem(double price) {
        super.price = price;
    }

    @Override
    public double getValue() {
        return super.getValue()*1.03;
    }
}


Inside each concrete Item classes like GroceryItem , GiftItem, andElectronicItem implement the getValue() method which contains the business logic for the taxation and value calculation.


Now, we will make the Cart class depend on the abstract class Item and invoke the getValue() method for each item as shown below.


import java.util.ArrayList;
import java.util.List;

public class Cart {
    private List<Item> items;

    public Cart(Payment paymentOption) {
        this.items = new ArrayList<>();
    }

    public void addToCart(Item item) {
        items.add(item);
    }

    public double calculateCartValue() {
        double value = 0.0;
        for(Item item: items) {
            value += item.getValue();
        }
        return value;
    }
}


Now, in this refactored code, even if new types of Item(s) are introduced, the Cart class remains unchanged. Due to polymorphism, whatever is the actual type of the Item inside the items ArrayList, that class’s getValue() method would be invoked.

3. Liskov’s Substitution Principle

Liskov’s substitution principle states that in a given code, even if we replace the object of a superclass with an object of the child class, the code shouldn’t break. In other words, when a subclass inherits a superclass and overrides its methods, it should maintain consistency with the behavior of the method in the super class.


For example, if we make the following classes Vehicle and two classes Car and Bicycle class. Now, let’s say we create a method called as startEngine() within the Vehicle class, it can be overridden in Car class, but it will be unsupported in the Bicycle class as Bicycle doesn’t have an engine (see code sample below)


The subclass should maintain consistency with the behavior of the superclass when overriding methods.

class Vehicle {
   public void startEngine() {
      // start engine of the vehicle
   }
}

class Car extends Vehicle {
  @Override
  public void startEngine() {
    // Start Engine
  }
}

class Bicycle extends Vehicle {
  @Override
  public void startEngine(){ 
    throw new UnsupportedOperationException("Bicycle doesn't have engine");
  }
}


Now, let’s say there is some code that expects an object of type vehicle and relies on the startEngine() method. If, while calling that piece of code instead of passing an object of type Vehicle we pass a Bicycle object, it would lead to issues in the code. Since the Bicycle(s) class’s method will throw an exception when the startEngine() method is called. This would be a violation of the SOLID Principles (Liskov’s substitution principle)


To resolve this issue, we can create two classes MotorizedVehicle and NonMotorizedVehicle and have Car inherit from the MotorizedVehicle class and have Bicycle inherit from NonMotorizedVehicle


class Vehicle {
}

class MotorizedVehicle extends Vehicle {
   public void startEngine() {
      // start engine here
   }
}

class Car extends MotorizedVehicle {
  @Override
  public void startEngine() {
    // Start Engine
  }
}

class NonMotorizedVehicle extends Vehicle {
   public void startRiding() {
     // Start without engine
   }
}

class Bicycle extends NonMotorizedVehicle {
  @Override
  public void startRiding(){ 
    // Start riding without the engine.
  }
}


4. Interface Segregation Principle

The “I” in SOLID Principles stands for the Interface Segregation Principle.


The Interface segregation principle states that rather than having bigger interfaces that force implementing classes to implement unused methods, we should have smaller interfaces and have classes implemented. This way, classes only implement the relevant methods and remain clean.

Divide your interfaces into multiple smaller interfaces rather than one big interface.

For example, let’s look at the built-in Collections framework in Java. Among other data structures, Java also provides LinkedList and ArrayList data structure,


ArrayList class implements the following interface(s): Serializable, Cloneable, Iterable, Collection, List, and RandomAccess.

LinkedList class implements Serializable, Cloneable, Iterable, Collection, Deque, List, and Queue.


That’s quite a lot of interfaces!


Rather than having so many interfaces, Java developers could have combined Serializable, Cloneable, Iterable , Collecton, List, and RandomAccess into one interface, let’s say IList interface. Now, both ArrayList and LinkedList classes could have implemented this new IList interface.


However, since LinkedList does not support random access, it could have implemented the methods in the RandomAccess interface and could have thrown UnsupportedOperationException when somebody tries to call it.


However, that would be a violation of the Interface Segregation principle in SOLID Principles as it would “force” the LinkedList class to implement the methods inside the RandomAccess interface even though not required.


Therefore, it is better to split the interface based on the common behavior and let each class implement many interfaces rather than one big interface.

Dependency Inversion Principle

The Dependency Inversion Principle states that classes at the upper level shouldn’t directly depend on the classes at the lower level. This causes a tight coupling b/w the two levels.


Instead of that, lower classes should provide an interface on which the upper-level classes should depend.


Depend on interfaces rather than classes


For example, let’s continue with the Cart example we saw above and enhance it to add some payment options. Let’s assume we have two types of payment options with us DebitCard and Paypal . Now, in the Cart class, we want to add a method to placeOrder which would calculate the cart value and initiate the payment based on the supplied payment. method.


To do this, we could have added dependency in the Cart example above by adding the two payment options as fields inside the Cart class. However, that would tightly couple the Cart class with DebitCard and Paypal class.


Instead of that, we would create a Payment interface and have both the DebitCard and Paypal classes implement the Payment interfaces. Now, the Cart class will depend on the Payment interface, and not the individual payment types. This keeps the classes loosely coupled.


See the code below.



public interface Payment {
    void doPayment(double amount);
}



public class PaypalPayment implements Payment {
    @Override
    public void doPayment(double amount) {
        // logic to initiate paypal payment
    }
}


public class DebitCardPayment implements Payment {
    @Override
    public void doPayment(double amount) {
        // logic to initiate payment via debit card
    }
}



import java.util.ArrayList;
import java.util.List;

public class Cart {
    private List<Item> items;

    private Payment paymentOption;


    public Cart(Payment paymentOption) {
        this.items = new ArrayList<>();
        this.paymentOption = paymentOption;
    }

    public void addToCart(Item item) {
        items.add(item);
    }

    public double calculateCartValue() {
        double value = 0.0;
        for(Item item: items) {
            value += item.getValue();
        }
        return value;
    }

    public void placeOrder() {
        this.paymentOption.doPayment(calculateCartValue());
    }
}


If you are interested in learning more about object-oriented programming, GoF design patterns, and low-level design interviews, then do check my highly rated course Object Oriented Programming + Java Design Patterns