Grasping the SOLID Principles
SOLID is a core principle of object-oriented design promoted by Robert C. Martin. It is an acronym for software design principles applied when crafting maintainable software solutions.
S - Single Responsibility Principle
O - Open–Closed Principle
L - Liskov Substitution Principle
I - Interface Segregation Principle
D - Dependency Inversion Principle
I'll be going through each of these with code samples to make these principles easy to understand.
Single responsibility
A class should have one, and only one, reason to change.
A class should perform only one task. It should only do one thing. That is to say that classes should be loosely coupled. Changes to just one part of a specification should not be able to affect the class. When changes to different parts of the specification affects a class in multiple ways, it means that this element of the principle fails.
class Shape {
constructor(width:number, height:number) {
this.width = width;
this.height = height;
}
calculateArea() {
// calculate the area;
}
calculatePerimeter() {
// calculate the perimeter;
}
formatResult() {
// format the result
}
}
In the above example, the class does more than one thing i.e. having formatResult()
method,
it violates the single responsibility principle and therefore increases its likeliness to change when something else changes.
What if the required result is not provided in the format specified by formatResult()
?
It will need the method to be modified thereby also violating the open-close principle.
A better approach is
class Result {
constructor(format:string){
this.format = format;
}
formatResult() {
switch(this.format) {
case "string":
return JSON.stringify(this.result);
case "object":
return JSON.parse(this.result);
case "number":
return parseInt(this.result);
case "binary":
return parseInt(this.result, 2);
default:
return result;
}
}
}
class Shape {
constructor(width:number, height:number, format){
this.width = width;
this.height = height;
this.format = format;
}
calculateArea(width, height, format) {
// calculate and return the area;
}
calculatePerimeter(width, height, format) {
// calculate and return the perimeter;
}
}
To use the class,
const area = new Shape(5, 7, 'string');
Open–closed
Software entities should be open for extension, but closed for modification.
This advocates that software entities should be extensible, but not modifiable. It should be open for extension by other classes but closed for modification.
class Shape {
constructor(shape: string) {
this.shape = shape;
}
calculateArea() {
let area;
foreach(shapes as shape) {
if(shape === 'Square')) {
area = Math.pow(length, 2);
}
else if(shape === 'Circle')) {
area = Math.pi() * Math.pow(radius, 2);
}
else {
throw new Error("Unknown shape")
}
}
return area;
}
}
To calculate the area of another shape with the example above, it requires another else if block which leads to the modification of the calculateArea
in the given class.
A better approach is to move the logic to the particular shape's (e.g. Circle) class and implement shape interface.
interface ShapeInterface {
calculateArea();
}
class Square implements ShapeInterface {
constructor(length:number) {
super();
this.length = length;
}
calculateArea() {
return Math.pow(this.length, 2);
}
}
class Circle implements ShapeInterface {
constructor(radius: number) {
super();
this.radius = radius;
}
calculateArea() {
return Math.pi() * Math.pow(this.radius, 2);
}
calculateDiameter() {
return this.radius * 2;
}
}
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.calculateArea(),
0
);
}
Liskov Substitution
Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
This postulates that objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.
If you have Circle class as a sub class of Shape, Shape class should be easily substituted with Circle class without causing any thing to break
interface ShapeInterface {
// returns a number
calculateArea();
}
class Shape implements ShapeInterface {
calculateArea() {
// returns a number
}
}
class Circle extends Shape {
calculateArea() {
// return a number
// if the implementation of this returns the value in another format,
// it violates this principle as it cannot be used in place of the parent class
}
}
Interface Segregation
A Client should not be forced to implement an interface that it doesn’t use.
Many client-specific interfaces are better than one general-purpose interface. Similar to the Single Responsibility Principle, the goal here is to minimize redundant code by dividing the software components into multiple, independent parts.
interface ShapeInterface {
calculateArea();
calculateVolume();
}
Given the example above, shapes will be forced to abide by the contract specified by the interface.
This violates the single responsibility and interface segregation principles.
To fix this, calculateVolume();
would be moved to a different interface.
interface ShapeInterface {
calculateArea();
}
interface ShapesWithDepthInterface {
calculateVolume();
}
Usage:
class Square implements ShapeInterface {
calculateArea() {
// calculate the area
}
}
class Cone implements ShapeInterface, ShapesWithDepthInterface {
calculateArea() {
// calculate the area
}
calculateVolume() {
// calculate the volume
}
}
Dependency Inversion
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.
This advocates for decoupling. One should depend upon abstractions(interfaces), NOT concretions (classes). Despite the similarity to dependency injection, this is a bit different.
class Form {
constructor(Input type) {
this.type = type;
}
}
Input is the low-level module while Form is high-level. The form should not care about the input you pass to it.
interface InputInterface {
public function constructInput();
}
class Input implements InputInterface {
public function constructInput(type) {
// construct the input
}
}
class Form {
constructor(InputInterface input) {
this.input = input;
}
}
It is paramount to note that most of the principles rely on the use of interfaces. Using interfaces (Interface Segregation principle) helps you to isolate implementation details from business logic(Single Responsibility principle). This will also enable you to replace an implementation with its subtype(Liskov Substitution principle) and enable you to write applications that are open for extension but close for modification (Open/Close principle).
Conclusion
SOLID Principles is a standard that all developers should adopt to write decoupled and maintainable code. It might sound abstract at first, but with practice, you'll have a good grasp of it and improve the quality of code you write.