Skip to main content

Command Palette

Search for a command to run...

Wtf is SOLID? (JavaScript edition)

Let's learn SOLID the JavaScript way with functions.

Updated
β€’7 min read
Wtf is SOLID? (JavaScript edition)
T

Just a guy who loves to write code and watch anime.

Introduction

Ever heard of SOLID?

No, not the fun UI library Solid.js.

The five famous SOLID principles to write better code:

  1. Single Responsibility Principle (SRP)

  2. Open/Closed Principle (OCP)

  3. Liskov Substitution Principle (LSP)

  4. Interface Segregation Principle (ISP)

  5. Dependency Inversion Principle (DIP)

This is a full guide with JavaScript examples.

In JavaScript, classes are syntactic sugar over its prototype system.

I'll be sticking to functions for the examples.

Autumn 2013 – Week 9 Anime Review | Avvesione's Anime Blog

Single Responsibility Principle (SRP)

This principle states that a function, module, or class should have one, and only one, reason to change. This means it should have one job.

Example

Bad example

This is difficult to maintain and work with. As the codebase changes, complexity just keeps increasing drastically.

// A function that handles fetching user data, creating a user card,
// and directly manipulating styles β€” all at the same time.
function createUserProfile(userId) {
  // Fetch user data
  fetch(`/api/users/${userId}`)
    .then(response => response.json())
    .then(userData => {
      // Create user card
      const card = document.createElement('div');
      card.className = 'user-card';
      const name = document.createElement('h2');
      name.textContent = userData.name;
      card.appendChild(name);

      // Add style directly
      card.style.border = '1px solid #000';
      card.style.padding = '10px';
      card.style.margin = '10px';

      // Append card to body
      document.body.appendChild(card);
    });
}

Good example

Now, look at this! Isn't it more clean?

// Function to fetch user data
function fetchUserData(userId) {
  return fetch(`/api/users/${userId}`).then(response => response.json());
}

// Function to create user card
function createUserCard(userData) {
  const card = document.createElement('div');
  card.className = 'user-card';
  const name = document.createElement('h2');
  name.textContent = userData.name;
  card.appendChild(name);
  return card;
}

// Function to apply style to user card
function styleUserCard(card) {
  card.style.border = '1px solid #000';
  card.style.padding = '10px';
  card.style.margin = '10px';
}

// Orchestrating function that uses the above three functions
function displayUserProfile(userId) {
  fetchUserData(userId)
    .then(userData => {
      const card = createUserCard(userData);
      styleUserCard(card);
      document.body.appendChild(card);
    });
}

Open/Closed Principle (OCP)

This principle states that software entities (modules, classes, functions, etc.) should be open for extension, but closed for modification. This means you should be able to add new functionality without changing existing code.

Example

Bad example

This function generates a report. It's bad because you've to directly modify the function to generate a report for someone different.

function generateReport(user) {
  if (user.type === 'user') {
    // generate a user report
    console.log('Generating user report');
  } else if (user.type === 'admin') {
    // generate an admin report
    console.log('Generating admin report');
  }
  // What if we need to add a new type like 'manager'?
  // We'll need to modify this function directly.
}

Good example

A better way is to use a strategy pattern where each report generator is a function of an object. We now have a separate entity handles the mapping of user types to these functions.

// Define a dictionary of report strategies for each user type
const reportStrategies = {
  user: () => {
    console.log('Generating user report');
  },
  admin: () => {
    console.log('Generating admin report');
  },
  // We can easily add a new strategy without modifying existing ones
  manager: () => {
    console.log('Generating manager report');
  }
};

function generateReport(user) {
  // Execute the strategy based on the user type
  const reportGenerator = reportStrategies[user.type];
  if (reportGenerator) {
    reportGenerator();
  } else {
    throw new Error('No report strategy found for this user type');
  }
}

// Usage
generateReport({ type: 'user' }); // "Generating user report"
generateReport({ type: 'admin' }); // "Generating admin report"
generateReport({ type: 'manager' }); // "Generating manager report"

If you need to add a function, you simply extend the object without needing to touch or look into the object.

// Adding a new strategy for 'vendor'
reportStrategies.vendor = () => // ...

Liskov Substitution Principle (LSP)

This principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

In JavaScript and the world of functions, it means that a function that uses a base type must be able to use subtypes without knowing the difference.

Example

Bad example

We need to be able to handle all rectangle-like shapes.

function getArea(rectangle) {
  return rectangle.width * rectangle.height;
}

function getAreaForSquare(square) {
  if (typeof square.size !== 'number') {
    throw new Error('Invalid input');
  }
  return square.size * square.size;
}

// Suppose we have an array of rectangle-like shapes:
const shapes = [
  { width: 5, height: 10 },
  { size: 7 },
];

// Let's calculate the area for all shapes:
shapes.forEach(shape => {
  // This will throw an error for the second shape 
  // because the 'size' property is not expected by getArea.
  console.log(getArea(shape));
});

Good example

function getArea(shape) {
  // Check for the 'size' property to handle squares
  const width = shape.size || shape.width;
  const height = shape.size || shape.height;
  return width * height;
}

// We can now call getArea on any 'rectangle-like' or 
// 'square-like' shape without concern:
const shapes = [
  { width: 5, height: 10 },
  { size: 7 },
];

shapes.forEach(shape => {
  // This won't throw an error, as getArea can handle both 
  // types of inputs.
  console.log(getArea(shape)); // Correctly calculates area for rectangles and squares.
});

Interface Segregation Principle (ISP)

This principle states that no client should be forced to depend on methods it does not use.

I guess in human language:

You shouldn't have access to what you don't need.

Example

Bad example

Here we have a notification manager for all kinds of notifications.

const NotificationManager = {
  sendEmail(recipient, subject, message) {
    console.log(`Sending email to ${recipient}: ${subject} - ${message}`);
  },
  sendSMS(number, message) {
    console.log(`Sending SMS to ${number}: ${message}`);
  },
  sendPushNotification(deviceId, message) {
    console.log(`Sending push notification to ${deviceId}: ${message}`);
  }
};

// A function that only needs to 
// send emails also knows about SMS and push notifications
function alertUserByEmail(manager, user) {
  manager.sendEmail(user.email, 'Alert', 'This is an important alert.');
}

Good example

Instead, we should separate the concerns.

Smaller and more focused modules.

const EmailNotifier = {
  sendEmail(recipient, subject, message) {
    console.log(`Sending email to ${recipient}: ${subject} - ${message}`);
  }
};

const SMSNotifier = {
  sendSMS(number, message) {
    console.log(`Sending SMS to ${number}: ${message}`);
  }
};

const PushNotifier = {
  sendPushNotification(deviceId, message) {
    console.log(`Sending push notification to ${deviceId}: ${message}`);
  }
};

// Now functions can use only what they need:
function alertUserByEmail(emailNotifier, user) {
  emailNotifier.sendEmail(user.email, 'Alert', 'This is an important alert.');
}

Dependency Inversion Principle (DIP)

This principle states that:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.

  2. Abstractions should not depend on details. Details (implementations) should depend on abstractions.

Example

Bad example

// A low-level module for email notification
const EmailService = {
  sendEmail: (address, content) => {
    console.log(`Sending email to ${address}: ${content}`);
    // Code to send email
  }
};

// High-level module for user notifications
function notifyUser(userId, message) {
  // Simulate fetching user email from the database
  const userEmail = getUserEmail(userId);
  // Directly using the EmailService
  EmailService.sendEmail(userEmail, message);
}

// Simulate fetching user email (for example purposes)
function getUserEmail(userId) {
  return `${userId}@example.com`;
}

// Usage
notifyUser('john.doe', 'Your report is ready!');

Good example

