SOLID Design Principles: Everything you need to know (with examples)

Cyber Drudge
7 min readApr 13, 2023

--

Photo by Edho Pratama on Unsplash

The SOLID Principles are five Object-Oriented class design principles.

They are a set of guidelines and best practices to follow while designing a class structure.

It takes some level of understanding, but if you write code following these principles, it will improve code quality, reduce code complexity and make your code more extendable, flexible, logical, and easier to read.

The following five concepts make up our SOLID principles:

  1. Single Responsibility
  2. Open/Closed
  3. Liskov Substitution
  4. Interface Segregation
  5. Dependency Inversion

This article will teach you everything you need to know to apply SOLID principles to your projects.

So grab a cup of coffee and let’s jump right in!

Benefits of using SOLID Principles:

  • They help to Avoid Duplicate Code.
  • They make the codebase easy to maintain and understand.
  • Flexible Code.
  • They help in reducing Code Complexity.

1. Single Responsibility Principle

The Single Responsibility Principle states that a class should do one and only one thing and therefore it should have only a single reason to change.

To state this principle more technically: Only one potential change (change in database logic, logging logic and so on.) in the software’s specification should be able to affect the specification of the class.

It is important to follow the Single Responsibility Principle.
For starters, multiple teams may work on the same project and edit the same class for various reasons, which may result in incompatible modules.
Another example would be merge conflicts. They appear when multiple teams make changes to the same file. However, if this principle is followed, fewer conflicts will occur because files will only have one reason to change, and conflicts that do occur will be easier to resolve.

Now, let us look at the code for a simple E-Commerce website’s invoice program as an example. Let’s start by defining an Item class to use in our invoice.

Now let’s create the invoice class which will contain the logic for creating the invoice and calculating the total price. For now, assume that our bookstore only sells books and nothing else.

Here is our invoice class. It also contains some fields about invoicing and 3 methods.

Now, take a moment to consider and think what is wrong with this class design.

So what’s going on here? Our class violates the Single Responsibility Principle in multiple ways.

The first violation is the print_invoice method, which contains our printing logic. The Single Responsibility Principle states that our class should only have a single reason to change, and that reason should be a change in the invoice calculation for our class.
But in this architecture, if we wanted to change the printing format, we would need to change the class. This is why we should not have printing logic mixed with business logic in the same class.

There is another method that violates this principle in our class: the save_in_file method. It is also an extremely common mistake to mix persistence logic with business logic.

Don’t just think in terms of writing to a file — it could be saving to a database, making an API call, or other stuff related to persistence.

So how can we fix this print function, you may ask.

We can create new classes for our printing and persistence logic so we will no longer need to modify the invoice class for those purposes.

We create 2 classes: InvoicePrinter and InvoiceDAO, and move the methods

Our class structure now follows the Single Responsibility Principle, and each class is only responsible for one aspect of our application. Great!

Unfortunately, it’s possible to take this concept too far. Some may interpret the single responsibility principle to indicate that every method that performs operations that are distinct from the others should be allotted to its own class. This, however, is incorrect. Over-parsing these methods will only result in the creation of redundant classes that accomplish the same thing in slightly different ways. This might add unnecessary complexity to your code. You’ll end up with a complex and tangled web of tightly coupled classes that must all update at the same time.

Instead, developers should focus on the method’s overall functionality, rather than the fact that it operates slightly differently from other methods. If two unique methods both support the same higher-level process, they should be in the same class — regardless of the specifics behind their respective operations.

2. Open-Closed Principle

The Open-Closed Principle requires that classes should be open for extension and closed to modification. We should be able to add new functionality without interfering with the existing code for the class.
In doing so, we stop ourselves from modifying existing code and causing potential new bugs in an otherwise stable application.

Of course, the one exception to the rule is when fixing bugs in existing code.

Let’s say we want invoices to be saved to a database so that we can search them easily.

We create the database, connect to it, and we add a save method to our InvoiceDAO class:

Now our persistence logic is easily extendable. If our boss asks us to add another database and have 2 different types of databases like MySQL and MongoDB, we can easily extend our class to handle that.

