MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Understanding the SOLID Principles in JavaScript

Apply SOLID design principles to JavaScript: SRP, OCP, LSP, ISP, DIP.

JavaScriptSOLIDDesign PatternsArchitecture

By MinhVo

Introduction

The SOLID principles represent five foundational object-oriented design guidelines that dramatically improve the maintainability, flexibility, and testability of software systems. Originally articulated by Robert C. Martin (Uncle Bob) in the early 2000s, these principles have become a cornerstone of professional software engineering. While they were conceived with statically-typed languages like Java and C# in mind, their application in JavaScript is not only possible but highly beneficial—especially as JavaScript applications grow in complexity and team size.

JavaScript's dynamic nature, prototype-based inheritance, and first-class functions offer unique opportunities to apply SOLID in ways that are sometimes even more elegant than in traditional OOP languages. Whether you're building a Node.js backend, a React frontend, or a full-stack application, understanding SOLID will help you write code that is easier to reason about, extend, and debug.

In this guide, we'll explore each of the five SOLID principles in depth, examine practical JavaScript implementations, and discuss common pitfalls that arise when these principles are ignored. By the end, you'll have a solid mental model for applying SOLID to your own JavaScript projects.

SOLID Principles Overview

Understanding SOLID: Core Concepts

The SOLID acronym stands for five distinct but interconnected principles:

  1. Single Responsibility Principle (SRP): A class or module should have one, and only one, reason to change. This means each unit of code should encapsulate a single piece of functionality.

  2. Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification. You should be able to add new behavior without changing existing code.

  3. Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.

  4. Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. Prefer small, focused interfaces over large, monolithic ones.

  5. Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.

These principles work together to create systems where change is predictable, bugs are isolated, and new features can be added with minimal risk. In JavaScript, where type safety is not enforced at compile time, adhering to SOLID becomes even more critical as a defense against the chaos that can emerge in large codebases.

Code Architecture

Architecture and Design Patterns

SOLID principles naturally lead to several well-known design patterns in JavaScript. Understanding the architectural implications of each principle helps you choose the right pattern for your specific problem.

Single Responsibility in Practice

The Single Responsibility Principle is arguably the most impactful of the five. When a function or class does too many things, it becomes difficult to test, hard to reason about, and risky to modify. In JavaScript, this manifests in several common anti-patterns:

  • A React component that fetches data, transforms it, manages state, and renders UI
  • An Express middleware that validates input, authenticates the user, logs the request, and transforms the response
  • A utility module that handles dates, strings, and math operations

The solution is decomposition. Each responsibility should be extracted into its own module, function, or class. This creates a clear separation of concerns and makes each piece independently testable.

Open/Closed with Strategy Pattern

The Open/Closed Principle is beautifully implemented through the Strategy pattern. Instead of writing conditional logic that must be modified every time a new case is added, you define a family of algorithms and make them interchangeable. JavaScript's first-class functions make this pattern particularly natural.

Liskov Substitution with Polymorphism

In JavaScript, LSP is often violated when subclass methods throw unexpected errors or return incompatible types. The principle ensures that any code working with a base type will continue to work correctly when given a derived type.

Interface Segregation with Composition

JavaScript doesn't have formal interfaces, but ISP still applies. When you create a "god object" that exposes dozens of methods, consumers are forced to depend on functionality they don't need. The solution is to break large objects into smaller, focused ones.

Dependency Inversion with Injection

DIP is the principle behind dependency injection frameworks. In JavaScript, we can implement this pattern manually by passing dependencies as constructor arguments or function parameters rather than importing them directly.

Design Patterns

Step-by-Step Implementation

Let's implement each SOLID principle with practical, working JavaScript examples.

Single Responsibility Principle (SRP)

// BAD: A class that does too many things
class UserManager {
  constructor(db) {
    this.db = db;
  }
 
  async createUser(userData) {
    // Validation logic
    if (!userData.email || !userData.email.includes('@')) {
      throw new Error('Invalid email');
    }
    if (!userData.password || userData.password.length < 8) {
      throw new Error('Password too short');
    }
 
    // Password hashing
    const hashedPassword = await bcrypt.hash(userData.password, 10);
 
    // Database operation
    const user = await this.db.users.create({
      ...userData,
      password: hashedPassword,
    });
 
    // Email sending
    await sendEmail(user.email, 'Welcome!', 'Thanks for signing up.');
 
    // Logging
    console.log(`User created: ${user.id}`);
 
    return user;
  }
}
 
