Design patterns are reusable solutions to common problems in software design. While they originated in the world of object-oriented programming, they’re just as relevant in modern JavaScript development. Let’s explore how to implement some of the most useful patterns using ES6+ syntax.

Why Design Patterns Matter

Design patterns aren’t about memorizing code templates. They’re about understanding common problems and having a vocabulary to discuss solutions. When you say “let’s use a Factory here,” your team immediately understands the approach.

“Design patterns are not about dogma. They’re about communication and proven solutions.”

Creational Patterns

The Factory Pattern

The Factory pattern is one of the most commonly used patterns. It provides an interface for creating objects without specifying their exact classes.

class NotificationFactory {
  static create(type, message) {
    switch (type) {
      case 'email':
        return new EmailNotification(message);
      case 'sms':
        return new SMSNotification(message);
      case 'push':
        return new PushNotification(message);
      default:
        throw new Error(`Unknown notification type: ${type}`);
    }
  }
}

// Usage
const notification = NotificationFactory.create('email', 'Hello!');
notification.send();

When to use it:

  • When you need to create objects without knowing the exact class
  • When you want to centralize object creation logic
  • When you need to add new types without modifying existing code

The Singleton Pattern

The Singleton ensures a class has only one instance and provides global access to it. In JavaScript, we can implement this elegantly using modules.

// database.js
class Database {
  constructor() {
    if (Database.instance) {
      return Database.instance;
    }
    this.connection = null;
    Database.instance = this;
  }

  connect(url) {
    if (!this.connection) {
      this.connection = { url, connected: true };
      console.log(`Connected to ${url}`);
    }
    return this.connection;
  }
}

export const database = new Database();

Modern alternative using modules:

// config.js - ES modules are singletons by default
const config = {
  apiUrl: process.env.API_URL,
  timeout: 5000
};

export default config;

Structural Patterns

The Adapter Pattern

The Adapter pattern allows incompatible interfaces to work together. It’s especially useful when integrating third-party libraries.

// Old analytics library
class OldAnalytics {
  track(eventName, eventData) {
    console.log(`[Old] ${eventName}:`, eventData);
  }
}

// New analytics library with different interface
class NewAnalytics {
  send(event) {
    console.log(`[New] ${event.name}:`, event.properties);
  }
}

// Adapter to make new library work with old interface
class AnalyticsAdapter {
  constructor(newAnalytics) {
    this.analytics = newAnalytics;
  }

  track(eventName, eventData) {
    this.analytics.send({
      name: eventName,
      properties: eventData,
      timestamp: Date.now()
    });
  }
}

// Usage - same interface, different implementation
const analytics = new AnalyticsAdapter(new NewAnalytics());
analytics.track('page_view', { path: '/home' });

The Decorator Pattern

Decorators add behavior to objects dynamically. With TypeScript, we can use actual decorator syntax.

// Functional approach
function withLogging(fn) {
  return function (...args) {
    console.log(`Calling ${fn.name} with:`, args);
    const result = fn.apply(this, args);
    console.log(`${fn.name} returned:`, result);
    return result;
  };
}

function withTiming(fn) {
  return function (...args) {
    const start = performance.now();
    const result = fn.apply(this, args);
    const end = performance.now();
    console.log(`${fn.name} took ${end - start}ms`);
    return result;
  };
}

// Usage
const processData = withLogging(withTiming((data) => {
  return data.map(x => x * 2);
}));

processData([1, 2, 3]);

Behavioral Patterns

The Observer Pattern

The Observer pattern defines a one-to-many dependency. When one object changes state, all dependents are notified. This is the foundation of reactive programming.

class EventEmitter {
  constructor() {
    this.events = new Map();
  }

  on(event, callback) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event).push(callback);
    
    // Return unsubscribe function
    return () => this.off(event, callback);
  }

  off(event, callback) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      const index = callbacks.indexOf(callback);
      if (index > -1) callbacks.splice(index, 1);
    }
  }

  emit(event, data) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      callbacks.forEach(callback => callback(data));
    }
  }
}

// Usage
const store = new EventEmitter();

const unsubscribe = store.on('userLoggedIn', (user) => {
  console.log(`Welcome, ${user.name}!`);
});

store.emit('userLoggedIn', { name: 'Alex' });
unsubscribe(); // Clean up when done

The Strategy Pattern

The Strategy pattern defines a family of algorithms and makes them interchangeable. It’s perfect for implementing different behaviors based on context.

// Payment strategies
const paymentStrategies = {
  creditCard: (amount, details) => {
    console.log(`Processing $${amount} via Credit Card`);
    // Credit card processing logic
    return { success: true, transactionId: 'cc_123' };
  },
  
  paypal: (amount, details) => {
    console.log(`Processing $${amount} via PayPal`);
    // PayPal processing logic
    return { success: true, transactionId: 'pp_456' };
  },
  
  crypto: (amount, details) => {
    console.log(`Processing $${amount} via Crypto`);
    // Crypto processing logic
    return { success: true, transactionId: 'cr_789' };
  }
};

class PaymentProcessor {
  constructor(strategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy) {
    this.strategy = strategy;
  }

  process(amount, details) {
    return this.strategy(amount, details);
  }
}

// Usage
const processor = new PaymentProcessor(paymentStrategies.creditCard);
processor.process(100, { cardNumber: '****' });

processor.setStrategy(paymentStrategies.paypal);
processor.process(50, { email: 'user@example.com' });

Putting It All Together

The best code often combines multiple patterns. Here’s a more complete example:

// Combining Factory, Strategy, and Observer
class NotificationService extends EventEmitter {
  constructor() {
    super();
    this.strategies = new Map();
  }

  registerStrategy(type, strategy) {
    this.strategies.set(type, strategy);
  }

  async send(type, message, recipient) {
    const strategy = this.strategies.get(type);
    if (!strategy) {
      throw new Error(`No strategy for type: ${type}`);
    }

    this.emit('sending', { type, message, recipient });
    
    try {
      const result = await strategy(message, recipient);
      this.emit('sent', { type, message, recipient, result });
      return result;
    } catch (error) {
      this.emit('error', { type, message, recipient, error });
      throw error;
    }
  }
}

// Usage
const notifications = new NotificationService();

notifications.registerStrategy('email', async (message, to) => {
  // Send email logic
  return { delivered: true };
});

notifications.on('sent', ({ type, recipient }) => {
  console.log(`${type} sent to ${recipient}`);
});

await notifications.send('email', 'Hello!', 'user@example.com');

Key Takeaways

  1. Don’t over-engineer — Use patterns when they solve a real problem, not to seem clever
  2. Start simple — You can always refactor to a pattern when the need becomes clear
  3. Combine patterns — Real-world code often uses multiple patterns together
  4. Modern JavaScript helps — ES6+ features like classes, modules, and arrow functions make patterns cleaner
  5. Document your choices — Help future developers understand why you chose a particular pattern

Design patterns are tools, not rules. Learn them, understand when they’re useful, and apply them judiciously. The goal is always maintainable, readable code.


Want to learn more? Check out our other engineering articles or reach out to discuss how we apply these patterns in our client projects.