Sane, scalable Angular apps are tricky, but not impossible. Lessons learned from PayPal Checkout.

A year ago at PayPal we re-built our Checkout flow using Angular.

We’ve learned a lot along the way. Angular’s surface area is huge; as a framework, it’s taken many years to evolve and maintain backwards compatibility. As a result, without a lot of trial and error, it’s pretty difficult to figure out which parts of Angular to use and which parts to avoid.

This has led to a pretty strong backlash in the community, with an influx of blog posts about why Angular sucks: some things are weird, some things just don’t work in the way you expect them to, some things are inconsistent, others are non-performant.

This article will lay out a set of good practices that we’ve come to terms with, to avoid many of these pitfalls when writing medium to large end-to-end Angular apps.

Fair warning: this is a fairly opinionated set of rules, and if you’re already an Angular aficionado, there may be many things here you disagree with. But if you’re starting (or refactoring) an app on the 1.x stack, this list may save you a lot of headaches.

This assumes a working knowledge of Angular 1.x. I’m not going touch on Angular 2.0 at all, since it comes with its own set of best practices.

Don’t use ng-controller

<div ng-controller="myController">
<strong>{{foo}}</strong>
</div>

So what’s the problem with this?

  • This pattern doesn’t enforce a 1–1 coupling between controller and template, which is the essence of a more component-oriented app
  • Your controllers don’t end up being especially re-usable, unless you’re very careful to use some kind of convention with the stuff you put into $scope.
  • There are way too many disparate ways to attach a controller to a template. ng-controller, route definitions, directives, etc… ng-controller is the least flexible of the bunch.

Don’t specify controllers in your routes

$routeProvider.
when('/phones', {
templateUrl: 'partials/phone-list.html',
controller: 'PhoneListCtrl'
})

The problem? This only works for routes. The moment I need a reusable child component for my page which isn’t associated with a route, I’m stuck using a different pattern altogether, and my app becomes harder to reason about.

So how can we avoid all of this inconsistency? What is the ‘one true way’ to link together templates and a controllers, and create components? The answer is…

Make everything a directive

myapp.directive('foo', function() {
return {
scope: {},
template: myTemplate,
controller: function($scope) {
$scope.foo = 'bar';
}
};
});

Now I can use a <foo></foo> wherever I want in my app, and:

  • It is always guaranteed to use the same template and controller, I no longer have to worry about specifying both correctly
  • I can pass down attributes to my new element, including complex objects, arrays and functions in order to change how it behaves
  • I can pass down a callback and have my component callback and tell me when an action has occurred, or something has successfully take place or changed
  • It has an isolated scope, so I don’t have to worry about leaking anything between this component and its ancestors.

But what about routing? It actually ends up being pretty straightforward, and directives are pretty-much routable out of the box:

$routeProvider.
when('/phones', {
template: '<phone-list></phone-list>',
})

Now everything is a component, including our top level pages, and all of our components work exactly the same way. This is a big win for readability, comprehensibility, and reusability.

Always use an isolated scope

Instead, in the above example, we pass —

scope: {}

— which has the effect of isolating the scope, and making sure we can’t modify the parent’s scope. This is a really good rule to follow, as it ensures we don’t have any leaky state or leaky abstractions between our component and its parent.

Another bizarre option provided to us by Angular is —

scope: true

— which gives the directive a new scope. However, this scope prototypically inherits from its parent scope. If that sounds like a confusing, terrible idea for component scopes, you’re dead right.

So, always isolate your directive’s scope.

Only ever bind to sub-properties of an object

Just be sure not to do this:

<input type="text" ng-model="username" />

Spot the bug? Probably not. The issue here is you’re binding directly to a property of your parent scope. The problem is, you don’t necessarily know what scope that is going to be.

This has been discussed in much greater detail elsewhere, but suffice to say that if you do this, you’re going to run into some horrific, virtually un-debuggable issues later when you accidentally bind to a scope you’re not expecting to.

For example, whenever you use ‘ng-if’, your scope is now one of those weird ‘prototypically inherited scopes’ we talked about earlier. This means that it’s very difficult to determine whether the form data is going to be set on your directive’s scope, or on the child scope created by ng-if.

So how do you guarantee that ng-model always binds to your directive’s scope? Simple, but good to remember:

  1. Create a container object in your directive’s scope
  2. Bind your input to a property of that object, never directly to $scope
<input type="text" ng-model="user.name" />

Limit your use of $rootScope

So, definitely don’t keep state here. Instead…

Keep your state as close as possible to the components which need it.

This is made really easy with directives. Just specify what parameters you want yours to accept:

myapp.directive('foo', function() {
return {
scope: {
bar: '='
},
template: myTemplate,
controller: function($scope) {
console.log($scope.bar);
}
};
});

And pass them down:

<foo bar="baz"></foo>

Forget about services and providers

Just use factories. Forget about the rest.

myapp.factory('foo', function() {
return something;
});

Now when you specify ‘foo’ as a dependency, you’ll get whatever was returned from your factory function, be it a constant, an object, a function, or anything else.

That’s all you need to remember to start creating dependencies that can be injected elsewhere.

Forget about module.config

