Understanding SOLID Principles for Better Software Design
Saurabh Kumar
Engineering@Vunet | Fintech | Maps | Ex-Barq |Ex-OLA Maps | Ex-Meetrecord
Design principles help create good software by ensuring the code is clean and organised. The SOLID principles guide how to structure code into groups, making the system easy to change, understand, and reuse. These principles apply to all kinds of software, not just object-oriented programming. They focus on building parts of the system that are flexible and work well together. SOLID became formalised in the early 2000s and is key to creating strong, maintainable software systems.
Single Responsibility Principle: The Single Responsibility Principle (SRP) means a module should have only one reason to change, not just do one thing. The idea that a function should do one thing is a different rule, not part of SRP.
Problem 1: Duplication
An example of violating the Single Responsibility Principle (SRP) is a?Product Management?class with three methods:?addProduct(),?generateReport(), and?sendNotification().
By combining these methods in one class, different teams are linked, which can cause issues.
Example Problem:
If a developer modifies a shared function called?validateProduct()?to be stricter for the sales team, it could lead to incorrect reports for the marketing team. This happens because changes for one team unintentionally affect another.
The SRP suggests separating code for different responsibilities to prevent these issues.
Problem 2: Merges
Merges often occur when a source file has many methods serving different teams.
Example:
In a?Customer Management?class with methods for processing orders, handling returns, and collecting feedback, changes from the?Sales Team?and the?Support Team?can conflict if they work on the same file at the same time.
Merges can be risky, as no tool can handle every situation perfectly, potentially affecting multiple teams.
To avoid these issues, it's important to separate code for different teams so they can work independently without conflicts.
Solution:
There are several ways to address this issue, mainly by moving functions into separate classes.
One simple solution is to keep data and functions apart. For example, a?ProductData?class could store product information, while different classes handle adding products, generating reports, and processing returns. These classes would remain independent to avoid accidental duplication.
Downside:?Developers have to manage multiple classes. The?Facade?pattern can help by using a?ProductFacade?class to coordinate these functions with minimal code.
Some developers prefer to keep important logic in the original?Product?class and use it as a facade for the other methods.
While it might seem like each class would have just one function, they can have many methods for various tasks, with several private methods hidden from view.
Open-Closed Principle (OCP)
The Open-Closed Principle says that software should be?open for extension?but?closed for modification. This means you can add new features without changing existing code.
Why is OCP Important?
Following OCP helps keep software stable. If adding a new feature requires changing a lot of old code, it can lead to errors.
Example Scenario
Imagine you have a mobile app that shows products in a grid view. If you want to add a list view for users:
How to Apply OCP
DIRECTIONAL CONTROL
If the previous class design seemed complex, it’s mainly to ensure that the connections between parts point the right way. For example, in an e-commerce app, the?OrderProcessor?should depend on the?PaymentGateway?rather than the other way around. This way, changes in payment methods won’t affect how orders are processed.
INFORMATION HIDING
The?OrderRequester?interface helps keep the?OrderController?from needing to know the details of the?InventoryManager. If this interface wasn’t there, the OrderController would have indirect dependencies on the Inventory components, which isn’t ideal.
By using the?OrderRequester, we protect both the OrderController and the InventoryManager from changes in each other, simplifying maintenance and updates.
LSP: THE LISKOV SUBSTITUTION PRINCIPLE
The Liskov Substitution Principle (LSP), defined by Barbara Liskov in 1988, states that if you have a class S and a class T, you should be able to replace instances of T with instances of S without changing the program's behaviour. If this holds true, S is a subtype of T.
EXAMPLES TO ILLUMINATE LSP
GUIDING INHERITANCE
Let’s consider an e-commerce application with a class named PaymentMethod. There are two subtypes: CreditCard and PayPal. Each type has its own method for processing payments.
In this case, the PaymentProcessor class that uses PaymentMethod can process payments without needing to know if it’s dealing with a CreditCard or PayPal. This follows LSP, as both subtypes can be used interchangeably.
THE SQUARE/RECTANGLE EXAMPLE
A classic example of LSP violation is the square/rectangle problem. Suppose we have a Product class that can calculate discounts based on price and quantity.
If we create a BulkProduct class as a subtype of Product that has special rules for discounts, it might break LSP if the discount calculation expects a standard Product. For instance, if the BulkProduct applies a discount per item instead of a flat rate, it could lead to unexpected results when a program expects standard product behavior.
LSP IN ARCHITECTURE
Originally seen as a guideline for using inheritance, LSP has evolved into a broader principle that applies to interfaces and implementations. In an e-commerce application, various payment methods, shipping methods, or product types can implement similar interfaces, ensuring that they behave consistently.
领英推荐
EXAMPLE OF LSP VIOLATION
Imagine an e-commerce platform where customers can choose delivery methods. Each delivery service has a unique API for processing delivery requests.
For example:
fastdelivery.com/api/request?address=24 Maple St&time=3 PM
standarddelivery.com/api/request?pickup=24 Maple St&delivery_time=3 PM
If the FastDelivery service changes the query parameters to something different (e.g., using "pickup" instead of "address"), the existing code that constructs requests for these services might break.
To accommodate this, developers might resort to adding if-statements to handle different delivery services, leading to complex and fragile code. Instead, a better design would involve using a configuration file to define API formats for each delivery service:
{
"FastDelivery": {
"url": "fastdelivery.com/api/request",
"params": {
"address": "%s",
"time": "%s"
}
},
"StandardDelivery": {
"url": "standarddelivery.com/api/request",
"params": {
"pickup": "%s",
"delivery_time": "%s"
}
}
}
This approach allows the e-commerce platform to dynamically adapt to different API formats without altering core logic. As a result, all delivery services can be treated uniformly, ensuring adherence to the LSP.
ISP: THE INTERFACE SEGREGATION PRINCIPLE
The Interface Segregation Principle (ISP) is about creating smaller, focused interfaces to avoid unnecessary dependencies.
THE PROBLEM WITH A BIG INTERFACE
Imagine you have a class called OPS that has several operations (op1, op2, op3). Now, let's say three users interact with this class:
If OPS is implemented in a language like Java, User1 will depend on all operations, even the ones it doesn’t use. This means that if op2 or op3 changes, User1 has to be recompiled and redeployed, even though it doesn’t use those operations. This creates unnecessary work.
SOLUTION: SEPARATE INTERFACES
To solve this, we can split the operations into separate interfaces. This way, User1 only depends on U1Ops and op1, so changes to other operations won’t affect it.
ISP AND PROGRAMMING LANGUAGES
The impact of ISP can vary based on the programming language:
ISP IN SOFTWARE ARCHITECTURE
At a broader level, the ISP warns against depending on larger modules than necessary. For example, if a system S includes a framework F that is tied to a specific database D, then S also depends on D through F.
If D has features that F doesn’t use, changes to those features can force F and S to redeploy, even though they don’t rely on those features. This can lead to unnecessary complications and failures in the system.
Understanding the Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) emphasizes that the most flexible systems depend on abstractions rather than concrete implementations. This means your code should refer to interfaces or abstract classes instead of specific classes.
Why It Matters
In languages like Java, using concrete classes directly can lead to tightly coupled code. For example, if a method in one class calls another class’s method, any change to that method could require recompiling and redeploying all dependent code. While it’s impossible to avoid all concrete dependencies—like the String class in Java—it's crucial to minimize dependencies on unstable or frequently changing components.
Favoring Stable Abstractions
Managing Concrete Dependencies
Creating instances of concrete classes can introduce unwanted dependencies. To manage this, use the Abstract Factorypattern. For example:
In this way, all dependencies flow toward the abstract components, while control flows in the opposite direction. This inversion of dependencies is the essence of DIP.
Concrete Components and Main Functions
While it’s ideal to minimize direct dependencies on concrete components, every system will likely have at least one, often called main, which contains the main function. This component may instantiate the factory and expose it for use throughout the application, keeping the overall architecture cleaner and more maintainable.
Simple Example of Dependency Inversion Principle (DIP)
Imagine you are building an app that sends notifications, and you have two types of notifications: Email and SMS.
Without DIP (Bad Approach):
Here, the NotificationService directly depends on the EmailSender and SmsSender classes, creating a tight coupling.
class NotificationService {
private EmailSender emailSender = new EmailSender();
private SmsSender smsSender = new SmsSender();
public void send(String message) {
emailSender.sendEmail(message);
smsSender.sendSms(message);
}
}
With DIP (Good Approach):
We introduce an abstraction (NotificationSender) and let the NotificationService depend on this interface, allowing for easy extension in the future (e.g., adding Push Notifications).
interface NotificationSender {
void send(String message);
}
class EmailSender implements NotificationSender {
public void send(String message) {
// Send email
}
}
class SmsSender implements NotificationSender {
public void send(String message) {
// Send SMS
}
}
class NotificationService {
private NotificationSender sender;
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void send(String message) {
sender.send(message);
}
}
Now, NotificationService depends on the NotificationSender interface, not the concrete EmailSender or SmsSender. This makes it easy to add new types of notifications without changing the core logic.
Reference: Clean Architecture by Robert C. Martin (ISBN-13: 978-0-13-449416-6 | ISBN-10: 0-13-449416-4)