Asher Cohen
Back to posts

JavaScript Design Patterns: Strategy, Factory, Adapter, Singleton, and CQRS

Complete guide to essential design patterns in JavaScript — practical implementations with real-world examples

Introduction

Design patterns are proven solutions to common software design problems. While many patterns originated in classical OOP languages, they adapt beautifully to JavaScript's flexible nature. This guide covers five essential patterns with practical JavaScript implementations.

Strategy Pattern

Purpose: Define a family of algorithms, encapsulate each one, and make them interchangeable.

The Strategy pattern lets you switch algorithms at runtime without modifying the client code.

// ❌ Without Strategy - conditional logic everywhere
class PaymentProcessor {
  process(method, amount) {
    if (method === 'credit') {
      console.log(`Processing $${amount} via credit card`);
    } else if (method === 'paypal') {
      console.log(`Processing $${amount} via PayPal`);
    } else if (method === 'crypto') {
      console.log(`Processing $${amount} via cryptocurrency`);
    }
  }
}

// ✅ With Strategy - each algorithm is a separate strategy
const paymentStrategies = {
  credit: (amount) => console.log(`Processing $${amount} via credit card`),
  paypal: (amount) => console.log(`Processing $${amount} via PayPal`),
  crypto: (amount) => console.log(`Processing $${amount} via cryptocurrency`)
};

class PaymentProcessor {
  process(strategy, amount) {
    strategy(amount);
  }
}

// Add new strategies without modifying existing code
paymentStrategies.applePay = (amount) => console.log(`Processing $${amount} via Apple Pay`);

Real-world example: Form validation

const validationStrategies = {
  required: (value) => value ? null : 'This field is required',
  email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'Invalid email',
  minLength: (min) => (value) => value.length >= min ? null : `Minimum ${min} characters`,
  maxLength: (max) => (value) => value.length <= max ? null : `Maximum ${max} characters`
};

function validateField(value, rules) {
  for (const rule of rules) {
    const error = rule(value);
    if (error) return error;
  }
  return null;
}

// Usage
const emailRules = [validationStrategies.required, validationStrategies.email];
console.log(validateField('', emailRules)); // 'This field is required'
console.log(validateField('invalid', emailRules)); // 'Invalid email'
console.log(validateField('user@example.com', emailRules)); // null

Factory Pattern

Purpose: Create objects without specifying the exact class of object to be created.

The Factory pattern centralizes object creation logic, making it easy to add new types.

// ❌ Without Factory - scattered object creation
function createReport(type, data) {
  if (type === 'pdf') {
    return new PDFReport(data);
  } else if (type === 'csv') {
    return new CSVReport(data);
  } else if (type === 'json') {
    return new JSONReport(data);
  }
}

// ✅ With Factory - centralized, extensible
class ReportFactory {
  constructor() {
    this.reportTypes = {};
  }
  
  register(type, reportClass) {
    this.reportTypes[type] = reportClass;
  }
  
  create(type, data) {
    const ReportClass = this.reportTypes[type];
    if (!ReportClass) throw new Error(`Unknown report type: ${type}`);
    return new ReportClass(data);
  }
}

// Register report types
const factory = new ReportFactory();
factory.register('pdf', PDFReport);
factory.register('csv', CSVReport);
factory.register('json', JSONReport);

// Create reports
const pdfReport = factory.create('pdf', data);
const csvReport = factory.create('csv', data);

Real-world example: API client factory

class APIClientFactory {
  static createClient(type, config) {
    switch (type) {
      case 'rest':
        return new RESTClient(config);
      case 'graphql':
        return new GraphQLClient(config);
      case 'grpc':
        return new GRPCClient(config);
      default:
        throw new Error(`Unknown client type: ${type}`);
    }
  }
}

// Usage
const restClient = APIClientFactory.createClient('rest', { baseURL: '/api' });
const gqlClient = APIClientFactory.createClient('graphql', { endpoint: '/graphql' });

Adapter Pattern

Purpose: Allow incompatible interfaces to work together.

The Adapter pattern wraps an existing class with a new interface, making it compatible with your code.

// ❌ Without Adapter - incompatible interfaces
class OldLogger {
  logMessage(msg) { console.log(msg); }
}

class NewLogger {
  info(msg) { console.log(`[INFO] ${msg}`); }
  error(msg) { console.error(`[ERROR] ${msg}`); }
}

// Code expects NewLogger interface
function processData(logger) {
  logger.info('Processing started');
  // ...
  logger.info('Processing complete');
}

// OldLogger doesn't have info() method!

// ✅ With Adapter - wrap OldLogger to match NewLogger interface
class LoggerAdapter {
  constructor(oldLogger) {
    this.oldLogger = oldLogger;
  }
  
  info(msg) {
    this.oldLogger.logMessage(`[INFO] ${msg}`);
  }
  
  error(msg) {
    this.oldLogger.logMessage(`[ERROR] ${msg}`);
  }
}

// Now OldLogger works with code expecting NewLogger
const oldLogger = new OldLogger();
const adapted = new LoggerAdapter(oldLogger);
processData(adapted); // Works!

Real-world example: Third-party library adapter

