SOLID - Principles of Object-Oriented Design
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.
These principles help you write maintainable and extensible code. They also help you catch code smells, refactor easily, and practice 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.
An entity should do only one thing. Single responsibility means work in isolation. If a software entity performs calculations, the only reason to change it is when those calculations need to change.
An example clarifies this principle. Say we must implement an application that calculates the total area of given shapes and prints the result. Let's start with our shape 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 AreaCalculator, create an array of shapes, instantiate the class, and
display the output.
const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];const areas = new AreaCalculator(shapes);console.log(areas.output());
This implementation has a problem: AreaCalculator handles both the area
calculation logic and the output formatting. What if the user wants JSON
output?
The Single Responsibility Principle addresses this. AreaCalculator should
change only when the calculation logic changes, not when we want different
output formats.
We fix this by creating a class whose sole responsibility is output formatting.
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 each class has one responsibility. Changing the calculation logic affects
only AreaCalculator; changing the output format affects only Outputter.
OCP - Open-Closed Principle
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
Software entities should be easy to extend without modifying the entity itself.
Using the previous example, we want to add a new shape: the Triangle. First,
examine the sum method in AreaCalculator.
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;}}
This violates the Open/Closed Principle: adding triangle support requires
modifying AreaCalculator with a new else if block.
To fix this, move the area calculation to each shape class and define an interface that describes 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 AreaCalculator accepts any shape that 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.
Objects in a program should be replaceable with instances of their subtypes without altering the program's correctness. A subclass must preserve the behavior and state semantics of its 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}}
A detailed Outputter class clarifies this example:
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 runs but produces inconsistent output:
Areas - Sum of provided shapes: 42 versus
Volumes - Sum of provided shapes: 13, 15, 14. This breaks our expectations.
The Liskov Substitution Principle is violated: VolumeCalculator.sum()
returns an array of numbers, while AreaCalculator.sum() returns a single
number.
The fix: VolumeCalculator.sum() must return a number, not 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.
Keep interfaces small so clients implement only the methods they need.
{/* REVIEW: This explanation could be more detailed */}
Our shape interface now includes volume calculation:
interface Shape {area(): number;volume(): number;}
But Square is a 2D shape with no volume, yet the interface forces it to
implement a volume method.
The Interface Segregation Principle leads us to split Shape into separate
interfaces for 2D and 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.
High-level modules should depend on abstractions, not on low-level modules.
{/* REVIEW: This explanation could be more detailed */}
This principle enables decoupling. Consider a ShapeManager class that saves
shapes:
class ShapeManager {private database;constructor(database: MySQL) {this.database = database;}public load(name: string): Shape {}}
ShapeManager (high-level) depends directly on MySQL (low-level), violating
the Dependency Inversion Principle.
Changing databases would require editing ShapeManager, also violating the
Open-Closed Principle. The solution: depend on a Database interface instead:
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
The SOLID principles may seem difficult at first, and knowing when to apply them takes practice. But with experience, applying these principles becomes natural and intuitive.