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: ...
}
{
success: true,
data: ...
}
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 })
}
Here we use default arguments for both functions, the benefit here is we can use the function as:
errorRes(res, err)
successRes(res, data)
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
...
}
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) => ...
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)
}
}
Export all functions in ./common/response.js
module.exports = { errorRes, successRes, errData }
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 }
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');
./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');
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))
./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()
}
}
./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;
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.
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
- origin: restaurants.json
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',
}
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)
Very thanks