// Third-party analytics library
class ThirdPartyAnalytics {
  trackEvent(name, properties) {
    // Different API than what our app uses
  }
}

// Our app's analytics interface
class AnalyticsAdapter {
  constructor(thirdParty) {
    this.thirdParty = thirdParty;
  }
  
  track(eventName, metadata = {}) {
    this.thirdParty.trackEvent(eventName, {
      ...metadata,
      timestamp: new Date().toISOString()
    });
  }
  
  identify(userId, traits = {}) {
    this.thirdParty.identifyUser(userId, traits);
  }
}

Singleton Pattern

Purpose: Ensure a class has only one instance and provide a global access point.

// ✅ Singleton using ES modules (preferred in modern JS)
// database.js
class Database {
  constructor() {
    if (Database.instance) {
      return Database.instance;
    }
    this.connection = this.connect();
    Database.instance = this;
  }
  
  connect() {
    console.log('Connecting to database...');
    return { /* connection object */ };
  }
  
  query(sql) {
    return this.connection.execute(sql);
  }
}

// Every import gets the same instance
import db from './database.js';
// db is always the same instance

// ✅ Singleton using closures
const createLogger = (() => {
  let instance;
  
  return () => {
    if (instance) return instance;
    
    instance = {
      logs: [],
      log(message) {
        const entry = { message, timestamp: new Date() };
        this.logs.push(entry);
        console.log(message);
      },
      getLogs() {
        return [...this.logs];
      }
    };
    
    return instance;
  };
})();

const logger1 = createLogger();
const logger2 = createLogger();
console.log(logger1 === logger2); // true

When to use Singleton:

  • ✅ Database connections
  • ✅ Configuration management
  • ✅ Logging services
  • ✅ Cache stores

When to avoid Singleton:

  • ❌ When you need testability (hard to mock)
  • ❌ When state isolation is needed
  • ❌ When you might need multiple instances later

CQRS (Command Query Responsibility Segregation)

Purpose: Separate read operations (queries) from write operations (commands).

CQRS splits your data model into two parts: one for reading and one for writing.

// ❌ Without CQRS - mixed reads and writes
class UserService {
  getUser(id) {
    return db.users.findById(id);
  }
  
  updateUser(id, data) {
    const user = db.users.findById(id);
    user.name = data.name;
    user.email = data.email;
    db.users.save(user);
    return user;
  }
  
  listUsers() {
    return db.users.findAll();
  }
}

// ✅ With CQRS - separated commands and queries
// Commands (write side)
class UserCommands {
  createUser(data) {
    const user = { id: generateId(), ...data, createdAt: new Date() };
    db.users.insert(user);
    eventBus.emit('UserCreated', user);
    return user;
  }
  
  updateUser(id, data) {
    const user = db.users.findById(id);
    if (!user) throw new Error('User not found');
    Object.assign(user, data, { updatedAt: new Date() });
    db.users.save(user);
    eventBus.emit('UserUpdated', user);
    return user;
  }
  
  deleteUser(id) {
    db.users.delete(id);
    eventBus.emit('UserDeleted', { id });
  }
}

// Queries (read side)
class UserQueries {
  getUser(id) {
    return db.users.findById(id);
  }
  
  listUsers(filters = {}) {
    let query = db.users.createQuery();
    if (filters.status) query = query.where('status', filters.status);
    if (filters.role) query = query.where('role', filters.role);
    return query.execute();
  }
  
  searchUsers(term) {
    return db.users.search(term);
  }
}

Benefits of CQRS:

  • ✅ Optimize reads and writes independently
  • ✅ Scale read and write sides separately
  • ✅ Simpler queries (no write concerns)
  • ✅ Better security (separate permissions)

Pattern Selection Guide

PatternUse WhenExample
StrategyMultiple algorithms for same taskPayment methods, sorting, validation
FactoryComplex object creationReport generation, API clients
AdapterIncompatible interfacesThird-party integrations, legacy code
SingletonSingle instance neededDatabase, config, logger
CQRSSeparate reads from writesComplex domains, high-scale apps

Best Practices

Do's

// ✅ Use ES modules for singletons
export default new Database();

// ✅ Register strategies/factories dynamically
factory.register('newType', NewTypeClass);

// ✅ Keep adapters thin - just translate interfaces
class Adapter {
  newMethod(args) { return this.old.method(args); }
}

// ✅ Start simple, add CQRS when needed
// Don't over-engineer early

Don'ts

// ❌ Don't use Singleton when testability matters
// Use dependency injection instead

// ❌ Don't create factories for simple objects
// new User({ name: 'Alice' }) is fine

// ❌ Don't over-abstract with patterns
// Patterns solve problems, don't create them

// ❌ Don't apply CQRS to simple CRUD apps
// It adds complexity without benefit

Conclusion

Design patterns are tools, not rules:

  • Strategy — Swap algorithms at runtime
  • Factory — Centralize object creation
  • Adapter — Make incompatible interfaces work together
  • Singleton — Ensure single instance (use sparingly)
  • CQRS — Separate reads from writes (for complex domains)

The best pattern is the simplest solution that works. Don't force patterns where they don't add value.

#javascript #design-patterns #software-architecture #clean-code #web-development