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

Daniel Brain
10 min readSep 26, 2015

--

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

ng-controller is the starting point for shimming angular into your apps. The idea is, you have some html, you want Angular to take over and do something dynamic for you, so you annotate your html with an ng-controller and now you’re rolling!

<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

Angular’s router allows you to specify a template and a controller for each route. Cool! So this kind of mitigates the problem of keeping a 1–1 coupling between controller and template, avoids using ng-controller, and is kind of a crude way of creating a top-level component.

$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

Seriously, everything, including your pages. Consider this:

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

Directives, by default, inherit their parent’s scope. This is not a good thing.

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

Angular provides two-way binding through ng-model. I’m not going to tell you to avoid this; if you’re not a fan of two way binding, you’re probably not going to use Angular in the first place.

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

Ever hear anyone tell you that globals are bad? That’s what $rootScope is. It’s like putting data in the window object. Keeping track of what’s contained in $rootScope is gonna get really tricky the bigger your app grows.

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

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

If you have a section on your page which contains three different components, which all need shared data or state, your best bet is probably to encapsulate them all within a parent component, and pass down whatever data or state they need to share from that parent.

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

There’s no good reason for Angular having services, providers, factories, constants; they all fundamentally do the same thing. Really.

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

There’s equally very little good reason to have module.config(…) blocks attached to your modules. Everything you need to do at bootstrap time can normally safely be done in module.run(…), and even then this should be a pretty minimal, since, with the exception of routing, most of your UI initialization can be done in your top level directive.

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

If you need two components to communicate, you may think the easiest way is just to start publishing and listening to events using Angular’s pub/sub mechanism on $rootScope or $scope.

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

This is where all of the unhandled errors in your app will end up, and unless you’re writing totally bug free code, you will end up in here at some point. Angular wraps all of your controllers, factories, promises, and everything else in a try/catch block, and gives you a global error handler in $exceptionHandler to perform some last-minute recovery.

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 came up with a very simple log-buffer, which queues logs and publishes them on route transitions, and on a time interval. This way we can keep track of everything that’s going on (and going wrong) on the client-side, without making an exorbitant number of http requests from the browser.

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

Angular’s built in router works, but it’s extremely basic and only allows you to run through a flat list of pages with no hierarchy.

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

Promises are (almost) first class citizens in the Angular world, and you’ll encounter them the moment you fire off your first $http request.

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

Seriously. It’s a rabbit hole.

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

I’m not going to talk about Angular’s performance, because it is what it is, and it’s been talked about to death. But it’s really important to have a basic understanding of roughly how Angular’s digest works when you update things in scope.

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

--

--

Daniel Brain
Daniel Brain

Responses (31)