DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

JavaScript Design Patterns: Practical Patterns for Scalable Applications

Design patterns are reusable approaches to common software design problems. In JavaScript, they help developers structure code that is easier to understand, test, extend, and maintain as applications grow.

JavaScript supports multiple programming styles, so patterns can be implemented with functions, objects, classes, modules, and closures. The key is not to use patterns everywhere, but to recognize situations where a proven structure can reduce complexity.

In this article, we will explore several practical JavaScript design patterns through a runnable example. We will focus on the Module, Factory, Observer, and Strategy patterns and see how they can work together in a realistic application.

Practical JavaScript Design Patterns in Action

The Module Pattern is useful when you want to encapsulate private state and expose only a controlled public API. JavaScript closures make this possible by allowing inner functions to access variables that remain inaccessible from outside the module.

The Factory Pattern centralizes object creation. Instead of scattering constructor logic throughout an application, a factory can decide which object implementation should be created based on the requested type.

The Observer Pattern allows one part of an application to notify multiple listeners when something changes. This is especially useful for events, notifications, UI updates, logging systems, and loosely coupled application components.

The Strategy Pattern lets an application switch between different algorithms without changing the code that uses them. For example, a checkout system can support multiple discount strategies while keeping the order-processing logic unchanged.

The following example combines these patterns into a small order-processing system. The module manages the order state, the factory creates notification services, the observer publishes order events, and the strategy pattern applies different discount rules.

class EventEmitter {
  constructor() {
    this.listeners = {};
  }

  // Register a listener for a specific event.
  on(eventName, listener) {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }

    this.listeners[eventName].push(listener);
    console.log(`[EventEmitter] Listener registered for: ${eventName}`);
  }

  // Notify every listener subscribed to the event.
  emit(eventName, data) {
    console.log(`[EventEmitter] Emitting event: ${eventName}`);

    const listeners = this.listeners[eventName] || [];
    listeners.forEach((listener) => listener(data));
  }
}

// Module Pattern: private state is protected inside the closure.
const OrderModule = (() => {
  let orders = [];

  return {
    add(order) {
      orders.push(order);
      console.log(`[OrderModule] Order added: ${order.id}`);
    },

    getAll() {
      return [...orders];
    },

    getTotal() {
      return orders.reduce((total, order) => total + order.total, 0);
    }
  };
})();

// Strategy Pattern: each strategy implements the same discount contract.
const DiscountStrategies = {
  none: (total) => total,
  student: (total) => total * 0.90,
  premium: (total) => total * 0.80,
  festival: (total) => total * 0.75
};

// Factory Pattern: create notification services without exposing construction details.
class EmailNotifier {
  send(message) {
    console.log(`[Email] ${message}`);
  }
}

class SmsNotifier {
  send(message) {
    console.log(`[SMS] ${message}`);
  }
}

class ConsoleNotifier {
  send(message) {
    console.log(`[Console] ${message}`);
  }
}

function NotificationFactory(type) {
  switch (type) {
    case "email":
      return new EmailNotifier();
    case "sms":
      return new SmsNotifier();
    case "console":
      return new ConsoleNotifier();
    default:
      throw new Error(`Unsupported notification type: ${type}`);
  }
}

// Application service that coordinates the patterns.
class OrderService {
  constructor(eventEmitter, notifier, discountStrategy) {
    this.eventEmitter = eventEmitter;
    this.notifier = notifier;
    this.discountStrategy = discountStrategy;
  }

  createOrder(customer, items) {
    console.log("\n[OrderService] Creating order...");

    const subtotal = items.reduce(
      (total, item) => total + item.price * item.quantity,
      0
    );

    const total = this.discountStrategy(subtotal);

    const order = {
      id: `ORD-${Date.now()}`,
      customer,
      items,
      subtotal,
      total
    };

    OrderModule.add(order);
    this.eventEmitter.emit("orderCreated", order);

    this.notifier.send(
      `Order ${order.id} created for ${customer}. Final total: ₹${total.toFixed(2)}`
    );

    return order;
  }
}

console.log("=== JavaScript Design Patterns Demo ===");

// Observer Pattern: create a shared event system.
const eventEmitter = new EventEmitter();

eventEmitter.on("orderCreated", (order) => {
  console.log(`[Analytics] Tracking order ${order.id}`);
});

eventEmitter.on("orderCreated", (order) => {
  console.log(`[Inventory] Reserving inventory for ${order.items.length} item(s)`);
});

// Factory Pattern creates the required notification implementation.
const notifier = NotificationFactory("console");

// Strategy Pattern selects the festival discount algorithm.
const discountStrategy = DiscountStrategies.festival;

// Dependency injection keeps OrderService independent of implementations.
const orderService = new OrderService(
  eventEmitter,
  notifier,
  discountStrategy
);

const items = [
  { name: "Keyboard", price: 2500, quantity: 1 },
  { name: "Mouse", price: 1200, quantity: 2 },
  { name: "USB Hub", price: 800, quantity: 1 }
];

const order = orderService.createOrder("Ansh", items);

console.log("\n=== Final Application State ===");
console.log("Orders:", OrderModule.getAll());
console.log(`Total revenue: ₹${OrderModule.getTotal().toFixed(2)}`);
console.log("\nDemo completed successfully.");
Enter fullscreen mode Exit fullscreen mode

Conclusion

Design patterns are valuable because they provide structure for recurring engineering problems rather than because they make code automatically better. A pattern should simplify communication and maintenance, not introduce unnecessary abstraction.

In real JavaScript applications, patterns often appear naturally in frameworks and libraries. Event-driven systems use Observer-like behavior, dependency injection resembles Factory and Strategy concepts, and modules provide encapsulation.

The most useful skill is learning to identify the underlying problem before selecting a pattern. Start with simple code, introduce a pattern when complexity or change requires it, and keep the implementation focused on the application's actual needs.

Top comments (0)