Modular Monolith with Node.js: A Lifesaver for Projects Before Considering Microservices

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Real-world Issues: When the “Monolith” Becomes a Mess

Node.js developers are likely familiar with this scenario: A project starts with just a few CRUD features and runs incredibly smoothly. But after about a year, as the team grows from 2 to 10 people and features expand, the codebase begins to turn into a “Big Ball of Mud”.

I once directly handled a project with about 50,000 lines of code and over 60 API endpoints. Initially, it was a simple sales management app. However, after just a few months, the logic for the Order module became tightly intertwined with Inventory and Promotion. A small bug fix in the pricing logic could crash the email notification or inventory deduction features. The dev team became afraid to touch each other’s code. Code merge sessions lasting 2-3 hours due to file conflicts became a daily occurrence.

When stuck, the natural reflex is often: “Why don’t we just split it into Microservices?”. But hold on. Microservices aren’t a magic wand. If you don’t know how to organize your code, splitting it up will only turn a “centralized mess” into a “distributed mess”. You’ll then face additional burdens like infrastructure overhead, network latency, and the extremely complex challenge of distributed data.

Why Does Our Code Become Uncontrollable?

After dissecting the problem, I realized the fault didn’t lie with the Monolith itself. The real issue was that we lacked clear boundaries between features.

  • Tight Coupling: Module A directly calls internal functions of Module B, or even “pokes” directly into another module’s database tables to fetch data.
  • Indiscriminate Database Sharing: Every module queries the same set of tables. Renaming a single column in the User table could instantly break the Reporting module.
  • Outdated Folder Structure: Organizing folders by controllers/, services/, and models/ scatters the logic. To understand a single feature, you have to jump between 4-5 different folders.

Three Common Paths When a System Expands

Typically, teams face three main choices:

  1. Endure the pain: Keep layering Unit Tests on top. However, with a messy structure, writing tests becomes a nightmare because you have to mock too many related components.
  2. Jump straight to Microservices: Split every module into its own repo and server. This approach is resource-intensive and requires excellent DevOps skills to operate CI/CD, Kubernetes, or Service Mesh.
  3. Modular Monolith: This is a balanced yet effective solution. The system still runs within a single Node.js process and one database, but the code is completely separated logically.

How to Properly Implement Modular Monolith in Node.js

In this architecture, each module acts as an independent unit. Module A doesn’t need to know how Module B executes; it only interacts through a predefined “gateway”.

1. Organize Folders by Domain (Business Logic)

Instead of splitting by technical layers, group by functionality. Each module folder will contain everything from its own Routes and Controllers to its Repositories.

src/
  modules/
    users/
      index.js          # Single communication gateway (Public API)
      users.controller.js
      users.service.js
      users.model.js
    orders/
      index.js
      orders.service.js
    catalog/
      ...
  shared/               # Shared utilities (Logger, DB client)
  app.js                # Server entry point

2. Establish Boundaries Using a “Public API”

The golden rule here is: Only access a module through its index.js file. Every other file inside should be considered internal (private).

Suppose the orders module needs customer information; it must never require('../users/users.service') directly. Instead, the users module will expose the necessary functions in its index file.

// src/modules/users/index.js
const userService = require('./users.service');

module.exports = {
  getUserInfo: async (userId) => {
    return await userService.getById(userId);
  }
};

3. Inter-module Communication via Events

To minimize dependencies, leverage Node.js’s EventEmitter. When an order is successfully paid, the orders module simply emits a signal.

// src/modules/orders/orders.service.js
const eventBus = require('../../../shared/event-bus');

async function completeOrder(orderId) {
  const order = await db.orders.update(orderId, { status: 'paid' });
  // Emit event, regardless of who handles it
  eventBus.emit('ORDER_PAID', { orderId: order.id, customerId: order.customerId });
}

At this point, the inventory module will listen to deduct stock, and the email module will listen to send the invoice. The orders module is now completely “blind” to the existence of other modules.

4. Database Management: Virtual Boundaries

Even if you share a single PostgreSQL or MongoDB database, stipulate that each table belongs to only one specific module. If Module A wants data from Module B, it must call Module B’s service instead of using a direct JOIN in SQL.

A painful lesson learned when refactoring a 50K-line project: you must have full Unit Test coverage for query functions before splitting tables. Otherwise, searching for “backdoor” queries hidden deep in the code will exhaust you.

Why Should You Adopt This Model Today?

  • Easier Testing: You can write tests for each module without worrying about the logic of other parts.
  • Effective Teamwork: Team A handles Users, Team B handles Orders. The two groups rarely touch each other’s code, significantly speeding up release cycles.
  • Clear Path to Microservices: When the orders module becomes overloaded, you just need to move the entire modules/orders folder to a new repo. Since 90% of the logic is already encapsulated, you only need to change function calls to HTTP or gRPC calls.

Conclusion

Don’t chase trendy keywords like Microservices if your system doesn’t truly need complex distribution. Modular Monolith is a smart choice to keep a project organized while maintaining rapid development speed.

Start by grouping files by function and establishing strict communication boundaries. Good luck building Node.js systems that are both lean and scalable!

Share: