DEV Community

Cover image for Bulletproof node.js project architecture πŸ›‘οΈ
Sam
Sam

Posted on • Updated on • Originally published at softwareontheroad.com

Bulletproof node.js project architecture πŸ›‘οΈ

Originally posted on softwareontheroad.com

Update 04/21/2019: Implementation example in a GitHub repository

Introduction

Express.js is great frameworks for making a node.js REST APIs however it doesn't give you any clue on how to organizing your node.js project.

While it may sound silly, this is a real problem.

The correct organization of your node.js project structure will avoid duplication of code, will improve stability, and potentially, will help you scale your services if is done correctly.

This post is extense research, from my years of experience dealing with a poor structured node.js project, bad patterns, and countless hours of refactoring code and moving things around.

If you need help to align your node.js project architecture, just drop me a letter at santiago@softwareontheroad.com

Table of contents

The folder structure 🏒

Here is the node.js project structure that I'm talking about.

I use this in every node.js REST API service that I build, let's see in details what every component do.

  src
  β”‚   app.js          # App entry point
  └───api             # Express route controllers for all the endpoints of the app
  └───config          # Environment variables and configuration related stuff
  └───jobs            # Jobs definitions for agenda.js
  └───loaders         # Split the startup process into modules
  └───models          # Database models
  └───services        # All the business logic is here
  └───subscribers     # Event handlers for async task
  └───types           # Type declaration files (d.ts) for Typescript
Enter fullscreen mode Exit fullscreen mode

It is more than just a way of ordering javascript files...

3 Layer architecture πŸ₯ͺ

The idea is to use the principle of separation of concerns to move the business logic away from the node.js API Routes.

3 layer pattern

Because someday, you will want to use your business logic on a CLI tool, or not going far, in a recurring task.

And make an API call from the node.js server to itself it's not a good idea...

3 layer pattern for node.js REST API

☠️ Don't put your business logic inside the controllers!! ☠️

You may be tempted to just use the express.js controllers to store the business logic of your application, but this quickly becomes spaghetti code, as soon as you need to write unit tests, you will end up dealing with complex mocks for req or res express.js objects.

It's complicated to distingue when a response should be sent, and when to continue processing in 'background', let's say after the response is sent to the client.

Here is an example of what not to do.

  route.post('/', async (req, res, next) => {

    // This should be a middleware or should be handled by a library like Joi.
    const userDTO = req.body;
    const isUserValid = validators.user(userDTO)
    if(!isUserValid) {
      return res.status(400).end();
    }

    // Lot of business logic here...
    const userRecord = await UserModel.create(userDTO);
    delete userRecord.password;
    delete userRecord.salt;
    const companyRecord = await CompanyModel.create(userRecord);
    const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);

    ...whatever...


    // And here is the 'optimization' that mess up everything.
    // The response is sent to client...
    res.json({ user: userRecord, company: companyRecord });

    // But code execution continues :(
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
    eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);
    intercom.createUser(userRecord);
    gaAnalytics.event('user_signup',userRecord);
    await EmailService.startSignupSequence(userRecord)
  });

Enter fullscreen mode Exit fullscreen mode

# Use a service layer for your business logic πŸ’Ό

This layer is where your business logic should live.

It's just a collection of classes with clear porpuses, following the SOLID principles applied to node.js.

In this layer there should not exists any form of 'SQL query', use the data access layer for that.

  • Move your code away from the express.js router

  • Don't pass the req or res object to the service layer

  • Don't return anything related to the HTTP transport layer like a status code or headers from the service layer.

Example

  route.post('/', 
    validators.userSignup, // this middleware take care of validation
    async (req, res, next) => {
      // The actual responsability of the route layer.
      const userDTO = req.body;

      // Call to service layer.
      // Abstraction on how to access the data layer and the business logic.
      const { user, company } = await UserService.Signup(userDTO);

      // Return a response to client.
      return res.json({ user, company });
    });
