Skip links

Mastering Advanced JavaScript Design Patterns: A Comprehensive Guide

Introduction

JavaScript has evolved dramatically from its humble beginnings as a simple scripting language for web pages to a powerful programming language that powers complex applications across browsers, servers, mobile devices, and even desktop environments. As applications grow in complexity, developers face increasing challenges in maintaining clean, efficient, and scalable code. This is where design patterns—proven solutions to recurring design problems—become invaluable tools in a developer’s arsenal.

Design patterns are not specific pieces of code you can simply copy and paste. Rather, they are templates or paradigms that provide structured approaches to solving common software design challenges. They represent the collective wisdom of software engineers who have encountered and solved similar problems repeatedly. By implementing these patterns, developers can create more maintainable, flexible, and robust applications while avoiding common pitfalls that lead to technical debt.

JavaScript’s unique characteristics—its prototype-based inheritance, first-class functions, and dynamic typing—create both opportunities and challenges when implementing design patterns. Patterns that might be implemented one way in classical object-oriented languages like Java or C++ often take on different forms in JavaScript. Additionally, the language’s evolution through ECMAScript standards has introduced new features that enable more elegant implementations of many patterns.

This comprehensive guide explores advanced JavaScript design patterns that every serious developer should understand. We’ll dive deep into implementation details, examine real-world use cases, and discuss the strengths and weaknesses of each pattern. By mastering these patterns, you’ll be equipped to write more elegant, maintainable code and solve complex architectural challenges in your JavaScript applications.

Module Pattern: Encapsulation and Organization

Understanding the Module Pattern

The Module pattern is one of the most commonly used design patterns in JavaScript. It provides a way to encapsulate related code—variables, functions, classes—into a single unit, preventing pollution of the global namespace and creating privacy through closures.

At its core, the Module pattern leverages JavaScript’s function scope and closures to create private and public sections of code. Variables and functions defined within the module but not explicitly returned or exposed remain private, while those that are returned become the public API.

Implementing the Module Pattern

There are several ways to implement the Module pattern in JavaScript. Let’s explore the most common approaches:

1. Immediately Invoked Function Expressions (IIFE)

The traditional approach uses an IIFE to create a closure:

const Calculator = (function() {
  // Private variables and functions
  let result = 0;
  
  function validateNumber(num) {
    return typeof num === 'number' && !isNaN(num);
  }
  
  // Public API
  return {
    add: function(num) {
      if (validateNumber(num)) {
        result += num;
      }
      return this;
    },
    subtract: function(num) {
      if (validateNumber(num)) {
        result -= num;
      }
      return this;
    },
    multiply: function(num) {
      if (validateNumber(num)) {
        result *= num;
      }
      return this;
    },
    divide: function(num) {
      if (validateNumber(num) && num !== 0) {
        result /= num;
      }
      return this;
    },
    getResult: function() {
      return result;
    },
    reset: function() {
      result = 0;
      return this;
    }
  };
})();

// Usage
Calculator.add(5).multiply(2).subtract(3).divide(2).getResult(); // 3.5

In this example, result and validateNumber are private, while the methods returned in the object literal form the public API.

2. ES6 Modules

With the introduction of ES6 modules, we have a standardized way to create modules:

// calculator.js
let result = 0;

function validateNumber(num) {
  return typeof num === 'number' && !isNaN(num);
}

export function add(num) {
  if (validateNumber(num)) {
    result += num;
  }
  return this;
}

export function subtract(num) {
  if (validateNumber(num)) {
    result -= num;
  }
  return this;
}

export function multiply(num) {
  if (validateNumber(num)) {
    result *= num;
  }
  return this;
}

export function divide(num) {
  if (validateNumber(num) && num !== 0) {
    result /= num;
  }
  return this;
}

export function getResult() {
  return result;
}

export function reset() {
  result = 0;
  return this;
}

// Usage in another file
import { add, multiply, subtract, divide, getResult, reset } from './calculator.js';

add(5);
multiply(2);
subtract(3);
divide(2);
console.log(getResult()); // 3.5

ES6 modules provide true encapsulation at the language level. Variables and functions not explicitly exported remain private to the module.

3. Revealing Module Pattern

A variation of the Module pattern is the Revealing Module pattern, which defines all functions and variables privately and then exposes a selected API:

const Calculator = (function() {
  let result = 0;
  
  function validateNumber(num) {
    return typeof num === 'number' && !isNaN(num);
  }
  
  function add(num) {
    if (validateNumber(num)) {
      result += num;
    }
    return this;
  }
  
  function subtract(num) {
    if (validateNumber(num)) {
      result -= num;
    }
    return this;
  }
  
  function multiply(num) {
    if (validateNumber(num)) {
      result *= num;
    }
    return this;
  }
  
  function divide(num) {
    if (validateNumber(num) && num !== 0) {
      result /= num;
    }
    return this;
  }
  
  function getResult() {
    return result;
  }
  
  function reset() {
    result = 0;
    return this;
  }
  
  // Reveal public API
  return {
    add: add,
    subtract: subtract,
    multiply: multiply,
    divide: divide,
    getResult: getResult,
    reset: reset
  };
})();

This approach makes the code more maintainable as all functions are defined in the same way, and the public API is clearly defined at the bottom.

Advanced Module Pattern Techniques

1. Importing Dependencies

Modules often depend on other modules or libraries. We can pass these dependencies as parameters to our IIFE:

const DataModule = (function($, _) {
  // Now we have access to jQuery ($) and Lodash (_)
  
  function processData(data) {
    return _.map(data, item => {
      return {
        id: item.id,
        name: item.name.toUpperCase()
      };
    });
  }
  
  function displayData(data) {
    const processed = processData(data);
    $.each(processed, (index, item) => {
      $('
‘).text(`${item.id}: ${item.name}`).appendTo(‘body’); }); } return { display: displayData }; })(jQuery, _);

2. Submodules

For complex applications, we might want to organize code into nested modules:

const Application = (function() {
  // Main module
  
  // Submodule for user management
  const UserModule = (function() {
    const users = [];
    
    function addUser(user) {
      users.push(user);
    }
    
    function getUsers() {
      return [...users]; // Return a copy to prevent external modification
    }
    
    return {
      add: addUser,
      getAll: getUsers
    };
  })();
  
  // Submodule for content management
  const ContentModule = (function() {
    const content = {};
    
    function addContent(id, data) {
      content[id] = data;
    }
    
    function getContent(id) {
      return content[id];
    }
    
    return {
      add: addContent,
      get: getContent
    };
  })();
  
  // Main module public API
  return {
    users: UserModule,
    content: ContentModule
  };
})();