// GOOD: Separated responsibilities
class UserValidator {
  validate(userData) {
    if (!userData.email || !userData.email.includes('@')) {
      throw new Error('Invalid email');
    }
    if (!userData.password || userData.password.length < 8) {
      throw new Error('Password too short');
    }
    return true;
  }
}
 
class PasswordHasher {
  async hash(password) {
    return bcrypt.hash(password, 10);
  }
}
 
class UserRepository {
  constructor(db) {
    this.db = db;
  }
 
  async create(userData) {
    return this.db.users.create(userData);
  }
}
 
class WelcomeEmailSender {
  async send(email) {
    await sendEmail(email, 'Welcome!', 'Thanks for signing up.');
  }
}
 
class UserService {
  constructor(validator, hasher, repository, emailSender) {
    this.validator = validator;
    this.hasher = hasher;
    this.repository = repository;
    this.emailSender = emailSender;
  }
 
  async createUser(userData) {
    this.validator.validate(userData);
    const hashedPassword = await this.hasher.hash(userData.password);
    const user = await this.repository.create({
      ...userData,
      password: hashedPassword,
    });
    await this.emailSender.send(user.email);
    return user;
  }
}

Open/Closed Principle (OCP)

// BAD: Must modify this function for every new payment method
function processPayment(method, amount) {
  if (method === 'credit_card') {
    return processCreditCard(amount);
  } else if (method === 'paypal') {
    return processPayPal(amount);
  } else if (method === 'crypto') {
    return processCrypto(amount);
  }
  // Must add new else-if for every new method!
}
 
// GOOD: Open for extension, closed for modification
class PaymentProcessor {
  constructor() {
    this.processors = new Map();
  }
 
  register(method, handler) {
    this.processors.set(method, handler);
  }
 
  async process(method, amount) {
    const handler = this.processors.get(method);
    if (!handler) {
      throw new Error(`Unknown payment method: ${method}`);
    }
    return handler(amount);
  }
}
 
// Usage
const processor = new PaymentProcessor();
processor.register('credit_card', async (amount) => {
  const stripe = require('stripe')(process.env.STRIPE_KEY);
  return stripe.charges.create({ amount, currency: 'usd' });
});
processor.register('paypal', async (amount) => {
  // PayPal integration
});
processor.register('crypto', async (amount) => {
  // Crypto payment integration
});
 
// Adding new methods requires NO changes to PaymentProcessor
processor.register('apple_pay', async (amount) => {
  // Apple Pay integration
});

Liskov Substitution Principle (LSP)

// BAD: Square violates LSP when substituted for Rectangle
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; // Side effect!
  }
 
  setHeight(height) {
    this.width = height; // Side effect!
    this.height = height;
  }
}
 
// This breaks with Square:
function increaseRectangleWidth(rectangle) {
  rectangle.setWidth(rectangle.width + 10);
  console.log(`Expected area: ${rectangle.width * rectangle.height}`);
  // For Square, the height also changed!
}
 
// GOOD: Use composition and explicit interfaces
class Shape {
  getArea() {
    throw new Error('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(side) {
    super();
    this.side = side;
  }
 
  getArea() {
    return this.side * this.side;
  }
}
 
// Both can be used interchangeably through Shape interface
function printArea(shape) {
  console.log(`Area: ${shape.getArea()}`);
}

Interface Segregation Principle (ISP)

// BAD: A monolithic interface that forces unnecessary dependencies
class EventEmitter {
  on(event, handler) { /* ... */ }
  off(event, handler) { /* ... */ }
  emit(event, data) { /* ... */ }
  once(event, handler) { /* ... */ }
  removeAllListeners() { /* ... */ }
  listenerCount(event) { /* ... */ }
  prependListener(event, handler) { /* ... */ }
  prependOnceListener(event, handler) { /* ... */ }
}
 
// A simple logger only needs emit, but must depend on everything
class Logger extends EventEmitter {
  log(message) {
    this.emit('log', message);
  }
}
 
// GOOD: Segregated interfaces
class Emitter {
  emit(event, data) { /* ... */ }
}
 
class Listener {
  on(event, handler) { /* ... */ }
  off(event, handler) { /* ... */ }
}
 
class FullEventSystem extends Emitter {
  constructor() {
    super();
    this.listeners = new Map();
  }
 
