The Art of Clean Code: A Journey through the SOLID Principles


The SOLID principles are five design principles that improve the maintainability, scalability, and readability of software. They are particularly useful when writing clean and efficient code in Node.js, Express.js, React, or any other framework.
Introduction
Applying SOLID principles in full-stack JavaScript development—whether in Node.js, Express.js, React, or any other framework - helps create scalable, maintainable, and bug-free applications.
Why SOLID Matters?
🏀 Easier Debugging & Maintenance - Each module has a single responsibility, making bug fixes and enhancements more manageable.
🚀 Scalability - As applications grow, SOLID principles ensure that adding new features does not introduce unnecessary modifications.
🧽 Code Reusability - By following OCP and DIP, new features can be added with minimal changes to existing code.
🧪 Better Testing & Dependency Management - Decoupling modules and using dependency injection (DIP) makes unit testing easier.
1. Single Responsibility Principle (SRP)
A class (or function) should have only one reason to change.
This means each module or class should focus on a single responsibility.
If a class has multiple responsibilities, it becomes more difficult to understand, maintain, and test.
For example, a class that manages both user authentication and user data would violate the SRP. If the authentication mechanism changes, the class would need to be updated to reflect those changes. This could affect the user data, which could lead to data corruption or security vulnerabilities.
Example
❌ Bad Practice (Violates SRP)
class UserAuthentication {
authenticateUser() {
// Logic related to user authentication
}
processUserData() {
// Logic related to user data processing
}
}
The provided code violates the single responsibility principle (SRP) by combining two distinct responsibilities into a single class:
- User authentication: The class attempts to authenticate users and process user data.
This combination of responsibilities introduces several drawbacks:
Code complexity: The class becomes more complex, making it harder to understand and maintain.
Testing difficulty: Both authentication and data processing logic are intertwined, making it difficult to isolate and test each functionality independently.
Code brittleness: If changes are made to either authentication or data processing logic, it can ripple through the class, affecting both functionalities.
✅ Good Practice (Following SRP)
class UserAuthentication {
authenticateUser() {
// Logic related to user authentication
}
}
class UserDataProcessor {
processUserData() {
// Logic related to user data processing
}
}
This refactoring improves the code's maintainability, testability, and robustness. Each class focuses on a single responsibility, making it easier to understand, modify, and test. The code becomes more modular and reusable, allowing for better code organization and reusability in different contexts.
2. Open-Closed Principle (OCP)
Software entities (classes, functions, modules) should be open for extension but closed for modification.
This means adding new functionality should not require modifying existing code.
Examples of Violations of the Open-Closed Principle:
Hardcoding data: If data is embedded directly into classes, it becomes difficult to add new data without modifying the classes.
Directly accessing class state: If classes provide direct access to their internal state, it becomes difficult to extend their functionality without modifying the classes.
Using inheritance for code modification: Inheritance can be used to modify existing classes, but it can lead to tight coupling and code brittleness.
❌ Bad Practice (Violates OCP)
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
}
// This class violates the OCP because modifying it is necessary to add new functionality.
class AreaCalculator {
calculateArea(rectangle) {
return rectangle.width * rectangle.height;
}
}
✅ Good Practice (Following OCP)
Let's fix this. Instead of modifying the existing class, we create a new class that extends the functionality.
class Shape {
area() {
// This method is meant to be overridden by subclasses.
throw new Error("Method 'area' must be implemented.");
}
}
The Shape
class serves as an abstract class with a method area()
that must be implemented by its subclasses. This allows you to add new shapes like Rectangle
and Circle
without modifying the existing code, adhering to the Open/Closed Principle.
// Good Design (Following OCP):
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
}
Now, we can add new shapes without modifying the existing code.
3. Liskov Substitution Principle (LSP)
Subtypes should be replaceable without altering the correctness of the program.
This ensures that child classes can substitute parent classes without breaking the application.
❌ Bad Practice (Violates LSP)
A subclass that breaks the behavior of the parent class:
class Bird {
fly() {
console.log('Flying...');
}
}
// Subclass representing a Penguin
class Penguin extends Bird {
// Penguins can't fly, so we override the fly method
fly() {
throw new Error('Penguins can\'t fly!');
}
}
// Subclass representing a Sparrow
class Sparrow extends Bird {
// Sparrows can fly
fly() {
console.log('Sparrow flying...');
}
}
// Function expecting a Bird instance
function makeBirdFly(bird) {
bird.fly();
}
// Usage
const bird = new Bird();
const penguin = new Penguin();
const sparrow = new Sparrow();
makeBirdFly(bird); // Output: Flying...
makeBirdFly(sparrow); // Output: Sparrow flying...
// This will throw an error at runtime because Penguins can't fly,
// violating the Liskov Substitution Principle
makeBirdFly(penguin);
✅ Good Practice (Following LSP)
Let's fix this. We need to ensure that subclasses can be used interchangeably with their base classes without altering the correctness of the program.
class FlyingBird {
fly() {
throw new Error('This method should be overridden by subclasses.');
}
}
// Base class representing a bird, now extending the interface
class Bird extends FlyingBird {
// Base class can still have a default implementation if needed
fly() {
console.log('Default flying...');
}
}
// Subclass representing a Penguin
class Penguin extends Bird {
// Penguins can't fly, but they still adhere to the contract
}
// Subclass representing a Sparrow
class Sparrow extends Bird {
// Sparrows can fly
fly() {
console.log('Sparrow flying...');
}
}
// Function expecting a FlyingBird instance
function makeBirdFly(bird) {
bird.fly();
}
// Usage
const bird = new Bird();
const penguin = new Penguin();
const sparrow = new Sparrow();
makeBirdFly(bird); // Output: Default flying...
makeBirdFly(sparrow); // Output: Sparrow flying...
// Now, this works without errors since Penguins adhere to the contract
makeBirdFly(penguin);
Violations of Liskov Substitution Principle can lead to unexpected behaviors in code, making it important to carefully design class hierarchies to ensure substitutability without altering the program's correctness.
4. Interface Segregation Principle (ISP)
The interface segregation principle states:
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.
This principle is about the idea that we should avoid creating large interfaces that contain many methods or values. Instead of having one large interface, it's better to have multiple smaller, more specific interfaces.
❌ Bad Practice (Violates ISP)
A component that has too many props that not all components need:
function UserProfile({ name, age, address, isAdmin, manageUsers }) {
return <div>{name}, {age}, {address}</div>;
}
If a GuestUserProfile component doesn’t need isAdmin or manageUsers, it still gets these props.
✅ Good Practice (Following ISP)
function UserInfo({ name, age, address }) {
return <div>{name}, {age}, {address}</div>;
}
function AdminActions({ isAdmin, manageUsers }) {
return isAdmin ? <button onClick={manageUsers}>Manage Users</button> : null;
}
// Usage
<UserInfo name="John" age={30} address="123 Street" />;
<AdminActions isAdmin={true} manageUsers={() => console.log("Managing users")} />;
Now, components only depend on what they actually need.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. This means avoiding hard dependencies and using dependency injection.
Example in Express.js (Backend)
❌ Bad Practice (Violates DIP)
class UserService {
constructor() {
this.database = new MySQLDatabase(); // Direct dependency
}
getUser(id) {
return this.database.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
✅ Good Practice (Following DIP)
class UserService {
constructor(database) {
this.database = database;
}
getUser(id) {
return this.database.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
// Dependency injection
const mySQLDatabase = new MySQLDatabase();
const userService = new UserService(mySQLDatabase);
Now, switching databases (e.g., PostgreSQL or MongoDB) is easy without modifying UserService.
Conclusion
By applying SOLID principles:
⭐ Code is cleaner and easier to maintain
⭐ Changes are less risky
⭐ Scalability and testability improve
While SOLID is essential for large-scale applications, in smaller projects, being too rigid with these principles might lead to over-engineering. Use them where they add value - especially in long-term projects that require maintenance and expansion.
Useful Links and Resources
https://www.thepowermba.com/en/blog/what-are-the-solid-principles
https://okso.app/showcase/solid
https://medium.com/backticks-tildes/the-s-o-l-i-d-principles-in-pictures-b34ce2f1e898
Subscribe to my newsletter
Read articles from Saphie Hanadi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Saphie Hanadi
Saphie Hanadi
A Software Developer, who loves writing JavaScript and explore new technologies and trends.