DEV Community

Ting Zhou
Ting Zhou

Posted on

My understanding of SOLID principles

SOLID is made up of 5 design principles which is supposed to avoid unnecessary complexity. That being said, some things are unavoidably complex.

S: Single Responsibility Principle

A class should have a single responsibility

This was slightly confusing for me because I'm wondering how to define the scope of responsibility. We can understand it as the class needing to manage one well-defined aspect of the application.

Say you have an employee at a hotel that wears many hats - Chef, Receptionist, and Cleaner. If we were to create a class Employee which encompasses all of these roles, the responsibility wouldn't be very well defined.

If we need to change how the food is cooked, the class has to be modified. If we need to change how the room is clean, the class has to be modified.

class Employee {
  cookFood() {}
  serveGuest() {}
  cleanRoom() {}
}
Enter fullscreen mode Exit fullscreen mode

Instead, we can define 3 classes relating to the food (chef), guests (receptionist), and housekeeping (cleaner).

class Chef extends Employee {
  cookFood() {}
}

class Receptionist extends Employee {
  serveGuest() {}
}

class Cleaner extends Employee {
  cleanRoom() {}
}
Enter fullscreen mode Exit fullscreen mode

O: Open/Closed Principle

Classes should be open for extension, but closed for modification.

In this context, extension means to extend the class by creating a new sub-class, modification means to modify the existing class.

If we wanted to introduce a Station Chef who's in charge of cooking the omelette at the breakfast buffet line, we could either extend or modify.

This is how it would look like if we were to modify.

class Chef extends Employee {
  cookFood() {}
  cookOmelette() {}
}
Enter fullscreen mode Exit fullscreen mode

The problem with doing so would be potentially introducing bugs to the Chef class. Other parts of the application that use the Chef class might also fail.

Instead, we can extend, creating a new sub-class StationChef which inherits Chef.

class Chef extends Employee {
  cookFood() {}
  cookOmelette() {}
}

class StationChef extends Chef {
  cookOmelette() {}
}
Enter fullscreen mode Exit fullscreen mode

L: Liskov Substitution

The child class needs to be able to perform the same actions as the parent class.

If StationChef extends Chef, objects of Chef can be replaced with objects of Chef.

Can't think of an example using this chef analogy, but a good one I found here is on how a square should not be a subclass of a rectangle.

I: Interface Segregation

A class should not need to implement methods it does not need.

Say we want to give some chefs the ability to clean, and we do so by adding this method into the Chef interface. Later on, we have a HeadChef, which we don't expect to clean.

However, we are forced to implement the method cleanKitchen for the head chef.

interface Chef {
  cookFood(): void;
  cleanKitchen(): void;
}

class HeadChef implements Chef {
  cookFood() {}
  cleanKitchen() {}
}
Enter fullscreen mode Exit fullscreen mode

Instead, we could break the interface down, and avoid having to implement the cleanKitchen method in HeadChef.

interface ChefCook {
  cookFood(): void;
}

interface ChefClean {
  cleanKitchen(): void;
}

class HeadChef implements ChefCook {
  cookFood() {}
}
Enter fullscreen mode Exit fullscreen mode

D: Dependency Inversion

High level modules should not depend on low level modules. Both should depend on abstraction.

Abstractions should not depend on details. Details should depend on abstractions.

I have no idea what the second line meant.

Say we want the Chef to log every item that was cooked using the logger.

class Chef {
  private logger;
  constructor() {
    this.logger = new Logger();
  }

  cookFood(foodName) {
    this.logger.log(foodName);
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above example, our high level module is Chef, and low level module is Logger.

I can clearly see that Chef and Logger are now tightly coupled. Scenario: I want to add a new feature to Logger that is used in the Chef class. If I decide to follow the Open/Closed principle, I would add a subclass, say ChildLogger. This means that my Chef class will also need to change to use the new ChildLogger, which also means that the Chef class (high level module) is dependant on the Logger class (low level module).

However, if I had used dependency inversion, and followed the Liskov principle, I will no longer need to change the Chef class, because a ChildLogger is a type of Logger.

class Chef {
  private logger;
  constructor(logger: Logger) {
    this.logger = logger;
  }

  cookFood(foodName) {
    this.logger.log(foodName);
  }
}
Enter fullscreen mode Exit fullscreen mode

What about the abstractions and details part? Note that the above example does not violate this rule, but I will continue explaining using this analogy.

The abstraction here is that my logger will record the foodName passed to it. The details will be how this foodName will be stored. It could be sending a SMS to the CEO, or just being recorded in a computer.

When we say that abstraction should not depend on details, we always expect the logger to log the foodName, no matter what the implementation of logger.log is.

When we say that details should depend on abstractions, the very name of the method, log, implies that the data is being logged somewhere. In that case, we should not expect the method, log, to perform any other unrelated operations, such as calling another chef to cook the food.

Top comments (1)

Collapse
 
lamontgranquist profile image
Lamont Granquist

Liskov Substitution gets violated all the time in reality by programmers attempting to reuse code via using Inheritance.

It is really trivial to do in an OO language like Ruby which has zero data hiding (which just encourages violating LSP).

What programmers will do is see a class that does 95% of what they want, they'll inherit absolutely everything from that base class and then override 5% of it.

This creates a pile of shared code, which was probably never designed to be used as an API and creates the "brittle/fragile base class" problem.

I don't like the way it is usually presented because the mental image it paints is of a designer sitting down and wanting to have squares and rectangles and making a poor choice in initial design. Usually the way it happens in reality is someone builds a class, and then a year or three later someone comes along and inherits from it in order to be "DRY" and not copy the code in that class to a new one. What should happen though is that someone extracts a base class / mixin / interface /etc out of the original class and then both classes use that.

A better example I think is that you've got some inheritance hierarchy where you have a ToyotaHilux inheriting from a generic PickupTruck (which inherits from Car, which inherits from Vehicle or something). Someone comes along and needs to create a DatsunTruck and seeing that its nearly identical to a ToyotaHilux picks inheriting from a ToyotaHilux and then overrides things to make it a Datsun. That saves a lot of time because the generic PickupTruck is the base class for Ford/Chevy/Dodge Trucks as well and is overly generic, so you'd have to copypasta a bunch of stuff from ToyotaHilux to DatsunTruck if they inherited from PickupTruck and its all the same, so you're being more "DRY" by inheriting from the Hilux. But a Datsun IS NOT A Toyota. It is very similar, but those are two concrete instances and you shouldn't be inheriting like that. Instead you need to construct an AbstractToyotaMotorsTruck that both the Hilux and the Datsun models inherit from.

Then you have one spot for all the stuff which is the same between the two, and you have individual spots for all the unique customization.

And what will happen is that Toyota will update the Hilux with some superficial customization and trim levels that won't make it into the down-brand Datsun, and the programmer who attempts to fix that will update the ToyotaHilux class. If the Datsun inherits from ToyotaHilux directly then there's no indication that the programmer just changed the Datsun (hopefully tests will fail, but test coverage is never 100%), and the programmer needs to know they have another task to revert the change after it is inherited by the Datsun.