DEV Community

Cover image for Express Setup: Simple & Scalable
Andrew Bone
Andrew Bone

Posted on

Express Setup: Simple & Scalable

There are a few things conventional wisdom has taught us to do when making an Express microservice, from the layout of our source files to how we document the code we're writing. I'll be honest: I haven't always adhered to these conventions. Sometimes, you just need to get code out the door, and all that boilerplate can really slow you down.

In this post, I'll take a quick look at how we're traditionally told to create an Express microservice, explain what I do differently, and walk you through a very simple project to see it all in action.

An Introduction to Express

Express is one of those tools that feels like magic when you first use it. It's a minimal and flexible Node.js framework that makes building web applications and APIs a breeze. Whether you're spinning up a quick prototype or building a production-ready microservice, Express has you covered.

At its core, Express is all about simplicity. It doesn’t force you into a specific structure or way of doing things, which is both its greatest strength and, sometimes, its biggest challenge. With so much freedom, it’s easy to get overwhelmed by all the "best practices" floating around.

Routes

Routes are the backbone of any Express app. They define how your application responds to different HTTP requests. For example, if someone sends a GET request to /users, you might want to return a list of users. Express makes this super simple:

app.get('/users', (req, res) => {
  res.send('Here’s a list of users!');
});
Enter fullscreen mode Exit fullscreen mode

The conventional wisdom is to separate your routes into their own files to keep things organised.

Controllers

Controllers are where the logic lives. They take the requests from your routes, do the heavy lifting (like talking to a database), and send back a response. A typical setup might look like this:

// usersController.js
export const getUsers = (req, res) => {
  res.send('Here’s a list of users!');
};
Enter fullscreen mode Exit fullscreen mode

Then, in your route file, you’d import the controller:

import usersController from './usersController';
app.get('/users', usersController.getUsers);
Enter fullscreen mode Exit fullscreen mode

Middleware

Middleware is one of the coolest parts of Express. Think of it as a series of steps your request goes through before it gets to your route. Middleware can do things like log requests, check authentication or parse JSON bodies.

Here’s a simple example of middleware that logs every request:

app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next(); // Pass control to the next middleware or route
});
Enter fullscreen mode Exit fullscreen mode

OpenAPI (Swagger)

Documenting your API is one of those things we all know we should do, but it often gets pushed to the bottom of the to-do list. OpenAPI (often referred to as Swagger) is the de facto method for documenting your APIs.

Whilst this is great in theory, the API documentation is quite complex and time-consuming to write and often goes out of sync with the actual API, making debugging a nightmare.

Here’s how you would serve your swagger file from Express:

import swaggerUi from 'swagger-ui-express';
import swaggerDocument from './swagger.json';

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
Enter fullscreen mode Exit fullscreen mode

There are packages that attempt to avoid the desync issue by discerning meaning directly from the code and its comments, but I find these make the code messy and aren't always correct.

A Brief Look at JSDoc

If you’ve ever worked on a project for more than a week, you’ve probably had that moment where you look at a function and think, “What does this even do?” That’s where JSDoc comes in. It’s a simple way to add comments to your code that describe what your functions, classes and variables are supposed to do.

JSDoc is great because it doesn’t just help you remember what your code does, it also helps others (or future you) understand it. Plus, tools like VSCode can use JSDoc comments to provide inline documentation and autocomplete suggestions, which is a huge productivity boost.

Here’s a quick example of what JSDoc looks like:

/**
 * Gets a list of users.
 * @param {Request} req - The request object.
 * @param {Response} res - The response object.
 * @returns {void}
 */
function getUsers(req, res) {
  res.send('Here’s a list of users!');
}
Enter fullscreen mode Exit fullscreen mode

The @param tags describe the parameters the function takes, and the @returns tag explains what the function returns. It’s simple, but it makes a big difference when you’re working on a team or revisiting old code.

I love JSDoc and use it as much as possible. It also has a handy little feature that will generate HTML pages for you based on your comments.

My structure

OK, let's talk about how I go about setting up a project. You'll have noticed my aversion to excessive boilerplate, but it's not entirely avoidable. We're going to set up a little bit of code to start with; then, hopefully, everything else in the project will get easier.