Enter fullscreen mode Exit fullscreen mode

Here is how your service will be working behind the scenes.

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';

  export default class UserService {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(userRecord); // needs userRecord to have the database id 
      const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created

      ...whatever

      await EmailService.startSignupSequence(userRecord)

      ...do more stuff

      return { user: userRecord, company: companyRecord };
    }
  }

Enter fullscreen mode Exit fullscreen mode

Visit the example repository

Use a Pub/Sub layer too πŸŽ™οΈ

The pub/sub pattern goes beyond the classic 3 layer architecture proposed here but it's extremely useful.

The simple node.js API endpoint that creates a user right now, may want to call third-party services, maybe to an analytics service, or maybe start an email sequence.

Sooner than later, that simple "create" operation will be doing several things, and you will end up with 1000 lines of code, all in a single function.

That violates the principle of single responsibility.

So, it's better to separate responsibilities from the start, so your code remains maintainable.

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(user);
      const salaryRecord = await SalaryModel.create(user, salary);

      eventTracker.track(
        'user_signup',
        userRecord,
        companyRecord,
        salaryRecord
      );

      intercom.createUser(
        userRecord
      );

      gaAnalytics.event(
        'user_signup',
        userRecord
      );

      await EmailService.startSignupSequence(userRecord)

      ...more stuff

      return { user: userRecord, company: companyRecord };
    }

  }
Enter fullscreen mode Exit fullscreen mode

An imperative call to a dependent service is not the best way of doing it.

A better approach is by emitting an event i.e. 'a user signed up with this email'.

And you are done, now it's the responsibility of the listeners to do their job.

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await this.userModel.create(user);
      const companyRecord = await this.companyModel.create(user);
      this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord })
      return userRecord
    }

  }
Enter fullscreen mode Exit fullscreen mode

Now you can split the event handlers/listeners into multiple files.

  eventEmitter.on('user_signup', ({ user, company }) => {

    eventTracker.track(
      'user_signup',
      user,
      company,
    );

    intercom.createUser(
      user
    );

    gaAnalytics.event(
      'user_signup',
      user
    );
  })
Enter fullscreen mode Exit fullscreen mode
  eventEmitter.on('user_signup', async ({ user, company }) => {
    const salaryRecord = await SalaryModel.create(user, company);
  })
Enter fullscreen mode Exit fullscreen mode
  eventEmitter.on('user_signup', async ({ user, company }) => {
    await EmailService.startSignupSequence(user)
  })
Enter fullscreen mode Exit fullscreen mode

You can wrap the await statements into a try-catch block or you can just let it fail and handle the 'unhandledPromise' process.on('unhandledRejection',cb)

Dependency Injection πŸ’‰

D.I. or inversion of control (IoC) is a common pattern that will help the organization of your code, by 'injecting' or passing through the constructor the dependencies of your class or function.

By doing this way you will gain the flexibility to inject a 'compatible dependency' when, for example, you write the unit tests for the service, or when the service is used in another context.

Code with no D.I

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';  
  class UserService {
    constructor(){}
    Sigup(){
      // Caling UserMode, CompanyModel, etc
      ...
    }
  }
Enter fullscreen mode Exit fullscreen mode

