DEV Community

Cover image for Design Patterns in Node.js
Vinay Madan
Vinay Madan

Posted on

Design Patterns in Node.js

Node.js is a popular JavaScript runtime that allows developers to build scalable network applications using an event-driven, non-blocking I/O model. Like any sophisticated framework, Node.js applications can benefit from using proven design patterns to promote code reuse, maintainability and robustness. This article will provide an overview of some of the most useful design patterns for Node.js development
Introduction to Design Patterns
Design patterns are tried-and-true solutions to recurring problems that software developers encounter during their coding journey. They provide a structured approach to solving challenges and promote best practices in software architecture. By incorporating design patterns, developers can create more robust, maintainable, and extensible codebases.

Why Design Patterns Matter in Node.js

Node.js, known for its non-blocking event-driven architecture, presents unique challenges and opportunities in software design. Applying design patterns tailored to Node.js can lead to more efficient and optimized applications. Let’s explore some key design patterns that are particularly valuable in the Node.js ecosystem:

Singleton Pattern

The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. In Node.js, where modules can be cached and shared across the application, using the Singleton pattern can help manage resources effectively. For instance, a database connection pool can be implemented as a singleton to prevent resource wastage.

class Database {
  constructor() {
    this.connection = null;
  }

  static getInstance() {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance; 
  }

  connect() {
    // connect to database
    this.connection = 'Connected'; 
  }
}

const db1 = Database.getInstance();
const db2 = Database.getInstance();

console.log(db1 === db2); // true

db1.connect(); 

console.log(db1.connection); // 'Connected'
console.log(db2.connection); // 'Connected'

Enter fullscreen mode Exit fullscreen mode

The key points are:

  • The constructor is made private to prevent direct instantiation.
  • The static method getInstance() creates an instance if it doesn't exist yet, and returns it. This ensures only one instance is created.
  • The instances db1 and db2 refer to the same object.
  • When db1 connects, db2 also gets the connection because they are the same object.
  • This ensures there is only one database instance and prevents duplicating connections. The Singleton pattern is useful for situations where only one instance of a class should exist.

Factory Pattern

The Factory pattern offers a way to create objects without specifying the exact class of object that will be created. In a Node.js context, this can simplify object creation, especially when dealing with asynchronous operations such as reading files or making API calls. By abstracting object creation, the Factory pattern enhances code readability and reusability.

class Car {
  constructor(model, price) {
    this.model = model;
    this.price = price;
  }
}

class CarFactory {
  createCar(model) {
    switch(model) {
      case 'civic':
        return new Car('Honda Civic', 20000);
      case 'accord':  
        return new Car('Honda Accord', 25000);
      case 'odyssey':
        return new Car('Honda Odyssey', 30000);
      default:
        throw new Error('Unknown model');
    }
  }
}

const factory = new CarFactory();

const civic = factory.createCar('civic');
const accord = factory.createCar('accord');

console.log(civic.model); // Honda Civic 
console.log(accord.model); // Honda Accord
Enter fullscreen mode Exit fullscreen mode

The key points are:

  • The CarFactory class handles object creation logic.
  • The createCar() method returns a Car instance based on the model.
  • The client code uses the factory instead of direct constructor calls.
  • This abstracts away the object creation logic and allows easy extension of supported models. The Factory pattern is useful when there is complex object creation logic that should not be coupled to client code.

Observer Pattern

Node.js’s event-driven nature aligns well with the Observer pattern. This pattern involves a subject that maintains a list of its dependents, called observers, and notifies them of any state changes. In the context of Node.js, this can be leveraged to build event-driven systems, such as real-time applications and chat applications.

class Subject {
  constructor() {
    this.observers = []; 
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter(o => o !== observer);
  }

  notify(data) {
    this.observers.forEach(o => o.update(data));
  }
}

class Observer {
  constructor(name) {
    this.name = name;
  }

  update(data) {
    console.log(`${this.name} received ${data}`);
  }
}

const subject = new Subject();

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

subject.subscribe(observer1);
subject.subscribe(observer2);

subject.notify('Hello World');
// Observer 1 received Hello World 
// Observer 2 received Hello World

subject.unsubscribe(observer2);

subject.notify('Hello Again');
// Observer 1 received Hello Again
Enter fullscreen mode Exit fullscreen mode

The key points are:

  • The Subject maintains a list of observers.
  • The observers subscribe and unsubscribe to the subject.
  • When notify() is called, the subject updates all subscribed observers.
  • This allows publishing updates to multiple objects without coupling the publisher to the subscribers. The Observer pattern is useful for event handling and asynchronous workflows.

Middleware Pattern

Node.js’s middleware architecture is widely used for handling requests and responses in web applications. The Middleware pattern involves a chain of functions that process a request sequentially. Each function can modify the request or response before passing it to the next function in the chain. This pattern enhances modularity and allows developers to plug in various functionalities without tightly coupling them.

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

const logger = (req, res, next) => {
  console.log('Logged');
  next();
}

const authenticate = (req, res, next) => {
  // authenticate user 
  next(); 
}

app.use(logger); 
app.use(authenticate);

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

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The key points are:

  • The middleware functions logger and authenticate wrap the route handler.
  • They can execute logic before and after the route.
  • The next() function passes control to the next middleware.
  • app.use() mounts the middleware globally.
  • This allows decomposing request handling into smaller reusable units. The middleware pattern is very common in Express and other Node.js frameworks for things like logging, authentication, etc.

