System design

Common Node js design patterns

16 min read
#System design

Node.js design patterns are one of the most frequently asked questions in senior-level interviews. In this blog, we will discuss some common Node.js design patterns that are frequently use while working with Node.js. Before moving forward, let's answer what a design pattern is.

What are design patterns?

A design pattern is a general, reusable solution to a common problem that occurs in software design. It is a template or blueprint that can be applied in various programming contexts to solve recurring design issues in a systematic way. Design patterns aren't specific to a particular programming language but represent best practices that are adaptable across different languages.

Node.js offers several design patterns that help developers structure their applications in a scalable, maintainable, and efficient way. In this blog we will explore common node js paterns.

1. Singleton Pattern

Sometimes, we need to make sure that only one instance of the class has been created. Singleton Pattern Ensures that a class has only one instance and provides a global point of access to it.

Consider a database connection. We don’t need more than one database connection for the app at a given time. We Can reuse existing connection through out our app. In these type of scenerio singleton pattern is very useful.

In Node.js, we implement singleton pattern using IIFE modules or by creating ES6 class. Below is the example of a Datbase singleton class. Below, we are creating a database connection only if the database class has not been instantiated. If the database class has been instantiated, we simply return the instance of the database. This is why instance1 === instance2 returns true because it references the same object..

class Database {
  constructor() {
    if (!Database.instance) {
      this.connection = this.createConnection();
      Database.instance = this;
    }
    return Database.instance;
  }
  createConnection() {
    console.log("Creating database connection...");
  }
}
const instance1 = new Database();
const instance2 = new Database();
console.log(instance1 === instance2);  // true

Benefits:

  • Global Access: A convenient way to access shared data or functionality from anywhere in your application.
  • Resource Management: Ensures efficient use of resources like database connections, loggers, or file handles by having only one instance.
  • Consistency: Enforces consistent behavior as modifications affect only a single instance.
  • Controlled State: Simplifies state management by having a central point for data manipulation.

2. Factory Pattern

Factory pattern creates objects without exposing the instantiation logic to the client and refers to the newly created objects through a common interface.

The Factory pattern provides an interface for creating objects but lets subclasses alter the type of objects created. This help us in placing the object creation logic at one place.

Consider it as a manufacturing plant with different assembly lines for producing distinct products. In Node.js, the Factory pattern excels in creating objects without specifying their concrete classes, fostering flexibility and extensibility.

Below, I have provided an example of an animal factory pattern. The AnimalFactory contains the logic for creating the type passed to it. It creates a Dog object if the type is 'dog' and a Cat object if the type is 'cat'.

class Dog {
  speak() {
    return "Woof!";
  }
}
class Cat {
  speak() {
    return "Meow!";
  }
}
class AnimalFactory {
  static createAnimal(type) {
    switch (type) {
      case 'dog':
        return new Dog();
      case 'cat':
        return new Cat();
      default:
        throw new Error("Unknown animal type");
    }
  }
}
const animal1 = AnimalFactory.createAnimal('dog');
console.log(animal1.speak());  // Woof!

Benefits:

  • Decoupling: Client code is decoupled from specific object creation logic, promoting flexibility and maintainability.
  • Centralized Control: You can easily add new object types or modify existing ones without affecting client code, as long as the Factory handles the changes.
  • Flexibility: The Factory can choose the appropriate object based on runtime conditions or configuration, making your code more adaptable.
  • Encapsulation: Object creation details are hidden within the Factory, improving code readability and maintainability.

3. Observer Pattern

The next design pattern, the Observer Pattern, is one of the most widely used design patterns in event-driven architecture. This is used to notify multiple objects about any state changes in an object they are observing. The Observer Pattern is a behavioral design pattern that defines a one-to-many relationship between objects. In this pattern, when one object (the "subject") changes its state, all dependent objects (the "observers") are automatically notified and updated.

The common structure of an Observer pattern is shown in the example code below. The Subject class manages a list of observers and provides methods to add, remove, and notify them. When an observer is added using the addObserver() method, it becomes part of the subject's list of observers. If an observer is no longer needed, it can be removed using the removeObserver() method. When the subject wants to notify all observers about a change, it calls the notifyObservers() method, which sends a message to each observer by invoking their update() method.

The Observer class represents objects that want to receive updates from the subject. Each observer is created with a name, and its update() method prints a message to the console indicating that it received a notification. In the example, two observers, "Observer 1" and "Observer 2," are created and registered with the subject. When the subject calls notifyObservers() with the message "Subject state has changed!", both observers receive the message, and their update() methods log the received message along with their names.