  on(event, handler) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event).add(handler);
  }
 
  off(event, handler) {
    this.listeners.get(event)?.delete(handler);
  }
 
  emit(event, data) {
    this.listeners.get(event)?.forEach((handler) => handler(data));
  }
}
 
// Logger only depends on what it needs
class Logger {
  constructor(emitter) {
    this.emitter = emitter;
  }
 
  log(message) {
    this.emitter.emit('log', { message, timestamp: Date.now() });
  }
}

Dependency Inversion Principle (DIP)

// BAD: High-level module depends directly on low-level module
class OrderService {
  constructor() {
    this.db = require('./mysql-database'); // Direct dependency!
    this.mailer = require('./smtp-mailer'); // Direct dependency!
  }
 
  async placeOrder(orderData) {
    const order = await this.db.orders.create(orderData);
    await this.mailer.send(order.customerEmail, 'Order confirmed');
    return order;
  }
}
 
// GOOD: Both depend on abstractions
class OrderService {
  constructor(database, notificationService) {
    this.database = database;
    this.notificationService = notificationService;
  }
 
  async placeOrder(orderData) {
    const order = await this.database.createOrder(orderData);
    await this.notificationService.notifyOrderConfirmation(order);
    return order;
  }
}
 
// Abstractions
class DatabaseAdapter {
  createOrder(data) {
    throw new Error('Must implement createOrder');
  }
}
 
class NotificationService {
  notifyOrderConfirmation(order) {
    throw new Error('Must implement notifyOrderConfirmation');
  }
}
 
// Concrete implementations
class MySQLDatabase extends DatabaseAdapter {
  constructor(connection) {
    super();
    this.connection = connection;
  }
 
  async createOrder(data) {
    return this.connection.query('INSERT INTO orders ...', data);
  }
}
 
class PostgresDatabase extends DatabaseAdapter {
  constructor(pool) {
    super();
    this.pool = pool;
  }
 
  async createOrder(data) {
    return this.pool.query('INSERT INTO orders ...', data);
  }
}
 
class EmailNotificationService extends NotificationService {
  constructor(mailer) {
    super();
    this.mailer = mailer;
  }
 
  async notifyOrderConfirmation(order) {
    await this.mailer.send(order.customerEmail, 'Order confirmed');
  }
}
 
class SMSNotificationService extends NotificationService {
  constructor(twilioClient) {
    super();
    this.twilioClient = twilioClient;
  }
 
  async notifyOrderConfirmation(order) {
    await this.twilioClient.messages.create({
      to: order.customerPhone,
      body: `Order #${order.id} confirmed!`,
    });
  }
}
 
// Composition root
const db = new MySQLDatabase(mysqlConnection);
const notifier = new EmailNotificationService(smtpMailer);
const orderService = new OrderService(db, notifier);
 
// Easy to swap implementations:
const pgDb = new PostgresDatabase(pgPool);
const smsNotifier = new SMSNotificationService(twilioClient);
const testOrderService = new OrderService(pgDb, smsNotifier);

Implementation Patterns

Real-World Use Cases and Case Studies

Use Case 1: E-Commerce Payment Processing

In a real e-commerce platform, payment processing is a perfect candidate for SOLID principles. The Open/Closed Principle allows you to add new payment gateways (Stripe, PayPal, Square, cryptocurrency) without modifying existing processing logic. The Strategy pattern combined with a registry makes this straightforward. Each payment provider implements a common interface, and the payment service depends on the abstraction rather than concrete implementations. This architecture was used by Shopify to scale their payment processing to support over 100 payment providers globally.

Use Case 2: React Component Architecture

React applications benefit enormously from the Single Responsibility Principle. A common violation is the "smart component" that handles data fetching, state management, business logic, and rendering all in one component. By applying SRP, you create custom hooks for data fetching, utility functions for business logic, and presentational components for rendering. The Container/Presenter pattern is a direct application of SRP in React.

Use Case 3: Microservices Communication

In a microservices architecture, the Dependency Inversion Principle is crucial. High-level business logic services should not directly depend on specific message brokers (RabbitMQ, Kafka, SQS). Instead, they depend on a messaging abstraction that can be swapped based on the deployment environment. This allows you to use SQS in production, an in-memory queue in tests, and RabbitMQ in development—all without changing business logic.

Use Case 4: Configuration Management

