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 class that allows basic operations like debit, credit, and . The method takes an enum called (like Email, SMS, etc.) and sends the update with the appropriate medium. We will write the code for that as shown below. SavingsAccount sendUpdates sendUpdate NotificationMedium 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 class above, it can change due to multiple reasons: SavingsAccount If there is any change in the core logic of class (like , , etc.). SavingsAccount debit credit 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 or the format, we will change the class. However, if there is a change in the core logic of , there will be changes in the class. NotificationMedium Sender SavingsAccount SavingsAccount 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 which will contain a list of (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 class. Cart Item Cart 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, method calculates the cart value by iterating over all the items inside the cart and invoking logic based on the type of item. calculateCartValue 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 and write another condition inside it which checks for items of type . Cart else if 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 class abstract and make concrete classes for different types of (s) as shown below. Item Item 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 , , and implement the method which contains the business logic for the taxation and value calculation. GroceryItem GiftItem ElectronicItem getValue() Now, we will make the class depend on the abstract class and invoke the method for each item as shown below. Cart Item getValue() 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 (s) are introduced, the class remains unchanged. Due to polymorphism, whatever is the actual type of the inside the , that class’s method would be invoked. Item Cart Item items ArrayList getValue() 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 and two classes and class. Now, let’s say we create a method called as within the Vehicle class, it can be overridden in class, but it will be unsupported in the class as doesn’t have an engine (see code sample below) Vehicle Car Bicycle startEngine() Car Bicycle Bicycle 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 method. If, while calling that piece of code instead of passing an object of type we pass a object, it would lead to issues in the code. Since the (s) class’s method will throw an exception when the method is called. This would be a violation of the SOLID Principles (Liskov’s substitution principle) startEngine() Vehicle Bicycle Bicycle startEngine() To resolve this issue, we can create two classes and and have inherit from the class and have inherit from MotorizedVehicle NonMotorizedVehicle Car MotorizedVehicle Bicycle 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 and data structure, LinkedList ArrayList class implements the following interface(s): , , , , , and . ArrayList Serializable Cloneable Iterable Collection List RandomAccess class implements , , , , , , and . LinkedList Serializable Cloneable Iterable Collection Deque List Queue That’s quite a lot of interfaces! Rather than having so many interfaces, Java developers could have combined , , , , , and into one interface, let’s say interface. Now, both and classes could have implemented this new interface. Serializable Cloneable Iterable Collecton List RandomAccess IList ArrayList LinkedList IList However, since does not support random access, it could have implemented the methods in the interface and could have thrown when somebody tries to call it. LinkedList RandomAccess UnsupportedOperationException 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 interface even though not required. RandomAccess 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 example we saw above and enhance it to add some payment options. Let’s assume we have two types of payment options with us and . Now, in the class, we want to add a method to which would calculate the cart value and initiate the payment based on the supplied payment. method. Cart DebitCard Paypal Cart placeOrder To do this, we could have added dependency in the example above by adding the two payment options as fields inside the class. However, that would tightly couple the class with and class. Cart Cart Cart DebitCard Paypal Instead of that, we would create a interface and have both the and classes implement the interfaces. Now, the class will depend on the interface, and not the individual payment types. This keeps the classes loosely coupled. Payment DebitCard Paypal Payment Cart Payment 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