Code with manual dependency injection

  export default class UserService {
    constructor(userModel, companyModel, salaryModel){
      this.userModel = userModel;
      this.companyModel = companyModel;
      this.salaryModel = salaryModel;
    }
    getMyUser(userId){
      // models available throug 'this'
      const user = this.userModel.findById(userId);
      return user;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Now you can inject custom dependencies.

  import UserService from '../services/user';
  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  const salaryModelMock = {
    calculateNetSalary(){
      return 42;
    }
  }
  const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock);
  const user = await userServiceInstance.getMyUser('12346');
Enter fullscreen mode Exit fullscreen mode

The amount of dependencies a service can have is infinite, and refactor every instantiation of it when you add a new one is a boring and error-prone task.

That's why dependency injection frameworks were created.

The idea is you declare your dependencies in the class, and when you need an instance of that class, you just call the 'Service Locator'.

Let's see an example using typedi an npm library that brings D.I to node.js

You can read more on how to use typedi in the official documentation

WARNING typescript example

  import { Service } from 'typedi';
  @Service()
  export default class UserService {
    constructor(
      private userModel,
      private companyModel, 
      private salaryModel
    ){}

    getMyUser(userId){
      const user = this.userModel.findById(userId);
      return user;
    }
  }
Enter fullscreen mode Exit fullscreen mode

services/user.ts

Now typedi will take care of resolving any dependency the UserService require.

  import { Container } from 'typedi';
  import UserService from '../services/user';
  const userServiceInstance = Container.get(UserService);
  const user = await userServiceInstance.getMyUser('12346');
Enter fullscreen mode Exit fullscreen mode

Abusing service locator calls is an anti-pattern

Using Dependency Injection with Express.js in Node.js

Using D.I. in express.js is the final piece of the puzzle for this node.js project architecture.

Routing layer

  route.post('/', 
    async (req, res, next) => {
      const userDTO = req.body;

      const userServiceInstance = Container.get(UserService) // Service locator

      const { user, company } = userServiceInstance.Signup(userDTO);

      return res.json({ user, company });
    });
Enter fullscreen mode Exit fullscreen mode

Awesome, project is looking great!
It's so organized that makes me want to be coding something right now.

Visit the example repository

An unit test example πŸ•΅πŸ»

By using dependency injection and these organization patterns, unit testing becomes really simple.

You don't have to mock req/res objects or require(...) calls.

Example: Unit test for signup user method

tests/unit/services/user.js

  import UserService from '../../../src/services/user';

  describe('User service unit tests', () => {
    describe('Signup', () => {
      test('Should create user record and emit user_signup event', async () => {
        const eventEmitterService = {
          emit: jest.fn(),
        };

        const userModel = {
          create: (user) => {
            return {
              ...user,
              _id: 'mock-user-id'
            }
          },
        };

        const companyModel = {
          create: (user) => {
            return {
              owner: user._id,
              companyTaxId: '12345',
            }
          },
        };

        const userInput= {
          fullname: 'User Unit Test',
          email: 'test@example.com',
        };

        const userService = new UserService(userModel, companyModel, eventEmitterService);
        const userRecord = await userService.SignUp(teamId.toHexString(), userInput);

        expect(userRecord).toBeDefined();
        expect(userRecord._id).toBeDefined();
        expect(eventEmitterService.emit).toBeCalled();
      });
    })
  })

Enter fullscreen mode Exit fullscreen mode

Cron Jobs and recurring task ⚑

So, now that the business logic encapsulated into the service layer, it's easier to use it from a Cron job.

You should never rely on node.js setTimeout or another primitive way of delay the execution of code, but on a framework that persist your jobs, and the execution of them, in a database.

This way you will have control over the failed jobs, and feedback of those who succeed.
I already wrote on good practice for this so, check my guide on using agenda.js the best task manager for node.js.

Configurations and secrets 🀫

Following the battle-tested concepts of Twelve-Factor App for node.js the best approach to store API Keys and database string connections, it's by using dotenv.

Put a .env file, that must never be committed (but it has to exist with default values in your repository) then, the npm package dotenv loads the .env file and insert the vars into the process.env object of node.js.

That could be enough but, I like to add an extra step.
Have a config/index.ts file where the dotenv npm package and loads the .env file and then I use an object to store the variables, so we have a structure and code autocompletion.

config/index.js

  const dotenv = require('dotenv');
  // config() will read your .env file, parse the contents, assign it to process.env.
  dotenv.config();

  export default {
    port: process.env.PORT,
    databaseURL: process.env.DATABASE_URI,
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    mailchimp: {
      apiKey: process.env.MAILCHIMP_API_KEY,
      sender: process.env.MAILCHIMP_SENDER,
    }
  }
Enter fullscreen mode Exit fullscreen mode

This way you avoid flooding your code with process.env.MY_RANDOM_VAR instructions, and by having the autocompletion you don't have to know how to name the env var.

Visit the example repository

Loaders πŸ—οΈ

I took this pattern from W3Tech microframework but without depending upon their package.

The idea is that you split the startup process of your node.js service into testable modules.

Let's see a classic express.js app initialization

  const mongoose = require('mongoose');
  const express = require('express');
  const bodyParser = require('body-parser');
  const session = require('express-session');
  const cors = require('cors');
  const errorhandler = require('errorhandler');
  const app = express();

  app.get('/status', (req, res) => { res.status(200).end(); });
  app.head('/status', (req, res) => { res.status(200).end(); });
  app.use(cors());
  app.use(require('morgan')('dev'));
  app.use(bodyParser.urlencoded({ extended: false }));
  app.use(bodyParser.json(setupForStripeWebhooks));
  app.use(require('method-override')());
  app.use(express.static(__dirname + '/public'));
  app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
  mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });

  require('./config/passport');
  require('./models/user');
  require('./models/company');
  app.use(require('./routes'));
  app.use((req, res, next) => {
    var err = new Error('Not Found');
    err.status = 404;
    next(err);
  });
  app.use((err, req, res) => {
    res.status(err.status || 500);
    res.json({'errors': {
      message: err.message,
      error: {}
    }});
  });


  ... more stuff 

  ... maybe start up Redis

  ... maybe add more middlewares

  async function startServer() {    
    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  // Run the async function to start our server
  startServer();
Enter fullscreen mode Exit fullscreen mode

As you see, this part of your application can be a real mess.

Here is an effective way to deal with it.

  const loaders = require('./loaders');
  const express = require('express');

  async function startServer() {

    const app = express();

    await loaders.init({ expressApp: app });

    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  startServer();
Enter fullscreen mode Exit fullscreen mode

Now the loaders are just tiny files with a concise purpose

loaders/index.js

  import expressLoader from './express';
  import mongooseLoader from './mongoose';

  export default async ({ expressApp }) => {
    const mongoConnection = await mongooseLoader();
    console.log('MongoDB Intialized');
    await expressLoader({ app: expressApp });
    console.log('Express Intialized');

    // ... more loaders can be here

    // ... Initialize agenda
    // ... or Redis, or whatever you want
  }
Enter fullscreen mode Exit fullscreen mode

The express loader

loaders/express.js


  import * as express from 'express';
  import * as bodyParser from 'body-parser';
  import * as cors from 'cors';

  export default async ({ app }: { app: express.Application }) => {

    app.get('/status', (req, res) => { res.status(200).end(); });
    app.head('/status', (req, res) => { res.status(200).end(); });
    app.enable('trust proxy');

    app.use(cors());
    app.use(require('morgan')('dev'));
    app.use(bodyParser.urlencoded({ extended: false }));

    // ...More middlewares

    // Return the express app
    return app;
  })

Enter fullscreen mode Exit fullscreen mode

The mongo loader

loaders/mongoose.js

  import * as mongoose from 'mongoose'
  export default async (): Promise<any> => {
    const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
    return connection.connection.db;
  }
Enter fullscreen mode Exit fullscreen mode

See a complete example of loaders here

Conclusion

We deep dive into a production tested node.js project structure, here are some summarized tips:

  • Use a 3 layer architecture.

  • Don't put your business logic into the express.js controllers.

  • Use PubSub pattern and emit events for background tasks.

  • Have dependency injection for your peace of mind.

  • Never leak your passwords, secrets and API keys, use a configuration manager.

  • Split your node.js server configurations into small modules that can be loaded independently.

See the example repository here

βœ‹ Hey ! Before you go πŸƒβ€

If you enjoy this article, I recommend you to subscribe to my email list so you never miss another one like this. ⬇️ ⬇️

Email List Form

I will not try to sell you anything, I promise

And don't miss my latest post, I believe you will love it :)

Read my research on the most downloaded frontend framework, the result will surprise you!

Don't forget to visit my blog to get more awesome posts like this softwareontheroad.com

Latest comments (92)

Collapse
 
abidullah786 profile image
ABIDULLAH786

Well explained with simple examplesβ™₯️

Collapse
 
solianir profile image
Pedro Ivan Partida Galarza

Why use typedi instead of plain old higher-order functions? Functions are first-class citizens in JS/TS, so why not use HOCs instead of adding an additional dependency that applies a pattern that only makes sense in languages without HOCs?

Collapse
 
digen21 profile image
Digen More

How to create Database Access Layer/Object
In service based architecture?

Collapse
 
tusharshahi profile image
TusharShahi

Great article.

Maybe you want to add on to this :
An imperative call to a dependent service is not the best way of doing it.

Can you explain why?

Collapse
 
becklin profile image
Beck Lin • Edited

Great article and lots of valuable points ! learn so much from your architecture while Im working on my on nodejs project.
Just want to know your opinion regarding the future of nodejs. It seems that the market of nodejs is not so popular as java or python.

Collapse
 
shanshaji_8 profile image
Shan Shaji • Edited

Inspired by this, but didn't use typescript, DI
node-js-starter

Collapse
 
wickedknock profile image
wickedknock

I am new to nodejs , I have learnt express and mongodb crud , now I want to learn more , please can you only tell me the topics I have to learn myself to understand and use your repo .

Things I saw in repo I need to learn first is typescript , something called eslint and etc

Thanks <3

Collapse
 
prajwalch profile image
Prajwal Chapagain

Hey, but how should i load env using the config? should import on each module where it require?

Collapse
 
aslasn profile image
Ande • Edited

Why do we need two different api and services folder? The subscribers or pub/sub also falls under services.

Collapse
 
whydonti profile image
Nikhil Bhandarkar

Great post, great explanation.
But I can't figure out how to handle errors for different scenarios when I should not return any error message or http status from service layer.

Collapse
 
m07ameda7med profile image
Mohamed Ahmed

Thanks for the great article 🀩, I knew most of them but great to put all these in one place, but what about error handling it would be great if u discuss that too, thanks again πŸ™

Collapse
 
hallsamuel90 profile image
Sam Hall • Edited

Love the write up! One thing I'm struggling with is where to put more complex functionality in this structure.

Say for example I had some business need to pull data from an external source, transform it, and the push it to another source. In this scenario I would need something in the api and service layer to kick off the whole thing but from there I have trouble keeping things organized. I need all of the objects from data source A and B and another service to map from A to B. Would you create a module to house all of this logic or try to keep it flat in the structure you have shown here? Thanks!

Collapse
 
aurelmegn profile image
Aurel

Hello and thank you for your post,

I would like to suggest that you add an emphasis on the repositories,
Your post talk about the domain isolation from the controller but I think it would be great if you dive into the directory structure of the domain logic. I am referring to DDD philosophy

Collapse
 
codingstatus profile image
CodingStatus.com

This tutorial suggest me a basic & best way about MVC structure, Using this concept, I have developed MVC folder structure in Express with CRUD Example. So, Thanks for sharing..

Collapse
 
quentin_mrt profile image
Mauret πŸ‘¨πŸΌβ€πŸ’»

Hi, nice work ! I just don't understand your unit test. I understood that you had mock UserModel with create method, but when I try to do the same in my test i have got an error :

Argument of type '{ find: () => IUser[]; }' is not assignable to parameter of type 'UserModel'.
Type '{ find: () => IUser[]; }' is missing the following properties from type 'UserModel': watch, translateAliases, bulkWrite, findById, and 58 more.

Any idea to fix this ? Maybe I have to do this differently

Some comments may only be visible to logged-in visitors. Sign in to view all comments.