DEV Community

Cover image for Understanding SOLID
Darlan Guimarães
Darlan Guimarães

Posted on

Understanding SOLID

Quality of software is the fundamental basis for developing any system. After all, the higher the software quality, the fewer errors there are, and it facilitates maintenance and the addition of new functionalities in the future.

Simply put, software quality can be said to be inversely proportional to the incidence of errors.

With this in mind, various studies and approaches have been developed to increase software quality.

Among these approaches, the beloved or dreaded SOLID emerged.

But what is SOLID, exactly?

SOLID is an acronym that represents five principles that facilitate the development process, making the code cleaner, separating responsibilities, reducing dependencies, facilitating refactoring, and promoting code reuse, thereby increasing the quality of your system.

These principles can be applied in any object-oriented programming (OOP) language.

S - Single Responsibility Principle

In simple terms, this principle states that "Every class should have one, and only one, reason to change", which is the essence of the concept.

This means that a class or module should perform a single task, having responsibility only for that task.

For example, when you first started learning object-oriented programming, you probably encountered classes structured like this:

struct Report;

impl Report{
    fn load(){}
    fn save(){}
    fn update(){}
    fn delete(){}

    fn print_report(){}
    fn show_report(){}

    fn verify(){}
}
Enter fullscreen mode Exit fullscreen mode

Note: This is in Rust. In Rust, there is no concept of classes; instead, we use structures like struct and traits to achieve this kind of behavior. However, the implementation and explanation of SOLID are also possible in this language.

We can see that in this example, we have the struct Report (which in other languages would be a class) and it implements several methods. The issue isn't with the number of methods per se, but rather that each of them does completely unrelated things, causing the struct to have more than one responsibility in the system.

This violates the Single Responsibility Principle, causing a class to have more than one task and thus more than one responsibility within the system. These are known as God Classes — classes or structs that do everything. Initially, this might seem efficient, but when there's a need to change this class, it becomes difficult to modify one responsibility without affecting the others.

God ClassIn object-oriented programming, it's a class that knows too much or does too much.

Breaking this principle can lead to lack of cohesion, as a class shouldn't take on responsibilities that aren't its own, as well as creating high coupling due to increased dependencies and difficulty in code reuse.

  • Lack of cohesion:
    • A class shouldn't take on responsibilities that aren't its own.
  • High coupling:
    • Due to increased responsibilities, there's a higher level of dependencies.
  • Difficulty in code reuse:
    • Code with many responsibilities is harder to reuse.

We can fix that code simply by applying the Single Responsibility Principle:

struct Report;

impl Report {
    fn verify(){}
    fn get_report(){}
}

struct ReportRepository;

impl ReportRepository {
    fn load(){}
    fn save(){}
    fn update(){}
    fn delete(){}
}

struct ReportView;

impl ReportView {
    fn print_report(){}
    fn show_report(){}
}
Enter fullscreen mode Exit fullscreen mode

By doing this, you ensure that each struct/class has a single task, in other words, only one responsibility. Remember that this principle applies not only to structs/classes, but also to functions and methods.

O - Open-Closed Principle

This acronym is defined as "software entities (such as classes and methods) should be open for extension, but closed for modification". This means you should be able to add new functionalities to a class without altering the existing code. Essentially, the more features we add to a class, the more complex it becomes.

To better understand, let's consider a struct for a Shape:

struct Shape {
    t: String,
    r: f64,
    l: f64,
    h: f64,
}

impl Shape {
    fn area(&self) -> f64 {
        if self.t == "circle" {
            3.14 * self.r * self.r
        } else {
            0.0
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

To add the identification of a rectangle, a less experienced programmer might suggest modifying the existing if structure by adding a new condition. However, this goes against the Open-Closed Principle, as altering an already existing and fully functional class can introduce new bugs.

So, what should we do to add this new task?

Essentially, we need to build the function using an interface or a trait, isolating the extensible behavior behind this structure. In Rust, it would look like this:

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        3.14 * self.radius * self.radius
    }
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}
Enter fullscreen mode Exit fullscreen mode

With this approach, we can add various other shapes without modifying the existing code.

This is the Open-Closed Principle in action, making everything cleaner and simpler to analyze, if necessary, in the future.

The key is that each new shape implements the Shape trait with its own area method, allowing the system to be extended with new shapes without modifying existing implementations. This promotes software extensibility and minimizes the risk of introducing errors into already tested and functional code.

This way, we can keep the code organized and reduce the impact of future changes, facilitating system maintenance and evolution.

L - Liskov Substitution Principle

This principle has the following definition: "Derived classes (or child classes) must be able to substitute their base classes (or parent classes)". In other words, a child class should be able to perform all actions of its parent class.

Let's dive into a practical example to better understand:

struct Square {
    side: f64,
}

impl Square {
    fn set_height(&mut self, height: f64) {
        self.side = height;
    }

    fn set_width(&mut self, width: f64) {
        self.side = width;
    }

    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn main() {
    let mut sq = Square { side: 5.0 };
    sq.set_height(4.0);
    println!("Area: {}", sq.area());
}
Enter fullscreen mode Exit fullscreen mode

In this case, the square directly breaks this rule because the trait states that both height and width can be different, which is not valid for a square.

So, what can we do in this case? It's simple:

trait Rectangle {
    fn set_height(&mut self, height: f64);
    fn set_width(&mut self, width: f64);
    fn area(&self) -> f64;
}

struct Square {
    side: f64,
}

impl Square {
    fn set_side(&mut self, side: f64) {
        self.side = side;
    }
}

impl Rectangle for Square {
    fn set_height(&mut self, height: f64) {
        self.set_side(height);
    }

