Wtf is SOLID? (JavaScript edition)
Let's learn SOLID the JavaScript way with functions.
Introduction
Ever heard of SOLID?
No, not the fun UI library Solid.js.
The five famous SOLID principles to write better code:
Single Responsibility Principle (SRP)
Open/Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
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.
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:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
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.