// Usage
Application.users.add({ id: 1, name: 'John' });
Application.content.add('welcome', { title: 'Welcome', body: 'Hello World' });

3. Module Augmentation

Sometimes we need to extend existing modules. This can be done through module augmentation:

// Original module
var Module = (function() {
  var privateVar = 'I am private';
  
  function privateMethod() {
    console.log(privateVar);
  }
  
  return {
    publicMethod: function() {
      privateMethod();
    }
  };
})();

// Augmenting the module
Module = (function(module) {
  // Add new functionality
  module.newMethod = function() {
    console.log('This is a new method');
  };
  
  return module;
})(Module || {});

This technique is particularly useful when splitting a module across multiple files or when extending third-party modules.

When to Use the Module Pattern

The Module pattern is ideal when you need to:

  • Organize related code and prevent global namespace pollution
  • Create privacy and encapsulation for implementation details
  • Provide a clear public API for a component or service
  • Manage dependencies between different parts of your application

It’s particularly valuable in larger applications where code organization becomes critical for maintainability.

Factory Pattern: Dynamic Object Creation

Understanding the Factory Pattern

The Factory pattern is a creational design pattern that provides an interface for creating objects without specifying the exact class or constructor function that will be used. Instead of using the new operator directly with a constructor, you use a factory function or method that handles the object creation details.

This pattern is particularly useful in JavaScript when:

  • You need to create different but related objects based on conditions
  • You want to encapsulate the logic for creating complex objects
  • You need to create objects with a consistent interface but varying implementations

Implementing the Factory Pattern

1. Simple Factory

The simplest form of the Factory pattern is a function that creates and returns objects:

function createUser(type) {
  const user = {
    type: type,
    createdAt: new Date(),
    isActive: true
  };
  
  switch(type) {
    case 'admin':
      user.permissions = ['read', 'write', 'delete', 'manage-users'];
      user.accessLevel = 'full';
      break;
    case 'editor':
      user.permissions = ['read', 'write', 'delete'];
      user.accessLevel = 'content';
      break;
    case 'subscriber':
      user.permissions = ['read'];
      user.accessLevel = 'limited';
      break;
    default:
      user.permissions = [];
      user.accessLevel = 'none';
  }
  
  return user;
}

// Usage
const adminUser = createUser('admin');
const editorUser = createUser('editor');
const subscriberUser = createUser('subscriber');

2. Factory with Constructor Functions

We can use constructor functions with a factory to create more structured objects:

// Constructor functions for different user types
function User(data) {
  this.name = data.name;
  this.email = data.email;
  this.createdAt = new Date();
}

function AdminUser(data) {
  User.call(this, data);
  this.permissions = ['read', 'write', 'delete', 'manage-users'];
  this.accessLevel = 'full';
}

function EditorUser(data) {
  User.call(this, data);
  this.permissions = ['read', 'write', 'delete'];
  this.accessLevel = 'content';
}

function SubscriberUser(data) {
  User.call(this, data);
  this.permissions = ['read'];
  this.accessLevel = 'limited';
}

// Factory function
function createUser(type, data) {
  switch(type) {
    case 'admin':
      return new AdminUser(data);
    case 'editor':
      return new EditorUser(data);
    case 'subscriber':
      return new SubscriberUser(data);
    default:
      throw new Error(`User type ${type} is not recognized`);
  }
}

// Usage
const admin = createUser('admin', { name: 'John Admin', email: 'john@example.com' });
const editor = createUser('editor', { name: 'Jane Editor', email: 'jane@example.com' });

3. Factory with ES6 Classes

With ES6 classes, we can implement the Factory pattern more elegantly:

// Base class
class User {
  constructor(data) {
    this.name = data.name;
    this.email = data.email;
    this.createdAt = new Date();
  }
  
  displayInfo() {
    console.log(`Name: ${this.name}, Email: ${this.email}, Access: ${this.accessLevel}`);
  }
}

// Specific user types
class AdminUser extends User {
  constructor(data) {
    super(data);
    this.permissions = ['read', 'write', 'delete', 'manage-users'];
    this.accessLevel = 'full';
  }
  
  manageUsers() {
    console.log('Managing users...');
  }
}

class EditorUser extends User {
  constructor(data) {
    super(data);
    this.permissions = ['read', 'write', 'delete'];
    this.accessLevel = 'content';
  }
  
  publishContent() {
    console.log('Publishing content...');
  }
}

class SubscriberUser extends User {
  constructor(data) {
    super(data);
    this.permissions = ['read'];
    this.accessLevel = 'limited';
  }
}

// Factory class
class UserFactory {
  static createUser(type, data) {
    switch(type) {
      case 'admin':
        return new AdminUser(data);
      case 'editor':
        return new EditorUser(data);
      case 'subscriber':
        return new SubscriberUser(data);
      default:
        throw new Error(`User type ${type} is not recognized`);
    }
  }
}

// Usage
const admin = UserFactory.createUser('admin', { name: 'John Admin', email: 'john@example.com' });
admin.displayInfo();
admin.manageUsers();

const editor = UserFactory.createUser('editor', { name: 'Jane Editor', email: 'jane@example.com' });
editor.displayInfo();
editor.publishContent();

Advanced Factory Pattern Techniques

1. Abstract Factory

The Abstract Factory pattern provides an interface for creating families of related objects without specifying their concrete classes:

// Abstract factory for creating UI components based on platform
class UIFactory {
  createButton() {
    throw new Error('Method not implemented');
  }
  
  createInput() {
    throw new Error('Method not implemented');
  }
  
  createDialog() {
    throw new Error('Method not implemented');
  }
}

// Concrete factories for specific platforms
class WebUIFactory extends UIFactory {
  createButton() {
    return new WebButton();
  }
  
  createInput() {
    return new WebInput();
  }
  
  createDialog() {
    return new WebDialog();
  }
}

class MobileUIFactory extends UIFactory {
  createButton() {
    return new MobileButton();
  }
  
  createInput() {
    return new MobileInput();
  }
  
  createDialog() {
    return new MobileDialog();
  }
}

// Component base classes
class Button {
  render() {
    throw new Error('Method not implemented');
  }
}

class Input {
  render() {
    throw new Error('Method not implemented');
  }
}

class Dialog {
  show() {
    throw new Error('Method not implemented');
  }
}

// Web components
class WebButton extends Button {
  render() {
    return '
Dialog Content</div>’; } } // Mobile components class MobileButton extends Button { render() { return ‘Click Me</TouchableOpacity>’; } } class MobileInput extends Input { render() { return ‘

2. Dynamic Registration of Types

For more flexible factories, we can dynamically register and unregister types:

class ProductFactory {
  constructor() {
    this.types = {};
  }
  
  registerType(type, Class) {
    this.types[type] = Class;
  }
  
  unregisterType(type) {
    delete this.types[type];
  }
  
  createProduct(type, data) {
    const ProductClass = this.types[type];
    
    if (!ProductClass) {
      throw new Error(`Product type ${type} is not registered`);
    }
    
    return new ProductClass(data);
  }
}

// Base product class
class Product {
  constructor(data) {
    this.name = data.name;
    this.price = data.price;
  }
  
  getInfo() {
    return `${this.name} - $${this.price}`;
  }
}

// Specific product types
class Book extends Product {
  constructor(data) {
    super(data);
    this.author = data.author;
    this.pages = data.pages;
  }
  
  getInfo() {
    return `${super.getInfo()} - ${this.author} (${this.pages} pages)`;
  }
}

class Electronics extends Product {
  constructor(data) {
    super(data);
    this.brand = data.brand;
    this.warranty = data.warranty;
  }
  
  getInfo() {
    return `${super.getInfo()} - ${this.brand} (${this.warranty} year warranty)`;
  }
}

// Usage
const factory = new ProductFactory();

// Register product types
factory.registerType('book', Book);
factory.registerType('electronics', Electronics);

// Create products
const book = factory.createProduct('book', {
  name: 'JavaScript Design Patterns',
  price: 29.99,
  author: 'John Doe',
  pages: 350
});

const laptop = factory.createProduct('electronics', {
  name: 'Laptop',
  price: 999.99,
  brand: 'TechBrand',
  warranty: 2
});

console.log(book.getInfo());
console.log(laptop.getInfo());

// Later, we can add new product types without modifying the factory
class Clothing extends Product {
  constructor(data) {
    super(data);
    this.size = data.size;
    this.color = data.color;
  }
  
  getInfo() {
    return `${super.getInfo()} - ${this.color}, Size: ${this.size}`;
  }
}

factory.registerType('clothing', Clothing);

const shirt = factory.createProduct('clothing', {
  name: 'T-Shirt',
  price: 19.99,
  size: 'L',
  color: 'Blue'
});

console.log(shirt.getInfo());

When to Use the Factory Pattern

The Factory pattern is particularly useful when:

  • The exact types and dependencies of the objects your code works with are not known in advance
  • You want to provide a library of products and only expose their interfaces, not implementations
  • You need to create different objects based on specific conditions
  • You want to encapsulate object creation logic to make your code more maintainable
  • You’re working with complex object creation processes that should be isolated from the rest of the application

Observer Pattern: Event Handling and Reactive Programming

Understanding the Observer Pattern

The Observer pattern defines a one-to-many dependency between objects, where when one object (the subject or observable) changes state, all its dependents (observers) are notified and updated automatically. This pattern is fundamental to event handling in JavaScript and forms the basis of reactive programming paradigms.

The pattern consists of two main components:

  • Subject (Observable): Maintains a list of observers and provides methods to add, remove, and notify observers
  • Observer: Provides an interface for objects that should be notified of changes in the subject

Implementing the Observer Pattern

1. Basic Implementation

Let’s start with a simple implementation of the Observer pattern:

class Subject {
  constructor() {
    this.observers = [];
  }
  
  addObserver(observer) {
    if (!this.observers.includes(observer)) {
      this.observers.push(observer);
    }
  }
  
  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index !== -1) {
      this.observers.splice(index, 1);
    }
  }
  
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  constructor(name) {
    this.name = name;
  }
  
  update(data) {
    console.log(`${this.name} received update:`, data);
  }
}

// Usage
const subject = new Subject();

const observer1 = new Observer('Observer 1');
const observer2 = new Observer('Observer 2');

subject.addObserver(observer1);
subject.addObserver(observer2);

subject.notify({ message: 'Hello Observers!' });
// Observer 1 received update: { message: 'Hello Observers!' }
// Observer 2 received update: { message: 'Hello Observers!' }

subject.removeObserver(observer1);
subject.notify({ message: 'Hello again!' });
// Observer 2 received update: { message: 'Hello again!' }

2. Event Emitter

A more flexible implementation is an event emitter that allows subscribing to specific events:

class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  on(event, listener) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(listener);
    return () => this.off(event, listener); // Return unsubscribe function
  }
  
  off(event, listener) {
    if (!this.events[event]) return;
    
    this.events[event] = this.events[event].filter(l => l !== listener);
    
    if (this.events[event].length === 0) {
      delete this.events[event];
    }
  }
  
  once(event, listener) {
    const onceListener = (...args) => {
      listener(...args);
      this.off(event, onceListener);
    };
    return this.on(event, onceListener);
  }
  
  emit(event, ...args) {
    if (!this.events[event]) return;
    
    this.events[event].forEach(listener => {
      listener(...args);
    });
  }
  
  listenerCount(event) {
    return this.events[event] ? this.events[event].length : 0;
  }
}

// Usage
const emitter = new EventEmitter();

// Subscribe to events
const unsubscribe = emitter.on('userLoggedIn', user => {
  console.log(`User logged in: ${user.name}`);
});

emitter.on('userLoggedOut', user => {
  console.log(`User logged out: ${user.name}`);
});

// One-time event listener
emitter.once('serverStarted', port => {
  console.log(`Server started on port ${port}`);
});

// Emit events
emitter.emit('userLoggedIn', { id: 1, name: 'John' });
emitter.emit('serverStarted', 3000);
emitter.emit('serverStarted', 8080); // Won't trigger the listener again

// Unsubscribe from an event
unsubscribe();
emitter.emit('userLoggedIn', { id: 2, name: 'Jane' }); // No output

emitter.emit('userLoggedOut', { id: 1, name: 'John' });

3. Observable with ES6 Classes

We can create a more robust Observable implementation using ES6 classes:

class Observable {
  constructor() {
    this.observers = new Map();
  }
  
  subscribe(observer) {
    this.observers.set(observer, observer);
    
    return {
      unsubscribe: () => {
        this.observers.delete(observer);
      }
    };
  }
  
  notify(data) {
    this.observers.forEach(observer => {
      observer.update(data);
    });
  }
}

// Example with a data stream
class DataStream extends Observable {
  constructor() {
    super();
    this.data = [];
  }
  
  push(item) {
    this.data.push(item);
    this.notify(item);
  }
  
  getAll() {
    return [...this.data];
  }
}

// Observer implementation
class DataObserver {
  constructor(name) {
    this.name = name;
  }
  
  update(data) {
    console.log(`[${this.name}] Got data:`, data);
  }
}

// Usage
const stream = new DataStream();

