Promises as architecture

Hi there,

recently I’ve got an interesting issue which could be resolved by making several consequent async actions. Say,

  • you ask the back end if the user is logged in (GET /api/me),
  • if they are not, you ask Facebook API if the user is logged in there, (FB.getLoginStatus(callback)),
  • then, if they are logged into Facebook, you try to log in ’em into your app (POST /api/authenticate),
  • after receiving truthy response, you redo the GET /api/me and eventually render the user section.
  • Since we live in real world and shit happens, any step can return error, so we should show appropriate message and discontinue.

Possible solutions

The very obvious solution, invoking next action inside the callback of previous one, quickly leads to the callback hell. We don’t want horizontal scrollbar right?

I though also about async.js, it has pretty rich interface for handling async things. I remember using it 2 years ago. Is there something new in the industry?

Captain Promise comes to rescue

Since Promises are more or less standardized and are supported pretty well, I decided to go with this solution.

Architectural background

  1. If you have the Promise, you can attach .then(onResolve, onReject) to it.
  2. .then() returns Promise as well, so it’s chainable (remember jQuery chains?).
  3. Both onResolve and onReject can return either sync values, or Promises. Second means that next .then() happens after that Promise is resolved or rejected.Two important outcomes here:
    • we can mix sync and async actios in adjacent .then() calls; they will be invoked in proper order;
    • we don’t need wrap syns results in Promise.resolve() inside of .then() callbacks. Just return ’em.
  4. If .then() went through onRejected scenario, it returns Promise anyway and invokes next .then(). So rejecting the Promise in case of error is not the most convenient way. We’ll talk about this later.
  5. Don’t forget to add trailing .catch() other wise you spend a glory night from Friday to Monday debugging.That .catch() is the final destination for all uncaught exception happened anywhere in the .then() chain.

Let’s go!

First, make sure all your async things return new Promise():

export default const Ajax = {
  get (url) {
    return new Promise((resolve) => {
      // here the Ajax request goes
      const xhr = new XMLHTTPRequest();
      xhr.onreadystatehange = () => {
        if (xhr.readyState === 4 && xhr.status === 200) {
          resolve(JSON.parse(xhr.responseText));
        }
      }
      // ...all the rest that we need to fulfill the AJAX call
    });
  }
}

Something like that.

Then let’s negotiate the inter-Promise data protocol. Say, each Promise

  • resolves data only and does not got through the reject scenario. Why? First, we are too lazy to write onRejected on every .then(). Moreover, if this Promise was rejected, the next .then() happens anyway. So why use extra scenario?
  • resolves data as{error: Boolean, data: *|null, message: String, debug: *|null}

    Say in normal case we return

    {error: false, data: /* payload */},

    otherwise

    {error: true, message: 'Not loggen in', debug: /* network response */}.

    That lets us always go through the resolve scenario but have exhaustive answer about what happened and when.

Finally, the code

const DO_NOTHING = 'do-nothing';

Ajax.get('/api/me') // remember, this returns {Promise}, right?
// is user logged into the app?
.then((response) => {
  if (response.error) {
    return response;
  } else if (response.data.id) { // user is logged in
    response[DO_NOTHING] = true;
    return response;
  } else {
    // this is the Facebook's FB.getLoginStatus(callback)
    // but wrapped in Promise
    return FbAdapter.getLoginStatus();
  }
})

// is user logged into Facebook?
.then((response) => {
  if (response.error || response[DO_NOTHING]) {
    return response; // pass the data further
  } else if (response.status !== 'connected') {
    // not logged into Facebook
    return {
      error: true,
      message: 'Not logged into Facebook',
      debug: response
    };
  } else {
    // logged into Facebook; try to log into the app
    return Ajax.post('/api/authenticate', {
      id: response.authResponse.userID,
      token: response.authResponse.accessToken
    });
  }
});

// tried to authenticate the Facebook user into the app
.then((response) => {
  if (response.error || response[DO_NOTHING]) {
    return response; // pass the data further
  } else if (response.status !== 'logged-in') {
    // we don't expect this to happen but who knows...
    return {
      error: true,
      message: 'Facebook userID/authToken do not match?',
      debug: response
    };
  } else {
    return Ajax.get('/api/me');
  }
});

// finally pass the result to the Renderer
.then((response) => {
  if (response.data.id || response[DO_NOTHING]) {
    // ok, render the user section in normal way
    renderUser(response.data);
  } else {
    // something went wrong.
    // Inform the user and the developer separately
    console.log(response.debug);
    renderErrorMessage(response.message);
  }
})

.catch((error) => {
  // the exception happened in any `.then()`
  console.log(err);
});

So you see the code looks vertical and pretty straightforward, however it is asynchronous. It’s a bit long, but that’s unavoidable ― you must handle possible errors.
You can also see that all the code about rendering resides in one place: at very last .then() call. That separates receiving the data and using it.
By the way, you might find more elegant way to pass data from the first .then() to the last one as response[DO_NOTHING] 🙂

May the force be with you, Javascript warrior!

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s