The Developer’s Guide to SOLID Design Principles

Building stable and robust software is one of the primary goals of any software development project. While it is crucial to keep your code error-free for that, the real strength of your application comes from its foundation—the architecture of your code.

SOLID is one of the most popular collections of software design principles. Its goal is to help you avoid common pitfalls and plan your application’s architecture from a very high level.

Robert C. Martin first introduced the SOLID principles in his paper “Design Principles and Design Patterns.” Michael Feather later built upon these principles and paved the path towards the acronym SOLID. Since then, these principles have continued to revolutionize how we structure and develop our modern applications.

In this guide, we will take a look at these design principles in detail. We will analyze each constituent to its core—its meaning and its importance together. Feel free to navigate the article using these links:

What are the SOLID Design Principles?

The SOLID design principles are five software design principles that enable developers to write efficient and optimized code. As a modern software engineer, it is a no-brainer to be familiar with object-oriented programming and its various concepts, like abstraction, inheritance, encapsulation, etc. But it can get tricky to make the best use of these concepts in every application you work on. SOLID design principles aim to solve this problem for you by introducing principles that help you accommodate these concepts in your code in the most efficient way possible.

The SOLID principles focus on writing sustainable code—code that you can maintain as it grows. If you adopt these practices, you can look forward to a code repository free of code smells, requires minimal code refactoring with time, and easily accommodates in an agile or adaptive development process.

SOLID stands for:

These five principles, even though meant to be independent, overlap now and then. Also, these are not supposed to be implemented strictly in their entirety; you can use them broadly while structuring your app.

Single Responsibility Principle

The S in the SOLID design principles stands for the Single Responsibility Principle. Robert C Martin defined it, and it states that in a properly designed application, each module of code should ideally have only one responsibility at hand. Responsibility here refers to the sense of having a purpose to fulfill or a function to complete.

If your method or class takes up more than one job to do, you are straightaway writing co-dependent code. If you make changes to the logic of one of the jobs of the given function or class, you might end up messing with the second job’s functionality as well. If this happens to a small app, it is bad enough; but it can become nearly impossible to fix if it reaches an enterprise-level application.

Why it’s Important

Some of the most crucial reasons why this principle is essential are:

Making Changes Becomes Easy

With highly uncoupled classes and methods, you can move around and make changes as needed without worrying how other methods and logic would be affected. In situations where methods couple tightly together, making a slight change in the logic means going through the complete line of inter-dependent code modules and making the required changes everywhere, taking extra care not to mess with other non-related logic.

Unit Testing is Simplified

When you have modular code to test, it is pretty simple to test each method or class independently without refactoring any code. The purpose of unit testing aligns with that of the Single Responsibility Principle; each test case should test only one functionality of the code, just as each method should focus on one responsibility in the app.

If your code is not rightly modularised, you will find yourself working around the original code while defining clear test cases. More often than not, you will have to refactor large chunks of existing code to make them ready for unit testing. You can accommodate this effort earlier by keeping modularity as an integral part of the application design.

Code Becomes Easier to Understand

Modularity is directly related to simplicity. If each module of your code focuses on one job, it will be super easy for any person reading the code to figure out the purpose. This further adds to the codebase’s maintainability since understanding the intent behind existing code modules is an integral part of adding or updating functionalities in the app.

Scaling Your Codebase is an Easy Job

Since your code has singular modules that make up the application, it is easy to add or remove these modules to alter your application. You don’t need to be revisiting the basic foundations of your app to introduce newer features later in the lifecycle of the application. 

Example

Let’s understand this principle better using a simple example. Let’s say you are building an application that allows users to store the details of books. This is what a typical books class looks like in your Java application:

public class Book {
private int pageCount;
private String author;
private String title;

public int getPageCount() { return pageCount; }
public String getAuthor() { return author; }
public String getTitle() { return title; }