    fn set_width(&mut self, width: f64) {
        self.set_side(width);
    }

    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn main() {
    let mut sq = Square { side: 5.0 };
    sq.set_height(4.0);
    println!("Area: {}", sq.area());
}
Enter fullscreen mode Exit fullscreen mode

Now, the square adheres to the Liskov Substitution Principle because it respects the trait rules of the Rectangle. This is just one example of how this rule applies.

Examples of LSP Violation:

  • Overriding/implementing a method that does nothing.
  • Throwing an unexpected exception.
  • Returning values of types different from the base class.

To avoid violating this principle, it's often necessary to use dependency injection, along with other principles from SOLID itself.

With this, you can see how these principles connect and complement each other as you understand how they work.

I - Interface Segregation Principle

Simply put, the Interface Segregation Principle states that a class should not be forced to implement interfaces and methods it doesn't use. In other words, we shouldn't create a single generic interface.

Let's move on to a practical example to better understand:

trait Worker {
    fn work(&self);
    fn eat(&self);
}

struct Human;

impl Worker for Human {
    fn work(&self) {}

    fn eat(&self) {}
}

struct Robot;

impl Worker for Robot {
    fn work(&self) {}

    fn eat(&self) {} // robos comem????
}
Enter fullscreen mode Exit fullscreen mode

In this example, we have an interface (trait) Worker that requires classes implementing it to have two methods: work and eat. It makes sense for the Human class to implement these two methods, as humans work and eat. But what about Robot? A robot works but does not eat. Therefore, it is forced to implement the eat method even though it's not necessary.

How can we fix this? The answer is to create specific interfaces.

trait Workable {
    fn work(&self);
}

trait Eatable {
    fn eat(&self);
}

struct Human;

impl Workable for Human {
    fn work(&self) {}
}

impl Eatable for Human {
    fn eat(&self) {}
}

struct Robot;

impl Workable for Robot {
    fn work(&self) {}
}
Enter fullscreen mode Exit fullscreen mode

There we go, problem solved! Now we have two distinct interfaces: Workable and Eatable. Each one represents a specific responsibility. The Human class implements both interfaces, while the Robot class implements only the Workable interface.

By adopting specific interfaces, we avoid forcing classes to implement unnecessary methods, keeping the code cleaner, more cohesive, and easier to maintain. This is the essence of the Interface Segregation Principle, which helps create more flexible and robust systems.

D - Dependency Inversion Principle

This principle has two explicit rules:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
  • Abstractions should not depend on details. Details should depend on abstractions.

What does this mean? Simply put: depend on abstractions, not on implementations.

Let's move on to a practical example to better understand:

struct Light;

impl Light {
    fn turn_on(&self) {
        println!("Light is on");
    }

    fn turn_off(&self) {
        println!("Light is off");
    }
}

struct Switch {
    light: Light,
}

impl Switch {
    fn new(light: Light) -> Switch {
        Switch { light }
    }

    fn operate(&self, on: bool) {
        if on {
            self.light.turn_on();
        } else {
            self.light.turn_off();
        }
    }
}

fn main() {
    let light = Light;
    let switch = Switch::new(light);
    switch.operate(true);  
    switch.operate(false); 
}
Enter fullscreen mode Exit fullscreen mode

We can see that the Switch class depends entirely on the Light class. In this case, if we need to replace the Light class with a Fan class, we would also have to modify the Switch class.

How do we solve this? Depend on abstractions, not on implementations, following the principle:

trait Switchable {
    fn turn_on(&self);
    fn turn_off(&self);
}

struct Light;

impl Switchable for Light {
    fn turn_on(&self) {
        println!("Light is on");
    }

    fn turn_off(&self) {
        println!("Light is off");
    }
}

struct Fan;

impl Switchable for Fan {
    fn turn_on(&self) {
        println!("Fan is on");
    }

    fn turn_off(&self) {
        println!("Fan is off");
    }
}

struct Switch<'a> {
    device: &'a dyn Switchable,
}

impl<'a> Switch<'a> {
    fn new(device: &'a dyn Switchable) -> Switch<'a> {
        Switch { device }
    }

    fn operate(&self, on: bool) {
        if on {
            self.device.turn_on();
        } else {
            self.device.turn_off();
        }
    }
}

fn main() {
    let light = Light;
    let switch_for_light = Switch::new(&light);
    switch_for_light.operate(true); 
    switch_for_light.operate(false); 

    let fan = Fan;
    let switch_for_fan = Switch::new(&fan);
    switch_for_fan.operate(true);  
    switch_for_fan.operate(false); 
}
Enter fullscreen mode Exit fullscreen mode

Now, Switch depends on the abstraction Switchable, and both Light and Fan implement this abstraction. This allows Switch to work with any device that implements the Switchable interface.

Benefits:

  • Flexibility: Adding new devices that implement Switchable is easy without needing to change the existing Switch code.
  • Maintenance: Changes in specific device behaviors do not affect the control logic.
  • Decoupling: Reduces coupling between high-level and low-level modules, making testing and parallel development easier.

Conclusion

By applying the SOLID principles, you can make your software more robust, scalable, and flexible, easing modification without much hassle or difficulty.

SOLID is essential for developers and is often used in conjunction with Clean Code practices to further enhance system quality.

While understanding these concepts and examples may seem daunting at first, it's important to remember that we may not always apply all these principles during development. However, with practice and persistence, you can write increasingly mature and robust code. SOLID will be your guide on this journey.

If you want to delve deeper with illustrations, I recommend watching this video by Filipe Deschamps:

If you've read this far, I strongly encourage you to start implementing these principles in your projects, as there's no better way to learn programming than by: PROGRAMMING.

Thank you for reading!

References:

Top comments (0)