// The Subject class
class Subject {
  constructor() {
    this.observers = [];  // Array of observers
  }
  // Add an observer
  addObserver(observer) {
    this.observers.push(observer);
  }
  // Remove an observer
  removeObserver(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
  // Notify all observers about an event
  notifyObservers(message) {
    this.observers.forEach(observer => observer.update(message));
  }
}
// The Observer class
class Observer {
  constructor(name) {
    this.name = name;
  }
  // Update method to be called when subject notifies
  update(message) {
    console.log(`${this.name} received: ${message}`);
  }
}
// Create a subject
const subject = new Subject();
// Create observers
const observer1 = new Observer("Observer 1");
const observer2 = new Observer("Observer 2");

// Register observers with the subject
subject.addObserver(observer1);
subject.addObserver(observer2);

// Change subject state and notify observers
subject.notifyObservers("Subject state has changed!");

// Output:
// Observer 1 received: Subject state has changed!
// Observer 2 received: Subject state has changed!

In nodejs, this pattern often used for real-time communication, event handling, or messaging systems. Below is the example of observer patern using nodejs event class.

const EventEmitter = require('events');
const emitter = new EventEmitter();

// Subscriber 1
emitter.on('event', () => {
  console.log('Subscriber 1 received the event');
});

// Subscriber 2
emitter.on('event', () => {
  console.log('Subscriber 2 received the event');
});

// Emit an event
emitter.emit('event');

Benefits:

  • Loose Coupling: The subject doesn't need to know details about its observers. This decouples the subject and observers, making the system more flexible.
  • Scalability: You can add any number of observers, and they will all be updated when the subject changes.
  • Modularity: The logic of handling changes is separated from the main business logic of the subject, improving the maintainability of code.

4. Proxy Pattern

The Proxy Pattern provides an object (the proxy) as an interface to another object (the real subject). The proxy controls access to the real object, allowing additional functionality such as lazy initialization, access control, logging, or caching before passing the request to the real object. In below example, we have an expensive object that we want to initialize only when it's needed (lazy initialization).

// Real Subject
class ExpensiveObject {
  constructor() {
    console.log('Expensive Object is being created...');
    this.data = "Some heavy data";
  }

  getData() {
    return this.data;
  }
}

// Proxy
class ExpensiveObjectProxy {
  constructor() {
    this.expensiveObject = null;  // Lazy initialization
  }

  getData() {
    if (!this.expensiveObject) {
      console.log('Creating the expensive object now...');
      this.expensiveObject = new ExpensiveObject();
    }
    return this.expensiveObject.getData();
  }
}

// Client code
const proxy = new ExpensiveObjectProxy();
console.log("First call to getData:");
console.log(proxy.getData());  // Expensive object created here

console.log("\nSecond call to getData:");
console.log(proxy.getData());  // Already created, no reinitialization

In above example proxy (ExpensiveObjectProxy) holds a reference to the real subject (ExpensiveObject), but it doesn't instantiate the object immediately. The proxy delays the creation of the real object until getData() is called for the first time. This is called lazy initialization. On subsequent calls, the proxy checks if the real object has already been created. If it has, it forwards the request directly to the real object without reinitializing it.

In real-world applications, the Proxy Pattern can be used for API rate limiting. The proxy can act as an intermediary to limit the number of API calls made by a client within a certain time frame.

class Api {
  request(endpoint) {
    console.log(`Fetching data from ${endpoint}`);
    // Simulate an API request
    return `Data from ${endpoint}`;
  }
}

class ApiProxy {
  constructor(api) {
    this.api = api;
    this.requestCount = 0;
    this.limit = 5;  // Allow only 5 requests
    this.timeWindow = 10000;  // 10 seconds time window
    this.startTime = Date.now();
  }

  request(endpoint) {
    const currentTime = Date.now();

    if (currentTime - this.startTime > this.timeWindow) {
      // Reset count and time window
      this.requestCount = 0;
      this.startTime = currentTime;
    }

    if (this.requestCount < this.limit) {
      this.requestCount++;
      return this.api.request(endpoint);
    } else {
      return 'API request limit exceeded. Try again later.';
    }
  }
}

// Client code
const api = new Api();
const apiProxy = new ApiProxy(api);

for (let i = 0; i < 7; i++) {
  console.log(apiProxy.request(`/endpoint-${i}`));
}

The Proxy Pattern is useful when:

