SOLID design principles: Building stable and flexible systems

| 15 min. (3024 words)

To build stable and flexible software, we need to keep software design principles in mind. Having error-free code is essential. However, well-designed software architecture is just as important.

SOLID is one of the most well-known sets of software design principles. It can help you avoid common pitfalls and think about your apps’ architecture from a higher level.

What are SOLID design principles?

SOLID design principles are five software design principles that enable you to write effective object-oriented code. Knowing about OOP principles like abstraction, encapsulation, inheritance, and polymorphism is important, but how would you use them in your everyday work? SOLID design principles have become so popular in recent years because they answer this question in a straightforward way.

The SOLID name is a mnemonic acronym where each letter represents a software design principle, as follows:

  1. S for Single Responsibility Principle
  2. O for Open/Closed Principle
  3. L for Liskov Substitution Principle
  4. I for Interface Segregation Principle
  5. D for Dependency Inversion Principle

The five principles overlap here and there, and programmers use them broadly. SOLID principles lead to more flexible and stable software architecture that’s easier to maintain and extend, and less likely to break.

Single Responsibility Principle

The Single Responsibility Principle is the first SOLID design principle, represented by the letter “S” and defined by Robert C Martin. It states that in a well-designed application, each class (microservice, code module) should have only one single responsibility. Responsibility is used in the sense of having only one reason to change.

When a class handles more than one responsibility, any changes made to the functionalities might affect others. This is bad enough if you have a smaller app but can become a nightmare when you work with complex, enterprise-level software. By making sure that each module encapsulates only one responsibility, you can save a lot of testing time and create a more maintainable architecture.

Example of the Single Responsibility Principle

Let’s see an example. I’ll use Java but you can apply SOLID design principles to any other OOP languages, too.

Say, we are writing a Java application for a book store. We create a Book class that lets users get and set the titles and authors of each book, and search the book in the inventory.

class Book {

	String title;
	String author;

	String getTitle() {
		return title;
	}
	void setTitle(String title) {
		this.title = title;
	}
	String getAuthor() {
		return author;
	}
	void setAuthor(String author) {
		this.author = author;
	}
	void searchBook() {...}

}

However, the above code violates the Single Responsibility Principle, as the Book class has two responsibilities. First, it sets the data related to the books (title and author). Second, it searches for the book in the inventory. The setter methods change the Book object, which might cause problems when we want to search the same book in the inventory.

To apply the Single Responsibility Principle, we need to decouple the two responsibilities. In the refactored code, the Book class will only be responsible for getting and setting the data of the Book object.

class Book {

	String title;
	String author;

	String getTitle() {
		return title;
	}
	void setTitle(String title) {
		this.title = title;
	}
	String getAuthor() {
		return author;
	}
	void setAuthor(String author) {
		this.author = author;
	}

}

Then, we create another class called InventoryView that will be responsible for checking the inventory. We move the searchBook() method here and reference the Book class in the constructor.

class InventoryView {

  Book book;

	InventoryView(Book book) {
		this.book = book;
	}

	void searchBook() {...}

}

On the UML diagram below, you can see how the architecture changed after we refactored the code following the Single Responsibility Principle. We split the initial Book class that had two responsibilities into two classes, each having its own single responsibility.

Single Responsibility Principle

Open/Closed Principle

The Open/Closed Principle is the “O” of SOLID’s five software design principles. It was Bertrand Meyer who coined the term in his book “Object-Oriented Software Construction”. The Open/Closed Principle states that classes, modules, microservices, and other code units should be open for extension but closed for modification.

So, you should be able to extend your existing code using OOP features like inheritance via subclasses and interfaces. However, you should never modify classes, interfaces, and other code units that already exist (especially if you use them in production), as it can lead to unexpected behavior. If you add a new feature by extending your code rather than modifying it, you minimize the risk of failure as much as possible. Besides, you also don’t have to unit test existing functionalities.

Example of the Open/Closed Principle

Let’s stay with our book store example. Now, the store wants to hand out cookbooks at a discount price before Christmas. We already follow the Single Responsibility Principle, so we create two separate classes: CookbookDiscount to hold the details of the discount and DiscountManager to apply the discount to the price.

