The Art of Promise-Based Architecture

Written by Yuri Takhteyev

Any non-trivial JavaScript requires dealing with asynchronicity. JavaScript can't wait, so if something that you want is not available right away, your code can't just sit around until the result comes back. Instead, the baseline solution is a callback: you provide a function that will be called when the result is either available or the operation is known to have failed.

The basic callback model, however, creates serious problems for good code organization. Many of those problems can be resolved by switching from raw callbacks to promises. Embracing promises and treating them as a standard container for data can simplify your code organization. I call this "promise-based architecture."

Promises are awesome

This video is from my talk at the FITC Spotlight Front-End conference in Toronto and it presents our take on promises in a more extended way. Below I summarize the key points.

What's a Promise?

A promise is an object with a ".then()" method that represents an operation whose result may be unknown. Whoever has access to the object can use the ".then()" method to add callbacks to be notified about the successful completion or failure of the operation.

Custom Angular 2 Training

So, how is this better than just callbacks?

The standard callback pattern requires us to provide the callback at the same time as we specify the request. E.g.:

request(url, function(error, response) {  
  // handle success or error.
});
doSomethingElse();  

Unfortunately, the code that specifies the request usually doesn't know what's to be done when the request completes - nor should it have to know. So, we end up passing callbacks through, resulting in the proverbial "pyramid of doom":

queryTheDatabase(query, function(error, result) {  
  request(url, function(error, response) {
    doSomethingElse(response, function(error, result) {
      doAnotherThing(result, function(error, result) {
        request(anotherUrl, function(error, response) {
          ...
        });
      });
    });
  });
});

Promises solve this problem by allowing lower-level code to just make a request and return an object representing the incomplete operation, letting the caller decide what callbacks should be added.

Grokking Promises

Promises can be counterintuitive. To understand promises, the most important principle to remember is this:

.then() always returns a new promise.

Two parts of that "Law of Promises" are worth highlighting separately:

  1. .then() will always return a new promise, every time you call it. It doesn't matter what the callback does, since .then() gives you back a promise before the callback is even called. The behaviour of the callback only affects the resolution of the promise. If the callback returns a value, the promise will resolve with that value, or, if this value is itself a promise, to the value that the returned promise resolves to. If the callback throws an error, the promise will reject with that error. (See the video around 17 minutes in.)

  2. The promise returned by .then() is a new promise. It is not the same promise whose .then() was just invoked. Long chains of promises sometimes appear to hide this fact, yet every .then() produces a new promise. An important implication of this is that you really must consider the possibility that the promise returned by your last .then() represents a rejection. If you don't catch that rejection, it's easy to end up in a sitation where the errors appear to be "swallowed". (See the video around 21 minutes.)

Passing the Buck

One great thing about promises, however, is that you do not have to handle errors if your function will return the promise to the caller. Your caller can attach their own callbacks. This often makes a lot of sense, since the code that sets up the request often has little idea of what ought to happen if the request fails. Another useful pattern is to catch the errors with a callback, do something with them (e.g., log), then throw a new error that would be more appropriate to the caller. The video gets into this around the 27 minute mark.

Promise Chains Considered Harmful

Some people seem to think that being able to chain .then() calls in a "fluent" manner is the best thing about promises. Long promise chains, however, can get very confusing. It is a lot better to break them up into meaningful functions:

function getTasks() {  
  return $http.get('http://example.com/api/v1/tasks')
    .then(function(response) {
      return response.data;
    });
}

function getMyTasks() {  
  return getTasks()
    .then(function(tasks) {
      return filterTasks(tasks, {
        owner: user.username
      });
    });
}

In this example, each of the two functions gets a promise from elsewhere and attaches one callback. Neither attaches error callbacks, since those will be added by the upstream caller.

Stay Consistent

A functions that sometimes returns a promise should always return a promise. In this case, the caller does not need to check what the returned value is and can attach a callback without hesitation.

If you are not sure whether your operation will eventually be synchronous or not, assume asynchronous and return a promise.

Neat Things You Can Do with Promises

Promises provide a great abstraction and can serve as the main "currency" of your application. In this setup a typical function gets a promise (or several) by calling some other function, attaches a callback with .then() and returns the resulting promise. Nobody ever needs to wait and you don't end up with the infamous "callback hell".

Additionally, however, promises can allow you to do a few things that may not be obvious at first. The same promise can accept an arbitrary number of callbacks. When the promise is resolved, all of those callbacks are going to be invoked. Additionally, a promise can accept new callbacks even after it has resolved: those callbacks will just be called right away. This allows us to use callbacks to implement a simple form of caching:

var tasksPromise;  
function getTasks() {  
  taskPromise = taskPromise || getTasksFromTheServer();
  return taskPromise;
}

In this example, the getTasks() function can be called an arbitrary number of times and it will always return the same promise. The underlying function getTasksFromTheServer() will only be called once. See my talk video at around 40 minutes for this, plus additional examples.

Promises and Functional Composition

Promises combine well with functional programming. In particular, here at rangle we've been big fans of a functional library called Ramda, which we have discussed in a recent blog post "Practical Functional JavaScript with Ramda". In our efforts to make Ramda more friendly to promises we contributed a pair of methods for promise-aware functional composition (R.pCompose and R.pPipe). See the video or the linked blog post for examples.

Promises and ES6

And while promises are pretty great now, they'll get even better with ES6. One great piece of news is that promises will become a standard feature of JavaScript in ES6. The second great piece of news is that Koa-style use of generators will help us write asynchronous code in quasi-synchronous ways.

-

Are you optimistic about the future of promises? Let us know your thoughts on promise based architecture in the comments below!