  • Lazy Initialization: The real object is resource-intensive to create, so the proxy delays the creation until it's needed.
  • Access Control: The proxy can restrict or validate access to the real object.
  • Logging: The proxy can log requests before forwarding them to the real object.
  • Remote Proxy: The proxy can represent a remote object that resides in a different address space (such as another server or a database).

5. Decorator Pattern

This pattern allows behavior to be added to individual objects, without affecting the behavior of other objects from the same class. The main idea behind the Decorator Pattern is to wrap an object with a "decorator" object that adds new behaviors or responsibilities, while still exposing the same interface as the original object.

Key Concepts:

  • Component: The base interface or class that defines the common behaviors that can be extended.
  • Concrete Component: The original object that implements the base interface and will be decorated.
  • Decorator: A class that wraps the concrete component, extending its behavior without modifying its structure.
  • Concrete Decorators: Specific classes that add behavior to the component by wrapping it.

Let's use a coffee order system to explain the Decorator Pattern. The customer orders a base coffee, and then they can add various condiments like milk, sugar, or whipped cream.

// Component: Base Coffee Interface
class Coffee {
  cost() {
    return 5;  // Base cost of the coffee
  }

  description() {
    return "Plain coffee";
  }
}

// Concrete Decorator: Milk
class MilkDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }

  cost() {
    return this.coffee.cost() + 1;  // Milk adds $1 to the cost
  }

  description() {
    return this.coffee.description() + ", with milk";
  }
}

// Concrete Decorator: Sugar
class SugarDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }

  cost() {
    return this.coffee.cost() + 0.5;  // Sugar adds $0.50 to the cost
  }

  description() {
    return this.coffee.description() + ", with sugar";
  }
}

// Concrete Decorator: Whipped Cream
class WhippedCreamDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }

  cost() {
    return this.coffee.cost() + 1.5;  // Whipped cream adds $1.50 to the cost
  }

  description() {
    return this.coffee.description() + ", with whipped cream";
  }
}

// Client code
let myCoffee = new Coffee();
console.log(myCoffee.description() + " costs $" + myCoffee.cost());

// Add milk to the coffee
myCoffee = new MilkDecorator(myCoffee);
console.log(myCoffee.description() + " costs $" + myCoffee.cost());

// Add sugar to the coffee
myCoffee = new SugarDecorator(myCoffee);
console.log(myCoffee.description() + " costs $" + myCoffee.cost());

// Add whipped cream to the coffee
myCoffee = new WhippedCreamDecorator(myCoffee);
console.log(myCoffee.description() + " costs $" + myCoffee.cost());

Output:

Plain coffee costs $5
Plain coffee, with milk costs $6
Plain coffee, with milk, with sugar costs $6.5
Plain coffee, with milk, with sugar, with whipped cream costs $8

In the above example the basic coffee object with a cost() and description() method. Concrete Decorators (MilkDecorator, SugarDecorator, WhippedCreamDecorator) takes an instance of Coffee (or a previously decorated coffee) and wraps it, adding its own functionality (e.g., increasing cost, updating description). The client dynamically combines decorators to customize the object behavior.

Advantages of the Decorator Pattern:

  • Flexibility: Behaviors can be added or removed at runtime, allowing for more flexibility than subclassing.
  • Avoids Class Explosion: Instead of creating multiple subclasses for each combination of behavior, you can create simpler decorators and mix them as needed.
  • Single Responsibility Principle: Each decorator handles a single, specific enhancement, leading to more maintainable and understandable code. Drawbacks:
  • Complexity: The pattern can add complexity to the code, as you are adding multiple objects (decorators) that wrap around the core object.
  • Order Matters: Since decorators are applied dynamically, the order in which they are applied can affect the result, which may lead to unintended behaviors if not handled carefully.

6. Module Pattern

Modular pattern helps in organizing code by grouping related functionality, and it provides a way to mimic the concept of classes and private/public access in a language like JavaScript, which doesn't natively support private members. The goal of the Module Pattern is to maintain clean, modular code by restricting access to parts of the code and exposing only what is necessary. It uses javascript closures to create a private scope.

A basic structure of Module Pattern shown below:

const Module = (function() {
  // Private variable
  let privateVar = 'I am private';

  // Private method
  function privateMethod() {
    console.log('Accessing private method');
  }

  return {
    // Public method
    publicMethod: function() {
      console.log('Accessing public method');
      privateMethod();  // Can access private method
      console.log(privateVar);  // Can access private variable
    }
  };
})();

// Accessing public method
Module.publicMethod();

// Attempting to access private members (this will fail)
console.log(Module.privateVar);  // undefined
Module.privateMethod();  // TypeError: Module.privateMethod is not a function