class CookbookDiscount {

	String getCookbookDiscount() {

		String discount = "30% between Dec 1 and 24.";

		return discount;
	}

}

class DiscountManager {

	void processCookbookDiscount(CookbookDiscount discount) {...}

}

This code works fine until the store management informs us that their cookbook discount sales were so successful that they want to extend it. Now, they want to hand out every biography with a 50% discount on the subject’s birthday. To add the new feature, we create a new BiographyDiscount class:

class BiographyDiscount {

	String getBiographyDiscount() {

		String discount = "50% on the subject's birthday.";		

		return discount;

	}
}

To process the new type of discount, we need to add the new functionality to the DiscountManager class, too:

class DiscountManager {

	void processCookbookDiscount(CookbookDiscount discount) {...}

	void processBiographyDiscount(BiographyDiscount discount) {...}

}

However, as we changed existing functionality, we violated the Open/Closed Principle. Although the above code works properly, it might add new vulnerabilities to the application. We don’t know how the new addition would interact with other parts of the code that depends on the DiscountManager class. In a real-world application, this would mean that we need to test and deploy our entire app again.

But, we can also choose to refactor our code by adding an extra layer of abstraction that represents all types of discounts. So, let’s create a new interface called BookDiscount that the CookbookDiscount and BiographyDiscount classes will implement.

public interface BookDiscount {

	String getBookDiscount();

}

class CookbookDiscount implements BookDiscount {

	@Override
	public String getBookDiscount() {
		String discount = "30% between Dec 1 and 24.";

		return discount;
	}

}

class BiographyDiscount implements BookDiscount {

	@Override
	public String getBookDiscount() {
		String discount = "50% on the subject's birthday.";

		return discount;
	}

}

Now, DiscountManager can refer to the BookDiscount interface instead of the concrete classes. When the processBookDiscount() method is called, we can pass both CookbookDiscount and BiographyDiscount as an argument, as both are the implementation of the BookDiscount interface.

class DiscountManager {

	void processBookDiscount(BookDiscount discount) {...}
}

The refactored code follows the Open/Closed principle, as we could add the new CookbookDiscount class without modifying the existing code base. This also means that in the future, we can extend our app with other discount types (for instance, with CrimebookDiscount).

The UML graph below shows how our example code looks like before and after the refactoring. On the left, you can see that DiscountManager depends on the CookbookDiscount and BiographyDiscount classes. On the right, all three classes depend on the BookDiscount abstract layer (DiscountManager references it, while CookbookDiscount and BiographyDiscount implement it).

Open/Closed Principle

Liskov Substitution Principle

The Liskov Substitution Principle is the third principle of SOLID, represented by the letter “L”. It was Barbara Liskov who introduced the principle in 1987 in her conference keynote talk “Data Abstraction”. The original phrasing of the Liskov Substitution Principle is a bit complicated, as it asserts that:

“In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e., objects of type S may substitute objects of type T) without altering any of the desirable properties of that program (correctness, task performed, etc.).”

In layman’s terms, it states that an object of a superclass should be replaceable by objects of its subclasses without causing issues in the application. So, a child class should never change the characteristics of its parent class (such as the argument list and return types). You can implement the Liskov Substitution Principle by paying attention to the correct inheritance hierarchy.

Example of the Liskov Substitution Principle

Now, the book store asks us to add a new delivery functionality to the application. So, we create a BookDelivery class that informs customers about the number of locations where they can collect their order:

class BookDelivery {
	String titles;
	int userID;

	void getDeliveryLocations() {...}
}

However, the store also sells fancy hardcovers they only want to deliver to their high street shops. So, we create a new HardcoverDelivery subclass that extends BookDelivery and overrides the getDeliveryLocations() method with its own functionality:

class HardcoverDelivery extends BookDelivery {

	@Override
	void getDeliveryLocations() {...}

}

Later, the store asks us to create delivery functionalities for audiobooks, too. Now, we extend the existing BookDelivery class with an AudiobookDelivery subclass. But, when we want to override the getDeliveryLocations() method, we realize that audiobooks can’t be delivered to physical locations.

class AudiobookDelivery extends BookDelivery {

	@Override
	void getDeliveryLocations() {/* can't be implemented */}
}

We could change some characteristics of the getDeliveryLocations() method, however, that would violate the Liskov Substitution Principle. After the modification, we couldn’t replace the BookDelivery superclass with the AudiobookDelivery subclass without breaking the application.

To solve the problem, we need to fix the inheritance hierarchy. Let’s introduce an extra layer that better differentiates book delivery types. The new OfflineDelivery and OnlineDelivery classes split up the BookDelivery superclass. We also move the getDeliveryLocations() method to OfflineDelivery and create a new getSoftwareOptions() method for the OnlineDelivery class (as this is more suitable for online deliveries).

class BookDelivery {

	String title;
	int userID;

}

class OfflineDelivery extends BookDelivery {

	void getDeliveryLocations() {...}

}

class OnlineDelivery extends BookDelivery {

	void getSoftwareOptions() {...}

}

In the refactored code, HardcoverDelivery will be the child class of OfflineDelivery and it will override the getDeliveryLocations() method with its own functionality.

AudiobookDelivery will be the child class of OnlineDelivery which is good news, as now it doesn’t have to deal with the getDeliveryLocations() method. Instead, it can override the getSoftwareOptions() method of its parent with its own implementation (for instance, by listing and embedding available audio players).

class HardcoverDelivery extends OfflineDelivery {

	@Override
	void getDeliveryLocations() {...}

}

class AudiobookDelivery extends OnlineDelivery {

	@Override
	void getSoftwareOptions() {...}

}

After the refactoring, we could use any subclass in place of its superclass without breaking the application.

On the UML graph below, you can see that by applying the Liskov Substitution Principle, we added an extra layer to the inheritance hierarchy. While the new architecture is more complex, it provides us with a more flexible design.

Liskov Substitution Principle

Interface Segregation Principle

The Interface Segregation Principle is the fourth SOLID design principle represented by the letter “I” in the acronym. It was Robert C Martin who first defined the principle by stating that “clients should not be forced to depend on methods they don’t use.” By clients, he means classes that implement interfaces. In other words, interfaces shouldn’t include too many functionalities.

The violation of Interface Segregation Principle harms code readability and forces programmers to write dummy methods that do nothing. In a well-designed application, you should avoid interface pollution (also called fat interfaces). The solution is to create smaller interfaces that you can implement more flexibly.

Example of the Interface Segregation Principle

Let’s add some user actions to our online bookstore so that customers can interact with the content before making a purchase. To do so, we create an interface called BookAction with three methods: seeReviews(), searchSecondHand(), and listenSample().

public interface BookAction {

	void seeReviews();
	void searchSecondhand();
	void listenSample();

}

Then, we create two classes: HardcoverUI and an AudiobookUI that implement the BookAction interface with their own functionalities:

class HardcoverUI implements BookAction {

	@Override
	public void seeReviews() {...}

	@Override
	public void searchSecondhand() {...}

	@Override
	public void listenSample() {...}

}

class AudiobookUI implements BookAction {

	@Override
	public void seeReviews() {...}

	@Override
	public void searchSecondhand() {...}

	@Override
	public void listenSample() {...}

}

Both classes depend on methods they don’t use, so we have broken the Interface Segregation Principle. Hardcover books can’t be listened to, so the HardcoverUI class doesn’t need the listenSample() method. Similarly, audiobooks don’t have second-hand copies, so the AudiobookUI class doesn’t need it, either.

However, as the BookAction interface include these methods, all of its dependent classes have to implement them. In other words, BookAction is a polluted interface that we need to segregate. Let’s extend it with two more specific sub-interfaces: HardcoverAction and AudioAction.

public interface BookAction {

	void seeReviews();

}

public interface HardcoverAction extends BookAction {

	void searchSecondhand();

}

public interface AudioAction extends BookAction {

	void listenSample();

}

Now, the HardcoverUI class can implement the HardcoverAction interface and the AudiobookUI class can implement the AudioAction interface.

This way, both classes can implement the seeReviews() method of the BookAction super-interface. However, HardcoverUI doesn’t have to implement the irrelevant listenSample() method and AudioUI doesn’t have to implement searchSecondhand(), either.

class HardcoverUI implements HardcoverAction {

	@Override
	public void seeReviews() {...}

	@Override
	public void searchSecondhand() {...}

}

class AudiobookUI implements AudioAction {

	@Override
	public void seeReviews() {...}

	@Override
	public void listenSample() {...}

}

The refactored code follows the Interface Segregation Principle, as neither classes depend on methods they don’t use. The UML diagram below excellently shows that the segregated interfaces lead to simpler classes that only implement the methods they really need:

Interface Segregation Principle

Dependency Inversion Principle

The Dependency Inversion Principle is the fifth SOLID design principle represented by the last “D” and introduced by Robert C Martin. The goal of the Dependency Inversion Principle is to avoid tightly coupled code, as it easily breaks the application. The principle states that:

“High-level modules should not depend on low-level modules. Both should depend on abstractions.”

“Abstractions should not depend on details. Details should depend on abstractions.”

In other words, you need to decouple high-level and low-level classes. High-level classes usually encapsulate complex logic while low-level classes include data or utilities. Typically, most people would want to make high-level classes depend on low-level classes. However, according to the Dependency Inversion Principle, you need to invert the dependency. Otherwise, when the low-level class is replaced, the high-level class will be affected, too.

As a solution, you need to create an abstract layer for low-level classes, so that high-level classes can depend on abstraction rather than concrete implementations.

Robert C Martin also mentions that the Dependency Inversion Principle is a specific combination of the Open/Closed and Liskov Substitution Principles.

Example of the Dependency Inversion Principle

Now, the book store asked us to build a new feature that enables customers to put their favorite books on a shelf.

To implement the new functionality, we create a lower-level Book class and a higher-level Shelf class. The Book class will allow users to see reviews and read a sample of each book they store on their shelves. The Shelf class will let them add a book to their shelf and customize the shelf.

class Book {

	void seeReviews() {...}
	void readSample() {...}

}

class Shelf {

	Book book;

	void addBook(Book book) {...}
	void customizeShelf() {...}

}

Everything looks fine, but as the high-level Shelf class depends on the low-level Book, the above code violates the Dependency Inversion Principle. This becomes clear when the store asks us to enable customers to add DVDs to their shelves, too. To fulfill the demand, we create a new DVD class:

class DVD {

	void seeReviews() {...}
	void watchSample() {...}

}

Now, we should modify the Shelf class so that it can accept DVDs, too. However, this would clearly break the Open/Closed Principle. The solution is to create an abstraction layer for the lower-level classes (Book and DVD). We’ll do so by introducing the Product interface that both classes will implement.

public interface Product {

	void seeReviews();
	void getSample();

}

class Book implements Product {

	@Override
	public void seeReviews() {...}

	@Override
	public void getSample() {...}

}

class DVD implements Product {

	@Override
	public void seeReviews() {...}

	@Override
	public void getSample() {...}

}

Now, Shelf can reference the Product interface instead of its implementations (Book and DVD). The refactored code also allows us to later introduce new product types (for instance, Magazine) that customers can put on their shelves, too.

class Shelf {

	Product product;

	void addProduct(Product product) {...}

	void customizeShelf() {...}

}

The above code also follows the Liskov Substitution Principle, as the Product type can be substituted with both of its subtypes (Book and DVD) without breaking the program. At the same time, we have also implemented the Dependency Inversion Principle, as in the refactored code, high-level classes don’t depend on low-level classes, either.

As you can see on the left of the UML graph below, the high-level Shelf class depends on the low-level Book before the refactoring. Without applying the Dependency Inversion Principle, we should make it depend on the low-level DVD class, too. However, after the refactoring, both the high-level and low-level classes depend on the abstract Product interface (Shelf refers to it, while Book and DVD implement it).

Dependency Inversion Principle

How should you implement SOLID design principles?

Implementing the SOLID design principles increases the overall complexity of a code base, but it leads to more flexible design. Besides monolithic apps, you can also apply SOLID design principles to microservices where you can treat each microservice as a standalone code module (like a class in the above examples).

When you break a SOLID design principle, Java and other compiled languages might throw an Exception, but it doesn’t always happen. Software architecture problems are hard to detect, but advanced diagnostic software such as APM tools can provide you with many useful hints.