Wow, what a mouthful! Although this architecture has featured in a number of my other writings,
I haven't really described it in detail by itself. Which is a shame, because I think it
works really well and is quite simple, a case of
Sophisticated Simplicity.
Why a reference architecture?
The motivation for creating and now presenting this reference architecture is that the way we
build connected mobile apps is broken, and none of the proposed solutions appear to help.
How are they broken? They are
overly complex, require way too much code, perform poorly and are unreliable.
Very broadly speaking, these problems can be traced to the misuse of procedural abstraction for
a problem-space that is broadly state-based, and can be solved by adapting a state-based
architectural style such as in-process REST and combining it with well-known styles such
as MVC.
More specifically, MVC has been misapplied by combining UI updates with the model updates, a
practice that becomes especially egregious with asynchronous call-backs. In addition, data
is pushed to the UI, rather than having the UI pull data when and as needed.
Asynchronous code is modelled using call/return and call-backs, leading to call-back hell,
needless and arduous transformation of any dependent code into asynchronous code (see "what
color is your function") that is also much harder to read, discouraging appropriate
abstractions.
Backend communication is also an issue, with newer async/await implementations not really
being much of an improvement over callback-based ones, and arguably worse in
terms of actual readability. (They seem readable, but what actually happens is different
enough that the simplicity is deceptive).
Overview
The overall architecture has four fundamental components:
- The model
- The UI
- The backend
- The persistence
The main objective of the architecture is to keep these components in sync with each other, so the whole
thing somewhat resembles a control loop architecture: something disturbs the system, for example
the user did something in the UI, and the system responds by re-establishing equilibrium.
The model is the central component, it connects/coordinates all the pieces and is also the only one directly
connected to more than one piece. In keeping with hexagonal architecture, the model is also supposed to
be the only place with significant logic, the remainder of the system should be as minimal, transparent
and dumb as possible.
memory-model := persistence.
persistence |= memory-model.
ui =|= memory-model.
backend =|= memory-model.
Graphically:
Elements
Blackbird depends crucially on a number of architectural elements: first are
stores
of the in-process REST architectural style. These can be thought of as in-process HTTP servers
(without the HTTP, of course) or composable dictionaries. The core store protocol implements
the GET, PUT and DELETE verbs as messages.
The role of URLs in REST is taken by Polymorphic Identifiers. These are objects that can
reference identify values in the store, but are not direct pointers. For example, they
need to be a able to reference objects that aren't there yet.
Polymorphic Identifiers can be application-specific, for example they might consist
just of a numeric id,
MVC
For me, the key part of the MVC architectural style is the decoupling of input processing
and resultant output processing. That is, under MVC, the view (or a controller) make
some change to the model and then processing stops. At some undefined later time
(could be synchronous, but does not have to be) the Model informs the UI that it
has changed using some kind of notification mechanism.
In Smalltalk MVC, this is a
dependents list maintained in the model that interested views register with. All
these views are then sent a #changed
message when the model has changed.
In Cocoa, this can be accomplished using NSNotificationCenter
, but really
any kind of broadcast mechanism will do.
It is then the views' responsibility to update themselves by interrogating the model.
For views, Cocoa largely automates this: on receipt of the notification, the view just
needs invalidate itself, the system then automatically schedules it for redrawing the
next time through the event loop.
The reason the decoupling is important to maintain is that the update
notification can come for any other reason, including a different user interaction,
a backend request completing or even some sort of notification or push event
coming in remotely.
With the decoupled M-V update mechanism, all these different
kinds of events are handled identically, and thus the UI only ever needs to deal with
the local model. The UI is therefore almost entirely decoupled from network
communications, we thus have a local-first application that is also largely
testable locally.
Blackbird refines the MVC view update mechanism by adding the polymorphic identifier
of the modified item in question and placing those PIs in a queue. The queue
decouples model and view even more than in the basic MVC model, for example it
become fairly trivial to make the queue writable from any thread, but empty only
onto the main thread for view updates. In addition, providing update notifications
is no longer synchronous, the updater just writes an entry into the queue and can
then continue, it doesn't wait for the UI to finish its update.
Decoupling via a queue in this way is almost sufficient for making sure that
high-speed model updates don't overwhelm the UI or slow down the model. Both
these performance problems are fairly rampant, as an example of the first,
the Microsoft Office installer saturates both CPUs on a dual core machine
just painting its progress bar, because it massively overdraws.
An example of the second was one of the real performance puzzlers of my
career: an installer that was extremely slow, despite both CPU and disk
being mostly idle. The problem turned out to be that the developers of
that installer not only insisted on displaying every single file name
the installer was writing (bad enough), but also flushing the window to
screen to make sure the user got a chance to see it (worse). This then
interacted with a behavior of Apple's CoreGraphics, which disallows
screen flushes at a rate greater than the screen refresh rate, and will
simply throttle such requests. You really want to decouple your UI
from your model updates and let the UI process updates at its pace.
Having polymorphic identifiers in the queue makes it possible for the UI
to catch up on its own terms, and also to remove updates that are no longer
relevant, for example discarding duplicate updates of the same element.
The polymorphic identifier can also be used by views in order to determine
whether they need to update themselves, by matching against the polymorphic
identifier they are currently handling.
Backend communication
Almost every REST backend communication code I have seen in mobile applications has
created "convenient" cover methods for every operation of every endpoint
accessed by the application, possibly automatically generated.
This ignores the fact that REST only has a few verbs, combined with a great number
of identifiers (URLs). In Blackbird, there is a single channel for backend communication:
a queue that takes a polymorphic identifier and an http verb. The polymorphic
identifier is translated to a URL of the target backend system, the resulting request
executed and when the result returns it is placed in the central store using the provided
polymorphic identifier.
After the item has been stored, an MVC notification with the polymorphic identifier in
question is enqueued as per above.
The queue for backend operations is essentially the same one we described for model-view
communication above, for example also with the ability to deduplicate requests correctly
so only the final version of an object gets sent if there are multiple updates. The remainder
of the processing is performed in pipes-and-filters architectural style using polymorphic
write streams.
If the backend needs to communicate with the client, it can send URLs via a socket or
other mechanism that tells the client to pull that data via its normal request channels,
implementing the same pull-constraint as in the rest of the system.
One aspect of this part of the architecture is that backend requests are reified and
explicit, rather than implicitly encoded on the call-stack and its potentially
asynchronous continuations. This means it is straightforward for the UI to give the
user appropriate feedback for communication failures on the slow or disrupted network
connections that are the norm on mobile networks, as well as avoid accidental duplicate
requests.
Despite this extra visibility and introspection, the code required to implement backend
communications is drastically reduced. Last not least, the code is isolated: network code
can operate independently of the UI just as well as the UI can operate
independently of the network code.
Persistence
Persistence is handled by stacked stores (storage combinators).
The application is hooked up to the top of the storage stack, the CachingStore, which looks
to the application exactly like the DictStore (an in-memory store). If a read request cannot
be found in the cache, the data is instead read from disk, converted from JSON by a mapping
store.
For testing the rest of the app (rather than the storage stack), it is perfectly fine to
just use the in-memory store instead of the disk store, as it has the same interface and
behaves the same, except being faster and non-persistent.
Writes use the same asynchronous queues as the rest of the system, with the writer getting
the polymorphic identifiers of objects to write and then retrieving the relevant object(s)
from the in-memory store before persisting. Since they use the same mechanism, they also
benefit from the same uniquing properties, so when the I/O subsystem gets overloaded it
will adapt by dropping redundant writes.
Consequences
With the Blackbird reference architecture, we not only replace complex, bulky code with much
less and much simpler code, we also get to reuse that same code in all parts of the system
while making the pieces of the system highly independent of each other and optimising
performance.
In addition, the combination of REST-like stores that can be composed with constraint- and event-based
communication patterns makes the architecture highly decoupled. In essence it allows the
kind of decoupling we see in well-implemented microservices architectures, but on mobile
apps without having to run multiple processes (which is often not allowed).