const observer1 = new DataObserver('Analytics');
const observer2 = new DataObserver('Logger');
const observer3 = new DataObserver('UI');

const subscription1 = stream.subscribe(observer1);
const subscription2 = stream.subscribe(observer2);
const subscription3 = stream.subscribe(observer3);

stream.push({ id: 1, name: 'Item 1' });
// [Analytics] Got data: { id: 1, name: 'Item 1' }
// [Logger] Got data: { id: 1, name: 'Item 1' }
// [UI] Got data: { id: 1, name: 'Item 1' }

// Unsubscribe one observer
subscription1.unsubscribe();

stream.push({ id: 2, name: 'Item 2' });
// [Logger] Got data: { id: 2, name: 'Item 2' }
// [UI] Got data: { id: 2, name: 'Item 2' }

Advanced Observer Pattern Techniques

1. Publish/Subscribe Pattern

The Publish/Subscribe (Pub/Sub) pattern is a variation of the Observer pattern where the publisher and subscribers don’t need to know about each other. They communicate through a central event channel:

class PubSub {
  constructor() {
    this.topics = {};
    this.subUid = -1;
  }
  
  publish(topic, data) {
    if (!this.topics[topic]) return;
    
    const subscribers = this.topics[topic];
    subscribers.forEach(subscriber => {
      subscriber.callback(data);
    });
    
    return true;
  }
  
  subscribe(topic, callback) {
    if (!this.topics[topic]) {
      this.topics[topic] = [];
    }
    
    const token = (++this.subUid).toString();
    this.topics[topic].push({
      token,
      callback
    });
    
    return token;
  }
  
  unsubscribe(token) {
    for (const topic in this.topics) {
      if (this.topics.hasOwnProperty(topic)) {
        const subscribers = this.topics[topic];
        for (let i = 0; i < subscribers.length; i++) { if (subscribers[i].token === token) { subscribers.splice(i, 1); return token; } } } } return false; } } // Usage const pubsub = new PubSub(); // Subscribe to topics const tokenA = pubsub.subscribe('userActivity', data => {
  console.log('User activity subscriber A:', data);
});

const tokenB = pubsub.subscribe('userActivity', data => {
  console.log('User activity subscriber B:', data);
});

pubsub.subscribe('systemStatus', status => {
  console.log('System status changed:', status);
});

// Publish to topics
pubsub.publish('userActivity', { user: 'john', action: 'login' });
pubsub.publish('systemStatus', { memory: '80%', cpu: '30%' });

// Unsubscribe
pubsub.unsubscribe(tokenA);

// This will only trigger subscriber B
pubsub.publish('userActivity', { user: 'john', action: 'logout' });

2. Subject Types in Reactive Programming

In reactive programming libraries like RxJS, there are different types of subjects that offer various behaviors:

// This is a simplified implementation inspired by RxJS

// Basic Subject - multicasts to multiple observers
class Subject {
  constructor() {
    this.observers = [];
  }
  
  subscribe(observer) {
    this.observers.push(observer);
    return {
      unsubscribe: () => {
        this.observers = this.observers.filter(obs => obs !== observer);
      }
    };
  }
  
  next(value) {
    this.observers.forEach(observer => observer.next(value));
  }
  
  error(err) {
    this.observers.forEach(observer => observer.error(err));
    this.observers = [];
  }
  
  complete() {
    this.observers.forEach(observer => observer.complete());
    this.observers = [];
  }
}

// BehaviorSubject - stores current value and emits it to new subscribers
class BehaviorSubject extends Subject {
  constructor(initialValue) {
    super();
    this.value = initialValue;
  }
  
  subscribe(observer) {
    // Emit current value immediately to new subscriber
    observer.next(this.value);
    return super.subscribe(observer);
  }
  
  next(value) {
    this.value = value;
    super.next(value);
  }
}

// ReplaySubject - stores and replays a specified number of values to new subscribers
class ReplaySubject extends Subject {
  constructor(bufferSize = Infinity) {
    super();
    this.buffer = [];
    this.bufferSize = bufferSize;
  }
  
  subscribe(observer) {
    // Replay buffered values to new subscriber
    this.buffer.forEach(value => observer.next(value));
    return super.subscribe(observer);
  }
  
  next(value) {
    this.buffer.push(value);
    if (this.buffer.length > this.bufferSize) {
      this.buffer.shift();
    }
    super.next(value);
  }
}

// Usage
const observer = {
  next: value => console.log('Next:', value),
  error: err => console.error('Error:', err),
  complete: () => console.log('Complete')
};

// Basic Subject
const subject = new Subject();
subject.next('Hello'); // Nothing happens (no subscribers yet)

const subscription = subject.subscribe(observer);
subject.next('World'); // Logs: Next: World

// BehaviorSubject
const behaviorSubject = new BehaviorSubject('Initial value');
const behaviorSubscription = behaviorSubject.subscribe(observer); // Logs: Next: Initial value
behaviorSubject.next('New value'); // Logs: Next: New value

// ReplaySubject
const replaySubject = new ReplaySubject(2);
replaySubject.next('First');
replaySubject.next('Second');
replaySubject.next('Third');

const replaySubscription = replaySubject.subscribe(observer);
// Logs: Next: Second
// Logs: Next: Third
// (Only the last 2 values are replayed)

When to Use the Observer Pattern

The Observer pattern is particularly useful when:

  • You need to maintain consistency between related objects without making them tightly coupled
  • You’re building event-handling systems
  • You need a one-to-many dependency between objects where the dependency can change at runtime
  • You’re implementing reactive programming concepts
  • You need to notify multiple objects about state changes

Singleton Pattern: Managing Shared Resources

Understanding the Singleton Pattern

The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance. This is particularly useful for coordinating actions across an application or managing shared resources like configuration settings, connection pools, or caches.

The key characteristics of the Singleton pattern are:

  • A private constructor to prevent direct instantiation
  • A static method that returns the single instance, creating it if it doesn’t exist
  • The instance is stored in a private static variable

Implementing the Singleton Pattern

1. Classic Implementation

The traditional way to implement a Singleton in JavaScript:

const Database = (function() {
  let instance;
  
  // Private constructor
  function createInstance() {
    const object = new Object({
      connections: 0,
      
      connect() {
        this.connections++;
        console.log(`DB Connection established. Active connections: ${this.connections}`);
      },
      
      disconnect() {
        if (this.connections > 0) {
          this.connections--;
        }
        console.log(`DB Connection closed. Active connections: ${this.connections}`);
      },
      
      executeQuery(query) {
        console.log(`Executing query: ${query}`);
        return [`Result for: ${query}`];
      }
    });
    return object;
  }
  
  return {
    // Public method to get the instance
    getInstance: function() {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    }
  };
})();

