SOLID Principles — Simplified with Illustrations

Animesh Gaitonde
Level Up Coding
Published in
9 min readJun 9, 2020

--

Importance of S.O.L.I.D principles

S.O.L.I.D principles

Introduction

As developers, we have always dealt with legacy codebases. Most of the legacy codebases have tightly coupled classes, redundant code, and less test coverage. This gives a developer a hard time to comprehend the functionality of a codebase when giving a quick glance at the code.

Imagine the pain of going through endless lines of code in a class to only fix a bug. The developer might end up reading more lines of code than writing. Further, fixing one flow might result in breaking the other. This reminds me of the below famous meme.

Fix one bug & you have 10 more ready

Since no active development happens in legacy software, it puts developers and managers in a dilemma. The team then contemplates on rewriting the whole service and deprecating the old one.

Why Software design principles?

In today’s evolving world, customer requirements keep changing at an unprecedented pace. It becomes essential for the software teams to accommodate the new requirements and ship the changes swiftly. To achieve this, it’s necessary to reduce software development and testing time.

At the same time, new technologies are introduced every other year. It’s common to experiment with more optimal, and efficient technologies by replacing the existing ones. Thus, the written code must be flexible and loosely coupled to introduce any change.

Well written code is easy to grasp. A new developer doesn’t have to spend more time reading the code than modifying a part of it. Well maintained software thus enhances developer’s and the team’s productivity. In addition, high test coverage increases the confidence to deploy a new change.

S.O.L.I.D is an acronym coined by Michael Feathers and a subset of principles published by Robert Martin (Uncle Bob). We will walk through the five principles and go through an illustration for each of them.

S — Single Responsibility principle

This is one of the simplest principles to understand. It states that ‘A class must only have one reason to change’. Many times, you might find a class performing multiple functions than what it’s supposed to.

Let’s suppose you are writing code for Banking software. The functionality is to display a statement for a given user. The code fetches data from the database and displays the data in the user-selected format. You end up writing the below code.

BankStatementManager

As you can see from the above code snippet, the class ‘BankStatementMgr’ is performing multiple things at once. It is fetching the data from the database, parsing the result, then displaying it in a user-specified format. You can observe the following flaws:

  • No responsibility segregation. This class will need changes in case a new format is introduced or a new database column is added
  • The class is tightly coupled with the database driver. Any change in the DB driver or SQL query will result in modification of this class
  • Formatting of the transactions can’t be tested in isolation as it's not exposed by the BankStatementMgr
  • The code is not modular as multiple functions are intertwined

The above shortcomings can be overcome with the following approach:-

  • Define a separate Formatter, whose responsibility would be to format the transactions
  • Add a Database Access Object or DAO, which will encapsulate the database driver and will do all the query heavy lifting
  • BankStatementMgr will delegate the request to the DAO to fetch the data and then pass the response to the formatter for prettifying
  • In this manner, we can test DAO and Formatter both in isolation and achieve loose coupling. Thus, it will make the code modular by separating the responsibilities

Following is our modified code:-

BankStatementManager
StatementFormatter
TransactionDAO

There is still a lot of scope of improvement and we will see in the next sections how we can refactor and make things better.

O — Open-Closed principle

This principle states that code should be open for extension and closed for modification. In case a new functionality needs to be added, the class must be extended. Further, to make the system extensible its behaviour should be segregated.

We will understand this with an example. Assume that you are an eCommerce merchant accepting payment through different modes. You have integrated different modes such as Paypal, Wepay, Google Pay, etc, and developed a payment processor. You come up with the below code.

PaymentProcessor
PaymentHandler

PaymentHandler that handles the payment request. PaymentProcessor determines the mode and delegates it to the correct action. This code violates the open-closed principle as any functionality will require modifications in both PaymentProcessor and PaymentHandler. This design is not extensible as every new payment mode will introduce a new case block in the switch statement.

To make the code extensible, we can make the PaymentHandler abstract and define a method to handle Payment. To handle new payment mode, we can extend this base class and override its handlePayment method. Below is the new code.

abstract PaymentHandler
GooglePayHandler
CardPaymentHandler

We will now create a factory class which will be responsible for storing the specific handlers and returning it depending on the mode.

PaymentHandlerFactory
PaymentProcessor

