Hi,
SOLID is a principle in software engineering to make our codebase clean & maintainable.
It was proposed by the man himself, Uncle Bob Martin.
SOLID stands for:
Let's go through them one by one.
The definition states that
The class should have one and only one reason to change
Huh, what does that mean?
"One reason to change" means that the class should only have one responsibility.
Let's take a look at this example in Typescript. Suppose we have this class.
class Circle {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
}
class AreaCalculator {
calculateArea(shape: object) {
if (shape instanceof Circle) {
return Math.PI * shape.radius * shape.radius;
}
throw new Error("Shape not supported");
}
round2DP(num: number) {
return Math.round(num * 100) / 100;
}
}
const circle = new Circle(4);
const areaCalculator = new AreaCalculator();
const circleArea = areaCalculator.calculateArea(circle);
console.log("Area = ", areaCalculator.round2DP(circleArea));
That round2DP
function inside AreaCalculator
violates Single Responsibility
principles because it has nothing to do with AreaCalculator's functionality.
What if in the future we want to change to round to 4 decimal places? That means there are more than 1 reason to change now:
To fix this, we should encapsulate it into another class.
class NumberRounder {
multiplier: number
constructor(dp: number) {
this.multiplier = Math.pow(10, dp)
}
round(num: number): number {
return Math.round(num * this.multiplier) / this.multipler
}
}
class Circle {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
}
class AreaCalculator {
calculateArea(shape: object) {
if (shape instanceof Circle) {
return Math.PI * shape.radius * shape.radius;
}
throw new Error("Shape not supported");
}
}
const rounder = new NumberRounder(2)
const circle = new Circle(4);
const areaCalculator = new AreaCalculator();
const circleArea = areaCalculator.calculateArea(circle);
console.log("Area = ", rounder.round(circleArea));
This principle states that
Software entities should be open for extension but closed for modification.
Software entities can be a Class, or a Function. I think the rest of the definition is self-explanatory.
Let's take a look back at the previous code. Suppose we want to support more shapes like a Square
.
class Square {
sideLength: number;
constructor(sideLength: number) {
this.sideLength = sideLength;
}
}
To calculate the area, we need to MODIFY calculateArea
method inside AreaCalculator
class.
class AreaCalculator {
calculateArea(shape: object) {
if (shape instanceof Circle) {
return Math.PI * shape.radius * shape.radius;
}
// NEW LOGIC
if (shape instanceof Square) {
return shape.sideLength * shape.sideLength;
}
throw new Error("Shape not supported");
}
}
This activity violates Open Closed Principle
. Because the principle said that we shouldn't ever modify calculateArea
. Well, unless there's a bug of course. But in a case like this, we shouldn't add more IF conditions.
What if there are 1000 shapes that we want to support in the future, are we going to add 1000 IF conditions as well ?
Ok, let's say we add 1000 IF conditions, what if one of them has a bug ? That would be TEDIOUS to fix.
To fix this, we need to utilize an interface
& it has a method called getArea()
. And then we make Circle
& Square
to implement that interface.
interface Shape {
getArea(): number
}
class Square implements Shape {
sideLength: number;
constructor(sideLength: number) {
this.sideLength = sideLength;
}
getArea(): number {
return this.sideLength * this.sideLength;
}
}
class Circle implements Shape {
private radius: number;
constructor(radius: number) {
this.radius = radius;
}
getArea(): number {
return Math.PI * this.radius * this.radius;
}
}
Then, we can simplify calculateArea
method inside AreaCalculator
like this
class AreaCalculator {
calculateArea(shape: Shape) {
return shape.getArea()
}
}
Notice that calculateArea
doesn't really care now whether the param is a Circle
or a Square
. It only knows that it accepts an object that implements Shape
interface. And Shape
interface has a method called getArea()
.
Now, with this approach, everytime we want to add a new shape, we don't have to MODIFY the logic of calculateArea
.
This makes the overall logic EXTENSIBLE.
Here's how it looks on the upstream logic.
const rounder = new NumberRounder(2);
const areaCalculator = new AreaCalculator();
const circle = new Circle(4);
const circleArea = areaCalculator.calculateArea(circle);
const square = new Square(5);
const squareArea = areaCalculator.calculateArea(square);
This principle came from Barbara Liskov. She said
What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T
Wait... what did I just read?
In a simpler term,
Object in a program should be replaceable with instances of their sub-types without altering the correctness of the program.
Let's see the example. Let's say I create a new class called NoShape
that implements the Shape
interface.
class NoShape implements Shape {
getArea(): number {
throw new Error("Can't calculate area. This is not a shape")
}
}
This basically violates this principle, cos this sub-class could break the calculateArea
method inside AreaCalculator
. Even though it's implementing Shape
interface.
So, this shouldn't happen.
This principle states
Keep interfaces small so that users don’t end up depending on things they don’t need.
So, let's say i want to add a new method inside Shape
interface
interface Shape {
getArea(): number
getVolume(): number
}
So, we added getVolume()
method to support 3D shapes.
But hold up, that means we must implement that method to Circle
& Square
as well. But they can't calculate volume!
If we force them to implement getVolume()
they will potentially break Liskov Substitution
principle.
So, what we need to do here is to Segregate the interfaces into smaller one.
interface Shape {
getArea(): number
}
interface ThreeDimensionalShape {
getArea(): number
getVolume(): number
}
So, it's like Single Responsibility
principle, but on the interface level
This is my most favorite one. This states
Entities must depend on abstractions, not on concretions
The important keyword here is depend
. So if a class depends on another functionality, we should make it to depends on an abstract
, or interface
, rather than the concrete class
.
So, let's say we want to add a formatting capability to AreaCalculator
class. It should be able to format the area into a JSON-formatted string.
First thing first, we shouldn't add a new formatting method there because it will break the Single Responsibility
principle. So AreaCalculator
should depends on another class.
class JSONFormatter {
formatValue(object: Object) {
return JSON.stringify(object)
}
}
Then we make some changes inside AreaCalculator
class
class AreaCalculator {
formatter: JSONFormatter = new JSONFormatter()
calculateArea(shape: Shape): string {
return shape.getArea()
}
calculateAndFormat(shape: Shape): string {
const area = shape.getArea()
return formatter.formatValue({area: area.toString()})
}
}
This violates Dependency Inversion
principle.
Because formatter: JSONFormatter = new JSONFormatter()
is a concrete object. And AreaCalculator
is depending on a concrete object.
Now, what happen if in the future we want to change the formatter to CSV formatter
?
We need to MODIFY AreaCalculator
class, change the formatter into CSV formatter. This would also break open-closed principle
.
To fix this, we need to make AreaCalculator
to implement an interface
. So let's create the interface
interface Formatter {
formatValue(object: Object): string
}
class JSONFormatter implements Formatter {
formatValue(object: Object): string {
return ......
}
}
class AreaCalculator {
formatter: Formatter
constructor(formatter: Formatter) {
this.formatter = formatter
}
calculateArea(shape: Shape): string {
return shape.getArea()
}
calculateAndFormat(shape: Shape): string {
const area = shape.getArea()
return this.formatter.formatValue({area: area.toString()})
}
}
Now, AreaCalculator
doesn't really care about what type of formatter is given to it. It only knows that it's an interface & that interface has formatValue
method.
This way, we can remove the Tight Couple between JSON formatter and AreaCalculator
.
This is how it looks on the upstream logic
const rounder = new NumberRounder(2);
const jsonFormatter = new JSONFormatter(); // create the concrete class here
const areaCalculator = new AreaCalculator(jsonFormatter); // pass the concrete class
const circle = new Circle(4);
const circleArea = areaCalculator.calculateAndFormat(circle);
If in the future we want to change the formatter to CSV formatter, we won't touch AreaCalculator
class anymore. We only touch the upstream logic.
const rounder = new NumberRounder(2);
const csvFormatter = new CSVFormatter(); // create the concrete class here
const areaCalculator = new AreaCalculator(csvFormatter); // pass the concrete class
const circle = new Circle(4);
const circleArea = areaCalculator.calculateAndFormat(circle);
Finally, we've covered all the SOLID principles. I hope you can understand more about this & apply it into your own work.
See you next time.