In the above example, Variables (privateVar) and methods (privateMethod) are declared within the function's local scope, so they aren't accessible outside the function. The publicMethod is exposed via the return object, allowing external access to interact with the module. This method can access private members inside the module.

A real life example: Shopping Cart Module

const ShoppingCart = (function() {
  // Private members
  let cart = [];

  function addItem(item) {
    cart.push(item);
    console.log(`${item} added to the cart.`);
  }

  function getTotalItems() {
    return cart.length;
  }

  function displayCart() {
    console.log('Cart items:', cart);
  }

  return {
    // Public API
    add: function(item) {
      addItem(item);  // Expose the ability to add items
    },
    show: function() {
      displayCart();  // Expose the ability to view the cart
    },
    totalItems: function() {
      return getTotalItems();  // Expose the ability to get the total number of items
    }
  };
})(); 

// Client code
ShoppingCart.add('Apple');
ShoppingCart.add('Banana');
ShoppingCart.show();  // Cart items: ['Apple', 'Banana']
console.log('Total items:', ShoppingCart.totalItems());  // Total items: 2

In the above example the cart array and the methods addItem, getTotalItems, and displayCart are private and cannot be accessed directly. Methods like add, show, and totalItems are exposed through the returned object, which is the public API of the ShoppingCart module.

IIFE (Immediately Invoked Function Expression): The Module Pattern commonly uses an IIFE to create a closure that helps in maintaining privacy. The function is invoked immediately after it's defined, and the returned object becomes the module's public interface.

const Module = (function() {
  // Private members
  return {
    // Public members
  };
})();

ES6 Modules: In modern JavaScript, ES6 introduced native module support. Using ES6 modules provides many of the same benefits as the Module Pattern, including encapsulation and reusability, but without needing to rely on IIFEs.

Here's an example using ES6 syntax cart.js (module file):

let cart = [];

function addItem(item) {
  cart.push(item);
  console.log(`${item} added to the cart.`);
}

function displayCart() {
  console.log('Cart items:', cart);
}

export { addItem, displayCart };  // Export public functions

main.js

import { addItem, displayCart } from './cart.js';  // Import public functions

addItem('Apple');
addItem('Orange');
displayCart();  // Cart items: ['Apple', 'Orange']

Node.js uses the CommonJS module system, allowing you to split your code into separate modules for better maintainability and reusability.

// myModule.js
module.exports = {
  greet: function() {
    console.log("Hello World");
  }
};

// app.js
const myModule = require('./myModule');
myModule.greet();

Advantages of the Module Pattern:

  • Encapsulation: It promotes data hiding and ensures that internal implementation details aren't exposed.
  • Maintainability: By splitting the code into logical modules, it's easier to manage and maintain.
  • Avoids Global Scope Pollution: The Module Pattern keeps variables and methods out of the global namespace, reducing the risk of conflicts.
  • Public/Private Separation: It clearly separates the public API from the private implementation details. Disadvantages:
  • Overhead: The IIFE and closures may introduce some runtime overhead compared to more straightforward approaches like ES6 modules.
  • Testability: Private methods are harder to test since they aren't exposed to the outside.

7. Callback Pattern

Node.js is asynchronous, relying heavily on callbacks to handle asynchronous operations.

const fs = require('fs');

fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

Problems:

  • Can lead to "callback hell" when nested callbacks become too complex.

8. Promise Pattern

Promises provide a cleaner and more readable way to handle asynchronous operations compared to callbacks.

const fs = require('fs').promises;

fs.readFile('file.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err => console.error(err));

Benefits:

  • Avoids callback hell
  • Easier error handling with .catch()
  • Can be further simplified with async/await

9. Async/Await Pattern

A more modern approach for handling asynchronous code, built on top of Promises.

const fs = require('fs').promises;

async function readFile() {
  try {
    const data = await fs.readFile('file.txt', 'utf8');
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

readFile();

Benefits:

  • Synchronous-looking asynchronous code
  • Easier to read and maintain

10. Middleware Pattern

Commonly used in frameworks like Express.js, middleware functions are used to handle requests, responses, and route handling in a modular fashion.

const express = require('express');
const app = express();

const logger = (req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
};

app.use(logger);

app.get('/', (req, res) => {
  res.send('Hello, World!');
});

app.listen(3000);

Benefits:

  • Extensibility of functionality
  • Modular code structure

Conclusion

Node.js design patterns help in building more maintainable and scalable applications. By using these patterns effectively, you can optimize your code for better performance, organization, and reusability.