Blackbird: An architectural framework for mobile apps that prioritize local-first connectivity

This architecture, though present in some of my past work, hasn’t been explained thoroughly on its own. This is unfortunate, as it’s both effective and straightforward, truly a case of Sophisticated Simplicity.

The Need for a Reference Architecture

The way we create connected mobile apps is flawed, and current solutions seem inadequate. They are overly intricate, demand excessive code, perform poorly, and lack reliability.

Generally, these issues stem from misapplying procedural abstraction to a problem space that is inherently state-based. A solution involves adopting a state-based architecture, like in-process REST, and merging it with established patterns like MVC.

MVC has been misused by coupling UI updates with model updates, a practice amplified by asynchronous callbacks. Furthermore, data is pushed to the UI instead of being pulled as needed. Asynchronous code, modeled using call/return and callbacks, leads to “callback hell,” unnecessarily transforming dependent code into asynchronous forms that are difficult to read and hinder proper abstraction.

Backend communication presents another challenge. Newer async/await implementations don’t significantly improve upon callback-based approaches and might even hinder readability due to deceptive simplicity.

Architecture at a Glance

This architecture comprises four key elements:

  1. Model
  2. UI
  3. Backend
  4. Persistence

The primary goal is to keep these elements synchronized, resembling a control loop. The model, acting as the central hub, connects and coordinates all parts. Adhering to hexagonal architecture, the model houses the core logic, while other components remain minimal and straightforward.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
memory-model := persistence.
persistence  |= memory-model.
ui          =|= memory-model. 
backend     =|= memory-model.

```Visually represented as: 
![](https://www.dropbox.com/s/lsxucl6m5rn7q9c/overall-arch.png?raw=1)

### Architectural Building Blocks

This approach heavily relies on several architectural elements. First are _stores_, inspired by the in-process REST style, functioning as in-process HTTP servers (without actual HTTP) or composable dictionaries. The core store protocol uses GET, PUT, and DELETE verbs as messages.

Polymorphic Identifiers replace URLs in this REST adaptation. These objects reference values within the store but aren't direct pointers, enabling them to reference objects not yet present.

Polymorphic Identifiers can be tailored to the application. For instance, they might consist solely of a numeric ID.

### MVC Revisited

The essence of MVC lies in separating input and output processing. In MVC, the view (or a controller) modifies the model, and processing halts. Later, the model notifies the UI of changes through a notification mechanism.

Smalltalk MVC utilizes a dependents list within the model, where interested views register. These views receive a `#changed` message upon model changes. Cocoa achieves this via `NSNotificationCenter`, but any broadcast mechanism suffices.

Views then update themselves by querying the model. Cocoa largely automates this for views: upon notification, the view invalidates itself, triggering an automatic redraw in the next event loop cycle.

This decoupling is crucial because update notifications can stem from various sources: user interactions, backend responses, or remote events.

This decoupled approach ensures uniform handling of all events, allowing the UI to focus solely on the local model. Consequently, the UI remains largely independent of network communication, resulting in a locally testable, local-first application.

This architecture refines MVC's view update mechanism by introducing a queue containing polymorphic identifiers of modified items. This queue further decouples the model and view. For example, it simplifies making the queue writable from any thread while ensuring it's emptied only onto the main thread for view updates. Additionally, update notifications become asynchronous  the updater adds an entry to the queue without waiting for UI updates.

This queue-based decoupling effectively prevents rapid model updates from overwhelming the UI or slowing down the model, addressing common performance bottlenecks.

The queue's polymorphic identifiers enable the UI to update at its own pace and discard irrelevant updates, such as duplicates for the same element.

Views can also utilize these identifiers to determine update necessity by matching against the identifier they are currently processing.

### Backend Communication Redefined

Many mobile applications implement REST backend communication using "convenient" cover methods for each endpoint operation, potentially auto-generated.

This approach disregards the fact that REST relies on a few verbs and numerous identifiers (URLs). This architecture employs a single backend communication channel: a queue accepting a polymorphic identifier and an HTTP verb. The identifier translates to a backend system URL, the request is executed, and the result is stored using the provided identifier.

Once stored, an MVC notification with the identifier is queued as described earlier.

This backend operation queue mirrors the model-view communication queue, including request deduplication to ensure only the final object version is sent. Remaining processing follows a pipes-and-filters architecture using polymorphic write streams.

For backend-initiated communication, URLs can be sent via sockets or other mechanisms, prompting the client to pull data using its regular request channels, maintaining the system's pull-based constraint.

This architecture makes backend requests explicit and reified, rather than implicitly encoded within the call stack. This provides the UI with clear visibility into communication failures common in mobile environments, enabling appropriate user feedback and preventing accidental duplicate requests.

Despite enhanced visibility and introspection, backend communication code is significantly reduced and isolated. Network code operates independently from the UI, and vice versa.

### Persistence Through Stacked Storage

Persistence is managed through stacked stores (storage combinators).

![](https://www.dropbox.com/s/8go76u12du9e5if/disk-cache-json-aligned.png?raw=1)

The application connects to the top of this stack, the CachingStore, which appears identical to the DictStore (an in-memory store). If a read request misses the cache, data is fetched from disk and converted from JSON by a mapping store.

For testing purposes, the in-memory store can replace the disk store, providing the same interface and behavior but with increased speed and no persistence.

Writes utilize the same asynchronous queues as other parts of the system. The writer retrieves object identifiers, fetches corresponding objects from the in-memory store, and then persists them. Sharing the same mechanism, writes benefit from the same deduplication, adapting to I/O overload by dropping redundant writes.

![](https://www.dropbox.com/s/h2kq2joy20lmy7f/async-writer.png?raw=1)

### Outcomes and Advantages

This reference architecture replaces complex code with a simpler, more concise alternative, promoting code reuse across the system. It also fosters independence between system components and optimizes performance.

Moreover, the combination of composable REST-like stores and constraint/event-based communication enables high decoupling, similar to well-implemented microservices architectures, but within mobile apps without relying on multiple processes (often restricted).

![](https://www.dropbox.com/s/lsxucl6m5rn7q9c/overall-arch.png?raw=1)

![](https://www.dropbox.com/s/8go76u12du9e5if/disk-cache-json-aligned.png?raw=1)

![](https://www.dropbox.com/s/h2kq2joy20lmy7f/async-writer.png?raw=1)
Licensed under CC BY-NC-SA 4.0
Last updated on Jan 04, 2023 01:20 +0100