Best Practices for Clean Code: Implementing SOLID Principles in Modern JS
Implementing SOLID principles in modern JavaScript ensures that software is maintainable, scalable, and easy to refactor by reducing tight coupling between components. By adhering to these five design guidelines, developers can prevent "code rot" and ensure that adding new features does not inadvertently break existing functionality.
Best Practices for Clean Code: Implementing SOLID Principles in Modern JS
Writing code that works is the first step; writing code that lasts is the professional standard. In the fast-paced ecosystem of JavaScript and TypeScript, where frameworks evolve rapidly, the SOLID principles provide a timeless architectural foundation. These principles shift the focus from simply solving a problem to designing a system that can evolve.
For developers looking to elevate their output, mastering these patterns is a core part of adopting Best Practices for Clean Code: Implementation Patterns for Scalable Software.
Key Takeaways
- Single Responsibility: A class or function should have one, and only one, reason to change.
- Open/Closed: Software entities should be open for extension but closed for modification.
- Liskov Substitution: Subtypes must be substitutable for their base types without altering program correctness.
- Interface Segregation: Clients should not be forced to depend on methods they do not use.
- Dependency Inversion: High-level modules should depend on abstractions, not on low-level concrete implementations.
1. The Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class, module, or function should have one single job. When a piece of code takes on too many responsibilities, it becomes "fragile," meaning a change in one area of the logic unexpectedly breaks an unrelated feature.
The Problem: The "God Object"
In many JavaScript projects, developers create a single UserService or AppManager that handles database queries, email notifications, and input validation. This creates a maintenance nightmare.
Before Refactoring (Violating SRP):
class UserSettings {
constructor(user) {
this.user = user;
}
updateSettings(settings) {
this.user.settings = settings;
console.log("Settings updated");
}
saveToDatabase() {
// Logic to connect to DB and save user
console.log("Saving to database...");
}
sendConfirmationEmail() {
// Logic to send an email via SMTP
console.log("Sending email...");
}
}
The Solution: Decomposition
To fix this, we decouple the persistence and notification logic from the business logic of updating settings.
After Refactoring (Following SRP):
class UserSettings {
constructor(user) {
this.user = user;
}
updateSettings(settings) {
this.user.settings = settings;
}
}
class UserPersistence {
save(user) {
console.log("Saving user to database...");
}
}
class EmailService {
sendEmail(user, message) {
console.log(`Sending email to ${user.email}: ${message}`);
}
}
By separating these concerns, you can now change your database provider or email service without touching the UserSettings logic.
2. The Open/Closed Principle (OCP)
The Open/Closed Principle dictates that software entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing, tested code.
The Problem: The Switch-Case Trap
A common violation of OCP is the use of large if/else or switch blocks to handle different types of data. Every time a new type is added, the core logic must be modified, risking regression errors.
Before Refactoring (Violating OCP):
class PaymentProcessor {
processPayment(paymentType, amount) {
if (paymentType === 'creditCard') {
return `Processing ${amount} via Credit Card`;
} else if (paymentType === 'paypal') {
return `Processing ${amount} via PayPal`;
} else if (paymentType === 'crypto') {
return `Processing ${amount} via Bitcoin`;
}
}
}
The Solution: Polymorphism
Instead of modifying the processor, we create a common interface (or base class) and extend it for each payment method.
After Refactoring (Following OCP):
class PaymentMethod {
process(amount) {
throw new Error("Method 'process()' must be implemented.");
}
}
class CreditCardPayment extends PaymentMethod {
process(amount) {
return `Processing ${amount} via Credit Card`;
}
}
class PayPalPayment extends PaymentMethod {
process(amount) {
return `Processing ${amount} via PayPal`;
}
}
class PaymentProcessor {
processPayment(paymentMethod, amount) {
return paymentMethod.process(amount);
}
}
Now, to add a new payment method (e.g., Apple Pay), you simply create a new class. The PaymentProcessor remains untouched.
3. The Liskov Substitution Principle (LSP)
LSP states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application. Essentially, a derived class must enhance the behavior of the parent, not fundamentally change it or restrict it.
The Problem: The "Square-Rectangle" Paradox
A classic example of an LSP violation occurs when a subclass removes functionality or changes the expected behavior of a parent class method.
Before Refactoring (Violating LSP):
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) { this.width = width; }
setHeight(height) { this.height = height; }
getArea() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(width) {
this.width = width;
this.height = width; // Forces height to match width
}
setHeight(height) {
this.width = height; // Forces width to match height
this.height = height;
}
}
If a function expects a Rectangle and changes only the width, it expects the area to change proportionally. However, if it receives a Square, the height changes unexpectedly, breaking the logic.
The Solution: Proper Hierarchy
If two classes share some properties but behave differently, they should not share a parent-child relationship that implies total substitutability.
After Refactoring (Following LSP):
class Shape {
getArea() {
throw new Error("Method 'getArea()' must be implemented.");
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
getArea() { return this.width * this.height; }
}
class Square extends Shape {
constructor(size) {
super();
this.size = size;
}
getArea() { return this.size * this.size; }
}
Now, Rectangle and Square are siblings under Shape. Any function accepting a Shape only cares that it can call getArea(), regardless of the specific geometry.
4. The Interface Segregation Principle (ISP)
ISP suggests that no client should be forced to depend on methods it does not use. While JavaScript does not have formal interfaces like Java or TypeScript, the principle applies to how we design our objects and API contracts.
The Problem: Fat Interfaces
When a class provides too many methods, implementing classes are forced to define "dummy" methods that do nothing, leading to cluttered and confusing code.
Before Refactoring (Violating ISP):
class SmartDevice {
print() {}
fax() {}
scan() {}
}
class BasicPrinter extends SmartDevice {
print() {
console.log("Printing document...");
}
// BasicPrinter cannot fax or scan, but is forced to inherit these methods
fax() { throw new Error("Fax not supported"); }
scan() { throw new Error("Scan not supported"); }
}
The Solution: Granular Interfaces
Break the large interface into smaller, more specific ones. In JavaScript, this is often achieved through composition or by creating specific utility classes.
After Refactoring (Following ISP):
const Printer = {
print() { console.log("Printing..."); }
};
const Scanner = {
scan() { console.log("Scanning..."); }
};
const Fax = {
fax() { console.log("Faxing..."); }
};
// A Basic Printer only uses the Printer capabilities
class BasicPrinter {
constructor() {
Object.assign(this, Printer);
}
}
// An All-in-One Printer uses all three
class AllInOnePrinter {
constructor() {
Object.assign(this, Printer, Scanner, Fax);
}
}
5. The Dependency Inversion Principle (DIP)
DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. This decouples the core business logic from the specific tools used to implement it.
The Problem: Hard-Coded Dependencies
When a high-level class directly instantiates a low-level class, it becomes impossible to test the high-level class in isolation or swap the low-level tool for another.
Before Refactoring (Violating DIP):
class MySQLDatabase {
save(data) {
console.log("Saving data to MySQL...");
}
}
class UserStore {
constructor() {
this.db = new MySQLDatabase(); // Hard-coded dependency
}
saveUser(user) {
this.db.save(user);
}
}
The Solution: Dependency Injection
Pass the dependency into the constructor (Dependency Injection). This allows the UserStore to work with any database object that implements a save method.
After Refactoring (Following DIP):
class MySQLDatabase {
save(data) { console.log("Saving to MySQL..."); }
}
class MongoDBDatabase {
save(data) { console.log("Saving to MongoDB..."); }
}
class UserStore {
constructor(database) {
this.db = database; // Depends on the abstraction of a 'database'
}
saveUser(user) {
this.db.save(user);
}
}
// Usage
const mysqlStore = new UserStore(new MySQLDatabase());
const mongoStore = new UserStore(new MongoDBMongoDB());
This pattern is essential when building professional applications. For those integrating external services, such as when learning how to integrate AI APIs into a website: a step-by-step guide, using DIP allows you to swap between different AI providers (e.g., OpenAI to Anthropic) without rewriting your entire application logic.
Applying SOLID in the Real World
Implementing these principles does not mean you must apply all five to every single function. Over-engineering can lead to "boilerplate fatigue," where the code becomes too fragmented to understand.
The goal of using CodeAmber’s recommended patterns is to find a balance between flexibility and simplicity. Start by identifying "pain points" in your codebase—areas where a small change requires updates in five different files. Those are the areas that most need SOLID intervention.
Summary Checklist for Code Reviews
When reviewing your JavaScript code, ask these questions: 1. SRP: Does this function do more than one thing? 2. OCP: If I add a new feature, do I have to change existing logic or can I just add a new class/module? 3. LSP: Does this subclass change the expected behavior of the parent? 4. ISP: Am I forcing this object to implement methods it doesn't need? 5. DIP: Is my business logic tied to a specific library or database, or is it using an abstraction?