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.
SOLID principles are important for multiple reasons:
Let’s now explore each of the SOLID principles in detail with real-world examples.
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:
If there is any change in the core logic of SavingsAccount
class (like debit
, credit
, etc.).
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.
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:
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.
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.
}
}
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.
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