Thoughts on architecting a large Angular app
For the last year I've been working on an Angular app for a financial trading platform. It's one of the largest projects I've been involved in and I've learnt a lot about how to develop a single-page app in the process. There are many complex aspects to developing an app of this size, from handling large volumes of data to performance and memory management, but more than anything else it's the architecture that might be the most difficult challenge.
Angular doesn't officially give much guidance on large-scale app architecture. There's some vague mentions of MVC and a rudimentary Todo app, but scaling these basic ideas doesn't always work, especially when you're dealing dozens of developers trying to write and integrate features simultaneously. While the particular app I worked on started off highly decoupled and encapsulated it soon became apparent that the features were far more interwoven and interrelated than they first appeared. This quickly led to a lot of spaghetti code that tried to connect all the pieces together.
The following is a list of some of the things that were done in order to simplify and make sense of this large-scale architectural problem, and deal with it in a way that allowed the app to scale gracefully in size and complexity.
As the app progressed I found myself using more directives and fewer controllers. By the end of the project the only controllers in the app were the ones attached to routes. These controllers actually became incredibly important, acting as sort of linchpins for the entire app, which I'll explain in more detail later.
The great thing about directives is they can be very explicit in their API. The data they need and the events they emit can be specified in the bindable HTML attributes. This allows other developers to see what goes into and comes out of them. When carefully encapsulated they can be reused across an app by simply dropping them into another view and binding new data. Controllers on the other hand a less explicit, leading to you to infer knowledge about the surrounding context and assume you know what the controller is affecting when it changes stuff.
When building directives it's worth using the internal
link() functions to separate concerns. The controller function should responsible for business logic, eg: preparing data for display or defining click handlers; and the link function should only contain code that directly manipulates the DOM. If the code doesn't need access the DOM then try to keep it in the controller. This makes it easier for people to reason about the roles of the functions inside your directive. After applying these rules I generally found the majority of directives held their code in the controller.
Coordinating state changes across a large app can be extremely complex and is commonly regarded as the source of most UI bugs. An app usually contains deeply nested hierarchies of components, each requiring their own state and perhaps some shared state from further up the tree. If you're not careful in coordinating changes you can be left with stale data lying around in components or partial updates not filtering down the tree. I've seen (and even written myself) complex sequences of events to try and connect disparate parts of the app together and coordinate changes, but it inevitably ends up creating a mess that's hard to debug.
It's important to note that when I'm talking about state I'm talking about two things: the model data, such as a list of products; and stateful non-model data, such as whether a panel is open in the UI. While technically they're slightly different they both end up describing the state of your app when it's rendered on the page.
In a lot of cases you'll need both of these types of state spread throughout your app, and many components will need the same state. For example the logged-in state of the user might be needed by several components so they can render the right label or button. In Angular there's a great temptation to keep state in Services and then inject them into your controllers and directives. It's quick, convenient, and at first seems to like a good solution.
But there's two problems with this: firstly it creates a direct dependency between directives and services, reducing the reusability of your components in other situations; secondly, and more importantly, it gives you the temptation to mutate the state directly from your directives.
In order to understand why the second point is a problem you need to imagine your app on a large scale. As it grows you'll have more and more components trying to mutate state. Sometimes several components will want to update the same state at once, or need another piece of state updated before updating it's own. If all your components are making direct mutations to the same state then you've spread the logic across the app in lots of little bits. This makes it hard to coordinate changes.
Moving state into a centralized location
There's a simple rule that alleviates this problem: code that mutates state needs to happens in as few places as possible. It needs to be predictable, coordinated, and have the ability to modify several related pieces of state at once.
The solution is to move state out of your directives and services and up into strategic points in the component hierarchy. I chose to create controllers at these points, and they were responsible for creating and managing a tree of directive components beneath them.
In order to choose the best point in your component hierarchy to put a controller you need to identify where you can hold state data that affects all child components. To do this find all the components that rely on the same state then go up the tree till you find their common parent (this is usually a lot higher than you think.) This is the point at where you want to place a controller. It will be responsible for holding state for an entire sub-tree of components.
The next point is very important: you should only ever mutate state in this same controller, never in your nested components. Remember our aim is to ensure state changes happen in as few places as possible. But how do we achieve this?
Push state down, bubble events up
All components beneath the stateful component should have data pushed into them. You do this via bindable attributes on directives. You can keep passing down the entire state, or portions of the state, into child components. If one of the child components wants to modify the state it shouldn't do so itself - instead it should signal the intent back up to the controller that owns the state, passing along any necessary information about the modification. The controller will listen to this event and make the change. Then, due to the magic of two-way binding, the hierarchy of components will automatically update and re-render.
There are a couple of ways of signalling back up the component tree, you can use
$scope.emit(), which will send an event up the scope tree, or you can open up callbacks on the component's bindable attributes. Both will work, but the second is more explicit and gives you the option of whether to attach the event or not.
Pushing data all the way down and bubbling events all the way up might seem like a laborious task, and will undoubtably mean you have to write lots of bindings at each level, but I can't stress enough how much this will help you in the long run. It means you have a very explicit API for every component, minimising the amount of 'magic' that goes on behind the scenes, and state mutations happen in a predictable, centralized location.
This is something you should be striving to use across your entire codebase. A pure function has two characteristics: it always returns the same output when given the same input, and it doesn't produce any other side-effects other than on the input itself. The biggest benefit of pure functions is they are predictable. Calling them a hundred times with the same input will always return the same output. This makes testing them easy.
Try and use pure functions wherever possible. Services are a good place to start, they can simply contain a collection of functions that are responsible for mutating data. A good portion of directives can also use pure functions, especially those put into the controller section. The stateful controllers are a little harder, as their main responsibility is to update the state held within them, so you're likely to have less pure functions there. But by doing all of this you're helping move the complex and error-prone stateful code to just a few places in your app.
Putting it all together
So to recap here's how the various aspects of the architecture are used and how they communicate with each other:
Services don't hold state, but contain a collection of side-effect free functions that load or mutate state. The state mutating functions should be pure and side-effect free and return the result of the mutation, this makes writing tests for them very simple.
Controllers exist only at strategic points in the component hierarchy, ideally where the state for various child components meet at a common parent. These controllers hold the state and model data in their scope then pass it down the tree of directives beneath. Each directive passes bits of the state down to their children, and so on. Controllers also have handlers for events that bubble up from the child directives, these handlers modify the state according to whatever new data was passed up in the event payload. State modification actually happens through services (as described above) and the result is saved back into the controller again.
Directives accept state and model data through their attributes, never implicitly through the scope chain. They also accept callback handlers in the attributes for events they might want to emit (again never through the scope chain). Internally directives are split into separate controller and link functions. The controller function is responsible for business logic, eg: preparing state or model data for display, or preparing events before emitting them. The link function only ever contains code that manipulates the DOM.
Where possible use pure side-effect free functions. You can use them liberally throughout your services, and frequently inside directives.
When using this architecture it can feel like you're writing a lot of duplicated code - you end up passing down data and bubbling up events through many layers of components - but that's OK. In the long run this explicitness will help with debugging. Knowing that state mutation only occurs in a few keys areas of your app means you can quickly limit the scope of your search.
Sometimes you might find the only logical place to hold state is at the very top of the component hierarchy - and that's OK too. You'd be surprised at how many components rely on the same pieces of state in a large app. In fact I'd go as far to say that in a small app you can have just one state object at the very top.
This particular architecture took many iterations and refactors before it became clear it was a more manageable and scalable approach. But it's by no means a definitive approach. The important thing to take away are some of the core concepts, not necessarily the implementation. The two concepts I'd say are worth remembering are: 1) centralize your state mutations, 2) use pure functions. Together they will help make your codebase a more predictable place.