DEV Community

Cover image for Designing a better architecture for a Node.js API
Thiago Pacheco
Thiago Pacheco

Posted on • Edited on

Designing a better architecture for a Node.js API

Some time ago I made a post about creating a Fullstack project with Node.js, React.js, and MongoDB. This is a very cool starter project that could help us get up and running with the basics.

But implementing a better architecture is very important, especially if you have a big project and you are working with a large team. This will help you develop and maintain easily your project.

So the objective of this post is to share my current API architecture and the way I found to create a better structure, applying design patterns and clean code.

Let's dive into code.

First of all, let's create our work folder and the initial files.

$ mkdir node-starter
$ cd node-starter
$ touch index.js
$ npm init -y
Enter fullscreen mode Exit fullscreen mode

Creating the structure

Now, let's create the base folders for the project

 $ mkdir config src src/controllers src/models src/services src/helpers
Enter fullscreen mode Exit fullscreen mode

Add dependencies

For this project, we are going to use Express and MongoDB, so let's add our initial dependencies.

$ npm install --save body-parser express mongoose mongoose-unique-validator slugify
Enter fullscreen mode Exit fullscreen mode

Add DEV dependencies

As we want to be able to use the latest ES6 syntax in this project, let's add babel and configure it.

npm i -D @babel/node @babel/core @babel/preset-env babel-loader nodemon
Enter fullscreen mode Exit fullscreen mode

Here we also added nodemon as a dev dependency to run and test the project easily.

Setting up babel

In the main folder, create a file called .babelrc with the following code:

{
  "presets": [
    "@babel/preset-env"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now go over to your package.json and add the following scripts

"scripts": {
    "start": "babel-node index.js",
    "dev:start": "clear; nodemon --exec babel-node index.js"
 }
Enter fullscreen mode Exit fullscreen mode

Create the server

Under config folder, create a file called server.js with the following code

import express from "express";
import bodyParser from "body-parser";
const server = express();

server.use(bodyParser.json());

export default server;
Enter fullscreen mode Exit fullscreen mode

Now let's import our server config into our index.js file:

import server from './config/server';

const PORT = process.env.PORT || 5000;
server.listen(PORT, () => {
  console.log(`app running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

At this point, you should be able to run your server with the following script:

$ npm run dev:start
Enter fullscreen mode Exit fullscreen mode

And you should get a response like this:

[nodemon] 1.19.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `babel-node index.js`
app running on port 5000
Enter fullscreen mode Exit fullscreen mode

Setting up the Database

Now let's set up our database.
For this, you must have MongoDB up and running in your local machine.

Under config, add the file database.js

//database.js

import mongoose from "mongoose";

class Connection {
  constructor() {
    const url =
      process.env.MONGODB_URI || `mongodb://localhost:27017/node-starter`;
    console.log("Establish new connection with url", url);
    mongoose.Promise = global.Promise;
    mongoose.set("useNewUrlParser", true);
    mongoose.set("useFindAndModify", false);
    mongoose.set("useCreateIndex", true);
    mongoose.set("useUnifiedTopology", true);
    mongoose.connect(url);
  }
}

export default new Connection();


Enter fullscreen mode Exit fullscreen mode

Here we are creating a singleton instance of our database by exporting a new Connection. This is automatically handled by node when you export it like this and it makes sure that you are only going to have one single instance of this class in your application.

And now, import it right in the beginning of your index.js file.

//index.js
import './config/database';
//...
Enter fullscreen mode Exit fullscreen mode

Create a model

Now let's create our first model.
Under src/models, create a file called Post.js with the following content.

//src/models/Post.js
import mongoose, { Schema } from "mongoose";
import uniqueValidator from "mongoose-unique-validator";
import slugify from 'slugify';

class Post {

  initSchema() {
    const schema = new Schema({
      title: {
        type: String,
        required: true,
      },
      slug: String,
      subtitle: {
        type: String,
        required: false,
      },
      description: {
        type: String,
        required: false,
      },
      content: {
        type: String,
        required: true,
      }
    }, { timestamps: true });
    schema.pre(
      "save",
      function(next) {
        let post = this;
        if (!post.isModified("title")) {
          return next();
        }
        post.slug = slugify(post.title, "_");
        console.log('set slug', post.slug);
        return next();
      },
      function(err) {
        next(err);
      }
    );
    schema.plugin(uniqueValidator);
    mongoose.model("posts", schema);
  }

  getInstance() {
    this.initSchema();
    return mongoose.model("posts");
  }
}

export default Post;

Enter fullscreen mode Exit fullscreen mode

Create our services

Let's create a Service class that is going to have all the common functionalities for our API, making it available for other services to inherit them.
Create a file Service.js under src/services folder;

//src/services/Service.js

import mongoose from "mongoose";

class Service {
  constructor(model) {
    this.model = model;
    this.getAll = this.getAll.bind(this);
    this.insert = this.insert.bind(this);
    this.update = this.update.bind(this);
    this.delete = this.delete.bind(this);
  }

  async getAll(query) {
    let { skip, limit } = query;

    skip = skip ? Number(skip) : 0;
    limit = limit ? Number(limit) : 10;

    delete query.skip;
    delete query.limit;

    if (query._id) {
      try {
        query._id = new mongoose.mongo.ObjectId(query._id);
      } catch (error) {
        console.log("not able to generate mongoose id with content", query._id);
      }
    }

    try {
      let items = await this.model
        .find(query)
        .skip(skip)
        .limit(limit);
      let total = await this.model.count();

      return {
        error: false,
        statusCode: 200,
        data: items,
        total
      };
    } catch (errors) {
      return {
        error: true,
        statusCode: 500,
        errors
      };
    }
  }

  async insert(data) {
    try {
      let item = await this.model.create(data);
      if (item)
        return {
          error: false,
          item
        };
    } catch (error) {
      console.log("error", error);
      return {
        error: true,
        statusCode: 500,
        message: error.errmsg || "Not able to create item",
        errors: error.errors
      };
    }
  }

  async update(id, data) {
    try {
      let item = await this.model.findByIdAndUpdate(id, data, { new: true });
      return {
        error: false,
        statusCode: 202,
        item
      };
    } catch (error) {
      return {
        error: true,
        statusCode: 500,
        error
      };
    }
  }

  async delete(id) {
    try {
      let item = await this.model.findByIdAndDelete(id);
      if (!item)
        return {
          error: true,
          statusCode: 404,
          message: "item not found"
        };

      return {
        error: false,
        deleted: true,
        statusCode: 202,
        item
      };
    } catch (error) {
      return {
        error: true,
        statusCode: 500,
        error
      };
    }
  }
}

export default Service;

Enter fullscreen mode Exit fullscreen mode

Ok, this seems a lot of code.

In this service, we created the main functionality (a basic CRUD) for our application, adding functions to get, insert, update and delete items.

Now, let's create our Post service and inherit all this functionality we just created.
Under src/services, create a file PostService.js with the following content:

//src/services/PostService
import Service from './Service';

class PostService extends Service {
  constructor(model) {
    super(model);
  }
};

export default PostService;


Enter fullscreen mode Exit fullscreen mode

It is as simple as that, it inherits all the functionality we created in our main Service.js file and it can be repeated across your API for all the other endpoints.

Create the controllers

We are going to follow the same principle we had while creating our services, here we are going to create a main Controller.js file that will have all the common functionalities and make the other controllers inherit it.

Create a file Controller.js under src/controllers and add the following code:

//src/controllers/Controller.js

class Controller {

  constructor(service) {
    this.service = service;
    this.getAll = this.getAll.bind(this);
    this.insert = this.insert.bind(this);
    this.update = this.update.bind(this);
    this.delete = this.delete.bind(this);
  }

  async getAll(req, res) {
    return res.status(200).send(await this.service.getAll(req.query));
  }

  async insert(req, res) {
    let response = await this.service.insert(req.body);
    if (response.error) return res.status(response.statusCode).send(response);
    return res.status(201).send(response);
  }

  async update(req, res) {
    const { id } = req.params;

    let response = await this.service.update(id, req.body);

    return res.status(response.statusCode).send(response);
  }

  async delete(req, res) {
    const { id } = req.params;

    let response = await this.service.delete(id);

    return res.status(response.statusCode).send(response);
  }

}

export default Controller;
Enter fullscreen mode Exit fullscreen mode

Now, let's create a PostController file under src/controllers

//src/controllers/PostController.js

import Controller from  './Controller';
import PostService from  "./../services/PostService";
import Post from  "./../models/Post";
const postService = new PostService(
  new Post().getInstance()
);

class PostController extends Controller {

  constructor(service) {
    super(service);
  }

}

export default new PostController(postService);

Enter fullscreen mode Exit fullscreen mode

Here, we are importing the desired service and model and we are also creating an instance of our Post service passing a Post model instance to its constructor.

Create the routes

Now it's time to create the routes for our API.

Under the config folder, create a file routes.js

//config/routes.js
import PostController from './../src/controllers/PostController';

export default (server) => {

  // POST ROUTES
  server.get(`/api/post`, PostController.getAll);
  server.post(`/api/post`, PostController.insert)
  server.put(`/api/post/:id`, PostController.update);
  server.delete(`/api/post/:id`, PostController.delete);

}
Enter fullscreen mode Exit fullscreen mode

This file imports the Post controller and map the functions to the desired routes.

Now we have to import our routes into our server.js file right after our body parser setup, like so:

//config/server.js
//...
import setRoutes from "./routes";
setRoutes(server);
//...
Enter fullscreen mode Exit fullscreen mode

Et Voila!!

At this point, you should be able to make requests to all the routes created, so let's test it.

Make a POST request for the route /api/post with the following json body:
Here you can use an API client like Postman or Insomnia to this task

{
    "title": "post 1",
    "subtitle": "subtitle post 1",
    "content": "content post 1"
}
Enter fullscreen mode Exit fullscreen mode

You should get something like this:

{
  "error": false,
  "item": {
    "_id": "5dbdea2e188d860cf3bd07d1",
    "title": "post 1",
    "subtitle": "subtitle post 1",
    "content": "content post 1",
    "createdAt": "2019-11-02T20:42:22.339Z",
    "updatedAt": "2019-11-02T20:42:22.339Z",
    "slug": "post_1",
    "__v": 0
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

There are lots of ways to design the architecture of an API, and the goal is always to have a cleaner and reusable code, don't repeating yourself and helping others to work along easily, plus it will also help yourself with maintenance and adding new functionalities.

You can find the source code here

Hope you may find it useful.
Bye!

Latest comments (99)

Collapse
 
diegoalejandrogomez profile image
Diego Alejandro Gomez

Btw, I am pretty surprised how toxic the community is. I would love to see code from those who talk about architecture

Collapse
 
diegoalejandrogomez profile image
Diego Alejandro Gomez

This is really helpful. I am a c++ engineer who suddenly had to do an api in node and this looks really practical. Good job and thank you!

Collapse
 
pacheco profile image
Thiago Pacheco

Thank you for the feedback Diego, I am super glad it helped you!

Collapse
 
mridul1024 profile image
Mridul Sharma • Edited

Thanks for this post Thiago! Its really helpful. By the way, I also have 2 questions to ask you -
Question 1 -> Do we need to write the business logic only inside the service module?
Question 2 -> Should we separate the database queries into a new module (a repository) or is it better to structure the application without a repository like you did?

Collapse
 
pacheco profile image
Thiago Pacheco

Hi Mridul, thanks for the feedback!
For the question one, yes the main idea was to have the business logic in the service layer.
For the second question I totally agree that it would be great to separate the database queries into a repository. I didn’t separate it like that in this tutorial for simplicity purposes.
I have created recently another structure that might accommodate these changes and some other features. For this new one I am using typescript and I also have the tests setup.
I am thinking about creating a series of articles about it, do you think it would be a helpful?
Thanks again!

Collapse
 
dadobaag profile image
DaDoBaag

Yes please make an article and/or repository about this architecture with TypeScript. I'm struggling to create a strictly typed backend with Express + Mongoose.

Thread Thread
 
pacheco profile image
Thiago Pacheco

I'll try to work on a tutorial about it this weekend. I can send you a repo earlier if you'd like to play around with the idea.

Thread Thread
 
dadobaag profile image
DaDoBaag

Awesome, I'd love to get a link to the repo!

Collapse
 
mridul1024 profile image
Mridul Sharma

Yes, It would definitely help me and other beginners like me. I was also looking for some good articles on unit testing and integration testing. Will it be possible for you to incorporate that in your articles?

Collapse
 
yrgamit profile image
Yrga well

What a great description thanks alot.

Collapse
 
mgurtd profile image
Marc Gurt

I used your arch proposal to build my new project API. It's working great!!

Now, for new features it's simple to scale ;)

Thank you for sharing, Thiago.

Collapse
 
pacheco profile image
Thiago Pacheco

Wow, really Marc!?
I am so glad to know that, thank you for sharing your experience with it!

Collapse
 
mgurtd profile image
Marc Gurt

Hi Thiago !

A made a pull request to the repo with some suggestions.

Thank you for share ,)

Thread Thread
 
pacheco profile image
Thiago Pacheco

Hey Marc, thank you for your collaboration!
I will have to update the article in order to merge your PR into the main branch, so I will do that once I have more time in here.
Nice implementation by the way with the 404 validation and the countDocuments update!

Collapse
 
slidenerd profile image
slidenerd

this works for something simple but most certainly not when you have different database needs for each model and your controllers also aggregate stuff

Collapse
 
sunilksamanta profile image
Sunil Kumar Samanta • Edited

Hey, This is really a very good structure. I've made my version following this tutorial.
Also included some basic modules like Media, Auth, and modified a little bit. Fixed some issues.
Here is the link for my article.
medium.com/@sunilksamanta/rest-api...

Collapse
 
pacheco profile image
Thiago Pacheco

Wow, that is super nice Sunil!
Great job on the implementation and all the extra functionality you created!

Collapse
 
alexn93 profile image
Aleksandar Nikolov

I tried adding another model(Comment) that is related to Post, but when I try to add .populate('comments') to getAll method in Service class I get
MissingSchemaError: Schema hasn't been registered for model "Comment".
This is how I've added it to Post model
comments: [
{
type: Schema.Types.ObjectId,
ref: 'Comment'
}
],

Collapse
 
pacheco profile image
Thiago Pacheco

Hi Aleksandar,

The property ref must receive the name of the schema you added in mongoose.model, not the name of the class you created.
For example, if your Comment schema is exporting the following: mongoose.model("comments"), then your ref should be: 'ref': 'comments'.

Let me know if that makes sense and if it solves the problem :)

Collapse
 
troywolf profile image
Troy Wolf

Your article got me from zero to a clean, easy-to-understand API in 10 minutes. Thank you!

Collapse
 
pacheco profile image
Thiago Pacheco

I am so glad to read that, thank you Troy!

Collapse
 
mausomau profile image
Mauricio

Excellent content¡¡ I am starting to study backend more seriously and I find your article very clear and simple, even though is a difficult topic (at least for me)

Collapse
 
pacheco profile image
Thiago Pacheco

Thank you very much Mauricio.

Let me know if you have any questions :)