Software Design Principles
In today’s world, customer requirements keep changing at an unprecedented pace. It becomes essential for the technical teams to accommodate the new requirements and deliver those very quickly. To develop and deliver faster, it’s necessary to reduce software development and testing time.
At the same time, new technologies are introduced every few months. It’s common to experiment with more optimal and efficient technologies by replacing the existing ones. Thus, it’s important to write the code that is flexible and loosely coupled to introduce any changes.
Well written code is easy to grasp as new developer doesn’t have to spend more time reading the code. A 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 to the production.
Where do SOLID principles come from?
SOLID principles came from an essay written in 2000 by Robert Martin, known as Uncle Bob, where he discussed that a successful application will change and, without good design, can become rigid, fragile, immobile and viscous.
- Rigid — Things are very fixed. You can’t move or change things without affecting other things, but it’s clear what will break if you make a change.
- Fragile — Easy to move and change things but not obvious what else might break as a result.
- Immobile — Code works fine but you can’t re-use code without duplicating or replicating it.
- Viscous — Everything falls apart when you make a change, you quickly push it back together and get your change working. The same thing happens when somebody else comes along to make a change.
The principles that Robert Martin talks about to avoid the four design anti-patterns above have evolved to be known as the SOLID principles. Although he didn’t invent the principles, he pulled together some good coding practices that already existed around a central theme of managing dependencies and put forward a good argument for using those practices together.
In object-oriented world, S.O.L.I.D is a mnemonic acronym for five design principles intended to make software designs more understandable, flexible, and maintainable. Let’s go through the five principles with an example for each to understand them better.
- Single Responsibility
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
Single Responsibility Principle
One of the simplest principles to understand. It states that, a class should only have one responsibility. Furthermore, it should only have one reason to change.
Many times, you might find a class performing multiple functions then what it’s supposed to.
Let’s suppose you are writing code for OrderHistoryManager. The functionality is to display the order history for a given user. The code retrieves data from the database and displays the data in the user-selected format. You end up writing the below code.
As you can see from the above code snippet, the class OrderHistoryManager is performing multiple things at once. It is fetching the data from the database, parsing the result, then transforming it to a user-specified format. It works as expected but violate the single responsibility principle we outlined earlier.
You can observe the following flaws:
- No responsibility segregation. This class need code changes in case a new format is introduced or a new table 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 orders can’t be tested in isolation as it’s not exposed by the OrderHistoryManager.
We can fix the above problems with the following approach:
- Add a DAO(Database Access Object) layer, which will encapsulate the database driver and will take care of all the query related things.
- Define a separate OrderDataFormatter, whose responsibility would be to format the order data in a required format.
- OrderHistoryManager will delegate the request to the DAO layer to fetch the data and then pass the response to the formatter for formatting in a specific format.
- In this manner, we can test DAO and OrderDataFormatter both in isolation and achieve loose coupling at the same time. Thus, it will make the code modular by separating the responsibilities.
Simply put, classes should be open for extension, but closed for modification. In doing so, we stop ourselves from modifying existing code and causing potential new bugs.
Of course, the one exception to the rule is when fixing bugs in the existing code 😄
PaymentProcessor determines the payment method and delegates it to the PaymentHandler for processing the request. This code violates the open-closed principle as any functionality changes, will require code modifications in both PaymentProcessor and PaymentHandler.
To make the code extensible, we can make the PaymentHandler as an abstract class and define a behaviour handlePayment. To handle new payment method, we can extend this base class and override its handlePayment method. Below is the new code.
Let’s create a factory class which will be responsible for having references of payment handlers and return the required reference depending on the paymentMethod.
Please note, we are adding the references of paymentHandler in the constructor for example over here which is still kind of hard-coded. You can write a custom annotation which can be annotated on all payment handler classes, you can get those list of classes and populate the paymentHandlerMap.
With modifications, 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 and no need to modify the PaymentProcessor.
Liskov Substitution Principle
The principle states that, objects of the same superclass should be able to substitute each other without breaking an existing code.
We will take the example of having a notification module for day to day activities. It provides an interface to notify the team members on the issue created.
We have three different implementations for MS Teams, Slack and Telegram. All of them are replaceable and can be accessed using the same interface MessageService.
The Liskov principle is violated if a method in the derived class is not implemented. In our case, the TelegramMessageServiceImpl is not implementing the sendIssueReport, so it will not result in a consistent behaviour when we change the NotificationType while invoking the NotificationService.
Interface Segregation Principle
According to this principle, the 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.
Easier to violate the concept, especially when the software grows eventually when the new requirements arise and have to add more and more features. Instead of creating one larger interface we can break down it to smaller interfaces to ensure that implementing classes only need to be concerned about the methods.
In the above example with the bulky interface, we had thrown an Exception in SingleFunctionInkjetPrinter and MultiFunctionInkjetPrinter classes. This is a violation of the Liskov Substitution Principle. The derived class doesn’t extend the functionality in this case.
We can fix the problem by changing the interface design and implementation classes, something like below:
Interface segregation shares some similarities with Single Responsibility and Liskov Substitution Principle. If unrelated methods are defined in the interface, then the class will have multiple reasons to change. This violates the Single Responsibility Principle.
Dependency Inversion Principle
The principle of Dependency Inversion refers to the decoupling of software modules. This way, instead of high-level modules depending on low-level modules, both will depend on abstractions.
Let’s take a look at the example of CartService class. Here the class is dependent on postgres driver to do an interaction with database. Now if we plan to change data store or database driver, then it require changes in PostgresDatabase as well as in CartService classes.
This coupling can be removed by declaring an interface DataStore. This interface will expose methods that the consumers will invoke. We can have multiple implementations of DataStore like Postgres, MySQL, MS-SQL etc.
The high-level module CartService relies on the interface DataStore to access the data. Any change in the lower level DataStore implementation doesn’t have any impact on this class.
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.
The above five principles form a foundation for the best practices followed in Software Engineering. Practicing the above principles in day to day work helps improve the readability, modularity, extensibility and testability of the software.
One thing you can guarantee with any application, if it’s successful and used extensively, it will change over the time. As it changes, the complexity factor gradually increases until you hit a tipping point where it becomes more difficult and takes longer time to ship new features on top of the poorly written code that was quickly shipped once.
I’ll leave you with following questions which I’ve found useful to ask myself while writing code:
- Is the class DRY(Don’t Repeat Yourself)?
- Does everything in a class change at the same rate?
- Have I abstracted out something that is likely to change?
- Have I abstracted out something that is not used in all classes that inherit it?