Keep it SOLID: Building Robust and Flexible Software Systems

Brahim Guaali
4 min readMay 25, 2023

--

Uncle bob spitting facts

The SOLID principles are a set of guidelines designed to help software developers design maintainable, flexible, and robust object-oriented systems. The acronym SOLID stands for:

  • S — Single-responsibility
  • O — Open-closed
  • L — Liskov substation
  • I — Interface segregation
  • D — Dependency Inversion

Single Responsibility Principle

A class should have only one reason to change. It states that a class should have a single responsibility or purpose, and that responsibility should be encapsulated within the class.

class UserService {
void registerUser(User user) {
// Logic to register a user
}

void sendEmail(User user, String message) {
// Logic to send an email to the user
}
}

In this example, the UserService class has two responsibilities: registering a user and sending emails. To adhere to the SRP, we could split these responsibilities into separate classes.

Open/Closed Principle

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. It encourages the use of abstraction and polymorphism to allow new functionality to be added without modifying existing code.

abstract class Shape {
double getArea();
}

class Rectangle implements Shape {
double width;
double height;

@override
double getArea() {
return width * height;
}
}

class Circle implements Shape {
double radius;

@override
double getArea() {
return 3.14 * radius * radius;
}
}

void printArea(Shape shape) {
print('Area: ${shape.getArea()}');
}

In this example, the Shape interface is open for extension, allowing new shapes like Rectangle and Circle to be added without modifying the printArea function. The function can work with any shape that implements the Shape interface.

Liskov Substitution Principle

Subtypes must be substitutable for their base types. It defines that objects of a superclass should be able to be replaced with objects of its subclasses without affecting the correctness of the program.

abstract class Vehicle {
void startEngine();
void accelerate();
}

class Car implements Vehicle {
@override
void startEngine() {
print('Car engine started');
}

@override
void accelerate() {
print('Car accelerated');
}

void openTrunk() {
print('Trunk opened');
}
}

class Motorbike implements Vehicle {
@override
void startEngine() {
print('Motorbike engine started');
}

@override
void accelerate() {
print('Motorbike accelerated');
}

void doWheelie() {
print('Wheelie performed');
}
}

void testVehicle(Vehicle vehicle) {
vehicle.startEngine();
vehicle.accelerate();

if (vehicle is Car) {
Car car = vehicle;
car.openTrunk();
} else if (vehicle is Motorbike) {
Motorbike motorbike = vehicle;
motorbike.doWheelie();
}
}

void main() {
Vehicle car = Car();
Vehicle motorbike = Motorbike();

testVehicle(car);
testVehicle(motorbike);
}

In this example, we have an abstract Vehicle class that defines common behaviors such as starting the engine and accelerating. The Car and Motorbike classes implement the Vehicle interface and provide their specific implementations of these methods.

The Car class has an additional method, openTrunk(), which is specific to cars. The Motorbike class has its own additional method, doWheelie(), which is specific to motorbikes.

The testVehicle() function demonstrates the Liskov Substitution Principle. It accepts a Vehicle object as a parameter and calls the common methods startEngine() and accelerate(). If the object is a Car, it also calls the openTrunk() method, and if it's a Motorbike, it calls the doWheelie() method.

In the main() function, we create instances of Car and Motorbike and pass them to the testVehicle() function. The Liskov Substitution Principle allows us to treat both objects as Vehicle types, and the appropriate behavior is invoked based on their actual types.

Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use. It promotes the idea of small, cohesive interfaces that are tailored to specific clients, instead of having large, monolithic interfaces.

abstract class Printer {
void print();
}

abstract class Scanner {
void scan();
}

class AllInOnePrinter implements Printer, Scanner {
@override
void print() {
print('Printing...');
}

@override
void scan() {
print('Scanning...');
}
}

void printDocument(Printer printer) {
printer.print();
}

In this example, the Printer and Scanner interfaces define specific methods for printing and scanning. The AllInOnePrinter class implements both interfaces, but the printDocument function only depends on the Printer interface, allowing it to work with any printer.

Dependency Inversion Principle

High-level modules should not depend on low-level modules; both should depend on abstractions. It emphasizes the use of interfaces or abstract classes to define dependencies, allowing for flexibility, decoupling, and easier testing.

abstract class Database {
void saveData(String data);
}

class MySQLDatabase implements Database {
@override
void saveData(String data) {
print('Saving data to MySQL database: $data');
}
}

class PostgresDatabase implements Database {
@override
void saveData(String data) {
print('Saving data to Postgres database: $data');
}
}

class DataManager {
final Database _database;

DataManager(this._database);

void processData(String data) {
// Logic to process data
_database.saveData(data);
}
}

void main() {
final mysqlDatabase = MySQLDatabase();
final dataManager = DataManager(mysqlDatabase);

dataManager.processData('Example data');
}

In this example, we have an abstract class Database that defines the contract for saving data. We have two concrete classes, MySQLDatabase and PostgresDatabase, that implement the Database interface.

The DataManager class depends on the Database abstraction rather than a specific database implementation. It takes an instance of the Database interface through its constructor, following the Dependency Inversion Principle.

In the main function, we create an instance of MySQLDatabase and pass it to the DataManager constructor. The DataManager then uses the injected Database implementation to save the data.

By depending on the abstraction (Database) rather than concrete implementations (MySQLDatabase or PostgresDatabase), we can easily swap out different database implementations without modifying the DataManager class. This promotes flexibility, decoupling, and easier testing in our codebase.

These principles aim to improve code quality, maintainability, reusability, and extensibility, ultimately leading to more robust and flexible software systems. By following these principles, developers can create software that is easier to understand, modify, and maintain over time.

--

--