For some reason, Angular makes an unnecessary distinction between ‘providers’ and ‘factories/services/everything else’, where providers (and nothing else) can be accessed in a config block, while everything else (but not providers) can be used elsewhere.

Clear as mud, right? My advice is to just forget that providers and config blocks even exist, as there’s no really useful reason for them that we’ve ever encountered.

The exception to this is when some Angular feature only comes with a ‘provider’, which you can only use within a .config() block. An example of this is when you need to set up your routes using $routeProvider.

If you find yourself contemplating creating your own ‘fooProvider’ though, don’t. It won’t help you.

Be careful with event publishing and listening

This has its legitimate uses; for example, publishing a ‘loading’ event to signal a global loading spinner to display, or publishing a ‘stateChange’ event to trigger some logging.

However, if you end up using this for actual business cases, you can quickly get tangled up, with cross-component events passing state and data here and there, ending up with code that’s very difficult to debug and trace through.

Generally, for these cases, it’s best to stick to using callbacks passed down through the component chain, which can be traced and debugged far more easily.

Take advantage of $exceptionHandler

By default, this handler will just log the error in question to the console, but if you’re anything like us, you’ll want to gracefully take the user to an error page, along with publishing the error to a server-side logger so you know what’s going wrong in production. Speaking of which…

Find a good way to publish logs to the server side

We’re hoping to open source our logger soon — but for any production app, doing something like this is absolutely crucial to figuring out the real issues with your Angular code. Just because you’re on the client side doesn’t mean you’re exempt from needing to log.

Use angular-ui-router

The really great thing about angular-ui-router, a third-party router, is it supports unlimited numbers of nested routes. No doubt you’ve probably at some point built out a toggle in your code to switch between two sub-views when the user clicks a button. With ui-router, this kind of thing becomes first-class behavior, and on top of that, your states are persisted in the url.

For example: in PayPal Checkout, we have

  • Parent page, containing —
  • ‘Review your payment’ page, containing —
  • Sidebar, containing —
  • ‘Add a new credit card’ page

Using ui-router, we end up with a route that looks something like

#/checkout/review/sidebar/addcard

Then, when the user refreshes the page, or hits back, they end up exactly where they expect to be, without losing their state.

Be careful with promises and error handling

However, they don’t behave the way you might expect in a few subtle ways, which you should bear in mind.

Firstly, because there’s no single thread of execution on the front-end, like there is in most server side javascript request handlers, there’s no one single place to handle all of your unresolved promises. Consider the following:

$http.get('/foo/bar').then(function(result) {
console.log('Success!', result);
});

In this example, the error is swallowed. As such, it’s important to remember to always catch rejected promises.

$http.get('/foo/bar').then(function(result) {
console.log('Success!', result);
}).catch(function(err) {
console.error(err.stack);
});

But the story doesn’t end there. Normally, with promises, if an error is thrown the promise is just rejected. However, in Angular, there is a distinction between ‘rejected’ promises and ‘thrown error’ promises.

If a promise is rejected, e.g. with `return $q.reject(err)`, that’s the end of the story, I can catch the error and handle it however I want with .catch().

However, if someone does a ‘throw’, or there’s a null-pointer exception or anything else which causes javascript to throw an error, Angular behaves in a slightly unexpected way. The promise is rejected, as normal, and the catch block is called. But even if there is a catch block, the error will still always be sent to $exceptionHandler.

So if you have error handling logic in $exceptionHandler, and a catch() block, this could mean a thrown error is handled twice in two different places; once in .catch(), and once in $exceptionHandler.

The best way we’ve found to mitigate this is, only throw when it’s an actual unhandleable exception, like a programmer error, but not for business cases. For business errors, always use $q.reject().

Try to avoid lazy loading

Angular only lets you create modules at bootstrap time, and while there are hacks which allow you to register directives and factories after bootstrap time on pre-existing modules, it isn’t worth the pain. Not to mention that it restricts your modularity, since all of your angular modules need to be created in advance.

Lazy loading of routes is also something we spent a long time figuring and getting to work — but we also eventually abandoned this due to the extra unnecessary complexity it added to our app.

If you absolutely must have lazy loading, it is possible. It’s just hairy.

Be careful with the digest cycle

Basically: Angular will try to digest the $scope object to determine what has changed, and since it’s a plain javascript object, with no observers, all the framework can do is cycle through the object and check ‘what changed?’.

This can introduce a really tricky bug when you have a function in $scope which returns a new object or array every time it’s called. For example:

$scope.foo = function() {
return {bar: 'baz'};
};

Angular will keep calling this function, and because it gets a new object every time, it will assume the scope has been changed. This triggers a new digest, ad-infinitum, and your app will error out

A better approach is:

var data = {bar: 'baz'};$scope.foo = function() {
return data;
};

So, there it is. Following these guidelines has enabled us to create a relatively sane Angular app at scale, with a lot of components and moving parts, and insofar as possible, ‘one way of doing things’.

Angular will let you shoot yourself in the foot, it will give you many different ways to do it, and in some cases it will encourage you — so hopefully this guide will help you dodge a few of those shots.

-Daniel

works for PayPal, as a lead engineer in Checkout. Opinions expressed herein belong to him and not his employer. daniel@bluesuncorp.co.uk

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store