Pre-requisites
Javascript objects
Javascript classes
ES6 import and export modules
Introduction
Apart from just writing classes and objects, we also need to neatly convey concepts like Inheritance and Composition. To write better cleaner code as web developers, there are guidelines to ensure we keep to efficient standards.
Two standards for this are
The SOLID principle contains five design patterns for writing efficient code.
Loosely coupled objects. Related to SOLID but a bit different.
In this article and the next, we would look at how modern web developers write Javascript code with SOLID principles and loosely coupled objects, and why they are important for efficiency.
SOLID
The SOLID principle stands for five design patterns or sub principles which are:
Single Responsibility
Open/Closed
Liskov Substitution
Interface Segregation
Dependency Inversion
You can use as many as all or as little as none when writing code, this has made life less stressful for developers worldwide, and you and I shouldn't have to suffer.
Single Responsibility
Touted as the most important and the most straightforward, this principle indicates that a class should not have more than one method or at least, have methods that do the same thing in the same class.
Let's have an example of a class that has methods mashed into it.
class MeterTracker {
constructor(maxMeter) {
this.maxMeter = maxMeter;
this.currentMeter = 0;
}
trackMeter(meter) {
this.currentMeter += meter;
if (this.currentMeter > this.maxMeter) {
this.showExcess();
} else {
this.support();
}
}
restRunner() {
console.log("Get some rest");
}
supportRunner() {
console.log("Keep running");
}
}
const runMeter = new MeterTracker(2000);
runMeter.trackMeter(500);
runMeter.trackMeter(400);
runMeter.trackMeter(200);
runMeter.trackMeter(1500);
In the code above, we want to design a function that adds and keeps note of meters being run, and we do this by designing a class. All is fine with the
MeterTracker
tracker class until we bring the Single Responsibility(SR) principle into play.There is more than one method in our class,
trackMeter()
,restRunner()
, andsupportRunner()
.Obviously, this is against the SR principle and we have to change it. To adhere to this principle, we should take two methods out of the class. Let's do this with the import/export statements.
Solution:
- Create a separate file In this file we are going to put our
restRunner()
andsupportRunner()
methods and import them in this file.
function restRunner() {
console.log("Get some rest");
}
function supportRunner() {
console.log("Keep running");
}
export { restRunner, supportRunnner };
- Import them into the main file
import { supportRunner,restRunner } from "./SR.js"
- Change the
MeterTracker
class to use the imported functions
import { supportRunner, restRunner } from "./SR.js";
class MeterTracker {
constructor(maxMeter) {
this.maxMeter = maxMeter;
this.currentMeter = 0;
}
trackMeter(meter) {
this.currentMeter += meter;
if (this.currentMeter > this.maxMeter) {
restRunner();
} else {
support();
}
}
}
const runMeter = new MeterTracker(2000);
runMeter.trackMeter(500);
runMeter.trackMeter(400);
runMeter.trackMeter(200);
runMeter.trackMeter(1500);
- Everything should work as before, we only have one method
trackMeter
in ourMeterTracker
class.
A wise man once said:
Don't put functions that change for different reasons in the same class.
-Uncle Bob
Open/Closed
This principle can be a bit tricky but can be understood with use. The Open/Closed principle states that a class, module, and function should be closed for modification but open for extension.
Let's use a code sample that does not follow the Open-Closed (OC) principle.
const questions = [
{
type: "boolean",
info: "Do you really like coding or just the money",
},
{
type: "multipleChoice",
info: "What's your best thing to do?",
options: ["Sleep", "Eat", "Work", "Anime", "Twitter"],
},
{
type: "text",
info: "What's your biggest motivation in life",
},
];
function printQuiz(questions) {
questions.forEach((question) => {
console.log(question.info);
switch (question.type) {
case "boolean":
console.log("1. Yes");
console.log("2. Naaah,the mula");
console.log("3. I'm not sure");
break;
case "multipleChoice":
question.options.forEach((option, index) => {
console.log(`${index + 1}. ${option}`);
});
break;
case "text":
console.log("______________");
break;
console.log("ALL GOOD?");
}
});
}
printQuiz(questions);
Even though the code above works, if we want to add a new question to our
questions
array, we would need to also change theswitch
statement in ourprintQuiz()
function. This is what violates the Open Closed rule.The rule is if we want to change anything outside the
printQuiz()
function, we should not change code inside theprintQuiz()
function. Trust me, I was shocked as you are when I first heard this, but there's a way to go around this.Just so you know, almost every time we have multiple
if
orswitch
statements, it is almost certain we are violating the Open Closed rule.
Solution:
Let's break the printQuiz()
function into smaller classes, so we don't have to write a lot of logic inside it.
class BooleanQuestion {
constructor(info) {
this.info = info;
}
printChoices() {
console.log("1. Yes");
console.log("2. Naaah,the mula");
console.log("3. I'm not sure");
}
}
class MultipleChoiceQuestion {
constructor(info, options) {
this.info = info;
this.options = options;
}
printChoices() {
this.options.forEach((option, index) => {
console.log(`${index + 1}. ${option}`);
});
}
}
class TextQuestion {
constructor(info) {
this.info = info;
}
printChoices() {
console.log("_____________");
}
}
const questions = [
new BooleanQuestion("Do you really coding or just the money"),
new MultipleChoiceQuestion("What's your best thing to do?", [
"Sleep",
"Eat",
"Work",
"Anime",
"Twitter",
]),
new TextQuestion("What's your biggest motivation in life")
];
function printQuiz(questions) {
questions.forEach((question) => {
console.log(question.info);
question.printChoices();
});
}
printQuiz(questions);
We created three new classes,
BooleanQuestion
,MultipleChoiceQuestion
, andTextQuestion
.We then used the classes to create new entries in the
questions
array.Now isn't that gorgeous? Our function
printQuiz
is smaller, and we don't have to touch to add a new detail.
Let's make a new class, and use the new
keyword to construct additional information in the questions
array. Change your code to look like this.
// Add new Class
class RangeQuestion {
constructor(info) {
this.info = info;
}
printChoices() {
console.log("1-5");
console.log("4-6");
}
}
const questions = [
new BooleanQuestion("Do you really coding or just the money"),
new MultipleChoiceQuestion("What's your best thing to do?", [
"Sleep",
"Eat",
"Work",
"Anime",
"Twitter",
]),
new TextQuestion("What's your biggest motivation in life"),
// Add new question to the array using RangeQuestion class
new RangeQuestion("Range of your coding confidence?")
];
Mutating our functions with huge if
and switch
could lead to buggy code, really buggy code. But we've avoided that in this example.
Even though we EXTENDED the abilities of our printQuiz()
function, we never had to MODIFY it. Now, isn't that impressive?
Liskov Substitution
We are at the L part of SOLID, which would be the last for today's piece, you need some rest. To the cute name, Liskov.
This deviates a bit from the message of the singularity of code to deal with inheritance.
Liskov Substitution advocates that if a class is a descendant of another class, it should be able to work in the place of its ancestor without any issue. As usual, let's write code that violates the Liskov Substitution principle.
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
calculateArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width) {
this.width = width;
this.height = width;
}
setHeight(height) {
this.width = height;
this.height = height;
}
}
function increaseRectangleWidth(rectangle) {
rectangle.setWidth(rectangle.width + 1);
}
const rectangle1 = new Rectangle(4, 5);
const rectangle2 = new Rectange(5, 5);
increaseRectangleWidth(rectangle1);
increaseRectangleWidth(rectangle2);
console.log(rectangle1.calculateArea());
console.log(rectangle2.calculateArea());
- The answers we get are
25
and30
, if we change theRectangle
construct to a classSquare
construct, it should do the same thing, but it does not.
- const rectangle2 = new Rectange(5, 5);
+ const rectangle2 = new Square(5, 5);
- We get
25
and36
. TheSquare
class changes the code's behavior through theincreaseRectangleWidth()
function, even though it is a descendant of theRectangle
class.
Solution:
This goes against everything the Liskov Substitution stands for, and although it is a bit tricky, it could be satisfied. A simple way to fulfill this is to simply change the Square class
to look like this:
class Square extends Rectangle {
setSize(size) {
this.width = size;
this.height = size;
}
}
- The
Square
class has a new methodsetSize
that doesn't change the behavior and can replace theRectangle
class.
Let me reiterate, the Liskov principle warrants every descendant class to work in any function its ancestors can work in without changing how the code works.
Conclusion
The Single Responsibility, Open-closed, and Liskov principles form the foundation of the SOLID principles.
These principles are essential in software design and web development as they ensure that the code is maintainable, extensible, and robust. By adhering to these principles, developers can create high-quality software that is easy to understand, modify, and test.
However, these three principles are just the tip of the iceberg. The remaining SOLID principles, namely Interface Segregation and Dependency Inversion, are equally important and complement the first three principles.
In the next article, we will delve into these two principles and see how they complete the SOLID principles. Stay tuned for more insights on creating better software through SOLID principles.