30 Jan 2024 · Software Engineering

    How to Organize All Your Routes in a Single Layer in Node.js

    16 min read
    Contents

    Ever get lost in the dozens of routes defined by your Express server? When the project grows big, it is common to lose track of the endpoints exposed by your backend. The reason is that the endpoint logic and its association with a route happen in the same place, leading to messy code. This is where the Node.js routing layer comes to the rescue!

    This approach involves splitting the API business logic from the routing logic. All route-handler functions will be located in one place and all routes in another. Separating these two elements leads to several benefits, such as improved maintainability and middleware management.

    In this article. you will discover why you need this layer in your Express architecture and see how to implement it in a step-by-step tutorial.

    Take your Node.js backend to the next level!

    What is a Node.js routing layer?

    In Node.js, a routing layer is the part of the backend application that contains the routing logic for all its endpoints. To understand the rationale behind this idea, let’s consider an example. This is how an API is usually associated with its route:

    app.get("/welcome/hello-world", (req, res) => {
        res.json({
          message: "Hello, World!",
        });
    });

    The goal of these lines is to associate the function that contains the API’s business logic with a route. When the user makes a request with the right HTTP method to that route, Express will call the associated function and return the produced response.

    The problem with this approach is that as the application gets larger it becomes difficult to navigate the codebase and keep track of all the routes exposed by the backend. A common solution is to split the route definitions into several controller files. However, this approach does not address the elephant in the room. Routing and business logic are two different things!

    The routing layer in Node.js tackles exactly this. It allows you to separate the controller from the routes for better maintainability and code organization. The idea is to move the route-handler functions in controller files as follows:

    const WelcomeController = {
      helloWorld: (req, res) => {
        res.json({
          message: "Hello, World!",
        });
      },
      // other route-handler functions...
    };

    Then, associate them with their routes in the router files as below:

    const router = new Router();
    
    router.get("/welcome/hello-world", WelcomeController.helloWorld());
    // other routes...

    These types of files should be stored in the controllers/ and routers/ folders of your architecture, respectively. In a real-world scenario, this is what those two folders may contain:

    ├── controllers/
        │    ├── authentication.js
        │    ├── ...
        │    ├── membership.js
        │    ├── ...
        │    └── welcome.js
        ├── routes/
             ├── authentication.js
             ├── ...
             ├── membership.js
             ├── ...
             └── welcome.js

    Note that, for each controller file, there is a corresponding router file.

    A routing layer consists of all the files in the routes/ folder. In particular, it contains all the routing logic of a Node.js backend.

    Let’s now look at the advantages of organizing all Node.js routes in a single layer.

    Why your Express backend needs a layer for handling routes

    There are several benefits that an Express routing layer brings to the table. See the three most important reasons why you should add it to your backend architecture.

    Increased maintainability and readability

    Having all Node.js routing logic in the same place makes the application much more organized and easier to maintain. Why? Consider what happens when you want to change your "api/v1/greetings/hello-world" endpoint.

    Without the routing layer, you would have to use your IDE’s search functionality to find the right line of code in the entire codebase. With the routing layer, it all comes down to opening the greetings.js route file in routes/ below and clicking on the route-handler function to access it in the IDE:

    const { Router } = require("express");
    const { GreetingController } = require("../controllers/greetings.js");
    
    const router = new Router();
    
    router.get("/api/v1/greetings/hello-world", GreetingController.helloWorld);
    router.get("/api/v1/greetings/users/:userId", GreetingController.helloUser);
    
    module.exports = { router };

    Another great advantage of this approach is that it makes your code easier to read. In the above snippet, it is quite easy to follow the logic as everything is so clean.

    Better middleware management

    Most of your endpoints are likely to use one or more Express middlewares. This is how you can specify middleware functions when defining an endpoint in a traditional Node.js application:

    app.get("/api/v1/tasks", loggingMiddleware, authenticationMiddleware, async (req, res) => {
        // retrieve all tasks from the database
        const tasks = await Task.findAll();
    
        res.json({
          tasks: tasks,
        });
    });

    Now, imagine these 8 lines of code nested in a file with hundreds of other lines of code. You could easily miss that the "/api/v1/tasks" API relies on the loggingMiddleware and authenticationMiddleware middlewares. So, you could call that endpoint and not understand why it logs some data in the console and the API is protected by authentication.

    Now, consider the equivalent line of code in a route file of the routing layer:

    router.get("/api/v1/tasks", loggingMiddleware, authenticationMiddleware, TaskController.getTasks());

    Here, it is much easier to keep track of what the middleware functions in use.

    If you take a look at an entire Node.js path file, you can see all the used middlewares at a glance:

    const { Router } = require("express");
    const { loggingMiddleware, authenticationMiddleware, bodyParserMiddleware } = require("../middlewares.js");
    const { TaskController } = require("../controllers/tasks.js");
    
    const router = new Router();
    
    router.get("/api/v1/tasks", loggingMiddleware, authenticationMiddleware, TaskController.getTasks());
    router.post("/api/v1/tasks", authenticationMiddleware, bodyParserMiddleware, TaskController.createTask());
    router.put("/api/v1/tasks/:taskId", authenticationMiddleware, bodyParserMiddleware, TaskController.updateTask());
    router.delete("/api/v1/tasks", loggingMiddleware, authenticationMiddleware, TaskController.deleteTasks());
    
    module.exports = { router };

    This makes managing Express middlewares more intuitive.

    Easier API versioning

    When you need to change the business logic of an API, the best approach is to create a new version of the same endpoint. This is necessary for backward compatibility. Production systems will keep relying on the old API, while new applications will have the opportunity to call the updated one.

    Without Node.js’s routing layer, there could be dozens of lines of code between the definition of an endpoint and its new version. As a result, keeping track of all versions of the same endpoint will not be easy.

    app.get("/api/v1/greetings/hello-world", (req, res) => {
        res.json("Hello, World!");
    });
    
    // other endpoints...
    
    app.get("/api/v2/greetings/hello-world", (req, res) => {
        res.json({
          message: "Hello, World!",
        });
    });
    

    When organizing routes in a single file, the two versions of the same API will be a handful of lines of code away at worst:

    router.get("/api/v1/greetings/hello-world", GreetingController.helloWorldV1());
    // other routes...
    router.get("/api/v2/greetings/hello-world", GreetingController.helloWorldV2());

    This is much better for dealing with API versioning.

    Implement a routing layer in Express

    In this step-by-step tutorial section, you will learn how to implement a Node.js routing layer on a demo application. Keep in mind that you can easily adapt the following implementation procedure to any other Express application.

    Prerequisites

    As a starting point, we will use a Node.js 20.x demo application. Clone it from the GitHub repository that supports this article:

    git clone https://github.com/Tonel/nodejs-routing-layer.git

    This repository has two branches:

    • no-routing-layer: A simple Express application with no particular routing handling logic.
    • master: The same Express application with all routes organized in a single layer.

    The goal of the next steps will be to show how to implement a Node.js routing layer. You will move from the messy, hard-to-maintain, and non-scalable no-routing-layer Express application to the scalable, readable, easy-to-maintain backend in the master branch.

    To get started, enter the project folder, checkout the no-routing-layer branch, and install the project’s dependencies:

    cd "nodejs-routing-layer"
    git checkout "no-routing-layer"
    npm install

    Now, focus on the src/index.js file:

    // src/index.js
    
    const express = require('express');
    
    // initialize an Express app
    const app = express();
    app.use(express.json());
    
    // sample data to simulate a database
    let users = [
      { id: 1, email: "john.doe@example.com", name: "John Doe" },
      { id: 2, email: "jane.smith@example.com", name: "Jane Smith" },
      { id: 3, email: "alice.jones@example.com", name: "Alice Jones" },
      { id: 4, email: "bob.miller@example.com", name: "Bob Miller" },
      { id: 5, email: "sara.white@example.com", name: "Sara White" },
      { id: 6, email: "mike.jenkins@example.com", name: "Mike Jenkins" },
      { id: 7, email: "emily.clark@example.com", name: "Emily Clark" },
      { id: 8, email: "david.ross@example.com", name: "David Ross" },
      { id: 9, email: "lisa.hall@example.com", name: "Lisa Hall" },
      { id: 10, email: "alex.garcia@example.com", name: "Alex Garcia" },
    ];
    let games = [
      {
        id: 1,
        title: "Game of Swords",
        description: "An epic fantasy series",
        users: [2, 4, 9, 10],
      },
      {
        id: 2,
        title: "Cardcraft",
        description: "A sandbox video game",
        users: [1, 4, 5, 7, 8, 10],
      },
      {
        id: 3,
        title: "The Legend of Zoey",
        description: "An action-adventure game",
        users: [9],
      },
    ];
    
    // greetings APIs
    app.get("/api/v1/greetings/hello-world", (req, res) => {
      res.json("Hello, World!");
    });
    
    app.get("/api/v1/greetings/:userId", (req, res) => {
      const user = users.find((user) => user.id === parseInt(req.params.userId));
    
      if (!user) {
        return res.status(404).json({ message: "User not found" });
      }
    
      res.json({ message: `Hello, ${user.name}! Welcome to the Game Collection!` });
    });
    
    // CRUD endpoints for users
    app.get("/api/v1/users", (req, res) => {
      res.json(users);
    });
    
    app.get("/api/v1/users/:userId", (req, res) => {
      const user = users.find((user) => user.id === parseInt(req.params.userId));
    
      if (!user) {
        return res.status(404).json({ message: "User not found" });
      }
    
      res.json({
        user: user,
      });
    });
    
    app.post("/api/v1/users", (req, res) => {
      const { name, email } = req.body;
    
      const newUser = {
        id: users.length + 1,
        name: name,
        email: email,
      };
    
      users.push(newUser);
    
      res.status(201).json({
        user: newUser,
      });
    });
    
    app.put("/api/v1/users/:userId", (req, res) => {
      const { name, email } = req.body;
    
      const user = users.find((user) => user.id === parseInt(req.params.userId));
    
      if (!user) {
        return res.status(404).json({ message: "User not found" });
      }
    
      user.name = name;
      user.email = email;
    
      res.json({
        user: user,
      });
    });
    
    app.delete("/api/v1/users/:userId", (req, res) => {
      users = users.filter((user) => user.id !== parseInt(req.params.userId));
    
      res.json();
    });
    
    // API for games
    app.get("/api/v1/games/:gameId/users", (req, res) => {
      const game = games.find((game) => game.id === parseInt(req.params.gameId));
    
      if (!game) {
        return res.status(404).json({ message: "Game not found" });
      }
    
      const gameUsers = users.filter((user) => game.users.includes(user.id));
    
      res.json({
        users: gameUsers,
      });
    });
    
    // start the server
    const PORT = 3000;
    app.listen(PORT, () => {
      console.log(`Running on PORT ${PORT}`);
    });

    As you can see, this contains all the logic required to define all endpoints in your backend. Specifically, it exposes some APIs to greet the user, perform CRUD operations on users, and get all the users associated with a given game. Note that it uses some local data structures to simulate a database.

    The idea is to organize these Node.js routes into specific routing files in a dedicated layer.

    To run the demo application, launch:

    npm run start

    The Express demo backend will now be running at http://localhost:3000. To make sure it works, open your favorite HTTP client and try calling some endpoints as below:

    Great, the demo application works like a charm! Time to add a routing layer to its architecture.

    Prepare the router and controller layers

    Right now, the definition of business logic functions and their associations with specific endpoints occur in the same place. To build a Node.js routing layer, you need to split that up. Prepare your application’s architecture for this change by adding the following two directories in src/:

    • routes: To store all Nodejs router files.
    • controllers: To store all Express controller files that expose the route-handler callback functions.

    This is what the new architecture of your Node.js project looks like:

    src/
     ├── routes/
     └── controllers/

    While you are doing this refactoring, you may also be interested in adding more useful layers to your architecture. Follow our tutorials to learn how to create an API layer in Node.js and how to add a constants layer in JavaScript.

    Separate routing from business logic

    It is time to separate API business logic from the routing logic. The best way to learn how to do that is through an example. So, let’s consider the "api/v1/users" endpoints in the src/index.js file:

    let users = [
         // omitted for brevity...
        ];
        
        app.get("/api/v1/greetings/hello-world", (req, res) => {
          res.json("Hello, World!");
        });
        
        app.get("/api/v1/greetings/:userId", (req, res) => {
          const user = users.find((user) => user.id === parseInt(req.params.userId));
        
          if (!user) {
            return res.status(404).json({ message: "User not found" });
          }
        
          res.json({ message: `Hello, ${user.name}! Welcome to the Game Collection!` });
        });
        
        // CRUD endpoints for users
        app.get("/api/v1/users", (req, res) => {
          res.json(users);
        });
        
        app.get("/api/v1/users/:userId", (req, res) => {
          const user = users.find((user) => user.id === parseInt(req.params.userId));
        
          if (!user) {
            return res.status(404).json({ message: "User not found" });
          }
        
          res.json({
            user: user,
          });
        });
        
        app.post("/api/v1/users", (req, res) => {
          const { name, email } = req.body;
        
          const newUser = {
            id: users.length + 1,
            name: name,
            email: email,
          };
        
          users.push(newUser);
        
          res.status(201).json({
            user: newUser,
          });
        });
        
        app.put("/api/v1/users/:userId", (req, res) => {
          const { name, email } = req.body;
        
          const user = users.find((user) => user.id === parseInt(req.params.userId));
        
          if (!user) {
            return res.status(404).json({ message: "User not found" });
          }
        
          user.name = name;
          user.email = email;
        
          res.json({
            user: user,
          });
        });
        
        app.delete("/api/v1/users/:userId", (req, res) => {
          users = users.filter((user) => user.id !== parseInt(req.params.userId));
        
          res.json();
        });
    

    What you want to do is move the route definitions to a router file and the route-handler functions to a controller file.

    Thus, create a users.js file in the controllers/ folder and initialize it as below:

    // src/controllers/users.js
    
    let users = [
      // omitted for brevity...
    ];
    
    const UserController = {
      getUsers: (req, res) => {
        res.json(users);
      },
    
      getUser: (req, res) => {
        const user = users.find((user) => user.id === parseInt(req.params.userId));
    
        if (!user) {
          return res.status(404).json({ message: "User not found" });
        }
    
        res.json({
          user: user,
        });
      },
    
      createUser: (req, res) => {
        const { name, email } = req.body;
    
        const newUser = {
          id: users.length + 1,
          name: name,
          email: email,
        };
    
        users.push(newUser);
    
        res.status(201).json({
          user: newUser,
        });
      },
    
      updateUser: (req, res) => {
        const { name, email } = req.body;
    
        const user = users.find((user) => user.id === parseInt(req.params.userId));
    
        if (!user) {
          return res.status(404).json({ message: "User not found" });
        }
    
        user.name = name;
        user.email = email;
    
        res.json({
          user: user,
        });
      },
    
      deleteUser: (req, res) => {
        users = users.filter((user) => user.id !== parseInt(req.params.userId));
    
        res.json();
      },
    };
    
    module.exports = { UserController };

    This controller file exports a UserController object that exposes all the route-handler functions containing the business logic of your "api/v1/users" endpoints. Note that the names of the object attributes make it easier to understand what each Express route-handler function does.

    Then, you need to create a users.js Node.js route file in routes/ as follows:

    // src/routes/users.js
    const { Router } = require("express");
    const { UserController } = require("../controllers/users.js");
    
    const router = new Router();
    
    router.get("/api/v1/users", UserController.getUsers);
    router.get("/api/v1/users/:userId", UserController.getUser);
    router.post("/api/v1/users", UserController.createUser);
    router.put("/api/v1/users/:userId", UserController.updateUser);
    router.delete("/api/v1/users/:userId", UserController.deleteUser);
    
    module.exports = { router };
    

    A router file creates an Express Router, registers the desired endpoints to it by importing their route-handler functions from the related controller, and exports it. If you are not familiar with Router, that is nothing more than an isolated instance of middleware and routes. You can think of it as a “mini-application” that can only perform middleware and routing functions. To make a Routerwork, you must register to your Express application with app.use().

    As a next step, make sure to remove the "api/v1/users" endpoint definition from the src/index.js file. You will no longer need them.

    Awesome, you just created your first Node.js routing layer file!

    Populate your Node.js route files

    Now that you know how to populate the controllers/ and routes/ folders, repeat what you did in the previous step to all the remaining endpoints in the application!

    A good way to understand how to organize APIs is to look at their endpoints. For example, if some of them start with the same prexif "api/v1/greetings", then it makes sense to create the controller and router greetings.js files.

    At the end of this operation, your Node.js project will contain the following files:

    nodejs-routing-layer
    ├── node_modules/
    ├── src/
    │    ├── controllers/
    │    │    ├── games.js
    │    │    ├── greetings.js
    │    │    └── users.js
    │    ├── routes/
    │    │     ├── games.js
    │    │     ├── greetings.js
    │    │     └── users.js
    │    └── index.js
    ├── .gitignore
    ├── package-lock.json
    ├── package.json
    └── README.md

    Wonderful! All that remains is to register the Node.js Router objects to the app server.

    Import all your routes

    This is what your current /src/index.js file should look like:

    const express = require("express");
    
    // initialize an Express application
    const app = express();
    app.use(express.json());
    
    const port = 3000;
    app.listen(port, () => {
      console.log(`Server is running on http://localhost:${port}`);
    });

    If you do not tell app to use the routers defined in the routes/ directory, this will not magically work. In particular, you have to pass the router objects returned by each file in routes/ to the app.use() method.

    The problem is that manually importing and passing all routers is tedious, error-prone, and not scalable. When adding a new Node.js route file, you would like the application to automatically detect it. Achieve that with the following code:

    const path = require("path");
    const fs = require("fs");
    
    // connect to the "src/routers" directory
    const routersPath = path.join(__dirname, "routes");
    
    // read all files in the "/src/routers" directory
    fs.readdirSync(routersPath).forEach((file) => {
      if (file.endsWith(".js")) {
        // dynamically import the router module
        const routerModule = require(path.join(routersPath, file));
    
        // get the "router" object exported by the router module
        const router = routerModule.router;
    
        // register the router
        app.use(router);
      }
    });

    This snippet reads all JavaScript files in routes/, imports them dynamically with require(), accesses their exported routerobjects, and passes them to app.use().

    Et voilà! You just implemented a Node.js routing layer!

    Put it all together

    If you followed the above steps carefully, you should now have the same code as in the master branch. Verify that, by exploring master. Move to that branch with:

    git checkout master

    Take some time to look into the provided solution and see how elegant the routing layer is.

    Then, run the Node.js application in master and make sure that the backend still works. Fire the command below to launch it:

    npm run start

    Again, the Express backend will listen at http://localhost:3000. Open your favorite HTTP client and test some endpoints. You will get the same results as before!

    Congratulations! Enjoy the benefits introduced by your new Node.js route handling system!

    Conclusion

    In this article, you learned what a Node.js routing layer is, the main benefits it can bring to the backend architecture, and how to implement it. A routing layer is a set of files in an Express project that contain all the routing logic. It enables you to keep your codebase tidy, organized, and easier to read and maintain. Adding such a layer to an Express backend application is easy, and here you saw how to do it in a guided tutorial.

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Avatar
    Writen by:
    I'm a software engineer, but I prefer to call myself a Technology Bishop. Spreading knowledge through writing is my mission.
    Avatar
    Reviewed by:
    I picked up most of my soft/hardware troubleshooting skills in the US Army. A decade of Java development drove me to operations, scaling infrastructure to cope with the thundering herd. Engineering coach and CTO of Teleclinic.