Some other examples of middleware are body parsers, compression, rate limiting, and more. The pattern allows building the request pipeline in a modular fashion.

Module Pattern

The module pattern is one of the most basic but fundamental patterns in Node.js. It allows you to organize your code into separate files or modules that encapsulate specific functionality.

// counter.js

let count = 0;

const increment = () => {
  count++;
}

const decrement = () => {
  count--;
}

const get = () => {
  return count; 
}

module.exports = {
  increment,
  decrement,
  get  
};

// app.js

const counter = require('./counter');

counter.increment();
counter.increment();

console.log(counter.get()); // 2

counter.decrement();

console.log(counter.get()); // 
Enter fullscreen mode Exit fullscreen mode

The key points are:

  • The module counter.js exports some functions that operate on the private count variable.
  • The functions encapsulate the logic and data within the module.
  • app.js imports the module and uses the public API.
  • This pattern provides data encapsulation and only exposes a public API. The module pattern is very common in Node.js to organize code into reusable and portable modules.

Some other examples are middleware modules, utility libraries, data access layers etc. The pattern helps manage dependencies and hide implementation details.

Decorator Pattern

Decorators dynamically add new functionality to objects without affecting other instances. This is ideal for extending core modules in Node.

class Car {
  constructor() {
    this.price = 10000;
  }

  getPrice() {
    return this.price;
  }
}

class CarOptions {
  constructor(car) {
    this.car = car;
  }

  addGPS() {
    this.car.price += 500;
  }

  addRims() {
    this.car.price += 300; 
  }
}

const basicCar = new Car();

console.log(basicCar.getPrice()); // 10000

const carWithOptions = new CarOptions(basicCar);

carWithOptions.addGPS();
carWithOptions.addRims();

console.log(carWithOptions.car.getPrice()); // 10800
Enter fullscreen mode Exit fullscreen mode

The key points are:

  • CarOptions wraps the Car class and extends its behavior.
  • Methods like addGPS() modify the state of the wrapped Car.
  • The client has a decorated instance of Car with added functionality.
  • This allows extending behavior dynamically at runtime. The Decorator pattern is useful for abstraction and not having to subclass just to add small features.

Some other examples are authenticated routes, logging wrappers, caching decorators etc. The pattern provides a flexible way to adhere to the Open/Closed principle in Node.js applications.

Dependency Injection pattern

Dependency injection is a pattern where modules or classes receive dependencies from an external source rather than creating them internally. It helps with decoupling, testing, and reusability.

// service.js
class Service {
  constructor(db, logger) {
    this.db = db;
    this.logger = logger;
  }

  async getUser(userId) {
    const user = await this.db.findUserById(userId);
    this.logger.log(`Fetched user ${user.name}`);
    return user;
  }
}

// app.js
const Database = require('./database'); 
const Logger = require('./logger');

const db = new Database();
const logger = new Logger();

const service = new Service(db, logger);

service.getUser(1);
Enter fullscreen mode Exit fullscreen mode

The key points are:

  • The Service class declares dependencies via the constructor.
  • The calling code injects the actual dependencies like db and logger.
  • This decouples Service from concrete dependencies.

Benefits:

Loose coupling between modules
Easier testing by mocking dependencies
Ability to swap implementations
The dependency injection pattern is commonly used with Node.js frameworks like NestJS. It enables better code organization and reusability.

Promise pattern
Promises are a pattern for asynchronous programming in Node.js. They represent the eventual result of an async operation. Here is a simple example:

const fetchData = new Promise((resolve, reject) => {
  // async operation
  const data = getDataFromDatabase();

  if (data) {
    resolve(data); 
  } else {  
    reject('Error fetching data');
  }
});

fetchData
  .then(data => {
    // handle successful data
  })
  .catch(err => {
    // handle error  
  });
Enter fullscreen mode Exit fullscreen mode

The key aspects are:

  • A Promise takes a callback with resolve and reject functions.
  • The async operation is started inside the callback.
  • resolve(data) returns the data on success.
  • reject(error) returns the error on failure.
  • Consumers use .then() and .catch() to get the result.

Benefits:

Avoid callback hell in async code
Standardized way to handle async results
Ability to chain and compose promises
Promises are integral to modern Node.js development and enable writing clean asynchronous code. They power libraries like axios, core APIs like fs.promises, and more.

Implementing Design Patterns
Now that we’ve explored some key design patterns that align with Node.js’s strengths, let’s dive into how to implement them effectively:

  1. Understanding Context
    Before applying any design pattern, it’s crucial to understand the context of your application. Consider factors such as the application’s requirements, scalability needs, and the specific challenges you’re trying to address. Design patterns are not one-size-fits-all solutions; they should be tailored to fit the unique characteristics of your project.

  2. Modularization
    Node.js encourages modularization through its module system. When implementing design patterns, strive to keep modules small, focused, and single-responsibility. This promotes code reusability and maintainability, making it easier to swap out or enhance specific functionalities without affecting the entire application.

  3. Asynchronous Patterns
    Given Node.js’s asynchronous nature, it’s essential to choose design patterns that align with asynchronous programming paradigms. Patterns like the Observer pattern and the Middleware pattern naturally fit into the asynchronous environment, allowing developers to handle events and asynchronous operations seamlessly.

Conclusion

Design patterns enable Node.js developers to write organized, flexible and robust code. Leveraging proven patterns like factories, decorators and singletons allows you to build large-scale applications that are easy to maintain and extend over time. Understanding how to apply design principles is key for mastering advanced Node development.

Top comments (0)