JavaScript Design Patterns for Clean Code

JavaScript Design Patterns for Clean Code

Design patterns get a bad rap, but a handful of them genuinely earn their keep in JavaScript. This opinionated walkthrough covers seven patterns — module, observer, singleton, factory, strategy, decorator, and facade — with honest takes on which ones I actually reach for in real code and which ones mostly live in textbooks.

Design patterns get a bad rap and honestly, some of them deserve it. But a few of them genuinely make code easier to work with.

I have been writing JavaScript professionally for years, and in that time I have used maybe seven patterns with any regularity. Not because I do not know the others exist — I have read the Gang of Four book, I have sat through the conference talks — but because most of them solve problems I rarely have. The ones I am going to talk about here are different. These are the ones I actually reach for when I am staring at a messy codebase and trying to figure out how to untangle it.

Let me be upfront: if someone tells you that you need to learn all 23 classical design patterns before you can call yourself a real developer, they are wrong. You need to learn the ones that match the problems you actually face. In JavaScript, that list is shorter than you think.

The Module Pattern

This is the one pattern I would genuinely be lost without. The module pattern uses closures to create private state and expose only what the outside world needs to see. Before ES modules existed, it was the only sane way to organize JavaScript code. Now ES modules handle it natively, but the underlying idea is the same.

// Classic Module Pattern (IIFE)
const AuthService = (() => {
  // Private state
  let currentUser = null;
  const TOKEN_KEY = 'auth_token';

  // Private functions
  function saveToken(token) {
    if (typeof window !== 'undefined') {
      localStorage.setItem(TOKEN_KEY, token);
    }
  }

  function clearToken() {
    if (typeof window !== 'undefined') {
      localStorage.removeItem(TOKEN_KEY);
    }
  }

  // Public API
  return {
    async login(email, password) {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });
      const data = await response.json();
      currentUser = data.user;
      saveToken(data.token);
      return currentUser;
    },

    logout() {
      currentUser = null;
      clearToken();
    },

    getCurrentUser() {
      return currentUser;
    },

    isAuthenticated() {
      return currentUser !== null;
    }
  };
})();

// Usage
await AuthService.login('[email protected]', 'password');
console.log(AuthService.isAuthenticated()); // true
console.log(AuthService.getCurrentUser());  // { ... user data }
// AuthService.currentUser is undefined — it's private!

And here is the modern ES module version, which does the same thing with less ceremony:

// authService.mjs — ES Module version
let currentUser = null; // private to this module

export async function login(email, password) {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password })
  });
  const data = await response.json();
  currentUser = data.user;
  return currentUser;
}

export function getCurrentUser() {
  return currentUser;
}

export function isAuthenticated() {
  return currentUser !== null;
}

I use this pattern every single day. Any time I have a service that manages internal state — auth, caching, connection pools — it is a module. This is not textbook fodder. This is the backbone of any well-organized JavaScript project.

The Observer / Pub-Sub Pattern

If you have written any event-driven code at all, you have already used this pattern. The observer pattern sets up a one-to-many relationship: when one thing changes, everything watching it gets notified. The pub/sub variation adds a middleman so publishers and subscribers do not need to know about each other directly.

I am going to show you a simple event bus, and then I will tell you why you probably do not need to build one yourself.

// EventBus — a practical pub/sub implementation
class EventBus {
  constructor() {
    this.subscribers = new Map();
  }

  subscribe(event, callback) {
    if (!this.subscribers.has(event)) {
      this.subscribers.set(event, []);
    }
    this.subscribers.get(event).push(callback);

    // Return unsubscribe function
    return () => {
      const callbacks = this.subscribers.get(event);
      const index = callbacks.indexOf(callback);
      if (index > -1) callbacks.splice(index, 1);
    };
  }

  publish(event, data) {
    if (!this.subscribers.has(event)) return;
    this.subscribers.get(event).forEach(callback => {
      try {
        callback(data);
      } catch (error) {
        console.error(`Error in subscriber for ${event}:`, error);
      }
    });
  }

  clear(event) {
    if (event) {
      this.subscribers.delete(event);
    } else {
      this.subscribers.clear();
    }
  }
}

// Usage in a Node.js application
const bus = new EventBus();

// Order service subscribes to payment events
const unsubscribe = bus.subscribe('payment:completed', (order) => {
  console.log(`Processing order ${order.id} for $${order.total}`);
});

// Email service subscribes to the same event
bus.subscribe('payment:completed', (order) => {
  console.log(`Sending confirmation email for order ${order.id}`);
});

