Editor’s note: The best practices in this Node.js project structure guide were last updated on 5 July 2023 to include steps such as practicing modular code and using the modul model-view-controller pattern.
A good start is half the battle, said someone wiser than me. And I can’t think of any quote that could better describe every developer’s situation when embarking on a new project. Crafting a practical project structure is one of the most challenging aspects of development and a delicate process indeed.
In this tutorial, our focus will be the Node.js project structure and architecture. We’ll cover the fundamentals of application architecture in general and discuss some best practices for organizing your Node.js apps.
- Why project architecture is important
- Best practices for Node.js project structure
- Separating roles and concerns using folder structures
- Practice modular code
- Focus on code readability
- Separate business logic and API routes
- Utilize the MVC pattern
- Use service and data access layers
- Organize configuration separately
- Separate development scripts from the main code
- Use dependency injection
- Conduct unit testing
- Use another layer for third-party services calls
- Use a style guide
- Optimize performance with caching
- Consider serverless architecture
- Use gzip compression
- Use promises and
- Handle errors more often
Why project architecture is important
Having a good starting point when it comes to our project architecture is crucial for the longevity of the project itself and for effectively addressing future changing needs. A poorly designed project architecture often leads to:
- Unreadable and disorganized code, resulting in prolonged development processes and making the product itself more challenging to test
- Unnecessary repetition, making the code harder to maintain and manage
- Difficulties in implementing new features without interfering with existing code
The primary objective of any Node.js project structure is to assist you in:
- Writing clean and legible code
- Creating reusable code components and modules throughout the application
- Avoiding unnecessary repetition
- Incorporating new features seamlessly into the existing code
Best practices for Node.js project structure
Now, we can delve into what I commonly refer to as the application structure flow, which encompasses a set of rules and common practices aimed at enhancing the Node.js developer experience. The best practices outlined below can serve as a cheat sheet to assist you in establishing the ideal architecture flow for your upcoming project.
Separating roles and concerns using folder structures
Everything has to have its place in our application, and the folder structure is the perfect place to start organizing things.
The folder structure of a software project plays a significant role in enforcing the separation of concerns, which is a fundamental principle in software development. The separation of concerns refers to the practice of organizing code and components in a way such that each module or component has a clear and distinct responsibility:
By establishing an organized folder structure, you can group related files and components together, making it easier to locate and manage specific functionalities. Additionally, this organization promotes a clear separation of concerns by keeping different parts of the app separate and independent.
Practice modular code
The next best practice to organize your Node.js project architecture is to break your application into smaller modules, each handling a specific functionality. Following the Single Responsibility Principle (SRP) of SOLID software development to design each module will ensure a single responsibility or purpose, making it easier to understand, test, and maintain.
“A module should be responsible to one, and only one, actor.”
It’s also recommended to minimize the use of global variables as they can lead to tightly-coupled code and make it challenging to identify dependencies. Instead, encapsulate variables within modules and expose only the necessary interfaces.
If the code of a class, function, or file becomes excessively lengthy, consider splitting it into smaller modules wherever possible and bundle them within related folders. This approach helps in grouping related files together and is essential for enhancing code modularity and organization.
Focus on code readability
When writing complex code that is hard to comprehend, it is vital to ensure clarity through improved naming conventions or comments. While comments can be helpful, they are often not updated and can potentially provide outdated information. Therefore, it is advised to opt for descriptive names for the variables, functions, and classes in your code:
Readable code, which is easier to understand, reduces the need for extensive time and effort spent deciphering the code’s intent. This benefit extends not only to your fellow developers but also to your future self.
Separate business logic and API routes
Frameworks like Express.js offer incredible features for managing requests, views, and routes. With such support, it can be tempting to place our business logic directly in our API routes. Unfortunately, this approach quickly leads to the creation of large, monolithic blocks that become unmanageable, difficult to read, and prone to decomposition in the long run.
It’s important to consider how this approach impacts the testability of our application, leading to longer development times as a consequence. At this point, you might be wondering how to tackle this challenge effectively and where to position your business logic in a clear and intelligent manner. Let’s address that in the next point.
Utilize the MVC pattern
When it comes to code organization, implementing the widely-adopted model-view-controller (MVC) pattern in Node.js can help you neatly separate your application’s concerns into three main components: models, views, and controllers:
The Model component represents the data, database interactions, and the business logic of the application. It is responsible for implementing the core business rules and application logic and also focuses on CRUD operations, data validation, and maintaining data integrity.
The view‘s primary responsibility is to present the data to the user and handle UI components. It receives data from the Model via controllers and renders it for the user to interact.
Meanwhile, the Controller serves as an intermediary component that receives user input, updates the model data as needed, and coordinates the interaction between the model and the view. The Controller updates the Model based on user input and ensures that the View displays the updated data.
Use service and data access layers
We can add some additional components or layers in an MVC workflow to specifically manage the business logic and data access.
The service layer is an additional layer introduced to encapsulate the applications’ business logic. It is a collection of classes, methods, and functions that represent the core operations and workflows of our application. The controller delegates complex business logic tasks to the service layer, and the service layer, in turn, interacts with the Model to perform data-related operations.
The data access layer fits into the MVC architecture as an added service layer by acting as a separate component responsible for database interactions:
Both these layers allow for better separation of concerns, eventually improving code organization, maintainability, and scalability in complex applications.
Organize configuration separately
Organizing configuration files in a dedicated directory provides several benefits. It helps centralize and group various configurational settings and credentials, such as database, server, logging, caching, environments, localization, etc. This approach ensures stability and configurational consistency across the application.
Having separately managed configurations also simplifies the identification and isolation of files containing sensitive information. When utilizing version control systems like Git, you can establish appropriate ignore rules to prevent the config folder or specific configuration files from being committed to the repository.
This reduces the risk of accidentally exposing or granting unauthorized access to confidential data. As a result, it promotes collaboration, improved version control, and helps safeguard sensitive information:
Separate development scripts from the main code
Creating a dedicated folder for development scripts in your Node.js project is always a beneficial practice, enabling the organization and breaking down complex commands into smaller, more manageable scripts. This approach ensures a clear separation between the development scripts and the main application code, contributing to better organization and maintainability.
scripts folder not only enhances readability, maintainability, and reusability but also facilitates seamless integration with the build automation tools and task runners, resulting in a streamlined development process:
Use dependency injection
Node.js is packed with an array of incredible features and tools that make our lives easier. However, working with dependencies can often be troublesome due to challenges related to testability and code management.
Fortunately, there is a solution for that called dependency injection. By incorporating dependency injection in your Node.js applications, you can achieve several benefits, as outlined below:
- Streamlined unit testing: Inject dependencies directly into modules for easier and more comprehensive unit testing
- Reduced module coupling: Avoid tight coupling between modules, leading to better code maintainability and reusability
- Accelerated Git flow: Define stable interfaces for dependencies, reducing conflicts and facilitating collaborative development
- Enhanced scalability: Add or modify components without extensive changes to the existing codebase, enabling easier extension and adaptation
- Great code reusability: Decouple components and inject dependencies to reuse modules across different parts of the application or other projects
Let’s understand this with a simple example. Below is a simple example to query a database for user info based on a provided username:
While this approach may seem straightforward, it is not inherently flexible for our code. Consider the scenario where we need to alter this test to use an example database. In such cases, we would have to make changes to the existing code to accommodate this new requirement.
A more flexible alternative is to pass the database as a dependency, instead of casually hardcoding it inside the module. This approach allows for easier adaptation and ensures that the test can seamlessly utilize the desired database:
Remember to consider your project’s specific needs when deciding to use dependency injection. While it introduces some complexity, the benefits it offers can outweigh the initial investment. For smaller projects, the benefits of DI, such as testability, code reusability, and scalability, may not outweigh the added complexity it introduces. In such cases, a simpler approach where dependencies are manually instantiated or passed directly to the modules may be sufficient.
Deciding whether to adopt dependency injection can be a complex topic in itself. However, leveraging DI frameworks or containers to automate dependency resolution can greatly save you time and reduce errors.
Conduct unit testing
Now that we have implemented dependency injection, we can also incorporate unit testing into our project. Testing plays a vital role in application development, as it affects the entire project flow, not just the final result. It helps identify and address issues early, preventing them from slowing down development and causing other problems.
Unit testing is a common approach where code sections are isolated and verified for correctness. In procedural programming, a unit can refer to an individual function or procedure. Typically, unit testing is performed along with development which is commonly known as Test-driven Development, or TDD.
The benefits of unit testing include:
Improved code quality
Unit testing enhances the code quality by uncovering the problems that might have been overlooked before advancing to subsequent stages of development. It enables the identification of edge cases and encourages the creation of overall better code.
Early bug detection
By conducting tests early in the development process, issues are identified earlier. Because the tests are performed at the time of development, bugs can be caught sooner, reducing the time-consuming process of debugging.
Having fewer flaws in the application leads to less time spent on debugging, resulting in cost savings for the project. Time becomes a critical factor as resources can now be allocated toward developing new features for the product.
For more information on unit testing Node.js projects, check out “Unit and integration testing for Node.js apps.” And for a comparison on the best Node.js unit testing frameworks, check out this article.
Use another layer for third-party services calls
Often, in our application, there arises a need to interact with third-party services for data retrieval or performing operations. However, if we fail to separate these calls into a dedicated layer, we may encounter unwieldy code that becomes too difficult to manage due to its size.
A common solution to address this challenge is to employ the pub/sub pattern. This pattern revolves around a messaging mechanism where entities called publishers send messages, while entities called subscribers receive them:
Publishers do not directly program messages to be sent to specific receivers. Instead, they categorize published messages into specific classes, unaware of which subscribers may handle them. Similarly, subscribers express interest in processing one or more classes and exclusively receive messages relevant to their interests, without knowledge of the publishers involved.
The publish-subscribe model facilitates event-driven architectures and asynchronous parallel processing, resulting in enhanced performance, reliability, and scalability. It offers an effective approach for managing interactions with third-party services while improving overall system capabilities.
A code linter is a simple tool that aids in performing a faster and improved development process, helping you catch small errors on the go and ensuring uniformity throughout your application’s code.
The image below illustrates how ESLint analyzes source code for potential errors, stylistic issues, and best practices, simplifying the task of maintaining code uniformity across your codebases:
Use a style guide
Are you still considering which formatting style to adopt and how to maintain consistent code formatting in your projects? You might want to consider leveraging one of the excellent style guides offered by Google or Airbnb.
By adhering to a specific guide, reading code becomes significantly easier, and the frustration of correctly placing that curly brace is eliminated:
Optimize performance with caching
Implementing caching in Node.js apps can significantly improve the performance of your app in terms of responsiveness and speed. In-memory caching libraries like Redis or Memcached are ideal for storing frequently accessed data in memory. Define a cache strategy, such as time-based expiration or Least Recently Used (LRU) to efficiently manage cached data.
Also, consider caching database queries to avoid repeated slow retrievals in complex apps. Consider using Content Delivery Networks (CDNs) for caching static assets, reducing latency, and improving download speeds. Middleware caching can be used for computationally expensive tasks or frequently accessed API routes.
It is advised to implement the cache-aside pattern by checking the cache before fetching data from the original source. Employ cache-busting for clients to access the latest asset version after updates. For complex objects, use object-oriented caching with serialization and deserialization.
When the cache is unavailable or errors occur, ensure graceful degradation and regularly monitor and manage the cache, setting appropriate eviction policies to prevent stale data and excessive memory usage.
Keep in mind that caching is not a one-size-fits-all solution. Carefully choose what to cache and set proper expiration policies. Benchmark and test your caching strategy to verify its performance improvements.
Consider serverless architecture
Serverless architecture, powered by services like AWS Lambda, Azure Functions, and Google Cloud Functions is a suitable choice for certain types of applications. It is particularly well-suited for event-driven, microservices-powered, async-tasks-rich, scalable, and cost-efficient apps.
Despite its effectiveness, serverless tech might not be the best fit for all scenarios due to potential issues with cold start latency, time limits on function execution for long-running processes, increased complexity in apps with intricate workflows and dependencies, and the possibility of vendor lock-in.
It is crucial to carefully consider these factors to decide whether or not serverless is the appropriate choice for your Node.js application.
Use gzip compression
The server can employ gzip compression, a widely used method for compressing files, to effectively reduce their size before transmitting them to a web browser. By compressing files using gzip, the server can significantly decrease their size, resulting in faster transmission over the network.
This improvement in transmission speed leads to enhanced performance, reduced bandwidth usage, and better responsiveness of your web app:
Use promises and
While promises provide a significant improvement over raw callbacks, the
async/await syntax builds upon promises and offers added benefits when it comes to code readability and maintenance. The syntax allows you to write asynchronous code in a more synchronous and linear fashion, making it easier to understand and maintain.
async/await syntax can be a slightly more preferable choice:
Handle errors more often
Encountering unexpected errors or behavior in your app can be unpleasant, and as a natural part of the development process, it’s something that can’t be entirely avoided when writing code.
Taking responsibility for handling errors is crucial as a developer, and it’s important to not only use promises (or
async/await) in our applications but also leverage their error-handling support provided by the promise chaining, or try-catch blocks in an async-await implementation.
Additionally, you can use the
TypeError, and others to manually throw an error at any desired location in your code.
Creating a Node.js application can be challenging. I hope this set of rules helps put you in the right direction when establishing what type of architecture you are going to use and what practices are going to support that architecture.
For more content like this, check out my Twitter.
200’s only Monitor failed and slow network requests in productionDeploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. https://logrocket.com/signup/
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.