Our new code is now compliant with the open-closed principle. To add new behaviour, we only need to extend our abstract class PaymentHandler and configure the same in the factory. There is no need to modify the PaymentProcessor.

L — Liskov Substitution principle

At first glance, the name sounds intimidating. The principle states that objects of the same superclass should be able to substitute each other without breaking existing code.

We will take the example of developing a scrapper for movies. The scrapper provides an interface to search movies by the movie name or actor.

MovieSearch
IMDB Search
Rotten Tomatoes Search
Client code using the MovieSearch interface

We have two different implementations. One for Rotten Tomatoes and other for IMDB. Both of them are replaceable and can be accessed using the same interface.

The principle is violated if a method in the derived class is not implemented. The following is an example of the violation of the Liskov principle.

AllMoviesSearch

In this case, we can’t replace the other derived classes such as IMDB and Rotten Tomatoes with All Movies. The method searchByMovieName is not implemented by it and will not result in a consistent behaviour in client code.

I — Interface Segregation

According to this principle, a client is not supposed to implement methods that it doesn’t need. Interfaces become too bulky and polluted if you define methods not used by the client.

If an interface grows too big with mixed functionality, it makes sense to segregate it into multiple smaller interfaces. Let’s have a look at an example of Portfolio service that allows clients to order stocks, ETFs, options, etc

Interface Portfolio

We have defined an interface Portfolio, that allows a client to order stocks, ETFs, and a combination of the two.

ETFOrderService
StockOrderService

We have two different implementations of the Portfolio service. It’s observed that the StockOrderService hasn’t implemented the methods orderETF and orderStockAndETFs. The same applies to the ETFOrderService which only implements orderETF method.

What if we decide to add price as a parameter while ordering stocks. It will need a change in the orderStocks method to accept the price as a parameter. Further, this change will have to be incorporated by ETFOrderService, even though it doesn’t support orderStocks method.

To overcome this, we can segregate the interfaces into two — a) StockPortfolio b) ETFPortfolio

StockPortfolio
ETFPortfolio

With the new interfaces, the StockOrderService doesn’t need to deal with ordering ETFs. The same is applicable to ETFOrderService.

ETFOrderService
StockOrderService

Interface segregation shares some similarities with Single Responsibility and Liskov Substitution Principle.

In the above example with the bulky interface, we had thrown an Exception in StockOrderService. This is a violation of the Liskov Substitution Principle. The derived class doesn’t extend the functionality in this case.

If unrelated methods are defined in the interface, then the class will have multiple reasons to change. This violates the Single Responsibility Principle.

D — Dependency Inversion

According to Dependency Inversion, higher level modules in a program must not be tightly coupled with the lower-level modules. Both modules must depend on abstractions. This principle provides a mechanism to build loosely coupled software modules.

Let’s take a look at the following example. In this example, the class OrderHistory fetches data from a PostgreSQL data store.

OrderHistory

The OrderHistory class must know the implementation details of the PostgresDB dependency. If we decide to use a different database driver, we’ll need to replace all the instances of PostgresDB with the new dependency.

Further, what is one function of the DB driver changes? It will also need a change in the OrderHistory class that is calling the DB driver’s methods.

This coupling can be removed by declaring an interface DataStore. This interface will expose APIs that the consumer will invoke. We can have multiple implementations of DataStore — a) Postgres DataStore b) MySQL DataStore c) S3, etc

DataStore
PostgresDataStore
OrderHistory

Our consumer class now doesn’t have to deal with the lowe level details of what datastore is being used. The high-level module OrderHistory relies on the interface DataStore to access the data. Any change in the lower level DataStore implementation doesn’t have any impact on the OrderHistory.

Further, since the modules are loosely coupled, they can be independently tested. A new implementation can be easily injected in a High-level module using Dependency Injection.

Conclusion

The above five principles form a cornerstone to best practices followed in Software Engineering. Practicing the above principles in everyday work helps improve the readability, modularity, extensibility, and testability of software.

Eventually, it helps in building a well-maintained software that is easy to understand. Following the above practices helps enhance developer productivity and agility of the engineering team.

References

Before you leave

Thanks for being a part of our community! Before you go:

--

--

Senior Software Engineer @Microsoft. Writes about Distributed Systems, Programming Languages & Tech Interviews