// Payment service publishes when payment succeeds
bus.publish('payment:completed', { id: 'ORD-123', total: 59.99 });
// Output:
// Processing order ORD-123 for $59.99
// Sending confirmation email for order ORD-123

unsubscribe(); // Clean up when no longer needed

Here is the thing though: Node.js already gives you EventEmitter, and it is battle-tested across millions of production applications. Unless you need a very specific behavior that EventEmitter does not cover, just use it.

const { EventEmitter } = require('events');

class OrderProcessor extends EventEmitter {
  process(order) {
    this.emit('processing', order);
    // ... process the order ...
    this.emit('completed', { ...order, status: 'fulfilled' });
  }
}

const processor = new OrderProcessor();
processor.on('completed', (order) => console.log('Order done:', order.id));
processor.process({ id: 'ORD-456' });

This is a pattern I use constantly — but almost always through EventEmitter or a framework's built-in event system rather than rolling my own. The value is in understanding the concept so you can recognize when a problem calls for it.

The Singleton Pattern

Singletons are controversial, and I get why. They are basically global state with extra steps, and global state is the root of a lot of testing headaches. That said, there are cases where you really do want exactly one instance of something — a database connection, a logger, a configuration object.

The good news is that in Node.js, you get singletons almost for free because of how module caching works. When you require or import a module, Node.js caches it. Every subsequent import gives you the same object.

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

  async connect(config) {
    if (this.connection) {
      console.log('Returning existing connection');
      return this.connection;
    }
    console.log('Creating new database connection...');
    // Simulating connection setup
    this.connection = {
      host: config.host,
      port: config.port,
      connected: true,
      connectedAt: new Date()
    };
    return this.connection;
  }

  async query(sql, params = []) {
    if (!this.connection) throw new Error('Not connected');
    this.queryCount++;
    console.log(`Query #${this.queryCount}: ${sql}`);
    // ... execute query ...
    return [];
  }

  getStats() {
    return {
      connected: !!this.connection,
      queryCount: this.queryCount
    };
  }
}

// Both calls return the same instance
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true

But honestly? Most of the time I just do this:

// logger.js — Singleton via module caching
class Logger {
  constructor() {
    this.logs = [];
  }

  info(message) {
    const entry = { level: 'INFO', message, timestamp: Date.now() };
    this.logs.push(entry);
    console.log(`[INFO] ${message}`);
  }

  error(message) {
    const entry = { level: 'ERROR', message, timestamp: Date.now() };
    this.logs.push(entry);
    console.error(`[ERROR] ${message}`);
  }

  getHistory() {
    return [...this.logs];
  }
}

// Export a single instance — Node.js module cache ensures it's a singleton
module.exports = new Logger();

No fancy constructor tricks, no static instance properties. Just export the object and let the module system handle the rest. My honest advice: if you find yourself building an elaborate singleton implementation in JavaScript, stop and ask whether a plain module export would do the job. Nine times out of ten, it will.

The Factory Pattern

Factories create objects without forcing you to specify the exact class. You hand the factory some input, and it figures out what to give you back. I like this pattern a lot because it keeps the calling code blissfully ignorant of the implementation details.

// Notification factory — different channels, same interface
class EmailNotification {
  constructor(recipient, message) {
    this.type = 'email';
    this.recipient = recipient;
    this.message = message;
  }

  async send() {
    console.log(`Sending email to ${this.recipient}: ${this.message}`);
    // ... SMTP logic ...
    return { delivered: true, channel: 'email' };
  }
}

class SMSNotification {
  constructor(recipient, message) {
    this.type = 'sms';
    this.recipient = recipient;
    this.message = message;
  }

  async send() {
    console.log(`Sending SMS to ${this.recipient}: ${this.message}`);
    // ... Twilio API call ...
    return { delivered: true, channel: 'sms' };
  }
}

class PushNotification {
  constructor(recipient, message) {
    this.type = 'push';
    this.recipient = recipient;
    this.message = message;
  }

  async send() {
    console.log(`Sending push to ${this.recipient}: ${this.message}`);
    // ... Firebase Cloud Messaging ...
    return { delivered: true, channel: 'push' };
  }
}

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

// Usage — the caller doesn't need to know the concrete class
async function notifyUser(user, message) {
  const preferences = ['email', 'push']; // from user settings
  const results = await Promise.all(
    preferences.map(channel =>
      NotificationFactory.create(channel, user.id, message).send()
    )
  );
  return results;
}

Where this really starts to pay off is when you make the factory extensible. A registry-based factory lets new types plug in without touching the factory code at all:

class ExtensibleFactory {
  constructor() {
    this.creators = new Map();
  }

  register(type, creator) {
    this.creators.set(type, creator);
  }

