DEV Community

Cover image for A Minimalist Architecture Pattern for Express.js API Applications
Kane Ong
Kane Ong

Posted on • Updated on

A Minimalist Architecture Pattern for Express.js API Applications

Express.js is a minimal web application framework that improves the productivity of web developers. It is very flexible and does not enforce any architecture pattern. This article demonstrates a new architecture pattern which I designed that will further improve your productivity.

How to read this article

This article introduces a pattern that is different from the popular MVC or MSC (Model-Service-Controller) pattern. You can read this article before knowing any of those.

Demo project GitHub

Let's create a restaurant app RESTful API.

Access rules

  • Public users:
    • create an account
    • log in
  • Members:
    • read all nearby available restaurants
  • Owners:
    • CRUD all nearby restaurants
  • Admins:
    • CRUD all nearby restaurants
    • CRUD all users

Requirements

  • Each restaurant object must have a name, geolocation coordinates, available status, and owner id.
  • Each user object must have a name, email, user type (member/owner/admin), and password.

Tech stack in this demo

  • Database: MongoDB
  • ORM: Mongoose

JSON Response convention

When we send a JSON data back to the client, we may have conventions that identify a successful or failed operation, for example

{
  success: false,
  error: ...
}
Enter fullscreen mode Exit fullscreen mode
{
  success: true,
  data: ...
}
Enter fullscreen mode Exit fullscreen mode

Let's create functions for the JSON responses above.

./common/response.js

function errorRes (res, err, errMsg="failed operation", statusCode=500) {
  console.error("ERROR:", err)
  return res.status(statusCode).json({ success: false, error: errMsg })
}

function successRes (res, data, statusCode=200) {
  return res.status(statusCode).json({ success: true, data })
}
Enter fullscreen mode Exit fullscreen mode

Here we use default arguments for both functions, the benefit here is we can use the function as:

errorRes(res, err)
successRes(res, data)
Enter fullscreen mode Exit fullscreen mode

and we don't have to check if the optional arguments are null.

// Example when default arguments not in use.
function errorRes (res, err, errMsg, statusCode) {
  if (errMsg) {
    if (statusCode) {
      ...
    }
    ...
  }
}

// or using ternary operator
function successRes (res, data, statusCode) {
  const resStatusCode = statusCode ? statusCode : 200
  ...
}
Enter fullscreen mode Exit fullscreen mode

Feel free to replace console.error with logging function (from other library) you prefer.

Database async callback convention

For create, read, update and delete operations, most database ORMs/drivers have a callback convention as:

(err, data) => ...
Enter fullscreen mode Exit fullscreen mode

knowing this, let's add another function in ./common/response.js

./common/response.js

function errData (res, errMsg="failed operation") {
  return (err, data) => {
    if (err) return errorRes(res, err, errMsg)
    return successRes(res, data)
  }
}
Enter fullscreen mode Exit fullscreen mode

Export all functions in ./common/response.js

module.exports = { errorRes, successRes, errData }
Enter fullscreen mode Exit fullscreen mode

Database operations (CRUD) conventions

Let's define the database operations functions for all models. The conventions here are using req.body as the data source and req.params._id as collections' object id. Most of the functions will take a model and a list of populating fields as arguments, except delete operation (it is unnecessary to populate a deleted record). Since delete is a reserved keyword in JavaScript (for removing a property from an object), we use remove as the delete operation function name to avoid confliction.

./common/crud.js

const { errData, errorRes, successRes } = require('../common/response')
const mongoose = require('mongoose')


function create (model, populate=[]) {
  return (req, res) => {
    const newData = new model({
      _id: new mongoose.Types.ObjectId(),
      ...req.body
    })
    return newData.save()
      .then(t => t.populate(...populate, errData(res)))
      .catch(err => errorRes(res, err))
  }
}

function read (model, populate=[]) {
  return (req, res) => (
    model.find(...req.body, errData(res)).populate(...populate)
  )
}

function update (model, populate=[]) {
  return (req, res) => {
    req.body.updated_at = new Date()
    return model.findByIdAndUpdate(
            req.params._id,
            req.body,
            { new: true },
            errData(res)
          ).populate(...populate)
  }
}

function remove (model) {
  return (req, res) => (
    model.deleteOne({ _id: req.params._id }, errData(res))
  )
}

module.exports = { read, create, update, remove }
Enter fullscreen mode Exit fullscreen mode

The database CRUD function above used the functions from ./common/response.

Ready for development

With all the functions above defined, we are ready for application development. We now only require to define data models and routers.
Let's define the data models in ./models

./models/Restaurant.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema
const ObjectId = Schema.Types.ObjectId
const validator = require('validator')