// Usage
const dbInstance1 = Database.getInstance();
dbInstance1.connect(); // DB Connection established. Active connections: 1

const dbInstance2 = Database.getInstance();
dbInstance2.connect(); // DB Connection established. Active connections: 2

// Both variables reference the same instance
console.log(dbInstance1 === dbInstance2); // true

dbInstance1.disconnect(); // DB Connection closed. Active connections: 1
dbInstance2.disconnect(); // DB Connection closed. Active connections: 0

2. ES6 Class Implementation

Using ES6 classes for a cleaner implementation:

class ConfigManager {
  constructor() {
    if (ConfigManager.instance) {
      return ConfigManager.instance;
    }
    
    this.config = {
      apiUrl: 'https://api.example.com',
      timeout: 5000,
      retryCount: 3
    };
    
    ConfigManager.instance = this;
  }
  
  get(key) {
    return this.config[key];
  }
  
  set(key, value) {
    this.config[key] = value;
    console.log(`Config updated: ${key} = ${value}`);
  }
  
  getAll() {
    return {...this.config};
  }
}

// Usage
const config1 = new ConfigManager();
const config2 = new ConfigManager();

console.log(config1 === config2); // true

console.log(config1.get('apiUrl')); // https://api.example.com
config1.set('timeout', 10000); // Config updated: timeout = 10000

// Changes are reflected in all references to the singleton
console.log(config2.get('timeout')); // 10000

3. Module-Based Singleton

With ES modules, we can create singletons more naturally:

// logger.js
class Logger {
  constructor() {
    this.logs = [];
  }
  
  log(message) {
    const timestamp = new Date().toISOString();
    const logEntry = `${timestamp}: ${message}`;
    this.logs.push(logEntry);
    console.log(logEntry);
  }
  
  error(message) {
    const timestamp = new Date().toISOString();
    const logEntry = `ERROR - ${timestamp}: ${message}`;
    this.logs.push(logEntry);
    console.error(logEntry);
  }
  
  getLogHistory() {
    return [...this.logs];
  }
}

// Create and export a single instance
export default new Logger();

// Usage in other files
import logger from './logger.js';

logger.log('Application started');

// In another file
import logger from './logger.js';

logger.error('Something went wrong');
console.log(logger.getLogHistory()); // Shows both log entries

Advanced Singleton Pattern Techniques

1. Lazy Initialization

We can improve performance by initializing the singleton only when it’s first needed:

class DatabaseConnection {
  constructor(config) {
    this.config = config;
    this.connected = false;
    console.log('Database connection created with config:', config);
  }
  
  connect() {
    if (this.connected) return;
    
    console.log('Connecting to database...');
    // Simulate connection setup
    setTimeout(() => {
      this.connected = true;
      console.log('Database connected!');
    }, 1000);
  }
  
  query(sql) {
    if (!this.connected) {
      throw new Error('Cannot execute query. Database not connected.');
    }
    console.log(`Executing query: ${sql}`);
  }
}

class DatabaseSingleton {
  static getInstance(config) {
    if (!DatabaseSingleton.instance) {
      DatabaseSingleton.instance = new DatabaseConnection(config);
    }
    return DatabaseSingleton.instance;
  }
}

// Usage
const db = DatabaseSingleton.getInstance({
  host: 'localhost',
  port: 5432,
  username: 'admin',
  password: 'secret'
});

db.connect();

// Later in the code, get the same instance
const sameDb = DatabaseSingleton.getInstance(); // No config needed, already initialized
sameDb.query('SELECT * FROM users');

2. Subclassed Singletons

Sometimes we need to extend a singleton with specialized behavior:

// Base singleton class
class BaseSingleton {
  static getInstance() {
    if (!this.instance) {
      this.instance = new this();
    }
    return this.instance;
  }
}

// Specialized singleton subclasses
class UserStore extends BaseSingleton {
  constructor() {
    super();
    this.users = [];
  }
  
  addUser(user) {
    this.users.push(user);
  }
  
  getUser(id) {
    return this.users.find(user => user.id === id);
  }
  
  getAllUsers() {
    return [...this.users];
  }
}

class ProductStore extends BaseSingleton {
  constructor() {
    super();
    this.products = [];
  }
  
  addProduct(product) {
    this.products.push(product);
  }
  
  getProduct(id) {
    return this.products.find(product => product.id === id);
  }
  
  getAllProducts() {
    return [...this.products];
  }
}

// Usage
const userStore = UserStore.getInstance();
userStore.addUser({ id: 1, name: 'John' });
userStore.addUser({ id: 2, name: 'Jane' });

const productStore = ProductStore.getInstance();
productStore.addProduct({ id: 101, name: 'Laptop' });

// Later in the code
const sameUserStore = UserStore.getInstance();
console.log(sameUserStore.getAllUsers()); // Shows both users

const sameProductStore = ProductStore.getInstance();
console.log(sameProductStore.getAllProducts()); // Shows the laptop

3. Testing with Singletons

Singletons can make testing difficult. Here’s a pattern that makes them more testable:

class ApiClient {
  constructor(baseUrl, apiKey) {
    this.baseUrl = baseUrl || 'https://api.production.com';
    this.apiKey = apiKey || 'default-api-key';
  }
  
  async get(endpoint) {
    console.log(`GET ${this.baseUrl}/${endpoint} with key ${this.apiKey}`);
    // Actual implementation would use fetch or axios
    return { data: `Response from ${endpoint}` };
  }
  
  async post(endpoint, data) {
    console.log(`POST ${this.baseUrl}/${endpoint} with key ${this.apiKey}`);
    console.log('Data:', data);
    return { success: true };
  }
}

// Singleton wrapper with reset capability for testing
let instance = null;

export default {
  getInstance(config) {
    if (!instance) {
      instance = new ApiClient(config?.baseUrl, config?.apiKey);
    }
    return instance;
  },
  
  // For testing purposes
  resetInstance() {
    instance = null;
  },
  
  // For testing with a mock
  setInstance(mockInstance) {
    instance = mockInstance;
  }
};

// Normal usage
import ApiClientSingleton from './apiClient';

const api = ApiClientSingleton.getInstance();
api.get('users');

// In tests
import ApiClientSingleton from './apiClient';

beforeEach(() => {
  // Reset before each test
  ApiClientSingleton.resetInstance();
  
  // Or inject a mock
  ApiClientSingleton.setInstance({
    get: jest.fn().mockResolvedValue({ data: 'mocked data' }),
    post: jest.fn().mockResolvedValue({ success: true })
  });
});

