From Good to Great: Part 1

Applying SOLID Principles in Your Code

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(), and supportRunner().

  • 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:

  1. Create a separate file In this file we are going to put our restRunner() and supportRunner() methods and import them in this file.
function restRunner() {
  console.log("Get some rest");
}

function supportRunner() {
  console.log("Keep running");
}

export { restRunner, supportRunnner };
  1. Import them into the main file
import { supportRunner,restRunner } from "./SR.js"
  1. 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 our MeterTracker 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 the switch statement in our printQuiz() 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 the printQuiz() 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 or switch 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 , and TextQuestion .

  • 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 and 30, if we change the Rectangle construct to a class Square 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 and 36. The Square class changes the code's behavior through the increaseRectangleWidth() function, even though it is a descendant of the Rectangle 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 method setSize that doesn't change the behavior and can replace the Rectangle 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.