3. Liskov Substitution Principle

Next on our list is Liskov substitution, which is arguably the most complex of the five principles. This principle states that Subclasses should extend the capability of the parent class and not narrow it down. Subclass should be substitutable for their base classes.
Simply put, if class A is a subtype of class B, we should be able to replace B with A without disrupting the behavior of our program.

This is the expected behavior, because when we use inheritance we assume that the child class inherits everything that the superclass has. The child class extends the behavior but never narrows it down.

Therefore, when a class does not obey this principle, it leads to some nasty bugs that are hard to detect.

One major objective of Liskov substitution is to decrease the amount and frequency of crash-inducing runtime errors. If a requesting client calls a subclass, it’s foreseeable that it will also call the superclass at some point.

4. Interface Segregation Principle

Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

Interface Segregation simply means that larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.

The principle states that many client-specific interfaces are better than one general-purpose interface. Clients should not be forced to implement a function they do not need.

Lets say we want to model a very simplified parking lot. It is the type of parking lot where you pay an hourly fee. Now consider that we want to implement a parking lot that is free.

Our parking lot interface will be composed of 2 things: Parking related logic (park car, unpark car, get capacity) and payment related logic.

But it is too specific. Because of that, our FreeParking class will be forced to implement payment-related methods that are irrelevant. Let’s separate or segregate the interfaces.

We’ve now separated the parking lot. With this new model, we can even go further and split the PaidParkingLot to support different types of payment.

Now our model is much more flexible, extendable, and the clients do not need to implement any irrelevant logic because we provide only parking-related functionality in the parking lot interface.

5. Dependency Inversion Principle

The Dependency Inversion principle states that our classes should depend upon interfaces or abstract classes instead of concrete classes and functions.

This code will work, and we’ll be able to use the WiredKeyboard and Monitor freely within our Computer class.

But not only does this make our Computer hard to test, but we’ve also lost the ability to switch out our WiredKeyboard class with a different one should the need arise. And we’re stuck with our Monitor class too.

Here, we’re using the dependency injection pattern to facilitate adding the Keyboard dependency into the Computer class.

Let’s also modify our WiredKeyboard class to implement the Keyboard interface so that it’s suitable for injecting into the Computer class:

Now our classes are decoupled and communicate through the Keyboard abstraction. If we want, we can easily switch out the type of keyboard in our machine with a different implementation of the interface. We can follow the same principle for the Monitor class.

In essence, if any module in a software design is dependent on a higher-level component, that superior component should not be affected by any changes to its dependent. While the terms module and component can mean any number of things in software development, we’ll stick to illustrating this principle in terms of class dependencies.

At first glance, this may appear to be simply a matter of reversing problematic coupling and ensuring that dependencies only flow to higher-level classes. With dependency inversion, however, the objective is to add another interface that decouples the dependency entirely. This additional interface is called an abstraction.

This new abstraction will serve as the glue that connects lower-level and higher-level classes, while it also provides each the flexibility to change without affecting the other. Since the superior class can handle any job required of the lower class, it can use this newly abstracted interface to perform those tasks. Otherwise, the higher-level class will use its own interface to perform the more complex jobs it’s required to handle. As long as the abstractions remain intact, each class is free to change as it likes without interfering with the other’s operations.

There are all sorts of examples of these abstracted interfaces at the software architecture level. One of the most prominent examples, as of late, are the API gateways that developers use to decouple the components within a microservices architecture. However, these abstractions take many forms, and it’s safe to say that there will be more in the future.

Conclusion

In this article, we started with the history of SOLID principles, and then we tried to acquire a clear understanding of the why’s and how’s of each principle. We even refactored a simple Invoice application to obey SOLID principles.

I want to thank you for taking the time to read the whole article and I hope that the above concepts are clear.

I suggest keeping these principles in mind while designing, writing, and refactoring your code so that your code will be much more clean, extendable, and testable.

If you are interested in reading more articles like this, you can follow me to get notified when I publish a new article.

--

--

Cyber Drudge

Senior Software Engineer | Built a Tech Unicorn | Writes about Tech and Tech Interviews