JavaScript – S.O.L.I.D – The Five Principles Of Object Oriented Design

Hello Readers, CoolMonkTechie heartily welcomes you in this article.

In this article, we will learn about the Javascript 5 SOLID Principles. The SOLID principles are a set of software design principles, that help us to understand how we can structure our code in order to be robust, maintainable and flexible as much as possible.

“JavaScript is a loosely typed language, some consider it a functional language, others consider it an object oriented language, some think its both, and some think that having classes in JavaScript is just plain wrong. — Dor Tzur.”

From my experience, we’ll rarely want to use classes and long inheritance chains in JavaScript. But still, SOLID principles are pretty solid.

SOLID principles are the basic pillars of OOP set forward by our beloved Uncle Bob.

A famous quote about learning is :

” Live as if you were to die tomorrow. Learn as if you were to live forever. “

So Let’s begin.


What is S.O.L.I.D. ?

S.O.L.I.D. stands for :

  • S — Single responsibility principle
  • O — Open closed principle
  • L — Liskov substitution principle
  • I — Interface segregation principle
  • D — Dependency Inversion principle

Let’s see them one by one.


1. Single Responsibility Principle

“A class should have one and only one reason to change, meaning that a class should only have one job.”

For example, say we have some shapes and we wanted to sum all the areas of the shapes. 

const circle = (radius) => {
  const proto = { 
    type: 'Circle',
    //code 
  }
  return Object.assign(Object.create(proto), {radius})
}
const square = (length) => {
  const proto = { 
    type: 'Square',
    //code 
  }
  return Object.assign(Object.create(proto), {length})
}

First, we create our shapes factory functions and setup the required parameters.

so, What is a factory function ?

“In JavaScript, any function can return a new object. When it’s not a constructor function or class, it’s called a factory function.”

Next, we move on by creating the areaCalculator factory function and then write up our logic to sum up the area of all provided shapes.