Setting up a project

First things first, we need to actually initialise our project: create a directory and run the init command.

npm init
Enter fullscreen mode Exit fullscreen mode

We’ll be asked a few questions (name, description, etc). Fill these out and, when you're finished, a package.json file will be created for you. This is step one.

Next, we're going to want to install our dependencies. I'll just run down the list for now but I'll explain what each is for as and when we require them.

npm i express
npm i --save-dev @eslint/js clean-jsdoc-theme eslint eslint-config-prettier eslint-plugin-prettier jsdoc prettier
Enter fullscreen mode Exit fullscreen mode

That might feel like we've just installed a lot, but only one of the dependencies is actually required. The rest are all just to make our lives easier.

Linting and formatting your code

When we write code we're prone to mistakes. Even if you're writing code with the help of an AI, we also all write code a little differently. What's clear to one dev might be really hard to read for another. To help mitigate these issues, we can have eslint to look through our code for obvious mistakes and prettier to format our code, making all of our coding styles read similarly even if not exactly the same.

ESLint

ESLint statically analyses your code to quickly find problems. It can catch common errors, enforce style guidelines, and help prevent bugs before they happen. Depending on the rules you want to enforce, your config can be as complex or as simple as you like. As a starting point, I tend to just include a few recommended rules; @eslint/js has js.configs.recommended.rules, for instance. I also link my Prettier config here so ESLint will help keep everything tidy.

Prettier

Prettier is a code formatter that just handles all the boring stuff for you. Instead of arguing about tabs vs spaces or lining everything up perfectly by hand, you just write your code and Prettier makes it look clean and consistent. It’s opinionated (on purpose), so you don’t waste time picking styles, everything just is styled. Again, the rules used can vary depending on where your code is going or who is working on it, but I have some simplistic rules I like to use everywhere.

BaseRoute Class

Earlier I mentioned Routes and Controllers and how it's generally advised to split them up into separate files or even directories. Yeah, I don't do that. I keep my Routes and Controllers together in a single Route Class, which contains all controllers and also attaches itself to the Express app. Let's look at how that works.

export default class BaseRoute {
  constructor(path, router) {
    if (!path) {
      throw new Error('Path is required');
    }

    if (!router) {
      throw new Error('Router is required');
    }

    this.path = path;
    this.router = router;
    this.init();
  }

  /**
   * Initialises the route.
   */
  init() {
    // This method should be overridden in subclasses
    throw new Error('init() method must be implemented in subclass');
  }

  all(path, ...props) {
    this.router.all(`${this.path}${path}`, ...props);
  }

  get(path, ...props) {
    this.router.get(`${this.path}${path}`, ...props);
  }

  post(path, ...props) {
    this.router.post(`${this.path}${path}`, ...props);
  }

  put(path, ...props) {
    this.router.put(`${this.path}${path}`, ...props);
  }

  delete(path, ...props) {
    this.router.delete(`${this.path}${path}`, ...props);
  }

  patch(path, ...props) {
    this.router.patch(`${this.path}${path}`, ...props);
  }

  options(path, ...props) {
    this.router.options(`${this.path}${path}`, ...props);
  }

  head(path, ...props) {
    this.router.head(`${this.path}${path}`, ...props);
  }
}
Enter fullscreen mode Exit fullscreen mode

First we have a constructor that takes a path and a router. The path is the start of the URL, /some/url for instance, that will be used for all endpoints exposed by this class. The router is the Express router; we'll pass that in from outside so we don't have to declare it multiple times.

You'll notice the constructor also calls the init function, but all that does is throw an error telling us we need to implement it.

Extending the base class

When we actually want to add a new Route, we simply have to extend our base class and away we go.

/**
 * Example route for returning simple responses.
 */
class ExampleRoute extends BaseRoute {
  /**
   * Creates an instance of the ExampleRoute class.
   *
   * @param {object} router - The Express router instance.
   */
  constructor(router) {
    super('/example', router);
  }

  init() {
    this.get('', this.simpleResponse);
    this.get('error', this.errorResponse);
  }