// Abstract notification service interface
const INotificationService = {
  sendNotification: (recipient, message) => {
    throw new Error('sendNotification() must be implemented');
  }
};

// Concrete implementation for email notification
const EmailNotificationService = {
  sendNotification: (address, content) => {
    console.log(`Sending email to ${address}: ${content}`);
    // Code to send email
  }
};

// High-level module for user notifications
function notifyUser(userId, message, notificationService) {
  // Simulate fetching user contact information from the database
  const userContact = getUserContact(userId);
  // Using an abstract notification service
  notificationService.sendNotification(userContact, message);
}

// Simulate fetching user contact information (for example purposes)
function getUserContact(userId) {
  return `${userId}@example.com`;
}

// Usage - the email service is injected, 
// can be replaced with a different notification service if needed
notifyUser('john.doe', 'Your report is ready!', EmailNotificationService);

This is the juicy part here:

If you decide to change the notification method (e.g. from email to SMS), you can do so without having to change the notifyUser function itself. You just pass a different service that implements the sendNotification method.

Conclusion

SOLID principles are awesome.

Make sure to be pragmatic whenever you use them.

If there is one you should always keep in mind, it's the first one!

Single Responsibility Principle, every time I stray away from it, my code increases drastically in complexity.

A
around me2y ago

Its a very nice article knowledgeable. Aroundme Directory. Looking forward to participate in helpful discussion.

L

Great article is rare see people showing SOLID with functions (in not a functional way), but there are few things wrong.

The OCP is wrong, in that example you aren't extending the base object, you're modifiyng it soo breaking the OCP.

A better approach is pass objects tied by the same contract instead of an object with a type property, as in this way you'll be using polymorphism to handle the dynamic dispatch instead of implementing coupling a function to an object.

In the LSP example you're breaking the principle too. Your example makes the function depend on a union type, but the LSP is a complement to the DIP as the goal is make the sub-types behave like the base types, and a sub-types are intersection types not union types.

By making the sub-types behave like the original you can pass they interchangeably without the need of a caution because if one thing exist and behave in the base, It is the same in the sub-type. A union type goes in the oposite direction by making a function depend on a value that can be different.

Other approach you could do is in the function type instead of the param type, by applying the ideas of contravariance and covariance.

And the DIP example is good, but instead of implementing a interface-like mechanism, you could just made the function depend of other function, this effectly could have made the function depend on other abstract thing that is other function signature.

The notify function still breaks the principle as it is coupled with the function that gets the contact, that is a concrete module, receiving that as a param should fix this.

10
U

Can you reference an article that does it correctly in JS?

L

Uchenna Egbo Sorry, but i don't know any. But i doubt that are some there, because some principles are incompatible with procedural programming due to require the use of polymorphism.

You can apply the SRP because code cohesion is a principle thats possible in every paradigm that allows some form of logical grouping such as procedures, functions, modules, classes, objects, etc.

The SRP is just apply cohesion in the code, if 2 things change for the same motives, they are likely to be together.

OCP its a direct application of the polymorphism soo its not possible do in procedural programming, its possible apply It with functions but you'll need to use functional programming instead, as any high order function probably comply with the OCP.

Maybe you can arguee that creating various atomic functions that really do they job and only they job really well, and do some form of composition with them somewhat applies the OCP, but i think this is a bit forced.

The LSP and the ISP while cannot be applied directly to functions (in the procedural paradigm, not in the functional), If the language has some form of way to create typed structured data like the type in TS or the structs in C, you can make the argument that the Liskov sub-type definition can be applied here even If isn't the real intent of the principle, while the ISP can somewhat be applied to the creation of new types too.

The DIP isn't appliable too because in procedural programming you are always tigh coupled with the functions soo isn't possible to make a function not depend of something abstract because all the functions are concrete.

B

Love the article! Especially the anime cover!!!

S

That's nice article. YO anime fan hereπŸ”₯πŸ˜πŸ˜ƒ

10
Wtf is SOLID? (JavaScript edition)