const areaCalculator = (s) => {
  const proto = {
    sum() {
      // logic to sum
    },
    output () {
     return `
       <h1>
         Sum of the areas of provided shapes:
         ${this.sum()} 
       </h1>
    }
  }
  return Object.assign(Object.create(proto), {shapes: s})
}

To use the areaCalculator factory function, we simply call the function and pass in an array of shapes, and display the output at the bottom of the page.

const shapes = [
  circle(2),
  square(5),
  square(6)
]
const areas = areaCalculator(shapes)
console.log(areas.output())

The problem with the output method is that the areaCalculator handles the logic to output the data. Therefore, what if the user wanted to output the data as json or something else ?

All of the logic would be handled by the areaCalculator factory function, this is what ‘Single Responsibility principle’ frowns against; the areaCalculator factory function should only sum the areas of provided shapes, it should not care whether the user wants JSON or HTML.

So, to fix this you can create an SumCalculatorOutputter factory function and use this to handle whatever logic you need on how the sum areas of all provided shapes are displayed.

The sumCalculatorOutputter factory function would work liked this:

const shapes = [
  circle(2),
  square(5),
  square(6)
]
const areas  = areaCalculator(shapes)
const output = sumCalculatorOputter(areas)
console.log(output.JSON())
console.log(output.HAML())
console.log(output.HTML())
console.log(output.JADE())

Now, whatever logic you need to output the data to the users is now handled by the sumCalculatorOutputter factory function.


2. Open-Closed Principle

“Objects or entities should be open for extension, but closed for modification.”

Open for extension means that we should be able to add new features or components to the application without breaking existing code. Closed for modification means that we should not introduce breaking changes to existing functionality, because that would force you to refactor a lot of existing code — Eric Elliott”

In simpler words, means that a class or factory function in our case, should be easily extendable without modifying the class or function itself. Let’s look at the areaCalculator factory function, especially it’s sum method.

sum () {
 
 const area = []
 
 for (shape of this.shapes) {
  
  if (shape.type === 'Square') {
     area.push(Math.pow(shape.length, 2)
   } else if (shape.type === 'Circle') {
     area.push(Math.PI * Math.pow(shape.length, 2)
   }
 }
 return area.reduce((v, c) => c += v, 0)
}

If we wanted the sum method to be able to sum the areas of more shapes, we would have to add more if/else blocks and that goes against the open-closed principle.

A way we can make this sum method better is to remove the logic to calculate the area of each shape out of the sum method and attach it to the shape’s factory functions.

const square = (length) => {
  const proto = {
    type: 'Square',
    area () {
      return Math.pow(this.length, 2)
    }
  }
  return Object.assign(Object.create(proto), {length})
}

The same thing should be done for the circle factory function, an area method should be added. Now, to calculate the sum of any shape provided should be as simple as:

sum() {
 const area = []
 for (shape of this.shapes) {
   area.push(shape.area())
 }
 return area.reduce((v, c) => c += v, 0)
}

Now we can create another shape class and pass it in when calculating the sum without breaking our code. However, now another problem arises, how do we know that the object passed into the areaCalculator is actually a shape or if the shape has a method named area ?

Coding to an interface is an integral part of S.O.L.I.D., a quick example is we create an interface, that every shape implements.

Since JavaScript doesn’t have interfaces, So how can we achieved this, in the lack of interfaces ?

Function Composition to the rescue !

First we create shapeInterface factory function, as we are talking about interfaces, our shapeInterface will be as abstracted as an interface, using function composition.

const shapeInterface = (state) => ({
  type: 'shapeInterface',
  area: () => state.area(state)
})

Then we implement it to our square factory function.

const square = (length) => {
  const proto = {
    length,
    type : 'Square',
    area : (args) => Math.pow(args.length, 2)
  }
  const basics = shapeInterface(proto)
  const composite = Object.assign({}, basics)
  return Object.assign(Object.create(composite), {length})
}

And the result of calling the square factory function will be the next one:

const s = square(5)
console.log('OBJ\n', s)
console.log('PROTO\n', Object.getPrototypeOf(s))
s.area()

// output
OBJ
 { length: 5 }
PROTO
 { type: 'shapeInterface', area: [Function: area] }
25

In our areaCalculator sum method we can check if the shapes provided are actually types of shapeInterface, otherwise we throw an exception:

sum() {
  const area = []
  for (shape of this.shapes) {
    if (Object.getPrototypeOf(shape).type === 'shapeInterface') {
       area.push(shape.area())
     } else {
       throw new Error('this is not a shapeInterface object')
     }
   }
   return area.reduce((v, c) => c += v, 0)
}

and again, since JavaScript doesn’t have support for interfaces like typed languages the example above demonstrates how we can simulate it, but more than simulating interfaces, what we are doing is using closures and function composition.


3. Liskov Substitution Principle

“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.”

All this is stating is that every subclass/derived class should be substitutable for their base/parent class.

In other words, as simple as that, a subclass should override the parent class methods in a way that does not break functionality from a client’s point of view.

Still making use of our areaCalculator factory function, say we have a volumeCalculator factory function that extends the areaCalculator factory function, and in our case for extending an object without breaking changes in ES6 we do it by using Object.assign() and the Object.getPrototypeOf():

const volumeCalculator = (s) => {
  const proto = {
    type: 'volumeCalculator'
  }
  const areaCalProto = Object.getPrototypeOf(areaCalculator())
  const inherit = Object.assign({}, areaCalProto, proto)
  return Object.assign(Object.create(inherit), {shapes: s})
}

For Another example, if we want to implement classes for a bunch of shapes, we can have a parent Shape class, which are extended by all classes by implementing everything in the Shape class.

We can write the following to implement some shape classes and get the area of each instance:

class Shape {
  get area() {
    return 0;
  }
}
class Rectangle extends Shape {
  constructor(length, width) {
    super();
    this.length = length;
    this.width = width;
  }
  get area() {
    return this.length * this.width;
  }
}
class Square extends Shape {
  constructor(length) {
    super();
    this.length = length;
  }
  get area() {
    return this.length ** 2;
  }
}
class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }
  get area() {
    return Math.PI * (this.radius ** 2);
  }
}
const shapes = [
  new Rectangle(1, 2),
  new Square(1, 2),
  new Circle(2),
]
for (let s of shapes) {
  console.log(s.area);
}

Since we override the area getter in each class that extends Shape , we get the right area for each shape since the correct code is run for each shape to get the area.


4. Interface Segregation Principle

“A client should never be forced to implement an interface that it doesn’t use or clients shouldn’t be forced to depend on methods they do not use.”

Continuing with our shapes example, we know that we also have solid shapes, so since we would also want to calculate the volume of the shape, we can add another contract to the shapeInterface:

const shapeInterface = (state) => ({
  type: 'shapeInterface',
  area: () => state.area(state),
  volume: () => state.volume(state)
})

Any shape we create must implement the volume method, but we know that squares are flat shapes and that they do not have volumes, so this interface would force the square factory function to implement a method that it has no use of. 

Interface segregation principle says no to this, instead we could create another interface called solidShapeInterface that has the volume contract and solid shapes like cubes etc. can implement this interface.

const shapeInterface = (state) => ({
  type: 'shapeInterface',
  area: () => state.area(state)
})
const solidShapeInterface = (state) => ({
  type: 'solidShapeInterface',
  volume: () => state.volume(state)
})
const cubo = (length) => {
  const proto = {
    length,
    type   : 'Cubo',
    area   : (args) => Math.pow(args.length, 2),
    volume : (args) => Math.pow(args.length, 3)
  }
  const basics  = shapeInterface(proto)
  const complex = solidShapeInterface(proto)
  const composite = Object.assign({}, basics, complex)
  return Object.assign(Object.create(composite), {length})

This is a much better approach, but a pitfall to watch out for is when to calculate the sum for the shape, instead of using the shapeInterface or a solidShapeInterface.

We can create another interface, maybe manageShapeInterface, and implement it on both the flat and solid shapes, this is way we can easily see that it has a single API for managing the shapes, for example:

const manageShapeInterface = (fn) => ({
  type: 'manageShapeInterface',
  calculate: () => fn()
})
const circle = (radius) => {
  const proto = {
    radius,
    type: 'Circle',
    area: (args) => Math.PI * Math.pow(args.radius, 2)
  }
  const basics = shapeInterface(proto)
  const abstraccion = manageShapeInterface(() => basics.area())
  const composite = Object.assign({}, basics, abstraccion)
  return Object.assign(Object.create(composite), {radius})
}
const cubo = (length) => {
  const proto = {
    length,
    type   : 'Cubo',
    area   : (args) => Math.pow(args.length, 2),
    volume : (args) => Math.pow(args.length, 3)
  }
  const basics  = shapeInterface(proto)
  const complex = solidShapeInterface(proto)
  const abstraccion = manageShapeInterface(
    () => basics.area() + complex.volume()
  )
  const composite = Object.assign({}, basics, abstraccion)
  return Object.assign(Object.create(composite), {length})
}

As we can see until now, what we have been doing for interfaces in JavaScript are factory functions for function composition.

And here, with manageShapeInterface what we are doing is abstracting again the calculate function, what we doing here and in the other interfaces (if we can call it interfaces), we are using “high order functions” to achieve the abstractions.


5. Dependency Inversion Principle

“Entities must depend on abstractions not on concretions. It states that the high level module must not depend on the low level module, but they should depend on abstractions.”

As a dynamic language, JavaScript doesn’t require the use of abstractions to facilitate decoupling. Therefore, the stipulation that abstractions shouldn’t depend upon details isn’t particularly relevant to JavaScript applications. The stipulation that high level modules shouldn’t depend upon low level modules is, however, relevant.

From a functional point of view, these containers and injection concepts can be solved with a simple higher order function, or hole-in-the-middle type pattern which are built right into the language.”

This principle allows for decoupling. And we have made it before, let’s review our code with the manageShapeInterface and how we accomplish the calculate method.

const manageShapeInterface = (fn) => ({
  type: 'manageShapeInterface',
  calculate: () => fn()
})

What the manageShapeInterface factory function receives as the argument is a higher order function, that decouples for every shape the functionality to accomplish the needed logic to get to final calculation, let see how this is done in the shapes objects.

const square = (radius) => {
  // code
 
  const abstraccion = manageShapeInterface(() => basics.area())
 
 // more code ...
}
const cubo = (length) => {
  // code 
  const abstraccion = manageShapeInterface(
    () => basics.area() + complex.volume()
  )
  // more code ...
}

For the square what we need to calculate is just getting the area of the shape, and what we need is summing the area with the volume and that is everything need to avoid the coupling and get the abstraction.

For Another example, we analyse that the Http Request depends on the setState function, which is a detail. These code violates the Dependency Inversion principle.

http.get("http://address/api/examples", (res) => {
 this.setState({
  key1: res.value1,
  key2: res.value2,
  key3: res.value3
 });
});

The correct code is :

//Http request
const httpRequest = (url, setState) => {
 http.get(url, (res) => setState.setValues(res))
};

//State set in another function
const setState = {
 setValues: (res) => {
  this.setState({
    key1: res.value1,
    key2: res.value2,
    key3: res.value3
  })
 }
}

//Http request, state set in a different function
httpRequest("http://address/api/examples", setState);

That’s all about in this article.


Conclusion

In this article, We understood about the Javascript 5 SOLID Principles. The main goal of the SOLID principles is that any software should tolerate change and should be easy to understand. We conclude that SOLID is very useful for write code :

  • Easy to understand
  • Where things are where they’re supposed to be
  • Where classes do what they were intended to do
  • That can be easily adjusted and extended without bugs
  • That separates the abstraction from the implementation
  • That allows to easily swap implementation (Db, Api, frameworks, …)
  • Easily testable

Thanks for reading !! I hope you enjoyed and learned about SOLID Principles Concept in javascript. Reading is one thing, but the only way to master it is to do it yourself.

Please follow and subscribe us on this blog and and support us in any way possible. Also like and share the article with others for spread valuable knowledge.

If you have any comments, questions, or think I missed something, feel free to leave them below in the comment box.

Thanks again Reading. HAPPY READING !!???

Loading

Leave a Comment