test('should fetch user data', async () => {
  const api = ApiClientSingleton.getInstance();
  const result = await api.get('users');
  expect(result.data).toBe('mocked data');
});

When to Use the Singleton Pattern

The Singleton pattern is appropriate when:

  • Exactly one instance of a class is needed throughout the application
  • You need controlled access to a shared resource (database connections, file systems, configuration)
  • You want to coordinate actions across the system
  • You need to manage a resource that is expensive to create

However, be cautious with singletons as they can introduce global state, which can make testing and debugging more difficult. They can also create hidden dependencies between components of your application.

Command Pattern: Encapsulating Operations

Understanding the Command Pattern

The Command pattern encapsulates a request as an object, allowing you to parameterize clients with different requests, queue or log requests, and support undoable operations. It decouples the object that invokes the operation from the object that knows how to perform it.

The key components of the Command pattern are:

  • Command: An interface or abstract class that declares the execute method
  • Concrete Command: Implements the Command interface and defines the binding between a Receiver object and an action
  • Client: Creates and configures concrete Command objects
  • Invoker: Asks the command to execute the request
  • Receiver: Knows how to perform the operations associated with carrying out a request

Implementing the Command Pattern

1. Basic Implementation

Let’s start with a simple implementation of the Command pattern:

// Receiver classclass Light {
  constructor(location) {
    this.location = location;
    this.isOn = false;
  }
  
  turnOn() {
    this.isOn = true;
    console.log(`${this.location} light turned on`);
  }
  
  turnOff() {
    this.isOn = false;
    console.log(`${this.location} light turned off`);
  }
}

// Command interface (abstract class in JavaScript)
class Command {
  execute() {
    throw new Error('execute method must be implemented');
  }
}

// Concrete Commands
class LightOnCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  
  execute() {
    this.light.turnOn();
  }
}

class LightOffCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  
  execute() {
    this.light.turnOff();
  }
}

// Invoker
class RemoteControl {
  constructor() {
    this.command = null;
  }
  
  setCommand(command) {
    this.command = command;
  }
  
  pressButton() {
    if (this.command) {
      this.command.execute();
    }
  }
}

// Client code
const kitchenLight = new Light('Kitchen');
const livingRoomLight = new Light('Living Room');

const kitchenLightOn = new LightOnCommand(kitchenLight);
const kitchenLightOff = new LightOffCommand(kitchenLight);
const livingRoomLightOn = new LightOnCommand(livingRoomLight);
const livingRoomLightOff = new LightOffCommand(livingRoomLight);

const remote = new RemoteControl();

// Turn on kitchen light
remote.setCommand(kitchenLightOn);
remote.pressButton();

// Turn on living room light
remote.setCommand(livingRoomLightOn);
remote.pressButton();

// Turn off both lights
remote.setCommand(kitchenLightOff);
remote.pressButton();
remote.setCommand(livingRoomLightOff);
remote.pressButton();

2. Command Pattern with Undo Functionality

One of the powerful features of the Command pattern is the ability to implement undo operations:

// Enhanced Command interface with undoclass Command {
  execute() {
    throw new Error('execute method must be implemented');
  }
  
  undo() {
    throw new Error('undo method must be implemented');
  }
}

// Concrete Commands with undo capability
class LightOnCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  
  execute() {
    this.light.turnOn();
  }
  
  undo() {
    this.light.turnOff();
  }
}

class LightOffCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  
  execute() {
    this.light.turnOff();
  }
  
  undo() {
    this.light.turnOn();
  }
}

class ThermostatSetCommand extends Command {
  constructor(thermostat, temperature) {
    super();
    this.thermostat = thermostat;
    this.temperature = temperature;
    this.previousTemperature = null;
  }
  
  execute() {
    this.previousTemperature = this.thermostat.getTemperature();
    this.thermostat.setTemperature(this.temperature);
  }
  
  undo() {
    if (this.previousTemperature !== null) {
      this.thermostat.setTemperature(this.previousTemperature);
    }
  }
}

// Receiver for thermostat
class Thermostat {
  constructor(location) {
    this.location = location;
    this.temperature = 22; // Default temperature in Celsius
  }
  
  setTemperature(temperature) {
    this.temperature = temperature;
    console.log(`${this.location} thermostat set to ${temperature}°C`);
  }
  
  getTemperature() {
    return this.temperature;
  }
}

// Enhanced Invoker with undo capability
class RemoteControlWithUndo {
  constructor() {
    this.command = null;
    this.undoCommand = null;
  }
  
  setCommand(command) {
    this.command = command;
  }
  
  pressButton() {
    if (this.command) {
      this.command.execute();
      this.undoCommand = this.command;
    }
  }
  
  pressUndoButton() {
    if (this.undoCommand) {
      this.undoCommand.undo();
      this.undoCommand = null;
    }
  }
}

// Client code
const livingRoomLight = new Light('Living Room');
const livingRoomThermostat = new Thermostat('Living Room');

const lightOn = new LightOnCommand(livingRoomLight);
const lightOff = new LightOffCommand(livingRoomLight);
const thermostatUp = new ThermostatSetCommand(livingRoomThermostat, 25);

const remote = new RemoteControlWithUndo();

// Turn on the light
remote.setCommand(lightOn);
remote.pressButton();

// Increase the temperature
remote.setCommand(thermostatUp);
remote.pressButton();

// Undo the last command (set thermostat back to previous temperature)
remote.pressUndoButton();

// Turn off the light
remote.setCommand(lightOff);
remote.pressButton();

// Undo again (turn the light back on)
remote.pressUndoButton();

3. Command Queue and Macro Commands

We can extend the Command pattern to support command queues and macro commands (commands that execute multiple commands):

// Command Queueclass CommandQueue {
  constructor() {
    this.queue = [];
  }
  
  addCommand(command) {
    this.queue.push(command);
  }
  
  executeAll() {
    if (this.queue.length === 0) {
      console.log('Queue is empty');
      return;
    }
    
    console.log(`Executing ${this.queue.length} commands...`);
    
    // Execute commands in sequence
    this.queue.forEach(command => command.execute());
    
    // Clear the queue after execution
    this.queue = [];
  }
}

// Macro Command (Composite Command)
class MacroCommand extends Command {
  constructor(commands) {
    super();
    this.commands = commands || [];
  }
  
  addCommand(command) {
    this.commands.push(command);
  }
  
  execute() {
    console.log(`Executing macro with ${this.commands.length} commands`);
    this.commands.forEach(command => command.execute());
  }
  
  undo() {
    // Undo commands in reverse order
    for (let i = this.commands.length - 1; i >= 0; i--) {
      this.commands[i].undo();
    }
  }
}