      public void setPageCount(int pageCount) {
            this.pageCount = pageCount;
      }
public void setAuthor(String author) {
            this.author = author;
      }
public void setTitle(String title) {
this.title = title;
      }

      public String getCurrentPage() {
      // logic to return current page data
      }
      public void goToPage(int pageNumber) {
      // logic to turn to the given page
      }

}

Now imagine if you needed to add a printing functionality to your books. It means that you would need a method that you could use to print any book to an output stream(which could be the screen, a printer, or an external file). It may seem tempting to create another method for it like this:

public class Book {
private int pageCount;
private String author;
private String title;

//...

      public void goToPage(int pageNumber) {
      // logic to turn to the given page
      }
      public void printBook(String outputType) {
      switch(outputType) {
      case "FILE": // logic to print to file
      case "STDOUT":       // logic to print to screen
      // ...
      }
      }
}

However, this becomes a clear violation of the single responsibility principle. Your books class is now doing much more than just managing the data of your books. This is how you can abstract out the printing logic to make your code comply with the Single Responsibility Principle:

// Leave the book class as it is
public class Book {
private int pageCount;
private String author;
private String title;

public int getPageCount() { return pageCount; }
public String getAuthor() { return author; }
public String getTitle() { return title; }

      public void setPageCount(int pageCount) {
            this.pageCount = pageCount;
      }
public void setAuthor(String author) {
            this.author = author;
      }
public void setTitle(String title) {
this.title = title;
      }

      public String getCurrentPage() {
      // logic to return current page data
      }
      public String goToPage(int pageNumber) {
      // logic to turn to the given page
      }

}


// add a printer interface
interface Printer {
public void print(Book book);
}

// create a printer class for printing to file
class FilePrinter implements Printer {
public void print(Book book) {
// logic to print the given book to a file
}
}

// create a printer class for printing to stdout
class ScreenPrinter implements Printer {
public void print(Book book) {
// logic to print the given book to the standard output device
}
}

Now you can use these two printers in your code to print a book object as needed!

Open/Closed Principle

First defined by Bertrand Meyer in his 1988 book, the Open/Closed Principle states that in an ideal code repository, software entities such as classes, modules, functions, etc., should be open for extension but closed for modification.

The idea behind this principle is fundamental to the development of maintainable codebases. If your code is open for modification, you are vulnerable to inconsistencies with every change, which will lead to bugs and errors in the code. Also, you can not restrict your code modules to be inflexible; doing so will create a copy of the entire module to introduce a small change or addition.

This principle is considered one of the essential rules in object-oriented programming and was first implemented using inheritance. The idea is not to change the original class written first; instead, extend it to create a new class and add the required changes. While this made sense as it obeyed the rules of the principle, it, however, started adding to the complexity of the codebase. Every time a child class was created by extending a parent class, it depended on the parent class. If any changes were ever introduced in the parent class for whatsoever reasons, the child class was affected too.

As you might already know, polymorphism in object-oriented programming comes about with the help of interfaces. Interfaces are blueprints or outlines which you can use to generate classes. Just like classes, interfaces are extendable too. Hence if you create two classes using the same interface, none of the two classes will be affected by a change in the other. The use of an interface introduces an additional layer of abstraction, which loosens the coupling between the code modules. To remove this coupling, the open/closed principle shifted its implementation from inheritance to polymorphism.

Why it’s Important

Having understood the principle and its popular implementations in detail, now let’s look at the benefits this principle brings to the table.

Reduced Redundancies & Bugs

The first issue that this principle solves is that of redundancies and bugs introduced due to uncoordinated changes. In the case of a tightly coupled code, a slight change in one part can trigger issues in several places. You certainly don’t want to have a codebase waiting to explode with bugs on any new change.

The open/closed principle moderates the way you make changes in your codebase. It limits you from making changes in places that can affect other code segments with unpredictable results and instead provides you with a way to modify and grow your codebase safely.

Increased Flexibility