  create(type, ...args) {
    const creator = this.creators.get(type);
    if (!creator) throw new Error(`No creator registered for: ${type}`);
    return creator(...args);
  }
}

const factory = new ExtensibleFactory();
factory.register('email', (to, msg) => new EmailNotification(to, msg));
factory.register('sms', (to, msg) => new SMSNotification(to, msg));

// Later, a plugin can register new types without changing existing code
factory.register('slack', (to, msg) => new SlackNotification(to, msg));

I reach for factories whenever I am building something that needs to support multiple implementations behind a common interface. Notification systems, payment processors, file exporters — any situation where you might add a new variant six months from now. This is one of those patterns that feels like overkill in a tutorial but saves your neck in production.

The Strategy Pattern

Strategy is the pattern for when you have multiple ways of doing the same thing and want to swap between them at runtime. If you have ever written a long chain of if/else or switch statements to pick different behavior, you have a strategy pattern trying to escape.

// Pricing strategies for an e-commerce platform
const pricingStrategies = {
  regular: {
    calculate(items) {
      return items.reduce((sum, item) => sum + item.price * item.qty, 0);
    }
  },

  premium: {
    calculate(items) {
      const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
      return subtotal * 0.9; // 10% discount for premium members
    }
  },

  wholesale: {
    calculate(items) {
      return items.reduce((sum, item) => {
        const unitPrice = item.qty >= 100 ? item.price * 0.6 : item.price * 0.75;
        return sum + unitPrice * item.qty;
      }, 0);
    }
  },

  employee: {
    calculate(items) {
      const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
      return subtotal * 0.7; // 30% employee discount
    }
  }
};

class ShoppingCart {
  constructor(pricingStrategy) {
    this.items = [];
    this.strategy = pricingStrategy;
  }

  addItem(item) {
    this.items.push(item);
  }

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

  getTotal() {
    return this.strategy.calculate(this.items);
  }
}

// Usage
const cart = new ShoppingCart(pricingStrategies.regular);
cart.addItem({ name: 'Widget', price: 25, qty: 3 });
cart.addItem({ name: 'Gadget', price: 50, qty: 1 });

console.log('Regular:', cart.getTotal());  // 125

cart.setStrategy(pricingStrategies.premium);
console.log('Premium:', cart.getTotal());  // 112.5

cart.setStrategy(pricingStrategies.employee);
console.log('Employee:', cart.getTotal()); // 87.5

In JavaScript, since functions are first-class, strategies can just be functions. No need for classes or objects with a calculate method if all you need is a function:

// Compression strategies for a file processor
const compress = {
  gzip: (data) => { /* gzip logic */ return `gzipped(${data.length} bytes)` },
  brotli: (data) => { /* brotli logic */ return `brotli(${data.length} bytes)` },
  none: (data) => data
};

function processFile(data, compressionStrategy = compress.none) {
  const processed = transform(data);
  return compressionStrategy(processed);
}

This is another pattern I consider essential. Every time I catch myself writing a switch statement that picks between different algorithms, I refactor to strategies. The calling code gets cleaner, adding new options does not require touching existing logic, and testing is straightforward because each strategy is isolated.

The Decorator Pattern

Decorators wrap existing behavior with additional behavior, without changing the original. If you have used Express middleware, you already know this pattern intimately — you just might not have called it that.

// Decorating an API client with logging, retry, and caching
class ApiClient {
  async request(url, options = {}) {
    const response = await fetch(url, options);
    return response.json();
  }
}

// Logging decorator
function withLogging(client) {
  const originalRequest = client.request.bind(client);
  client.request = async function (url, options) {
    const start = Date.now();
    console.log(`[API] ${options.method || 'GET'} ${url}`);
    try {
      const result = await originalRequest(url, options);
      console.log(`[API] ${url} completed in ${Date.now() - start}ms`);
      return result;
    } catch (error) {
      console.error(`[API] ${url} failed in ${Date.now() - start}ms:`, error.message);
      throw error;
    }
  };
  return client;
}

// Retry decorator
function withRetry(client, maxRetries = 3) {
  const originalRequest = client.request.bind(client);
  client.request = async function (url, options) {
    let lastError;
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        return await originalRequest(url, options);
      } catch (error) {
        lastError = error;
        console.warn(`Retry ${attempt}/${maxRetries} for ${url}`);
        await new Promise(r => setTimeout(r, attempt * 1000));
      }
    }
    throw lastError;
  };
  return client;
}