The Interface Segregation Principle applies beautifully to configuration management. Instead of a single massive config object that every module depends on, create focused configuration interfaces. A database module depends only on database configuration, a caching module only on cache configuration, and so on. This makes testing easier since you only need to mock the specific configuration subset each module needs.

Best Practices for Production

  1. Start with SRP: When writing new code, ask yourself "What is the single responsibility of this function/class?" If you find yourself using "and" in the description, split it.

  2. Use composition over inheritance: JavaScript's class inheritance is a frequent source of LSP violations. Prefer composing behaviors from small, focused objects rather than building deep inheritance hierarchies.

  3. Inject dependencies explicitly: Avoid importing dependencies directly inside business logic. Pass them as constructor arguments or function parameters. This makes testing trivial and swapping implementations painless.

  4. Create thin adapters at boundaries: When integrating with external services (databases, APIs, file systems), create adapter classes that implement a common interface. This isolates your business logic from external changes.

  5. Apply the Rule of Three: Don't abstract prematurely. Wait until you see a pattern repeated three times before creating an abstraction. Premature abstraction is worse than code duplication.

  6. Write tests first: Test-Driven Development naturally leads to SOLID designs. If a test is hard to write, it's usually because the code has too many responsibilities or too many dependencies.

  7. Refactor incrementally: You don't need to rewrite your entire codebase to apply SOLID. Identify the most problematic areas and refactor them gradually as you touch them for other reasons.

  8. Document your interfaces: Even though JavaScript doesn't have formal interfaces, document them with JSDoc or TypeScript. This makes the contracts explicit and helps IDE tooling.

Common Pitfalls and Solutions

PitfallImpactSolution
Applying SOLID to everythingOver-engineering, unnecessary complexityApply principles proportionally to code complexity and change frequency
Deep inheritance hierarchiesFragile base class, LSP violationsUse composition and mixin patterns instead of multi-level inheritance
God objects that do everythingUntestable, high couplingDecompose into focused classes with single responsibilities
Tight coupling to frameworksFramework lock-in, difficult testingWrap framework dependencies behind adapter interfaces
Premature abstractionYAGNI violations, unnecessary indirectionWait for the Rule of Three before abstracting
Circular dependenciesModule loading failures, tight couplingUse dependency injection and event-based communication

Performance Optimization

SOLID principles can actually improve performance when applied correctly. By separating concerns, you can optimize each piece independently. For example, if your data access layer is properly isolated behind an interface, you can swap a naive implementation for a cached one without touching business logic.

// Caching decorator that doesn't violate OCP
class CachedUserRepository {
  constructor(repository, cache) {
    this.repository = repository;
    this.cache = cache;
  }
 
  async findById(id) {
    const cacheKey = `user:${id}`;
    let user = await this.cache.get(cacheKey);
    if (!user) {
      user = await this.repository.findById(id);
      if (user) {
        await this.cache.set(cacheKey, user, { ttl: 300 });
      }
    }
    return user;
  }
 
  async create(data) {
    const user = await this.repository.create(data);
    await this.cache.invalidate(`user:${user.id}`);
    return user;
  }
}
 
// Usage: transparent caching without modifying UserService
const cachedRepo = new CachedUserRepository(userRepo, redisCache);
const userService = new UserService(validator, hasher, cachedRepo, emailSender);

Comparison with Alternatives

FeatureSOLID PrinciplesFunctional ProgrammingReactive Programming
Code OrganizationClass/interface basedFunction compositionStream/event based
TestabilityExcellent with DIExcellent (pure functions)Moderate (async complexity)
Learning CurveModerateLow to moderateHigh
ScalabilityHigh for large teamsHigh for data pipelinesHigh for event systems
JavaScript FitGood with classesExcellent (first-class functions)Good with RxJS/observables
Best ForBusiness logic, OOP codebasesData transformation, utilitiesReal-time, async-heavy apps

Advanced Patterns

The Decorator Pattern for OCP

// Base logger
class Logger {
  log(message) {
    console.log(message);
  }
}
 
// Decorators extend behavior without modifying the base
class TimestampLogger {
  constructor(logger) {
    this.logger = logger;
  }
 
  log(message) {
    this.logger.log(`[${new Date().toISOString()}] ${message}`);
  }
}
 
class FileLogger {
  constructor(logger, filePath) {
    this.logger = logger;
    this.filePath = filePath;
  }
 
