~ 8 min read

The CJS module system, globals and other hardships with maintainable code in Node.js

share this story on
What are some common anti patterns and signs of tight coupling in a Node.js codebase and the challenges they present? Let's unfold some messy code and learn how the CJS module system and the use of globals has to do with it.

In the next set of examples we will review some common scenarios of tight coupling in Node.js applications and the challenges they present.

These are pretty common code pattern I’ve seen in many Node.js “seed” applications - source code repositories that are meant to provide you with a starting point for your application. I’ve also seen many Node.js tutorials and blog posts that follow this code pattern for database access in their examples.

In this article, we will review the following prime examples of tight coupling in a Node.js codebase:

  • Using global variables: Global variables are a common source of tight coupling in Node.js. When a variable is declared as global, it is accessible to all parts of the application. This can make it difficult to track down the source of a problem and can make it difficult to test the application.

  • Hardcoding dependencies: Hardcoding dependencies means specifying the dependencies of a class or function explicitly. This can make it difficult to change the dependencies of the class or function without modifying the code.

  • Using singleton patterns: Singleton patterns create a single instance of a class that is accessible to all parts of the application. This can make it difficult to test the class and can make it difficult to change the implementation of the class without affecting other parts of the application.

Scenario 1: hard-wired database dependency in a repository

In the following example, we have a UserRepo class that is responsible for fetching users from a database. The UserRepo class is tightly coupled to the database connection:

// FILE: user.repository.js
// ========================
const db = require('./db')

export class UserRepo {
  constructor () {};

  getUsers (): Promise<User[]> {
    return db.query('SELECT * FROM users');
  };
};

A classic follow-up usage of this repository is when a service or a controller requires the user.repository.js file and uses it to fetch users from the database. Here’s an example of a Fastify route definition that does so:

// FILE: user.route.js
// ========================
import { UserRepo } from './user.repository.js'

fastify.get('/users', async (request, reply) => {
  const userRepo = new UserRepo();
  const users = await userRepo.getUsers();
  return reply.status(200).json({ users });
});

What is the problem with the above code?

Requiring, or using, the UserRepo class impoliticly requires the database connection that is hard-wired in the UserRepo class. This means that if we want to change the database part of the repository, we will have to change the UserRepo class as well, or find ways around it.

Next, you are faced with the following test code example for the Fastify route:

// FILE: test.js
// ========================
const test = require("node:test");
const assert = require("node:assert");

test("users route returns a list of users ", async (t) => {

    const fastify = require("fastify")();
    const route = require("./user.route.js");

    fastify.register(route);

    const response = await fastify.inject({
        method: "GET",
        url: "/users",
    });

    assert.strictEqual(response.statusCode, 200);
});

For this test to run successfully it needs a database due to the hard-wired database dependency in the UserRepo class. However, it’s common to avoid requiring the real the database connection as-is bootstrapped with the application for different reasons:

  1. the database connection is configured to use your local testing database, where-as you want to configure another database for executing tests.
  2. you want to skip the database connection altogether, and use a mock database connection instead.

What is the common solution to work around the hard-wired database dependency?

  1. Using packages like sinon, proxyquire or rewire to change the db dependency in Node.js internal module cache that exchanges the db.js file with a mock of the database.
  2. Spinning up a real database for the test using Docker containers or other means.

Scenario 2: Global modules in Node.js

The CommonJS module system in Node.js acts as a singleton. This means that when you require a module, the module is cached and the same instance of the module is returned every time you require it.

This is a common pattern in Node.js applications, where a module is required once and then used throughout the application. For example, a database connection module is required once and then used throughout the application to execute queries against the database.

However, this pattern proves again to be problematic and is a source of tight coupling in Node.js applications. Let’s untangle the problem with an example that revolves around configuration.