The next issue that this principle solves is that of uncoordinated changes and inflexible code modules. If you were to write out your application using classes or modules that were utterly unrelated to each other, you would be unnecessarily writing a lot of redundant code over and over again. If you were to restrict yourself from making changes to existing code, you would not be able to reuse existing code while solving newer problems. The open part of this principle helps you utilize the code you have already written and reuse it to create new solutions.

The use of inheritance and polymorphism allows developers to reuse code and not worry about breaking old functionality. This adds a whole new level of reliable flexibility to your codebase.

Example

The open-closed example can be best understood using the example of area calculation or shapes. Let’s take a look at the example implemented using polymorphism, i.e., interface:

interface Shape {
    public int getArea();
}

class Rectangle implements Shape {
    private int length;
    private int breadth;
   
    public Rectangle(int length, int breadth) {
        this.length = length;
        this.breadth = breadth;
    }
   
    public int getArea() {
        return length * breadth;
    }
}


class Square implements Shape {
    private int side;
   
    public Square(int side) {
        this.side = side;
    }
   
    public int getArea() {
        return side * side;
    }
}

public class Main {
   
    private void printShapeAreas(Shape[] shapes) {
        for (int i=0; i<shapes.length; i++) {
            System.out.println(shapes[i].getArea());
        }
    }
   
    public static void main(String[] args) {
       
        Shape[] shapes = new Shape[4];
       
        shapes[0] = new Rectangle(1, 2);
        shapes[1] = new Rectangle(8,4);
        shapes[2] = new Square(4);
        shapes[3] = new Square(8);
       
        Main main = new Main();
        main.printShapeAreas(shapes);
       
    }
}

As you can see, the two shapes (rectangle and square) have a common parent Shape. Because of the presence of the Shape interface, you do not need to change the Main class whenever you introduce a new shape in your program. If you were to add a Circle class to your program, all you would do would be to add this class in the code:

class Circle implements Shape {
    private int radius;
   
    public Circle(int radius) {
        this.radius = radius;
    }
   
    public int getArea() {
        return (22 / 7) * radius * radius;
    }
}

The Main class’s printShapeAreas method would remain unchanged, as Circle would be a child of the Shape interface with a definition for the getArea method. This is how the Open/Closed Principle makes programming easy.

Liskov Substitution Principle

Introduced in 1987 by Barbara Liskov in her conference keynote “Data abstraction,” the Liskov principle adds to the open/closed principle discussed earlier. The Liskov principle states that you should design your class hierarchy such that the objects of a superclass should be replaceable with objects of its subclasses without breaking the code.

This basically means that the objects of subclasses should behave in the same way as those of the superclass objects. Their methods’ input and output entities determine the behavior of objects. So, to implement the Liskov substitution principle, you need to keep these two points in mind:

Implementing the Liskov Substitution principle is trickier than implementing the first two principles; there are no predefined language constructs to do so. More than its structure, the code’s behavior is essential for the proper enforcement of this principle. You need to implement your own checks and standards to ensure that your code implements the Liskov Substitution principle. An excellent way to do this is to write test cases and conduct code reviews to analyze and perfect the behavior of your codebase.

Why it’s Important

The Liskov Substitution principle is necessary to keep in mind if you aim to build scalable and maintainable applications. Here are some of the top advantages of implementing the Liskov Substitution principle in your codebase:

Better Design and Scalability

The Liskov Substitution Principle emphasizes creating a robust design from the beginning, which can help avoid any future errors. Like code smell, design smell is an actual threat to software projects, and the use of the Liskov Substitution principle ensures that your project is free of any design smells.

This principle helps you figure out when you have generalized a concept prematurely or created a super/subclass that you did not need. It also accounts for the flexibility required by future requirements and updates.

If your code does not handle the superclass-subclass relationship well, you will have to check object types using the instanceof or its equivalent keywords in your code. This conditional structure of your code will be extremely difficult to maintain. The Liskov principle can help you quickly avoid such situations. 

Increased Code Reusability and Reduced Codependency