// Additional Receiver
class Stereo {
  constructor(location) {
    this.location = location;
    this.isOn = false;
    this.volume = 0;
  }
  
  on() {
    this.isOn = true;
    console.log(`${this.location} stereo turned on`);
  }
  
  off() {
    this.isOn = false;
    console.log(`${this.location} stereo turned off`);
  }
  
  setVolume(level) {
    this.volume = level;
    console.log(`${this.location} stereo volume set to ${level}`);
  }
}

// Additional Commands
class StereoOnCommand extends Command {
  constructor(stereo) {
    super();
    this.stereo = stereo;
  }
  
  execute() {
    this.stereo.on();
  }
  
  undo() {
    this.stereo.off();
  }
}

class StereoOffCommand extends Command {
  constructor(stereo) {
    super();
    this.stereo = stereo;
  }
  
  execute() {
    this.stereo.off();
  }
  
  undo() {
    this.stereo.on();
  }
}

class StereoSetVolumeCommand extends Command {
  constructor(stereo, volume) {
    super();
    this.stereo = stereo;
    this.volume = volume;
    this.previousVolume = 0;
  }
  
  execute() {
    this.previousVolume = this.stereo.volume;
    this.stereo.setVolume(this.volume);
  }
  
  undo() {
    this.stereo.setVolume(this.previousVolume);
  }
}

// Client code
const livingRoomLight = new Light('Living Room');
const kitchenLight = new Light('Kitchen');
const livingRoomStereo = new Stereo('Living Room');

// Individual commands
const livingRoomLightOn = new LightOnCommand(livingRoomLight);
const kitchenLightOn = new LightOnCommand(kitchenLight);
const stereoOn = new StereoOnCommand(livingRoomStereo);
const stereoVolume = new StereoSetVolumeCommand(livingRoomStereo, 11);

// Create a command queue
const commandQueue = new CommandQueue();
commandQueue.addCommand(livingRoomLightOn);
commandQueue.addCommand(kitchenLightOn);
commandQueue.addCommand(stereoOn);
commandQueue.addCommand(stereoVolume);

// Execute all commands in the queue
commandQueue.executeAll();

// Create a macro command for "party mode"
const partyOn = new MacroCommand([
  new LightOnCommand(livingRoomLight),
  new LightOnCommand(kitchenLight),
  new StereoOnCommand(livingRoomStereo),
  new StereoSetVolumeCommand(livingRoomStereo, 11)
]);

const partyOff = new MacroCommand([
  new LightOffCommand(livingRoomLight),
  new LightOffCommand(kitchenLight),
  new StereoOffCommand(livingRoomStereo)
]);

const remote = new RemoteControlWithUndo();

// Turn on party mode
remote.setCommand(partyOn);
remote.pressButton();

// Turn off party mode
remote.setCommand(partyOff);
remote.pressButton();

// Undo (turn everything back on)
remote.pressUndoButton();

Advanced Command Pattern Techniques

1. Command Pattern for Asynchronous Operations

The Command pattern can be adapted for asynchronous operations:

// Async Command interfaceclass AsyncCommand {
  async execute() {
    throw new Error('execute method must be implemented');
  }
}

// API Service (Receiver)
class ApiService {
  async fetchData(endpoint) {
    console.log(`Fetching data from ${endpoint}...`);
    
    // Simulate API call
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log(`Data received from ${endpoint}`);
        resolve({ data: `Response from ${endpoint}`, timestamp: new Date() });
      }, 1000);
    });
  }
  
  async postData(endpoint, data) {
    console.log(`Posting data to ${endpoint}...`);
    console.log('Data:', data);
    
    // Simulate API call
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log(`Data posted to ${endpoint}`);
        resolve({ success: true, id: Math.floor(Math.random() * 1000) });
      }, 1000);
    });
  }
}

// Concrete Async Commands
class FetchUserCommand extends AsyncCommand {
  constructor(apiService, userId) {
    super();
    this.apiService = apiService;
    this.userId = userId;
    this.result = null;
  }
  
  async execute() {
    this.result = await this.apiService.fetchData(`/users/${this.userId}`);
    return this.result;
  }
  
  getResult() {
    return this.result;
  }
}

class CreateUserCommand extends AsyncCommand {
  constructor(apiService, userData) {
    super();
    this.apiService = apiService;
    this.userData = userData;
    this.result = null;
  }
  
  async execute() {
    this.result = await this.apiService.postData('/users', this.userData);
    return this.result;
  }
  
  getResult() {
    return this.result;
  }
}

// Async Command Processor
class CommandProcessor {
  constructor() {
    this.history = [];
  }
  
  async executeCommand(command) {
    try {
      console.log('Executing command...');
      const result = await command.execute();
      this.history.push(command);
      console.log('Command executed successfully');
      return result;
    } catch (error) {
      console.error('Command execution failed:', error);
      throw error;
    }
  }
  
  getHistory() {
    return [...this.history];
  }
}

// Client code
async function run() {
  const apiService = new ApiService();
  const processor = new CommandProcessor();
  
  // Create a new user
  const createCommand = new CreateUserCommand(apiService, {
    name: 'John Doe',
    email: 'john@example.com'
  });
  
  const createResult = await processor.executeCommand(createCommand);
  console.log('Create result:', createResult);
  
  // Fetch the user
  const userId = createResult.id;
  const fetchCommand = new FetchUserCommand(apiService, userId);
  
  const fetchResult = await processor.executeCommand(fetchCommand);
  console.log('Fetch result:', fetchResult);
  
  // Check command history
  console.log(`Command history length: ${processor.getHistory().length}`);
}

run().catch(console.error);

2. Command Pattern for Transactions

The Command pattern can be used to implement transactions with commit and rollback capabilities:

// Transaction Manager using Command Patternclass Transaction {
  constructor() {
    this.commands = [];
    this.isCommitted = false;
  }
  
  addCommand(command) {
    if (this.isCommitted) {
      throw new Error('Cannot add commands to a committed transaction');
    }
    this.commands.push(command);
  }
  
  async commit() {
    if (this.isCommitted) {
      throw new Error('Transaction already committed');
    }
    
    console.log(`Committing transaction with ${this.commands.length} commands...`);
    
    try {
      // Execute all commands
      for (const command of this.commands) {
        await command.execute();
      }
      
      this.isCommitted = true;
      console.log('Transaction committed successfully');
      return true;
    } catch (error) {
      console.error('Transaction failed, rolling back...', error);
      
      // Rollback executed commands in reverse order
      for (let i = this.commands.length - 1; i >= 0; i--) {
        const command = this.commands[i];
        if (command.executed) {
          try {
            await command.rollback();
          } catch (rollbackError) {
            console.error('Rollback failed for command:', rollbackError);
          }
        }
      }
      
      throw new Error(`Transaction failed: ${error.message}`);
    }
  }
}