  async log(message) {
    this.logger.log(message);
    await fs.appendFile(this.filePath, message + '\n');
  }
}
 
// Compose behaviors
const logger = new FileLogger(
  new TimestampLogger(new Logger()),
  '/var/log/app.log'
);

The Observer Pattern for DIP

class EventBus {
  constructor() {
    this.handlers = new Map();
  }
 
  subscribe(topic, handler) {
    if (!this.handlers.has(topic)) {
      this.handlers.set(topic, []);
    }
    this.handlers.get(topic).push(handler);
    return () => this.unsubscribe(topic, handler);
  }
 
  publish(topic, data) {
    (this.handlers.get(topic) || []).forEach((handler) => handler(data));
  }
 
  unsubscribe(topic, handler) {
    const handlers = this.handlers.get(topic);
    if (handlers) {
      this.handlers.set(topic, handlers.filter((h) => h !== handler));
    }
  }
}
 
// Services communicate through abstractions, not direct references
const eventBus = new EventBus();
eventBus.subscribe('order.created', (order) => {
  inventoryService.reserveItems(order.items);
  notificationService.sendConfirmation(order);
  analyticsService.trackPurchase(order);
});

Testing Strategies

SOLID principles make testing significantly easier. Here's how each principle contributes to testability:

// Testing with SOLID: Each dependency is easily mockable
describe('UserService', () => {
  let userService;
  let mockValidator;
  let mockHasher;
  let mockRepository;
  let mockEmailSender;
 
  beforeEach(() => {
    mockValidator = { validate: jest.fn() };
    mockHasher = { hash: jest.fn().mockResolvedValue('hashed123') };
    mockRepository = { create: jest.fn().mockResolvedValue({ id: 1, email: 'test@example.com' }) };
    mockEmailSender = { send: jest.fn().mockResolvedValue(true) };
 
    userService = new UserService(
      mockValidator,
      mockHasher,
      mockRepository,
      mockEmailSender
    );
  });
 
  it('should create a user successfully', async () => {
    const userData = { email: 'test@example.com', password: 'secure123' };
    const result = await userService.createUser(userData);
 
    expect(mockValidator.validate).toHaveBeenCalledWith(userData);
    expect(mockHasher.hash).toHaveBeenCalledWith('secure123');
    expect(mockRepository.create).toHaveBeenCalledWith({
      ...userData,
      password: 'hashed123',
    });
    expect(mockEmailSender.send).toHaveBeenCalledWith('test@example.com');
    expect(result.id).toBe(1);
  });
 
  it('should throw on invalid data', async () => {
    mockValidator.validate.mockImplementation(() => {
      throw new Error('Invalid email');
    });
 
    await expect(userService.createUser({})).rejects.toThrow('Invalid email');
    expect(mockRepository.create).not.toHaveBeenCalled();
  });
});

Future Outlook

The SOLID principles continue to evolve with modern JavaScript development. With the rise of functional programming paradigms in JavaScript (React hooks, Redux Toolkit, functional TypeScript), the principles are being adapted to functional contexts. SRP becomes "each function does one thing," OCP becomes "use higher-order functions to extend behavior," and DIP becomes "inject dependencies through function parameters."

TypeScript's adoption has also made SOLID more enforceable through formal interfaces, abstract classes, and strict type checking. The principles remain highly relevant and are increasingly being taught as foundational knowledge for JavaScript developers at all levels.

Conclusion

The SOLID principles provide a proven framework for writing maintainable, testable, and scalable JavaScript code. By applying Single Responsibility, you create focused modules that are easy to understand. Open/Closed lets you extend behavior safely. Liskov Substitution ensures polymorphism works correctly. Interface Segregation prevents unnecessary coupling. Dependency Inversion decouples your architecture at every level.

Key takeaways:

  1. Start with SRP—it's the highest-impact principle
  2. Use composition over inheritance to avoid LSP violations
  3. Inject dependencies to enable testing and flexibility
  4. Apply principles proportionally—not every utility function needs SOLID
  5. Refactor incrementally as your codebase evolves

The goal isn't rigid adherence to rules but thoughtful application that makes your code better for your team and your future self. As you gain experience, applying SOLID becomes second nature—a mental checklist that guides every design decision.

For further reading, explore Robert C. Martin's "Clean Architecture," study the Gang of Four design patterns, and practice refactoring real codebases to internalize these principles.