Exploring Asynchronous Promise-Based Workflows In AngularJS
At InVision, we're going to start using a lot more Node.js to build out micro-services. And, while I love JavaScript, the vast majority of my server-side code has dealt with the synchronous, blocking nature of ColdFusion. As such, I feel like I need to start exploring more asynchronous workflows in JavaScript. And, while AngularJS is clearly not Node.js, it is JavaScript and it does have promises. That, combined with all the dependency injection, makes it a convenient context in which to practice.
Run this demo in my JavaScript Demos project on GitHub.
While most of the Node.js community uses callbacks [hell] for everything, I have a hard time envisioning any asynchronous workflow without Promises. That said, promises are no walk in the park either; while they do simplify things, wrapping your head around a promise-based workflow can be tricky. Just the other day, I was joking with Kyle Simpson that, no matter what I write about promises, he'd likely be able to point out the problems in my thought process:
Right now, I think about the promise chain like a series of gates that the control flow must go through. Each gate has two openings: one for Resolved state and one for Rejected state. If one of your gates is missing a callback for a particular state, the control flow moves onto the next gate using the same state.
If your gate does have a callback for the particular state, however, the result of the callback will determine how the control flow moves onto the next gate. If you return a non-rejected value, the control flow moves onto the next gate in the Resolved state. If you return a rejected value, the control flow moves onto the next gate in the Rejected state. I've tried to illustrate this in the following graphic:
Each callback can also return a promise. This promise is then used to negotiate the control flow movement onto the next "gate."
To explore this, in an AngularJS context, I tried to create a workflow that includes a series of asynchronous steps:
- Load two different friend records.
- Make sure they are superficially compatible.
- Make sure there is no existing friendship.
- Forge a new friendship.
- Log the event to the metric systems.
Since I'm not using any server-side data, all the data is being mocked. That said, all the data is still being delivered via promises, so the lack of HTTP requests shouldn't actually make a difference.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Exploring Asynchronous Promise-Based Workflows In AngularJS
</title>
</head>
<body ng-controller="AppController">
<h1>
Exploring Asynchronous Promise-Based Workflows In AngularJS
</h1>
<p>
<strong style="background-color: yellow ;">CAUTION</strong>: I wouldn't actually
put so much workflow coordination on the <em>client-side</em>. Instead, I would
put all of this behind a single asynchronous call to the server API. I am doing
this here - in AngularJS - because the framework is <strong>JavaScript</strong> and
has <strong>Promises</strong>. As such, it makes it really easy to test with; and,
it makes it quite applicable to other asynchronous server-side technologies like Node.js.
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.8.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $q, friendService, logService ) {
// As the asynchronous workflow executes, we'll want to keep track of
// these values, externally, so that we don't have to constantly pass
// them through each resolve and reject transformation.
var friendOne = null;
var friendTwo = null;
// The goal here is to create a friendship. This requires us to:
// --
// 1. Get each friend in question.
// 2. Apply superficial validation.
// 3. Make sure a friendship doesn't already exist.
// 4. Create the friendship.
// --
$q.all([
friendService.getFriend( 1 ),
friendService.getFriend( 2 )
])
.then(
function handleFriendsResolve( friends ) {
friendOne = friends.shift();
friendTwo = friends.shift();
// Apply superficial validation.
if ( friendOne.likesMovies !== friendTwo.likesMovies ) {
return( $q.reject( "MovieIncompatibility" ) );
}
// If we've gotten this far, the superficial validation has
// completed. Now, we need to see if we can create a new
// friendship. This is an interesting step because we want
// to proceed IF the existing friendship check FAILS and stop
// if the check passes (ie, the friendship already exists).
// In order to do this, we have to create an intermediary
// promise chain that will, essentially, swap the state of
// the response for the next step in the asynchronous parent
// workflow.
var friendshipPromise = friendService.getFriendship( friendOne.id, friendTwo.id )
.then(
function handleResolve() {
// Since the friendship already exists, we don't
// want to try to re-create it. Return a rejected
// value so that the parent workflow will enter a
// rejected state.
return( $q.reject( "AlreadyExists" ) );
},
function handleReject() {
// This is rejecting because the friendship can't
// be found; but, that is good for us - it means
// that we can move ahead with the creation of a
// new friendship. As such, initiate the
// friendship creation request and then just pass
// the resultant promise back into the parent,
// asynchronous workflow.
return(
friendService.makeFriendship( friendOne.id, friendTwo.id )
);
}
)
;
// Return the pending result of our intermediary friendship
// check. The resolution of this will determine which
// callback gets invoked on the next step.
return( friendshipPromise );
}
)
.then(
function handleFriendshipResolve() {
// Woot! The friendship record was created successfully.
console.log( "Friendship forged!" );
console.log( friendOne.name, "and", friendTwo.name );
// Don't include this call as part of the promise workflow.
// If the logging were to fail, we don't want to actually
// put the asynchronous workflow into a rejected state.
// --
// NOTE: The lack of Try/Catch here is because we are assuming
// that the log-service uses promises.
logService.logEvent({
type: "friendship",
parameters: [ friendOne.id, friendTwo.id ]
});
}
)
.catch(
function handleReject( error ) {
// Something went wrong someone where in the asynchronous
// workflow. Since none of the steps above have an error-
// handler, this could be do:
// --
// 1. At least one Friend failed to load.
// 2. The friends were superficially incompatible.
// 3. The friendship already existed.
// 4. Unexpected network or server-side errors.
// --
console.warn( "Something went wrong." );
console.log( error );
}
)
.finally(
function handleFinally() {
// This method gets called regardless of whether or not the
// overall asynchronous workflow was successful. It gives us
// a chance to lean up after ourselves. For now, let's just
// clean up closed-over variables.
$q = friendService = logService = friendOne = friendTwo = null;
}
)
; // END: Asynchronous workflow.
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I provide some promise-based access to the friends repository.
// --
// CAUTION: These are not real access methods - they just provide immediately
// resolved or rejected values.
app.service(
"friendService",
function( $q ) {
// Return the public API.
return({
getFriend: getFriend,
getFriendship: getFriendship,
makeFriendship: makeFriendship
});
// ---
// PUBLIC METHODS.
// ---
// I get the friend with the given ID.
function getFriend( id ) {
if ( id === 1 ) {
var friend = {
id: 1,
name: "Tricia",
likesMovies: true
};
} else if ( id === 2 ) {
var friend = {
id: 2,
name: "Sarah",
likesMovies: true
};
} else if ( id === 3 ) {
var friend = {
id: 3,
name: "Joanna",
likesMovies: false
};
} else {
return( $q.reject( "NotFound" ) );
}
return( $q.when( friend ) );
}
// I get the friendship between the given friend IDs.
function getFriendship( idOne, idTwo ) {
// return( $q.when( {} ) );
return( $q.reject( "NotFound" ) );
}
// I forge a new friendship between the given friend IDs.
function makeFriendship( idOne, idTwo ) {
// return( $q.reject( "UnexpectedError" ) );
return( $q.when( true ) );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I provide a promise-based logging service.
// --
// CAUTION: These are not real access methods - they just provide immediately
// resolved or rejected values.
app.service(
"logService",
function( $q ) {
// Return the public API.
return({
logEvent: logEvent
});
// ---
// PUBLIC METHODS.
// ---
// I log the given event to the remote repository.
function logEvent( event ) {
return( $q.reject( "NetworkError" ) );
}
}
);
</script>
</body>
</html>
The most interesting part of this asynchronous Promise control flow is checking to see if the friendship already exists. Since we don't want to create duplicate friendships, a "rejection" of the friendship request is actually a good thing - it means that we can move forward. This is why we are translating the "rejected" state into a "resolved" state when moving onto the "create friendship" step.
You'll also notice that I need to break the friendship-check out into an intermediary promise workflow. I have to do this because the error / rejected handler needs to be isolated in order to properly transform the control flow. If it were part of the main promise chain, it would be invoked if any error occurred, such as a failure to load one of the friends. That said, the intermediary promise chain is easily piped back into the main promise chain - all we have to do is return the intermediary promise and it will automatically be used to resolve the next step.
Promises are insanely powerful. But, they are also quite complex in their simplicity. Hopefully, my mental model is starting to become more accurate. And, hopefully the practice of promises in AngularJS is easily translatable to relevant parts of Node.js.
Want to use code from this post? Check out the license.
Reader Comments
Good points, understanding how the rejected promise gets handled by the first catch function but then becomes good again is very important.
I call these promise paths, but overall the idea is the same http://bahmutov.calepin.co/promise-paths.html
@All,
Promises are complex! Here are two articles that I found particularly helpful:
Gleb Bahmutov - http://bahmutov.calepin.co/error-handling-in-promises.html
Tao Of Code - http://taoofcode.net/promise-anti-patterns/
This latter one has been particularly helpful. I've had that baby beast bookmarked for a long time!
@Gleb,
Ha ha, small world - I was *just* posting a comment here with a link to another one of your blog posts :D
The node community doesn't use callback hell. Rather, node style callbacks (err then value) are a low level construct that stay unopinionated and can be built upon. Both Q and bluebird can convert those into promises. Now that io.js ships with both promises and generators you can also start using those to do async/await style callback-less asynchronous code (see ES7 async method proposal, or if you want to use it today without transpiler, co.js or bluebird coroutines).
Promises are awesome, but they are just the middle ware. Callbacks are the low level constructs. Async method programming is the way to go once its accessible.
Nice article.
Although I think it could be improved if you use pure asynchronous services as $http (mocked) or $timeout. In this way, you could show how to use the deferred object $q.defer() and how to handle the promise while still unresolved (before being resolved/rejected). These parts can't be seen now.
Thanks for sharing!
@Francois,
I've heard really positive things about Bluebird, but have not looked into it personally. I can imagine, though, that so much of the Node.js core is based on callbacks that trying to shoe-horn promises on top of that could get a little funky. To date, all of my Node.js experience (which is primarily R&D on my localhost) has used callbacks.
I'm definitely a fan of asynchronous processing. But, have become quite smitten with Promises as the callback sugar. Just the other day, I was reading some code that was like:
get something from MongoDB
. . . get something else from MongoDB
. . . . . . save something to MongoDB
. . . . . . . . . redirect user to detail page.
... and, I was reading that in an eBook - so, you can imagine that by the time I got to that inner-most callback, I was getting like 1-2 words on a line before it wrapped :D
The author of the eBook did mention that Node.js has a super popular module called "Async", which looks like it was trying to tame the core callback approach with some parallel features. But, having not seen it before, it was a bit hard to follow - lots of "next()" callbacks being passed around.
So much to learn!
@Gerard,
Ah, good point. The demo definitely glosses over the asynchronous nature of remote-HTTP requests. But, the great thing about Promises is that they are always asynchronous! So, whether its a "next tick" under the hood, or a 5-second network latency, the logic for consuming the Promise shouldn't really change much at all.
If I were to replace things with a $timeout, it might look something like this:
Hope that makes things a bit more clear. You can see that it adds a bit more verbosity, which is why I just went with $q.when() and $q.reject().
Thanks for the post!
I am currently working on a "pendingRequests" service to track and to cancel all pending requests from all controllers. It uses a similar process to canceling promises as you have written about in your blog posts. The biggest difference is that I configure $httpProvider to add a promise to each http request and use my service to keep track of each pending request (add/remove requests to tracking array). Calling pendingRequests.cancelAll() loops through all the promises for pending requests and resolves them.
Do you think this is the best way to do this or is there additional cleanup I should do after I resolve the promises?