Large AngularJS Applications: The Missing Model
Angular is still enjoying quite a bit of popularity these days, and there have undoubtedly been some HUGE apps built with the framework at its core. It’s a joy to get started hacking away at your app, attaching data to your $scope
and seeing the magic of two-way binding; but once you start building mid-sized or large apps, some best practices will help you avoid controller bloat and maximize testability and code reuse.
I’ve been building apps with Angular for three years (since version 1.0!) and have incrementally improved my code organization process, lately with a MASSIVE app with 25+ pages and hundreds of directives. The breakthrough for me was to thin down controllers as much as possible. It’s so easy to declare variables on your scope that are immediately available in your templates (just about every example Angular app does this), but you should be absolutely brutal with paring down these variables. Treat your controller as an actual controller — initializing data, delegating events to services/models, and dealing with the page changes. Which gets us to the golden rule:
No business logic in the controllers!
Let me repeat: Keep business logic out of your controllers! No ifs, no map
s, and absolutely no math!
But Alec, where do I put all that code? Everybody else just puts it in their controllers!
Stop whining. Angular provides zero guidance as to the Models in your app, but here is a simple, flexible, scalable approach:
The Fat Model Service
Typically, you have two types of data in a web app: Persisted data and view data. Persisted data is what’s getting loaded, manipulated, and saved, and view data is either hard-coded or loaded from the server, but not generally manipulated by the user. For example, take an address form. City, state, and zip are persisted data, while a list of states to be selected from would be view data. For most pages, you’ll need to request some data, either through something like Restangular, $resource
, or plain old $http
. For anything but the most trivial apps, all code having to do with persistence should be isolated into separate services, to decouple persistence-related code from any DOM-related code (e.g. controllers, directives etc). We’re more interested in what to do with this data, however: how to make it available to the main page template as well as any nested directives within. Let’s look at an example:
In this example, we’re glossing over the data retrieval and just hardcoding a Session
object containing the user data we’re interested in. The interesting piece is the UserModel
service. Here, we’re exposing a constructor function which initializes an object mainly serving as a wrapper for a piece of data, in this case, a User. In this example we are passing in a Session
, which is just a user object, but we could just as easily asynchronously load a User object from the server and instantiate the UserModel within the fulfilled promise. The important thing is that we have defined a place for all the business logic concerning a User. These UserModel
properties and functions are now available in the template on the userModel
$scope attribute, which itself can be passed to any directives or used in any other controllers in the app. So far, this is great: We have the business logic concerning a User decoupled from anything tied to any view. But it’s equally important to strictly define what shouldn’t go in this new model. For instance, it’s tempting to write something like this:
Note the added save()
function in UserModel
. All it does is call an internal function to clean the user object up for persistence, persist via the new UserDataService
, and redirect to a “user” page. We can even call it directly from the template, leaving us with a one-line controller! Awesome, right?
The problem is that we’ve started mixing concerns. Most egregiously, we’ve injected the $location
service into our model, so that every time save()
is called, the user is redirected to another page. Let’s say that in a few months there comes a new requirement that users can view and edit their information from a sidebar no matter which page they’re on in the app. On saving their changes from the sidebar, the user would be erroneously redirected: This would require refactoring every piece of code in the app using that function.
The $location
service is very much related to view, and now our Model not only knows about page navigation, but initiates it. This is the job of the controller. Let’s look at a cleaner implementation:
Now we’ve moved the triggering of the save operation to the controller. The controller asks the model which properties are appropriate for persistence, then delegates the actual save operation to the UserDataService
. On success, the controller redirects to the next page. Now, the model has zero external dependencies, and the controller is triggering state changes as well as persistence, but not handling any business logic. Subsequently, it has become trivially easy to unit test, a surefire sign of any well-designed module. Which brings us to rule #2:
Models should have few external dependencies
I can see injecting other models, and possibly the $filter
service, but that should be it. If you’re injecting more than one or two things into your model, there’s a good chance you’re doing it wrong.
AngularJS is an amazing framework for getting quick SPAs off the ground quickly, but the lack of direction regarding Models requires very careful diligence when designing a non-trivial app. I have provided a solution above that has worked very well in several large apps, but the possibilities are endless. Let me know what you think in the comments below.
Comments are closed.