SOLID - Principles of Object-Oriented Design
SOLID Principles are a valuable tool to write good object-oriented software. This article tries to put some light on the subject with simple explanations and examples for each principle using TypeScript.
This article is based on the work done by Samuel Oloruntoba in his article S.O.L.I.D: The First 5 Principles of Object Oriented Design but using TypeScript instead of PHP for the examples.
SOLID is an acronym for the first five principles of the article Principles of Object-Oriented Design by Robert C. Martin.
Applying these principles helps to develop maintainable and extensible code. They also help to catch code smells, refactor code easily, and practice a good agile development.
- S stands for SRP - Single Responsibility Principle
- O stands for OCP - Open-Closed Principle
- L stands for LSP - Liskov Substitution Principle
- I stands for ISP - Interface Segregation Principle
- D stands for DIP - Dependency Inversion Principle
SRP - Single Responsibility Principle
A software entity (classes, modules, functions, etc.) should have one, and only one, reason to change.
This principle means that an entity should do only one thing. So single responsibility denotes some work in isolation. Therefore if we have a software entity that performs some calculations the only reason to change it is if these calculations need to change.
In order to understand better the principle, we can do an example. Let's say that we have to implement an application that given some shapes it calculates the sum of the area of these shapes and prints the output. So, let's start creating our shapes classes:
class Circle {public readonly radius: number;constructor(radius: number) {this.radius = radius;}}class Square {public readonly side: number;constructor(side: number) {this.side = side;}}
Now we create an AreaCalculator
class that is going to have the logic to sum
the area of our shapes.
class AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number {// logic to sum the areas}public output(): string {return `Sum of the areas of provided shapes: ${this.sum()}`}
To use the AreaCalculator
we have to create an array of shapes, instantiate
the class and show the output.
const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];const areas = new AreaCalculator(shapes);console.log(areas.output());
But this implementation has a problem. In this example, AreaCalculator
handles the logic to calculate the sum of the areas and to output the data.
What if the user wants the output in JSON?
Here is when Single Responsibility Principle comes into play. AreaCalculator
should only change if we change how we calculate the sum of the areas, not when
we want a different output or representation.
We can fix this by implementing a class which its only responsibility is to output the data.
const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];const areas = new AreaCalculator(shapes);const output = new Outputter(areas);console.log(output.text());console.log(output.json());
Now we have two classes with one responsibility each one, if we want to change
how calculations are made only AreaCalculator
will change and the same to
change the output, it will affect only Outputter
.
OCP - Open-Closed Principle
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
A desirable property that our software entities is to be easy to extend his functionality without the need to change the entity itself.
Using the previous example, now we want to introduce a new fancy shape: the
Triangle. But first, take a closer look at the sum part of our AreaCalculator
class.
class AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum() {let sum: number = 0;for (let shape of this.shapes) {if (shape instanceof Circle) {sum += Math.PI * Math.pow(shape.radius, 2);} else if (shape instanceof Square) {sum += shape.side * shape.side;}}return sum;}}
Here we are violating the Open/Close Principle, because in order to add support
for triangles we have to modify AreaCalculator
adding a new else if
block in
order to handle the calculation of the new area.
To fix this we can move the code that calculates the area to the corresponding shapes, and make that shapes implement an interface that describes better what a shape can do.
interface Shape {area(): number;}class Circle implements Shape {public readonly radius: number;constructor(radius: number) {this.radius = radius;}public area(): number {return Math.PI * Math.pow(this.radius, 2);}}
Now the AreaCalculator
looks like the code below, that allows us to create new
kind of shapes and it will work always that this new shape implements the Shape
interface.
class AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number {let sum: number = 0;for (let shape of this.shapes) {sum += shape.area();}return sum;}}
LSP - Liskov Substitution Principle
Derived class must be substitutable for their base class.
What this principle means is that objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program. So if you pass a subclass of an abstraction you need to make sure you don’t alter any behavior or state semantics of the parent abstraction.
Continuing with the AreaCalculator
class, now we want to create a
VolumeCalculator
class that extends AreaCalculator
:
class VolumeCalculator extends AreaCalculator {public readonly shapes: Shape[];constructor(shapes: Shape[]) {this.shapes = shapes;}public sum(): number[] {// logic to calculate the volumes and then return// and array of output}}
To understand better this example let's make a more detailed version of the
Outputter
class.
class Outputer {private calculator;constructor(calculator: AreaCalculator) {this.calculator = calculator;}public json(): string {return JSON.stringify({sum: this.calculator.sum();})}public text(): string {return `Sum of provided shapes: ${this.calculator.sum()}`;}}
With this implementation, if we try to run a code like this:
const areas = new AreaCalculator(shapes2D);const volumes = new VolumeCalculator(shapes3D);console.log('Areas - ', new Ouputter(areas).text());console.log('Volumes - ', new Ouputter(volumes).text());
The program is not gonna fail but the output will not be consistent since one
output will be something like Areas - Sum of provided shapes: 42
, and the
other Volumes - Sum of provided shapes: 13, 15, 14
. This is not what we expect
from our program.
This happens because the violation of the Liskov Substitution Principle, the
sum
method of the VolumeCalculator
class is an array of numbers while the
AreaCalculator
is just a number.
To fix this we have to reimplement the sum method of VolumeCalculator
to
return a number instead of an array.
class VolumeCalculator extends AreaCalculator {// constructorpublic function sum(): number {// logic to calculate the volumes and then return// and array of outputreturn sum;}}
ISP - Interface Segregation Principle
Make fine grained interfaces that are client specific.
In this case, we want to keep interfaces as small as possible, so clients are not forced to implement methods that they don't actually need.
So, coming back to our shape interface, now that we can calculate volumes our interface looks similar to this:
interface Shape {area(): number;volume(): number;}
But we know that not all our shapes have a volume, Square
is a 2D shape but
because of the interface, we are forced to implement a volume
method.
Applying the Interface Segregation Principle we split the Shape
interface
into two different interfaces, one to define 2D shapes and another for 3D shapes.
interface Shape2D {area(): number;}interface Shape3D {volume(): number;}class Cuboid implements Shape2D, Shape3D {public area(): number {// calculate the surface area of the cuboid}public volume(): number {// calculate the volume of the cuboid}}
DIP - Dependency Inversion Principle
Depend on abstractions, not on concretions.
What this principle comes to says is that high-level modules should not depend on low-level modules, but they should depend on abstractions.
This principle allows for decoupling, an example that seems like the best way to
explain this principle. Let's see a new class to save our shapes ShapeManager
:
class ShapeManager {private database;constructor(database: MySQL) {this.database = database;}public load(name: string): Shape {}}
In this case, ShapeManager
is a high-level module while MySQL
is a low-level
module, but this is a violation of the Dependency Inversion Principle
since we
are forced to depend on MySQL
.
If we want to change our database in the future we would have to edit the ShapeManager
class and thus violates Open-Close Principle. In this case we should not care
about which kind of database are we using, so to depend on abstractions on this
case we will make use of an interface:
interface Database {connect(): Connection;}class MySQL implements Database {public connect(): Connetion {// creates a connection}}class ShapeManager {private database;constructor(database: Database) {this.database = database;}public load(name: string): Shape {}}
And now our high-level and low-level modules are depending on abstractions.
Conclusion
When starting to write Object-Oriented programing the SOLID principles could be difficult to understand and, if they are understood, see where and when to apply them is not trivial. But they are an example of one of the most important things in software development, practice and experience will make you apply these principles in a very natural and intuitive way.