Drawing upon the instanceof example from the last section, using the Liskov principle helps decouple the code modules, i.e., classes, from one another. The fewer you run checks for class types, the easier it is to add new modules to your code without worrying about the existing modules and their type checks. In some cases, it might not even be possible to identify all places in the current code and change them. This can result in otherwise avoidable bugs.

Not using the Liskov principle reduces code reusability as well. With the help of the Liskov principle, you can freely exchange objects of children classes with objects of parent classes as and when needed. You do not need to worry about compatibility issues and other bugs that may arise when doing so. This can come in handy in some situations, which we will see in the examples section.

Example

Let’s take the example of birds to understand the Liskov Substitution principle better. Let’s say you have a class called Bird:

public class Bird {
    public void fly throws CanNotFlyException(){}
}

You intend to use this as a base for other bird classes that you will create later. You decide to create a crow class that looks like this:

public class Crow extends Bird {
    public void fly throws CanNotFlyException() {
        // logic to fly here
    }
}

All seems good until now. But what if you were to create an Ostrich class? We know for a fact that Ostriches can not fly, so the fly method, in this case, would be redundant. You might want to throw a CanNotFlyException to make sure that nobody forces a poor Ostrich to fly. This is what your class would look like:

public class Ostrich extends Bird {
    public void fly throws CanNotFlyException() {
        throw new CanNotFlyException("Ostriches can not fly!");
    }
}


This is a clear violation of the Liskov Substitution principle since you can not call Ostrich’s fly method like any other bird’s fly method without causing an exception. To fix this, you need to introduce another parent class called FlyingBirds. Your code would now look like this:

public class Bird {}
public class FlyingBird extends Bird {
    public void fly() {}
}

public class Crow extends FlyingBird {
    public void fly() {
        // logic to fly here
    }
}

public class Ostrich extends Bird {
    // other methods here
}

Interface Segregation Principle

The fourth principle in the series is the interface segregation principle. As apparent from its name, the principle advocates that your interfaces should be discrete, and classes should not implement interfaces that contain methods irrelevant to them. In more sophisticated terms, clients should not have to depend upon interfaces that they do not use.

This principle is an extension of the single responsibility principle, just for interfaces. The main goal of this principle is to break down the application interfaces into smaller ones and eliminate the side effects of large, singular interfaces with redundant elements. While it takes more time to design such structures, its benefits are manifold.

Why it’s Important

The Interface Segregation principle aims to provide more flexibility and robustness to the application. Here are some of the top benefits that it offers:

Reduced Side-effects Due to Unneeded Elements

Since the principle aims to break down more extensive interfaces into smaller ones and eliminate the members that are not relevant to the class implementing the interface, it reduces the issues that may arise due to such methods not being appropriately handled otherwise. 

In standard cases, you would have had to implement the unneeded method in your class and throw an exception every time it was called. This could lead to unexpected results in cases where the method was called mistakenly, and it also required additional efforts to ensure such methods throw the right exceptions. The use of this principle eliminates the need for such exception handling.

Enhanced Code Modularity and Flexibility

With smaller and more granular interfaces, you will get finer control over your codebase. Each segment will be independent of one another, and you can implement more detailed tests to ensure that your code is behaving correctly in all situations. 

Apart from robustness, you also gain the flexibility of modifying your application logic as and when needed. You do not need to worry about the existing code breaking, as each of the interfaces and classes is separate.

Example

Let’s take an example of bank transactions to understand the interface segregation principle. Let’s say there’s a bank that allows you to make cash payments. Here’s how it has defined its classes:

public interface Payment {
    void initiatePayment();
    Object getStatus();
}

public class AccountPayment implements Payment {
    @Override
    public void initiatePayment() {
        // logic to initiate a payment here
    }
   
    @Override
    public Object getStatus() {
        // logic to get the status of the payment here
    }
}

