Mastering SOLID Principles in Software Development: A Detailed Guide with Real-World Analogies: Part 1
Maintaining code quality is a constant challenge in software development. Why should you write clear code? Well, writing clean, maintainable, and scalable code ensures that applications can adapt to changes over time. One of the most effective ways to achieve this is by adhering to the SOLID principles of object-oriented programming.
In Part 1 of this article, we'll look at each SOLID principle with detailed explanations, real-world analogies, and practical examples that can be applied to any programming language or framework. Whether developing in Flutter, React Native, or any other platform, these principles will help you design better software.
What are SOLID Principles?
You may wonder what are SOLID principles? How to Apply SOLID Principles? SOLID is an acronym for five principles that guide developers toward writing clean and maintainable code. The principles are:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
1. Single Responsibility Principle (SRP)
A class should have only one reason to change.
The Single Responsibility Principle (SRP) dictates that a class should be responsible for only one task or functionality. This makes your code more modular, easier to understand, and simpler to maintain.
Real-World Analogy:
There are many practical examples of SOLID Principles. For Single Responsibility Principle (SRP), think of a librarian in a library. Each librarian is assigned a specific task: one manages book loans, another manages book returns, and yet another handles cataloging. If the book return process changes, only the librarian responsible for book returns needs to adjust their workflow, not the entire staff.
Explanation:
By adhering to SRP, you reduce the risk of unintended side effects when making changes. A class should do only one thing well, so when its responsibility changes, it doesn’t impact other unrelated tasks.
Without Single Responsibility Principle:
class UserProfilePage extends StatelessWidget {
final User user;
UserProfilePage(this.user);
Future<User> fetchUserData() async {
// Code to fetch user data from API
}
void saveUserData(User user) {
// Code to save user data to API
}
@override
Widget build(BuildContext context) {
// UI code to display user profile
}
}
Explanation:
In this version of the UserProfilePage class, we are violating the Single Responsibility Principle (SRP) because this class is doing multiple things:
- It is responsible for fetching user data from an API (fetchUserData).
- It is responsible for saving user data to an API (saveUserData).
- It is responsible for rendering the UI (build method).
The problem with this approach is that any change to the user data handling logic (e.g., changing the API endpoint) will require modifying the UserProfilePage class, even though the main responsibility of this class should be focused on building the UI. This leads to a tightly coupled class that is harder to maintain and test.
With Single Responsibility Principle:
// user_service.dart
class UserService {
Future<User> fetchUserData() async {
// Code to fetch user data from API
}
void saveUserData(User user) {
// Code to save user data to API
}
}
// user_profile_page.dart
class UserProfilePage extends StatelessWidget {
final UserService userService;
final User user;
UserProfilePage({required this.userService, required this.user});
@override
Widget build(BuildContext context) {
// UI code to display user profile
}
}
Explanation:
In this refactored version, we have separated the responsibilities:
-
The UserService class is now responsible for handling user data (fetching and saving).
-
The UserProfilePage class is now solely responsible for rendering the UI.
By moving the data-fetching and saving logic into UserService, we adhere to SRP. This makes the code more modular and easier to maintain. Now, if you need to change the API handling logic, you only need to modify UserService without affecting the UserProfilePage. Additionally, this structure improves testability, as you can easily mock UserService in your UI tests.
2. Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification.
The Open/Closed Principle (OCP) states that you should be able to add new functionality to a class without modifying its existing code. This prevents breaking existing features while making the software more adaptable to future requirements.
Real-World Analogy:
Think of a car manufacturing process. You can easily swap out different components, like engines or wheels, without having to redesign the entire car. The car's design is flexible enough to allow new features while keeping its core structure intact.
Explanation:
When adhering to OCP, you achieve flexibility in your design by writing code that can be extended through inheritance, interfaces, or other patterns—without modifying the existing logic.
Without Open/Closed Principle
class NotificationService {
void sendEmail(String message) {
// Send email
}
void sendSMS(String message) {
// Send SMS
}
}
Explanation:
In this version of the NotificationService class, we are violating the Open/Closed Principle (OCP), which states that a class should be open for extension but closed for modification.
Here, the NotificationService directly implements both email and SMS notifications within the class. If you want to add new notification types, such as push notifications or social media notifications, you would need to modify this class. This creates tight coupling and makes the system more fragile, as every change to the notification system requires altering the existing code.
With Open/Closed Principle
abstract class NotificationChannel {
void send(String message);
}
class EmailNotification implements NotificationChannel {
@override
void send(String message) {
// Send email
}
}
class SMSNotification implements NotificationChannel {
@override
void send(String message) {
// Send SMS
}
}
class NotificationService {
final List<NotificationChannel> channels;
NotificationService(this.channels);
void notifyAll(String message) {
for (var channel in channels) {
channel.send(message);
}
}
}
Explanation:
In this refactored version, we adhere to the Open/Closed Principle by introducing an abstract class NotificationChannel, which defines the contract for all types of notifications. The EmailNotification and SMSNotification classes implement this interface, but they are independent and follow their own logic for sending messages.
The NotificationService class no longer contains logic for sending specific types of notifications. Instead, it takes a list of NotificationChannel instances and loops through them, calling the send method on each.
- Open for Extension: If you need to add new types of notifications (like PushNotification), you can simply create a new class that implements the NotificationChannel interface without modifying the existing NotificationService class.
- Closed for Modification: The existing notification logic doesn’t change, even if new types are added. This prevents potential bugs that could arise from modifying the class repeatedly.
3. Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
The Liskov Substitution Principle (LSP) ensures that derived classes can be used interchangeably with their base classes without breaking the functionality of the program.
Real-World Analogy:
Imagine a vehicle rental service. A customer should be able to rent and drive any vehicle—whether it’s a car, a truck, or a motorcycle—without needing to know the specifics. All vehicles should share common behaviors like starting the engine and driving, which are defined in the base class Vehicle.
Explanation:
Wondering about code maintainability best practices? LSP helps maintain the integrity of your code. If a subclass overrides methods or introduces new behavior that violates the expectations of the base class, it can lead to bugs. Following LSP ensures that subclasses can replace their base classes seamlessly.
Without Liskov Substitution Principle
abstract class Bird {
void fly();
}
class Sparrow extends Bird {
@override
void fly() {
// Sparrow flying
}
}
class Penguin extends Bird {
@override
void fly() {
throw Exception("Penguins can't fly");
}
}
Explanation:
In this version, the Bird class has a fly method, which all birds are supposed to implement. However, not all birds can fly, as demonstrated by the Penguin class.
By forcing Penguin to implement the fly method and then throwing an exception, we violate the Liskov Substitution Principle (LSP), which states that objects of a superclass should be able to be replaced with objects of its subclasses without altering the correctness of the program. Since a Penguin is a bird, we expect it to behave like any other bird, but calling fly on it results in an exception, breaking the program's expected behavior.
With Liskov Substitution Principle
abstract class Bird {}
abstract class FlyingBird extends Bird {
void fly();
}
class Sparrow extends FlyingBird {
@override
void fly() {
// Sparrow flying
}
}
class Penguin extends Bird {
void swim() {
// Penguin swimming
}
}
Explanation:
In this refactored version, we follow the Liskov Substitution Principle by separating birds that can fly from birds that cannot.
- We introduced a new abstract class FlyingBird, which extends Bird and has the fly method. Only birds that can fly, such as the Sparrow, extend FlyingBird.
- The Penguin class now only extends Bird and has its own specific behavior (swim), without needing to throw an exception for the fly method.
This structure allows us to substitute objects of FlyingBird (e.g., Sparrow) without breaking the program, and Bird subclasses that don't fly (e.g., Penguin) are not forced to implement unrelated behavior.
That’s it for Part 1 of this blog on understanding SOLID principles in coding. In Part 2, we’ll dive into the last two principles so you understand why adhering to SOLID for better software is crucial and how it helps easily transform your code from chaotic to clean in no time. If you need any further help on SOLID principles in software development, contact us!