Keywords: Express.js | Modular Structure | Application Design
Abstract: This article delves into the structural design of Express.js applications, focusing on the advantages of modular architecture, directory organization principles, and best practices for code separation. By comparing traditional single-file structures with modular approaches, and incorporating specific code examples, it elaborates on how to choose an appropriate structure based on application scale. Key concepts such as configuration management, route organization, and middleware order are discussed in detail, aiming to assist developers in building maintainable and scalable Express.js applications.
Introduction
Express.js, as one of the most popular web frameworks in the Node.js ecosystem, is renowned for its lightweight and flexible nature. However, as applications grow in scale, effectively organizing code structure becomes a critical challenge. Many developers habitually consolidate all configurations, routes, and middleware into a single app.js file, which may suffice for small projects but leads to bloated files and reduced maintainability in medium to large applications. Based on community best practices, this article explores a modular application structure design aimed at enhancing code readability, testability, and scalability.
Application Scale and Structure Selection
The structure of an application should be flexibly adjusted according to its scale. For small applications, simple file organization, such as placing a few .js files in the project root, is sufficient. For medium-sized applications, this article recommends a modular directory structure that separates functionalities into distinct files and directories. This structure not only facilitates team collaboration but also reduces coupling through clear module boundaries. For large applications, further extracting subsystems into independent npm packages is a superior approach, aligning with the Node.js community's philosophy of "small modules."
Principles of Modular Structure Design
The core of modular design lies in adhering to a set of fundamental principles to ensure code is easy to understand and maintain. First, mental manageability requires decomposing complex systems into smaller chunks via directory structures, allowing developers to focus on specific functional modules. Second, size appropriateness emphasizes avoiding "mansion directories"—that is, not creating overly deep directory hierarchies for a small number of files. For instance, if a directory contains only one file, consider merging it into the parent directory. Third, balancing modularity and practicality: encourage extracting reusable code into modules, but for code not yet large enough to warrant an independent package, treat it as a "proto-module" with a clear upgrade path. Some community practices even include placing package.json files in app/node_modules directories to simplify future extraction.
Other key principles include: ease of code location, achieved through meaningful naming and timely removal of redundant code to ensure developers can quickly find relevant files; search-friendliness, by placing all first-party code in an app directory for easy searching with tools like grep; and simple naming conventions, using kebab-case for filenames (e.g., user-model.js) and camelCase for JavaScript variable names (e.g., userModel), complying with npm package naming requirements and avoiding case sensitivity issues across platforms.
Grouping by Coupling Rather Than Function
Unlike traditional Ruby on Rails MVC structures (e.g., app/views, app/controllers, app/models), this article suggests grouping files by feature. For example, in a user management feature, all related files (such as models, controllers, and views) should be placed in the same directory (e.g., app/users). This grouping is based on the principle of "coupling": when modifying a feature, multiple closely related files often need changes simultaneously, and colocating them reduces cross-directory operations, improving development efficiency. It supports architectural styles like MVC or MOVE (Models, Operations, Views, Events) but avoids the inconvenience of scattered files.
Route organization should also follow this principle. Instead of a single routes.js file, distribute routes across functional modules. For instance, user-related routes are defined in the app/users directory, while deal-related routes are in app/deals. This way, each route file focuses only on its owned functionality, reducing the complexity of a global routes file. Although Rails-style centralized route files provide an overview, feature-based grouping is more maintainable in practice.
Testing and Code Organization
Placing test files in the same directory as their corresponding code files is a key aspect of modularity. For example, the test file for foo.js can be named foo.tape.js and located in the same place. This approach avoids path confusion in traditional test directories (e.g., excessive use of ../../../) and simplifies configuration for editors and build tools. Using filesystem glob patterns (e.g., find . -name '*.tape.js') allows easy execution of all tests, enhancing the development experience.
Event-Driven Architecture and Decoupling
In modular structures, reducing cross-module coupling is crucial. For example, when creating a new deal, avoid embedding email-sending logic directly in the route, as it can lead to messy code. Instead, adopt an event-driven architecture: the deal model emits a "create" event, and side effects like email sending are handled by independent event processors. This design allows user-related code to be fully isolated in the app/users directory, maintaining module purity and testability.
Express.js Specific Practices
In Express.js, certain common practices require special attention. First, avoid using app.configure: this function is redundant in most scenarios and prone to misuse. Configuration should be passed via explicit options rather than relying on environment variables. Second, the order of middleware and routes is critical: Express.js, inspired by Sinatra, executes code in the order it is written. Incorrect route ordering is a common source of issues. The recommended sequence is: critical application-wide middleware (e.g., logging), all routes and route-specific middleware, and finally error-handling middleware. Avoid global use of app.use for middleware needed only by a few routes (e.g., body-parser) to minimize performance overhead and potential conflicts.
For modular routes, ensure proper loading and ordering in the main file (e.g., app.js). For example, load route modules in a loop:
const routes = ['users', 'deals', 'api'];
routes.forEach(route => {
require(`./app/${route}/routes`)(app);
});This code dynamically loads files like app/users/routes.js and app/deals/routes.js, passing the app instance to register routes.
Configuration Management
Configuration should be centralized but avoid global dependencies. It is recommended to read environment variables (e.g., NODE_ENV) in app/config.js and generate configuration objects. Other modules should receive configuration options via constructor parameters or function arguments, not by directly accessing environment variables. For instance, an email module should accept an option like { deliver: 'smtp' } rather than checking NODE_ENV. This design enhances module testability and flexibility.
Resources like database connections should be created at application startup and passed to relevant modules. For example:
// app/config.js
module.exports = {
db: {
url: process.env.DB_URL || 'mongodb://localhost:27017/app_dev'
}
};
// app/server.js
const config = require('./app/config');
const db = require('./app/database')(config.db);
const userModel = require('./app/users/user-model')(db);This code retrieves the database URL from configuration, initializes the database connection, and passes it to the user model, implementing dependency injection.
Intra-Project Module Referencing Techniques
In modular structures, avoiding verbose relative paths (e.g., require('../../../config')) is key. An effective method is to create a symbolic link in node_modules:
cd node_modules && ln -nsf ../app .Then, add the symbolic link node_modules/app to Git (using git add -f node_modules/app), while keeping node_modules in .gitignore. This allows module references to be simplified as:
const config = require('app/config');
const userModel = require('app/users/user-model');This method mimics the referencing of external npm modules, improving code readability. Note that Windows users should use relative paths as an alternative.
Internal Organization of Code Files
Each JavaScript module file should be clearly segmented: the opening section for CommonJS require statements to declare dependencies; the main section for pure JavaScript code, avoiding references to exports, module, or require; and the closing section for setting up exports. For example:
// Dependency declarations
const util = require('util');
const events = require('events');
// Main code block
function UserModel(db) {
this.db = db;
}
UserModel.prototype.save = function(user) {
return this.db.insert(user);
};
// Export setup
module.exports = UserModel;This structure ensures modularity and maintainability of the code.
Conclusion
The modular Express.js application structure significantly improves code quality through separation of concerns, feature-based grouping, and event-driven design. Developers should choose an appropriate structure based on application scale, following the principle of "principles over convention" and avoiding blind imitation of other frameworks. The practices discussed in this article have been validated in multiple medium-sized projects, effectively supporting team collaboration and long-term maintenance. In practice, adjust directory hierarchies and module granularity according to project needs to achieve an optimal balance.