Everything seems good up to now. Let’s say the bank now decides to provide loan payments as well. They have their methods like disburseLoan and initiateRepayment. If the bank were to use the same Payment interface for the loan payments as well, it would pollute the codebase:

public interface Payment {
    void initiatePayment();
    Object getStatus();
   
    void disburseLoan();
    void initiateRepayment();
}

public class AccountPayment implements Payment {
    @Override
    public void initiatePayment() {
        // logic to initiate a payment here
    }
   
    @Override
    public Object getStatus() {
        // logic to get the status of the payment here
    }
   
    @Override
    public void disburseLoan() {
        throw new Exception("Operation not supported in account payments")
    }
   
    @Override
    public void initiateRepayment() {
        throw new Exception("Operation not supported in account payments")
    }
}

public class LoanPayment implements Payment {
    @Override
    public void initiatePayment() {
        throw new Exception("Operation not supported in loan payments")
    }
   
    @Override
    public Object getStatus() {
        // logic to get the status of the payment here
    }
   
    @Override
    public void disburseLoan() {
        // logic to initiate a loan disbursal here
    }
   
    @Override
    public void initiateRepayment() {
        // logic to initiate loan repayment here
    }
}


As you can see, both of our Payment classes now have unnecessary methods which have to be dealt with separately. This violates the Interface Segregation Principle, and you can fix this by creating separate interfaces for the two types of payments. Here’s what your final code would look like:

public interface Payment {
    Object getStatus();
}

public interface Account extends Payment {
    void initiatePayment();
}

public interface Loan extends Payment {
    void disburseLoan();
    void initiateRepayment();
}


public class AccountPayment implements Account {
    @Override
    public void initiatePayment() {
        // logic to initiate a payment here
    }
   
    @Override
    public Object getStatus() {
        // logic to get the status of the payment here
    }
}

public class LoanPayment implements Loan {
   
    @Override
    public Object getStatus() {
        // logic to get the status of the payment here
    }
   
    @Override
    public void disburseLoan() {
        // logic to initiate a loan disbursal here
    }
   
    @Override
    public void initiateRepayment() {
        // logic to initiate loan repayment here
    }
}

As you can see, each of the clients (classes) does not define any more irrelevant methods.

Dependency Inversion

The dependency inversion principle dictates that you should create higher-level modules of a codebase in such a way that they are reusable and unaffected by any change in other low-level modules. To achieve this, abstraction has to be introduced that decouples the higher-level modules from the lower-level modules.

The Dependency Inversion principle achieves this by two rules:

Dependency Injection (DI) is a popular technique that provides the indirection needed to avoid depending on low-level details and coupling with other classes. Dependency Injection forces classes to rely on a higher-level abstraction of their dependencies, thereby removing the hard coupling between two exact classes.

Why it’s Important

Dependency Inversion is one of the most important principles if you want to build a robust application for your end-users. Apart from the usual benefit of reduced coupling and increased modularity, here are some other reasons why this principle is super important in your project:

Reduced Dependencies

The whole purpose of this principle is to reduce direct dependencies between code entities. The most significant benefit that it offers is that your classes don’t depend on each other. You can freely make modifications to your application’s structure without worrying about the consistency of dependencies.

Reducing dependencies and coupling helps to reuse higher-level modules more often. This, in turn, reduces workload as well as leaves less room for bugs to creep in.

Stable Application Design

The ability to reuse high-level modules over and over again contributes to a sound application design. This adds to the stability of your application design since you do not need to go editing your parent classes to introduce new changes to your application’s structure. The use of elaborate abstraction allows you to introduce entirely new features without touching the high-level modules of your original codebase.

Example

Let’s take the example of a software team’s hierarchy to understand the Dependency Inversion Principle better. Say there’s a software team that consists of a manager and a few other members like designers, developers, testers, etc. In this system, a manager has the authority to add teammates to their team. Here’s how the classes for these entities look like:

class Manager{
    List<Developer> developers;
    List<Tester> testers;
    List<Designer> designers;
   
    public void addDeveloper(Developer developer) {
        developers.add(developer);
    }
   
    public void addTester(Tester tester) {
        testers.add(tester);
    }
   
    public void addDesigner(Designer designer) {
        designers.add(designer);
    }
}

class Developer {
    public Developer() {
        System.out.println("Developer created");
    }
   
    public void work() {
        System.out.println("Developing applications");
    }
}

class Tester {
    public Tester() {
        System.out.println("Tester created");
    }
   
    public void work() {
        System.out.println("Testing applications");
    }
}

class Designer {
    public Designer() {
        System.out.println("Designer created");
    }
   
    public void work() {
        System.out.println("Designing applications");
    }
}


As you can see, the above code is very inflexible. If you were to add another type of team member, say QA, you would have to redo the Manager class, which is inefficient.

class Manager{
    List<Developer> developers;
    List<Tester> testers;
    List<Designer> designers;
    // new list to hold the qa members
    List<QA> qas;
   
    public void addDeveloper(Developer developer) {
        developers.add(developer);
    }
   
    public void addDeveloper(Tester tester) {
        testers.add(tester);
    }
   
    public void addDeveloper(Designer designer) {
        designers.add(designer);
    }
   
    // new method to add qa members
    public void addQa(QA qa) {
        qas.add(qa);
    }
}

class Developer {
    public Developer() {
        System.out.println("Developer created");
    }
   
    public void work() {
        System.out.println("Developing applications");
    }
}

class Tester {
    public Tester() {
        System.out.println("Tester created");
    }
   
    public void work() {
        System.out.println("Testing applications");
    }
}

class Designer {
    public Designer() {
        System.out.println("Designer created");
    }
   
    public void work() {
        System.out.println("Designing applications");
    }
}

class QA {
    public QA() {
        System.out.println("QA created");
    }
   
    public void work() {
        System.out.println("QAing applications");
    }
}


This would have been a lot simpler if you put the dependency inversion principle in place. Here’s how you could do it:

class Manager{
    List<TeamMember> teamMembers;
   
    public void addTeamMember(TeamMember teamMember) {
        teamMembers.add(teamMember);
    }
 
}

interface TeamMember {
    public void work();
}

class Developer implements TeamMember{
    public Developer() {
        System.out.println("Developer created");
    }
   
    public void work() {
        System.out.println("Developing applications");
    }
}

class Tester implements TeamMember{
    public Tester() {
        System.out.println("Tester created");
    }
   
    public void work() {
        System.out.println("Testing applications");
    }
}

class Designer implements TeamMember{
    public Designer() {
        System.out.println("Designer created");
    }
   
    public void work() {
        System.out.println("Designing applications");
    }
}


The TeamMember interface generalizes and abstracts the various type of members from the manager so that the manager class now does not have to depend on the team members. Adding a fourth member, say QA again, would be as simple as defining another class for it:

class QA implements TeamMember {
    public QA() {
        System.out.println("QA created");
    }
   
    public void work() {
        System.out.println("QAing applications");
    }
}

You do not need to make any changes in the Manager class anymore.

Use SOLID Principles to Create Better Software

The SOLID design principles are among the best ways to write high-performing, efficient code that can be maintained and scaled quickly. However, focusing on these specific design techniques, the codebase can very quickly become highly complex. Therefore it is important to choose when to implement a SOLID principle and keep it simple.

It is possible to apply SOLID principles to any application, but it is essential not to use a dagger to do a needle’s job. Making the right call about the design & architecture of your app is an equally important task in the SDLC as any other step. However, in most cases, sticking to well-tested design principles is better than finding a way on your own. Therefore, it is crucial to understand these five principles well and use them wherever possible.

Discovering when an application violates these principles is also essential to figure out where your app is leaking resources. Advanced tools like the Scout APM can keep a check on your application and alert you when anything goes off. You can try out the product for a 14-day free trial without a credit card or schedule a demo with one of their expert team members today!