The CJS module system is an example of a module system that can lead to tight coupling. In the cjs module system, modules are loaded as global objects. This means that any module that imports another module will have direct access to the exported objects of the imported module. This can make it difficult to test the modules and can make it difficult to change the implementation of the modules without affecting other modules.

In this example Node.js application, we have a route that returns the weather for a city. The route uses a service to make an API call, and the service relies on accessing a configuration module to receive the URL for the API call.

Here’s the Fastify route definition which makes use of the getWeather call:

// FILE: route.js
// ========================
const { getWeather } = require("./service");

async function routes(fastify, options) {
  fastify.get("/", async (request, reply) => {
    const city = request.query.city;
    const weather = await getWeather({ city });
    return reply.send({ weather });
  });
}

module.exports = routes;

The getWeather function is defined in the service.js file which accesses the configuration module to receive the URL for the API call:

// FILE: service.js
// ========================
const config = require("./config");
const weatherApiUrl = config.WEATHER_API_URL;

module.exports.getWeather = async ({ city }) => {
  // in reality this is an API call to a weather service
  // but for the sake of tests we will just print a console log
  // that shows the API call that is being made
  console.log(`making an API call to ${weatherApiUrl} for ${city}`);

  const weatherDatabase = {
    Seattle: "Rainy",
    Singapore: "Sunny",
  };

  const weather = weatherDatabase[city] || "Unknown";

  return weather;
};

Lastly, the configuration module is defined in the config.js file which is a highly common pattern I’ve seen in many Node.js applications - a configuration module that creates a configuration from static json files, environment variables or other means and then export an object that is created once and then used throughout the application.

The config.js file:

// FILE: config.js
// ========================
const config = {
  WEATHER_API_URL: "https://api.example.com/weather",
  PORT: 4000,
};

module.exports = config;

Now, consider the following test code for the Fastify route:

// FILE: test.js
// ========================
const test = require("node:test");
const assert = require("node:assert");


test("the weather API should return the current weather but don't call the real API because its expensive", async (t) => {
  const config = require("./config");
  config.WEATHER_API_URL = "https://api.example.com/FAKE-WEATHER-API";

  const fastify = require("fastify")({ logger: false });
  const route = require("./route");

  fastify.register(route);

  const response = await fastify.inject({
    method: "GET",
    url: "/?city=Seattle",
  });

  // use the assert module to verify the server's response
  assert.strictEqual(response.statusCode, 200);
  assert.strictEqual(response.payload, '{"weather":"Rainy"}');

  fastify.close();
});

In this test, we don’t want to make an actual API call to the weather service, because it’s expensive. Instead, we want to mock the API call and return a fake weather response.

However, if you run the test, you’ll see that it still logs the API call to the real weather service with a log entry in the test suite as follows:

Making an API call to https://api.example.com/weather for Seattle

What is actually happening is not entirely config.js fault.

The service.js file, is a module too, and is also globally cached within the Node.js internal module cache. Taking a quick look again at the service.js file, we can see that the weatherApiUrl constant is defined in the global code of the file:

const config = require("./config");
const weatherApiUrl = config.WEATHER_API_URL;

module.exports.getWeather = async ({ city }) => {}

As you can see, we’ve accessed the configuration and defined the API URL constant within the global module code of the service.js file which means that now the weatherApiUrl is cached too.

The “module” terminology in Node.js is quite confusing here because of the singleton pattern that the CommonJS module system adheres to which creates an effect of “globals” in the application.

What to do next to avoid tight coupling in Node.js applications

It’s easy to give in and write quick and functional code that works, but it’s much harder to write code that is maintainable and easy to change. Your future self will thank you for writing code that is more cohesive, testable, and over well well designed.

Strive to write code that is loosely coupled. This means that the code should be easy to change without affecting other parts of the application. This is a common principle in software engineering and is often referred to as the Open/Closed Principle. The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

Those two words that have been lingering in your head for the length of this article - dependency injection - are the key to writing maintainable code in Node.js applications, but that is a whole other topic for another article.