Promises

A promise represents the eventual result of an asynchronous operation. The primary way of interacting with a promise is through its then method, which registers callbacks to receive either a promise’s eventual value or the reason why the promise cannot be fulfilled

A quick Google search will tell you that the two most popular implementations of Promises in NodeJS are the Q and Bluebird libraries.


In this blog I am mainly talking about using NodeJS as an API server to server requests from a MongoDB database using the Mongoose driver.


A Sample API Application

Consider the use case that we are creating APIs for an app which helps its users to book and schedule Sunday football matches among themselves.

We want a function getRegisteredUserNames which returns the names of all players who have registered this week. We look first at some of the the ways to achieve this and what could be wrong in them.


The Dreaded Deferred

We first take a look at an implementation using Q's defer

var Q = require('q');

var getRegisteredUserNames = function (startDate, endDate) {
    var defer = Q.defer();

    try {
        var query = {day: {$gte: startDate, $lte: endDate}};
        var project = {name: 1};
        function onDocsFunc(err, data) {
            if(err) {
                defer.reject(err);
            }
            else {
                defer.resolve(data);
            }
        }
        RegisteryCollection.find(query, project).lean().exec(onDocsFunc);
    }
    catch (ex) {
        defer.reject(ex);
    }
    return defer.promise;
};

The above function getRegisteredUserNames returns a promise, and would work as it should. We go and execute it, and in the then callback, we receive the data points. Notice the try-catch block here. This is added since if the code breaks here, the function calling this method has no way to detect what happened. Calling this function, the data looks like this -

[
    {name: 'John'},
    {name: 'Adam'},
    {name: 'Mat'},
    {name: 'Prince'}
]

But what we wanted was a list of names rather than objects. One easy workaround here would be to modify the defer.resolve(data), and resolving the modifiedData.

var modifiedData = data.map(function (x) {
    return x.name
});
defer.resolve(modifiedData);

Now we ask the question - Does this code handle all exceptions? What happens if it does not? Obviously Node will throw an exception. But who's catching that exception? What happens to the promise?

Yes, we need another try-catch over here, else the promise is never resolved nor rejected.

try {
    var modifiedData = data.map(function (x) {
        return x.name
    });
    defer.resolve(modifiedData);
}
catch (ex) {
    defer.reject(ex);
}

Ugly? Imagine doing this everywhere in your code, using try-catch blocks everywhere in all callbacks as you write multiple application logics. I'm sure half of your time and lines of code is spent writing these redundant blocks. Also it makes the code harder to debug and maintain. What can be done?


Separation of Concerns

The problem above is that we are trying to mix application logic into a promise creation utility. Create promise at one place and reuse it elsewhere is the general rule to follow. Also, using the defer objects is a kind of an anti-pattern here and can be fully avoided by making use of the Promise constructor provided by the promise library of your choice.

var getRegisteredUserNames = function (startDate, endDate) {
    return Q.Promise(function (resolve, reject) {
        var query = {day: {$gte: startDate, $lte: endDate}};
        var project = {name: 1};
        function onDocsFunc(err, data) {
            if(err) {
                reject(err);
            }
            else {
                resolve(data);
            }
        }

        RegisteryCollection.find(query, project).lean().exec(onDocsFunc);
    })
};

getRegisteredUserNames(startDate, endDate).then(function (data) {
   return data.map(function (x) {
       return x.name
   });
})
.catch(function (err) {
    logger.error(err);
});

Notice that the Q.Promise (Promise in Bluebird) implementation is a wrapper around our code. Any exceptions thrown by the code are made into rejections with the appropriate error. Here, we separated the application logic away into the callee function, where we perform the manipulations.


Second Argument of Promises

outPromise.then(
    resolveHandler, 
    rejectHandler
);

The second argument of promises takes a callback which is executed when the promise rejects. In most circumstances avoid using this. The .catch() / .fail() method is provided specifically for this purpose and makes life much simpler. Note that this is not part of the Promise A+ guidelines, but is anyways provided by both the promise implementations.

outPromise.then(
    resolveHandler
).catch(
    rejectHandler
);

Writing APIs

We now look at how to integrate everything together into a NodeJS API using ExpressJS.

We come back to our original example of creating a football match scheduling service. We now wish to write an API which given a start_date and end_date will generate random teams, then schedule the matches, and finally send emails to all the players informing them of the team they belong to.


We break down the problem into the following parts -

  • Fetch the list players who registered.
  • Fetch emails for each user
  • Randomly generate teams
  • Send emails to each player mentioning the team the user belongs
We assume that there are two collections in MongoDB - User and Registry.

Writing the API endpoint in the routes/index.js file

router.post('/createMatches', function (req, res) {
    var params = {
        startDate: req.body.start_date,
        endDate: req.body.end_date,
    };
    createMatches(params).then(data => {
        res.status(200);
        res.send(data);
    }).catch(err => {
        res.status(500);
        logger.error(err);
        res.send(err);
    })
});

We now write the controller createMatches. Remember that since this method will just implement application logic, we should not be creating promises here, rather just manipulating them around. However, there are still chances of typos here and there which might go uncaught, and our promise would just be dangling since there is no appropriate handler. To take care of this, the Bluebird library provides a handy wrapper around the functions - Promise.method. We just need to wrap the any function in this, and it should handle any such issues with the code.

Note that in case you are using ES6, you might want to fall back on using the full function definition instead of the array notation while using Promise.method.

var createMatches = Promise.method(function (params) {
    var startDate = params.startDate;
    var endDate = params.endDate;

    return Registry.getUserNames({startDate: startDate, endDate: endDate})
        .then(function (data) {
            return data.map(function (x) {
                return x.name; // ['john', 'adam', 'john']
            });
        })
        .then(function (data) {
            return Array.from(new Set(data)); // remove duplicates - ['john', 'adam']
        })
        .then(function (userNameData) {
            var userEmailPromises = userNameData.map(function (userName) {
                return User.getEmail(user);
            });
            return Promise.all(userEmailPromises); // ['john@gmail.com', 'adam@gmail.com']
        })
        .then(function (userEmailData) {
            var shuffledUserEmailData = shuffle(userEmailData); // random shuffling
            return assignTeam(shuffledUserEmailData);
            // [{email: 'john@gmail.com', team: 1}, {email: 'john@gmail.com', team: 2}]
        })
        .then(function (teamEmailData) {
            var sendEmailPromises = teamEmailData.map(function (data) {
                return sendEmail(data.email, data.team);
            });
            return Promise.allSettled(sendEmailPromises);
        })
});

The implementations of the methods under User, Registry and sendEmail should only do what they are supposed to do. Thus they should merely be wrappers around the existing Mongoose/Mongodb/SMTP functions, and might use the new Promise if the underlying API only provides a callback (Bluebird allows to promisify callbacks)


The advantages of following the above style is that even though we are doing a lot of things, our logic is flat. Using multiple then statements, we know the exact execution order. Any errors in any of the step is propagated forward and is caught at the index.js file in the .catch() block. Thus a single place to catch all errors.


Conclusion

Separation of concerns along with manipulating promises is the correct way. This way we utilize the maximum capabilities of the awesome promise libraries provided, making code easier to understand, and thus easier to maintain.


More link to read -


Comments Section

Feel free to comment on the post but keep it clean and on topic.

blog comments powered by Disqus