// Transactional Command interface
class TransactionalCommand {
  constructor() {
    this.executed = false;
  }
  
  async execute() {
    throw new Error('execute method must be implemented');
  }
  
  async rollback() {
    throw new Error('rollback method must be implemented');
  }
}

// Database Service (Receiver)
class DatabaseService {
  constructor() {
    this.users = new Map();
    this.products = new Map();
    this.orders = new Map();
  }
  
  async createUser(user) {
    console.log('Creating user:', user);
    const id = Date.now().toString();
    this.users.set(id, { ...user, id });
    return id;
  }
  
  async deleteUser(id) {
    console.log('Deleting user:', id);
    const deleted = this.users.delete(id);
    return deleted;
  }
  
  async createOrder(order) {
    console.log('Creating order:', order);
    
    // Validate user exists
    if (!this.users.has(order.userId)) {
      throw new Error(`User ${order.userId} not found`);
    }
    
    // Validate products exist
    for (const item of order.items) {
      if (!this.products.has(item.productId)) {
        throw new Error(`Product ${item.productId} not found`);
      }
    }
    
    const id = Date.now().toString();
    this.orders.set(id, { ...order, id, date: new Date() });
    return id;
  }
  
  async deleteOrder(id) {
    console.log('Deleting order:', id);
    const deleted = this.orders.delete(id);
    return deleted;
  }
  
  async createProduct(product) {
    console.log('Creating product:', product);
    const id = Date.now().toString();
    this.products.set(id, { ...product, id });
    return id;
  }
}

// Concrete Transactional Commands
class CreateUserCommand extends TransactionalCommand {
  constructor(dbService, userData) {
    super();
    this.dbService = dbService;
    this.userData = userData;
    this.userId = null;
  }
  
  async execute() {
    this.userId = await this.dbService.createUser(this.userData);
    this.executed = true;
    return this.userId;
  }
  
  async rollback() {
    if (this.userId) {
      await this.dbService.deleteUser(this.userId);
      console.log(`Rolled back user creation: ${this.userId}`);
    }
  }
}

class CreateProductCommand extends TransactionalCommand {
  constructor(dbService, productData) {
    super();
    this.dbService = dbService;
    this.productData = productData;
    this.productId = null;
  }
  
  async execute() {
    this.productId = await this.dbService.createProduct(this.productData);
    this.executed = true;
    return this.productId;
  }
  
  async rollback() {
    // Products might not need rollback in this example
    console.log(`No rollback needed for product: ${this.productId}`);
  }
}

class CreateOrderCommand extends TransactionalCommand {
  constructor(dbService, orderData) {
    super();
    this.dbService = dbService;
    this.orderData = orderData;
    this.orderId = null;
  }
  
  async execute() {
    this.orderId = await this.dbService.createOrder(this.orderData);
    this.executed = true;
    return this.orderId;
  }
  
  async rollback() {
    if (this.orderId) {
      await this.dbService.deleteOrder(this.orderId);
      console.log(`Rolled back order creation: ${this.orderId}`);
    }
  }
}

// Client code
async function runTransaction() {
  const dbService = new DatabaseService();
  const transaction = new Transaction();
  
  try {
    // Create user command
    const createUserCmd = new CreateUserCommand(dbService, {
      name: 'Alice',
      email: 'alice@example.com'
    });
    transaction.addCommand(createUserCmd);
    
    // Create product command
    const createProductCmd = new CreateProductCommand(dbService, {
      name: 'Laptop',
      price: 999.99
    });
    transaction.addCommand(createProductCmd);
    
    // Commit the transaction (executes all commands)
    await transaction.commit();
    
    // Get the IDs from the commands
    const userId = createUserCmd.userId;
    const productId = createProductCmd.productId;
    
    console.log(`Created user with ID: ${userId}`);
    console.log(`Created product with ID: ${productId}`);
    
    // Create a new transaction for the order
    const orderTransaction = new Transaction();
    
    // Create order command
    const createOrderCmd = new CreateOrderCommand(dbService, {
      userId: userId,
      items: [{ productId: productId, quantity: 1 }],
      totalAmount: 999.99
    });
    orderTransaction.addCommand(createOrderCmd);
    
    // Commit the order transaction
    await orderTransaction.commit();
    
    console.log(`Created order with ID: ${createOrderCmd.orderId}`);
    
  } catch (error) {
    console.error('Error:', error.message);
  }
}

runTransaction();

When to Use the Command Pattern

The Command pattern is particularly useful when you need to:

  • Parameterize objects with operations
  • Queue, schedule, or execute operations at different times
  • Support undoable operations
  • Structure a system around high-level operations built on primitive operations
  • Implement callback functionality
  • Create transactional behavior where a group of operations must all succeed or all fail

Bottom Line

Design patterns are essential tools in a JavaScript developer’s toolkit, providing proven solutions to common architectural challenges. By understanding and implementing these patterns, you can write more maintainable, flexible, and robust code that stands the test of time.

The patterns we’ve explored in this guide—Module, Factory, Observer, Singleton, and Command—represent just a subset of the design patterns available to JavaScript developers. Each pattern addresses specific design challenges:

  • The Module pattern provides encapsulation and organization, helping you structure your code into manageable, private units.
  • The Factory pattern abstracts object creation, allowing you to create different objects based on conditions without exposing the instantiation logic.
  • The Observer pattern establishes a subscription mechanism for notifying multiple objects about events, forming the foundation for reactive programming.
  • The Singleton pattern ensures a class has only one instance, providing a global point of access to shared resources.
  • The Command pattern encapsulates requests as objects, enabling parameterization of clients with operations and supporting advanced features like undo and transactions.

As you apply these patterns in your projects, remember that they are tools, not rules. The best developers know when to apply a pattern and, equally important, when not to. Overusing patterns can lead to unnecessary complexity, while appropriate application can significantly improve your code’s structure and maintainability.

To continue your journey with JavaScript design patterns, consider exploring other important patterns like Decorator, Strategy, Proxy, and Composite. Each offers unique solutions to different design challenges you’ll encounter in your development career.

By mastering these design patterns and understanding their appropriate applications, you’ll elevate your JavaScript development skills and be better equipped to build complex, maintainable applications that can evolve with changing requirements.

If you found this guide helpful, consider subscribing to our newsletter for more in-depth tech tutorials and guides. We also offer premium courses that dive deeper into advanced JavaScript concepts, design patterns, and architectural principles for those looking to further enhance their development skills.

This website uses cookies to improve your web experience.