const restaurantSchema = new Schema({
  _id: ObjectId,
  name: { type: String, required: true },
  location: {
    type: {
      type: String,
      enum: [ 'Point' ],
      required: true
    },
    coordinates: {
      type: [ Number ],
      required: true
    }
  },
  owner: { type: ObjectId, ref: 'User', required: true },
  available: {
    type: Boolean,
    required: true,
  },

  updated_at: Date,
});

module.exports = mongoose.model('Restaurant', restaurantSchema, 'restaurants');
Enter fullscreen mode Exit fullscreen mode

./models/User.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema
const ObjectId = Schema.ObjectId
const validator = require('validator')


const userSchema = new Schema({
  _id: ObjectId,
  name: { type: String, required: true },
  email: {
    type: String,
    required: true,
    unique: true,
    validate: [ validator.isEmail, 'invalid email' ]
  },
  type: {
    type: String,
    enum: ['member', 'owner', 'admin'],
    required: true
  },
  password: { type: String, required: true, select: false },

  updated_at: Date,
});

module.exports = mongoose.model('User', userSchema, 'users');

Enter fullscreen mode Exit fullscreen mode

The models above are very common, nothing new or fancy within.

Routing and handlers

From the database convention above, you may think that using req.body as the data source is very limited if one needs a backend-process JSON field. Here we can use middleware to resolve the limitation.

./api/user.js

router
.use(onlyAdmin)
.post('/', create(User))
.get('/all/:page', usersAtPage, read(User))
.put('/:_id', handlePassword, update(User))
.delete('/:_id', remove(User))
Enter fullscreen mode Exit fullscreen mode

./api/restaurant.js

const express = require('express')
const router = express.Router()
const { create, read, update, remove } = require('../common/crud')
const Restaurant = require('../models/Restaurant')

router
.get('/all/:lng/:lat/:page', nearBy(), read(Restaurant, ['owner']))
.get('/available/:lng/:lat/:page',
  nearBy({ available: true }),
  read(Restaurant, ['owner'])
)

function nearBy (query={}) {
  return (req, res, next) => {
    const { lng, lat, page } = req.params
    req.body = geoQuery(lng, lat, query, page)
    next()
  }
}
Enter fullscreen mode Exit fullscreen mode

./api/auth.js

router
.post('/signup', isValidPassword, hashPassword, signUp)
.post('/login', isValidPassword, findByEmail, verifyPassword, login)

// middlewares below are used for processing `password` field in `req.body`
function isValidPassword (req, res, next) {
  const { password } = req.body
  if (!password || password.length < 6) {
    const err = `invalid password: ${password}`
    const errMsg = 'password is too short'
    return errorRes(res, err, errMsg)
  }
  return next()
}

function hashPassword (req, res, next) {
  const { password } = req.body
  bcrypt.hash(password, saltRounds, (err, hashed) => {
    if (err)
      return errorRes(res, err, 'unable to sign up, try again')
    req.body.password = hashed
    return next()
  })
}

function signUp (req, res) {
...
}

function findByEmail (req, res, next) {
....
}

function verifyPassword (req, res, next) {
  ...
}

function login (req, res) {
  ...
}

module.exports = router;

Enter fullscreen mode Exit fullscreen mode

How to extend

Extending the application only requires to add new models and define new routers for endpoints.

Differences from MSC

The Model-Service-Controller pattern requires every database model to have a set of service functions for data operations. And those service functions are only specifically defined for a particular model. With the new architecture above, we skip the definition of service functions for each model by reusing the common database operations functions, hence improving our productivity.

Summary

This architecture provides great flexibility for customization, for example, it does not enforce a folder structure other than having a common folder, you are free from putting all middleware functions in router files or separating them by your rules. By using and extending the functions in the common folder, you can either start a project from scratch or refactor/continue a large project productively. So far I have been using this architecture for any size of ExpressJS projects.

GitHub logo dividedbynil / ko-architecture

A Minimalist Architecture Pattern for ExpressJS API Applications

K.O Architecture Demo

  • Framework: ExpressJS
  • Database: MongoDB
  • Authentication: JSON Web Token

Experiment data

APIs document

Postman APIs collection and environment can be imported from ./postman/

Pre-running

Update the ./config.js file

module.exports = {
  saltRounds: 10,
  jwtSecretSalt: '87908798',
  devMongoUrl: 'mongodb://localhost/kane',
  prodMongoUrl: 'mongodb://localhost/kane',
  testMongoUrl: 'mongodb://localhost/test',
}
Enter fullscreen mode Exit fullscreen mode

Import experiment data

Open a terminal and run:

mongod

Open another terminal in this directory:

bash ./data/import.sh

Start the server with

npm start

Start development with

npm run dev

Top comments (1)

Collapse
 
josepaulo95_23 profile image
JosePaulo95

Very thanks