  /**
   * @category API
   * @summary GET ./example
   * @desc Returns a simple JSON response.
   *
   * @returns 200 - Success - Returns an object with 'data' and 'error' properties.
   * @returns 500 - Internal Server Error - An error occurred while processing the request.
   *
   * @example Request:
   * GET http://localhost:5000/example
   *
   * @example Success response:
   * {
   *  "data": "Example",
   *  "error": null
   * }
   */
  simpleResponse(req, res) {
    res.json({ data: 'Example', error: null });
  }

  /**
   * @category API
   * @summary GET ./example/error
   * @desc Returns an error response.
   *
   * @returns 500 - Internal Server Error - An error occurred while processing the request.
   *
   * @example Request:
   * GET http://localhost:5000/example/error
   *
   * @example Error response:
   * {
   *  "data": null,
   *  "error": "This is an example error."
   * }
   */
  errorResponse() {
    throw new Error('This is an example error.', { cause: { status: 500 } });
  }
}

export default ExampleRoute;
Enter fullscreen mode Exit fullscreen mode

You will notice I am passing the methods directly, such as this.get('', this.simpleResponse). In these specific examples, it works fine because the controllers don't rely on any internal class state. However, if you want to access other properties or methods within your class using this, you will need to bind the method in the init() function: this.simpleResponse.bind(this).

The majority of this code is JSDoc, which you might think is overkill, but it's going to write our documentation for us and because it's right there next to our routes, we'll probably remember to update it when we update the route.

In order to add this new collection of endpoints our router we simply instantiate the ExampleRoute class in the same file we define our router and pass that router in and that's it.

const app = express();
const router = express.Router();

new ExampleRoute(router);
Enter fullscreen mode Exit fullscreen mode

Defining our Docs

In our JSDoc comments we describe our endpoints to using a set of tags, you can look at the different tags and what they do over on JSDoc's website but here's what we've used and why.

  • @category: This is brilliant for grouping related routes. If your microservice has multiple classes (e.g., UserRoute, PaymentRoute), setting the category to "API" ensures they all appear together in the generated sidebar.

  • @summary: A short, one-line description of what the route does. This often appears in the table of contents or headers.

  • @desc: A more detailed explanation of the logic or requirements for that specific endpoint.

  • @returns: In a standard function, this describes a JavaScript return value. In our API context, we use it to document HTTP status codes and the expected JSON structure.

  • @example: This is perhaps the most useful tag. It allows you to provide a "copy-paste" snippet of a request and its expected response, which is invaluable for front-end developers.

Serving our docs

If we had to read through the source code to understand the endpoints, JSDoc would still be useful for code readability, but we would be missing its best feature. We can build a professional webpage from our comments using the jsdoc package and the theme we installed earlier.

Make a config file called jsdoc.config.json

{
  "source": {
    "include": ["."],
    "exclude": ["node_modules", "docs"]
  },
  "opts": {
    "readme": "./README.md",
    "template": "node_modules/clean-jsdoc-theme",
    "destination": "./docs",
    "recurse": true
  }
}
Enter fullscreen mode Exit fullscreen mode

and then modify your package.json file so it has a script called jsdoc (you can name it anything but I've named mine jsdoc). This script will call jsdoc -c jsdoc.config.json and that's it. Simply run the npm command and it will generate a webpage for you that can be hosted with your docs in human readable form.

npm run jsdoc
Enter fullscreen mode Exit fullscreen mode

Simple Example Project

In order to show this layout in a slightly more real world scenario (though still incredibly simplified) I've made a quick project you can look at and the documentation to go alongside it.

Fin

There we have it, the way I tend to write express services. I don't think this way is any 'better' than any other way but it makes sense in my brain. Feel free to share any ways you prefer to write these services in the comments or even tell my ways in which you think this way is worse.

Thanks for reading! If you'd like to connect, here are my BlueSky and LinkedIn profiles. Come say hi 😊

Top comments (1)

Collapse
 
link2twenty profile image
Andrew Bone

I've had this post in my drafts for a while and finally had the time to finish it off.