// Cache decorator
function withCache(client, ttl = 60000) {
  const cache = new Map();
  const originalRequest = client.request.bind(client);
  client.request = async function (url, options) {
    if (!options.method || options.method === 'GET') {
      const cached = cache.get(url);
      if (cached && Date.now() - cached.time < ttl) {
        console.log(`[CACHE] Hit for ${url}`);
        return cached.data;
      }
    }
    const data = await originalRequest(url, options);
    cache.set(url, { data, time: Date.now() });
    return data;
  };
  return client;
}

// Stack decorators — order matters!
let api = new ApiClient();
api = withCache(api, 30000);
api = withRetry(api, 3);
api = withLogging(api);

// Now every request is logged, retried on failure, and cached
await api.request('https://api.example.com/users');

The Express middleware stack is the decorator pattern in action, and it is everywhere:

// Express middleware as decorators
app.use(cors());         // decorates with CORS headers
app.use(helmet());       // decorates with security headers
app.use(rateLimit());    // decorates with rate limiting
app.use(authenticate()); // decorates with auth check
app.get('/api/data', handler); // the core handler

I think the decorator pattern is underappreciated. People learn it as some abstract concept, but they use it every day without realizing it. Any time you need to bolt on logging, caching, retries, or auth checks without rewriting the thing you are wrapping, this is the pattern. The API client example above is almost exactly how I structure HTTP clients in real projects.

The Facade Pattern

The facade is the simplest pattern on this list to understand: you take a bunch of complicated subsystems and put a simple interface in front of them. That is it. No new functionality, just a nicer API for the code that has to call it.

// Complex subsystems
class FileSystem {
  readFile(path) {
    console.log(`Reading file: ${path}`);
    return { content: '...', size: 1024 };
  }
  writeFile(path, content) {
    console.log(`Writing file: ${path}`);
    return true;
  }
  deleteFile(path) {
    console.log(`Deleting file: ${path}`);
    return true;
  }
}

class ImageProcessor {
  resize(buffer, width, height) {
    console.log(`Resizing to ${width}x${height}`);
    return buffer;
  }
  compress(buffer, quality) {
    console.log(`Compressing at quality ${quality}`);
    return buffer;
  }
  convertFormat(buffer, format) {
    console.log(`Converting to ${format}`);
    return buffer;
  }
}

class CDNClient {
  upload(buffer, key) {
    console.log(`Uploading to CDN: ${key}`);
    return `https://cdn.example.com/${key}`;
  }
  invalidate(key) {
    console.log(`Invalidating CDN cache: ${key}`);
  }
}

class MetadataStore {
  save(key, metadata) {
    console.log(`Saving metadata for ${key}`);
  }
  get(key) {
    return { uploadedAt: new Date(), size: 1024 };
  }
}

// Facade: simplifies the complex interaction of four subsystems
class ImageUploadService {
  constructor() {
    this.fs = new FileSystem();
    this.processor = new ImageProcessor();
    this.cdn = new CDNClient();
    this.metadata = new MetadataStore();
  }

  async uploadAvatar(userId, filePath) {
    // Step 1: Read the file
    const file = this.fs.readFile(filePath);

    // Step 2: Process the image
    let buffer = file.content;
    buffer = this.processor.resize(buffer, 200, 200);
    buffer = this.processor.compress(buffer, 80);
    buffer = this.processor.convertFormat(buffer, 'webp');

    // Step 3: Upload to CDN
    const key = `avatars/${userId}.webp`;
    const url = this.cdn.upload(buffer, key);

    // Step 4: Save metadata
    this.metadata.save(key, {
      userId,
      url,
      uploadedAt: new Date(),
      originalFile: filePath
    });

    // Step 5: Clean up
    this.fs.deleteFile(filePath);

    return { url, key };
  }

  async deleteAvatar(userId) {
    const key = `avatars/${userId}.webp`;
    this.cdn.invalidate(key);
    this.metadata.save(key, null);
    return { deleted: true };
  }
}

// Usage — the caller doesn't need to know about file systems,
// image processing, CDNs, or metadata storage
const imageService = new ImageUploadService();
const result = await imageService.uploadAvatar('user_123', '/tmp/upload.jpg');
console.log(result.url); // https://cdn.example.com/avatars/user_123.webp

You see facades everywhere once you start looking. Mongoose is a facade over the MongoDB driver. Prisma is a facade over raw SQL. Express is a facade over Node's built-in HTTP module. Any time you wrap something complicated so the rest of your codebase does not have to think about it, you are building a facade.

Of all seven patterns, this is probably the one I apply most unconsciously. If I notice that multiple parts of my code are doing the same multi-step dance with the same set of subsystems, I pull that dance into a facade and call it a day. It is not glamorous, but it works.

Pick the pattern that fits your problem. Not the one from the blog post you read this morning.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!