Introduction
From the past few articles, I have been writing about design patterns and had promised to continue the series by focusing on their uses in Flutter development. However, before we dive deeper into new design patterns, I believe it is crucial to understand the SOLID principles. Even if we think we already know these principles, there is always room for a more profound understanding. Going forward in this article, we will shift our attention to the SOLID principles. Without further delay, let’s deep dive into this.
What are SOLID Principles?
The SOLID principles were introduced by Robert C. Martin (a.k.a Uncle Bob) in his paper Design Principles and Design Patterns. These principles were intended to make software designs more understandable, flexible, and maintainable. The acronym SOLID stands for:
- S: Single Responsibility Principle (SRP)
- O: Open-Closed Principle (OCP)
- L: Liskov Substitution Principle (LSP)
- I: Interface Segregation Principle (ISP)
- D: Dependency Inversion Principle (DIP)
These principles provide a way to decouple software (reduce dependencies between software components) and make it more modular, flexible, and adaptable to changes. They are widely used in object-oriented programming but can also be applied to other paradigms.
Single Responsibility Principle (SRP)
According to the Single Responsibility Principle (SRP), a class should only have one reason to change. Let's understand this principle better with an example.
Problem
class User {
String name;
String email;
User(this.name, this.email);
void changeEmail(String newEmail) {
// Validate the email
if (validateEmail(newEmail)) {
this.email = newEmail;
} else {
print('Invalid email');
}
}
bool validateEmail(String email) {
// Check if the email is valid
return email.contains('@');
}
}
In the above code, the User class violates the Single Responsibility Principle(SRP) as it has two responsibilities: managing the user's data and validating the email.
Solution
class User {
String name;
String email;
User(this.name, this.email);
void changeEmail(String newEmail) {
if (EmailValidator.validate(newEmail)) {
this.email = newEmail;
} else {
print('Invalid email');
}
}
}
class EmailValidator {
static bool validate(String email) {
// Check if the email is valid
return email.contains('@');
}
}
Now, the User class is only responsible for managing the user's data, and the EmailValidator class is only responsible for validating emails. This adheres to the Single Responsibility Principle(SRP).
Open-Closed Principle (OCP)
According to the Open-Closed Principle (OCP), software entities (classes, modules, functions, and so on) should be open for extension but closed for modification. Let's understand this principle better with an example.
Problem
class Greeter {
void greet(String language) {
switch (language) {
case 'English':
print('Hello!');
break;
case 'Spanish':
print('Hola!');
break;
default:
print('Language not supported');
}
}
}
Now, suppose I want to add a new language, I have to modify the greet() method, which will violate the Open-Closed Principle(OCP).
Solution
We can solve this by creating separate classes for each language that implement a common Greeter interface.
abstract class Greeter {
void greet();
}
class EnglishGreeter implements Greeter {
@override
void greet() {
print('Hello!');
}
}
class SpanishGreeter implements Greeter {
@override
void greet() {
print('Hola!');
}
}
class FrenchGreeter implements Greeter {
@override
void greet() {
print('Bonjour!');
}
}
class HindiGreeter implements Greeter {
@override
void greet() {
print("Namaste!");
}
}
Now, the Greeter interface is open for extension (we can add new languages by creating new classes) but closed for modification (we don't have to modify the Greeter interface or any of the existing classes). This adheres to the Open-Closed Principle.
Liskov Substitution Principle (LSP)
According to the Liskov Substitution Principle (LSP), any instance of a derived (child) class should be substitutable for an instance of its base (parent) class without affecting the correctness of the program. Let's understand this principle better with an example.
Problem
class Bird {
void fly() {
print('Flying...');
}
}
class Penguin extends Bird {
@override
void fly() {
throw Exception('Penguins can\'t fly!');
}
}
The code above shows that the Penguin class is a type of Bird, but it is unable to perform certain actions that a Bird can, like flying. When we attempt to use a Penguin in place of a Bird, we may encounter issues which go against the Liskov Substitution Principle(LSP).
Solution
abstract class Bird {
void eat();
}
abstract class FlyingBird extends Bird {
void fly();
}
abstract class NonFlyingBird extends Bird {}
class Sparrow implements FlyingBird {
@override
void eat() {
print("Eating...");
}
@override
void fly() {
print('Flying...');
}
}
class Penguin implements NonFlyingBird {
@override
void eat() {
print("Eating...");
}
}
Now, a FlyingBird can always be substituted for a Bird, and a NonFlyingBird can also always be substituted for a Bird. This adheres to the Liskov Substitution Principle(LSP).
Interface Segregation Principle (ISP)
According to the Interface Segregation Principle (ISP), Instead of creating a large interface that covers all the possible methods, it's better to create smaller, more focused interfaces for specific use cases. Let's understand this principle better with an example.
Problem
abstract class Animal {
void eat();
void sleep();
void fly();
}
In the above code, the Animal interface has multiple responsibilities. If an animal doesn't need one of these methods (like a dog doesn't fly), it still has to implement it. This violates the Interface Segregation Principle(ISP).
Solution
We can solve this by creating separate interfaces for each responsibility.
abstract class Eater {
void eat();
}
abstract class Sleeper {
void sleep();
}
abstract class Flyer {
void fly();
}
// Now an animal can implement only the interfaces it needs.
class Bird implements Eater, Sleeper, Flyer {
@override
void eat() {
print("Eating..");
}
@override
void sleep() {
print("Sleeping..");
}
@override
void fly() {
print("Flying..");
}
}
class Dog implements Eater, Sleeper {
@override
void eat() {
print("Eating..");
}
@override
void sleep() {
print("Sleeping..");
}
}
Now, each interface has a single responsibility, and an animal can implement only the interfaces it needs. This adheres to the Interface Segregation Principle
Dependency Inversion Principle (DIP)
According to the Dependency Inversion Principle (DIP), high-level modules should not depend on low-level modules, but both should depend on abstractions. Let's understand this principle better with an example.
Problem
class WeatherService {
String getWeather() {
// Returns the current weather
return "Sunny";
}
}
class WeatherReporter {
WeatherService _weatherService;
WeatherReporter(this._weatherService);
void reportWeather() {
String weather = _weatherService.getWeather();
print('The weather is $weather');
}
}
In the above code, the WeatherReporter class is directly dependent on the WeatherService class. If we want to change the way we get the weather (for example, by using a different weather service), we have to modify the WeatherReporter class. This violates the Dependency Inversion Principle(DIP).
Solution
We can solve this by creating a WeatherProvider interface and making WeatherReporter depend on this interface, not on the concrete WeatherService.
abstract class WeatherProvider {
String getWeather();
}
class WeatherService implements WeatherProvider {
@override
String getWeather() {
// Returns the current weather
return "Sunny";
}
}
class WeatherReporter {
WeatherProvider _weatherProvider;
WeatherReporter(this._weatherProvider);
void reportWeather() {
String weather = _weatherProvider.getWeather();
print('The weather is $weather');
}
}
Now, the WeatherReporter class depends on the abstraction (WeatherProvider), not on the concrete WeatherService. If we want to change the way we get the weather, we just create a new class that implements WeatherProvider. We don't have to modify the WeatherReporter class. This adheres to the Dependency Inversion Principle(DIP).
Conclusion
The SOLID principles are a set of guidelines that can help you create robust and maintainable code. It's important to use them wisely and not overcomplicate things. To get the most out of these principles, you need to understand their purpose and apply them appropriately to enhance the quality of your software design. Hope you found the article useful. We will continue learning about design pattern series in the next article. Thanks for reading.
References: