Building Large Backbone Applications
What is a Good Architecture?
Before we jump into the solution, let’s look at what constitutes a good architecture:
-
The domain logic of the application is separate from the delivery mechanism. In other words, the domain logic does not depend on the UI layer or the backend.
-
The initialization of the application is separate from its execution. There is only one place where all the components are created and wired up.
-
The application comprises lots of small components, each of which does only one thing. That is to say every component adheres to the single responsibility principle.
-
Coordination and computation are separate. This is probably the most fundamental principle of software development. It says that an object either makes decisions or executes somebody else’s decisions.
-
All interactions and use cases are explicit. Behaviour does not emerge, but instead is explicitly defined in the code. If there is an acceptance criterion describing some UI interaction, there should be a file and an object responsible for performing that interaction. By the same token, if there is a use case describing some domain interaction, there should be an object executing it.
-
Components depend on protocols rather than on concrete implementations. So it is possible to change the implementation of a component, or even replace it, without changing anything else as long as the protocol stays the same.
Overview
Now, having defined the desired properties of an architecture, let’s examine the following design.
There are quite a few parts in it:
- The supervising presenter coordinates a UI interaction.
- The view deals with the DOM. It has no logic apart from setting up data bindings.
- The use case service only coordinates use case execution.
- The domain service and the entity contain the context-independent business logic of the application.
- The repository (as well as the storage, serializer, and deserializer) encapsulates interactions with the backend.
Let’s go over each component starting with the supervising presenter.
Supervising Presenter
The supervising presenter pattern is used to organize complex UI interactions. To understand why we would want to use it, let’s look at the standard way of implementing the presentation logic in a Backbone application:
The view here knows and does a lot. First, it listens to the model, and when that changes, it re-renders the template. Second, it is responsible for processing user input. In addition, the view is often responsible for running validations and persisting the model. It is quite obvious that in complicated interactions the view tends to accumulate a lot of responsibilities and gets hard to maintain. On top of that, it is highly coupled to the template, which makes unit testing difficult.
The supervising presenter pattern solves all these problems by separating simple interactions from complex ones, and being completely decoupled from the DOM.
Simple relationships between the elements of the view and the model are set up using two-way data binding and since it is done in a declarative fashion, it does not require unit testing. The supervising presenter manages the complex relationships that cannot be declaratively expressed via data bindings. It is worth noting that the supervising presenter is a part of the presentation layer, and, therefore, should not contain any domain logic. All the domain logic should be delegated to the use case service.
Read more about the supervising presenter pattern.
Use Case Service
The use case service is a coordinator that describes, not surprisingly, a use case. Without this service the use case gets scattered across many files and objects and is not really represented in the code. Being a coordinator, the use case service does not perform any computation and has no state. In other words, it is a stateless object making decisions and delegating most of its work to domain objects and services, sometimes saving the results using the repository.
Repository
Even though more and more behaviour is being pushed down to the client, at some point you will have to communicate with the server. There is no way around it. This communication is often quite complex, and, therefore, should not be performed by entities. Instead, it should be handled by a designated object – the repository.
A typical interaction with the repository looks as follows:
The repository is invoked by the use case service. The service passes some objects to the repository. Those objects are transformed into data by the serializer. After that, the repository invokes the storage, which actually talks to the server. The storage returns a promise with some data, which, using the deserializer, the repository builds objects from.
Why four objects instead of one?
Why not place all the responsibilities into the repository? Why do we need the serializer and the storage?
-
Firstly, it separates the coordination from the actual work. The repository is a coordinator making decisions about which actions of the storage to call and what to do with the returned promises. The storage, serializer, deserializer do the actual work and do not coordinate things.
-
Secondly, this arrangement simplifies unit testing. The repository can be tested in isolation with stubbed out serializers and storages. The serializer and deserializer are stateless data transformations, and, therefore, are easy to test. The storage does not have to be tested at all.
Summing up:
- The repository encapsulates all the communication with the server.
- The repository’s API is object-oriented.
- The storage, on the other hand, is data-oriented.
- The serializer/deserializer transform objects into data and vice versa.
- The storage can be decorated or even replaced without affecting the repository. An example would be adding caching or using fake storage for testing.
- There is no need to define a custom storage or serializer for every repository if you can use generic ones that rely on convention.
Example
The following example illustrates the interaction between the supervising presenter, use case service, and repository.
var ProductUpdateService = function (orderRepository) {
this.update = function (product, listener) {
if(!product.valid()){
listener.productUpdateFailed(product);
}
orderRepository.save(product).done(listener.successfulProductUpdate)
};
};
// All the dependencies are injected and, therefore, can be mocked up.
var ProductUpdatePresenter = function (productUpdateService, productUpdateForm) {
this.updateProduct = function (product) {
// Does not contain any domain logic. Delegates to the use case service instead.
// Notice that we are passing the presenter into the use case service.
// The service will notify the presenter about the results of the use case execution.
// The presenter can react (for instance, close some sort of dialog).
productUpdateService.update(product, this);
};
// These methods will be called by the use case service
this.successfulProductUpdate = function (product) {/*...*/};
this.productUpdateFailed = function (product){/*...*/};
_.bindAll(this);
productUpdateForm.on('update', this.updateProduct);
};
Initialization
Having gone through all the components, let’s look at how they can be created and wired up.
//app.js
var orderRepository = new OrderRepository();
var productUpdateService = new ProductUpdateService(orderRepository);
var productUpdateForm = new ProductUpdateForm();
var productUpdatePresenter = new ProductUpdatePresenter(productUpdateService, productUpdateForm);
First, create all components in one place, for instance, in a file called app.js
. Try to keep this file as simple as possible (e.g., no if statements). Second, avoid using globals and instead inject all dependencies. If it gets too tedious and verbose, use the service locator pattern.
Going Through the Objectives
-
The domain logic of the application is separate from the delivery mechanism.
The described design makes this separation explicit. The domain logic of the application is represented by use case services, domain services, and entities. Everything else is the delivery mechanism.
-
The initialization of the application is separate from its execution.
There is one place,
app.js
, where all the components are created and wired up. -
The application comprises lots of small components, each of which does only one thing.
- The supervising presenter coordinates a UI interaction.
- The view is a widget that abstracts away the DOM.
- The use case service manages a domain interaction.
- The domain service does some domain-related computation.
- The domain entity stores some state.
- The repository coordinates client/server interactions.
- The storage makes AJAX requests.
- The serializers/deserializes transform objects into data and vice versa.
-
Coordination and computation are separate.
The supervising presenter, use case service, repository are coordinators. The actual work is done by the other components.
-
All interactions and use cases are explicit.
There is only one supervising presenter for every UI interaction. Similarly, there is one and only one use case service for every use case.
-
Components depend on protocols rather than on concrete implementations.
Dependency injection enables that.
What if my application does not have any business logic?
Some applications have little domain complexity. If it is the case, you can simplify the design by merging the use case and domain services with the supervising presenter.
Wrapping Up
Backbone gives structure to web applications, but often it is not enough. In this article I showed:
- How to manage UI interactions using the supervising presenter pattern
- How to represent use cases using the use case service pattern
- How to encapsulate client/server communication using the repository
- And how to wire everything up using dependency injection
Read More
Read more about architecture at
engineering.nulogy.com.