You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

4.0 KiB

Architecture

This document outlines the architecture of this software and the rationale that went into it. Architectural invariants more often than not define the absence of something. Seeing something that is not there is hard, which is why we make an effort to document these things in here.

We define components as software elements that talk to each other at runtime. The component split does not necessarily reflect the source code split of the software. For example, a library is embedded in two daemons but only exists once as a source code element.

Overview

The application is split into several components:

  • A web frontend for the taker
  • A taker daemon
  • A web frontend for the maker
  • A maker daemon

At runtime, both daemons embed their frontend and serve it via HTTP.

On a source-code level, we split into:

  • A library defining the core, cryptographic protocol
  • A crate defining the two daemons
  • Two React-based frontend projects

Invariants

Event-based communication between frontend and backend

Each frontend subscribes to a SSE-based feed from the backend. Each event notifies the frontend about a change in the backend's state. This state change can either be a result of a user interaction or incoming network communication.

User interaction MUST NOT directly change the state displayed to the user.

Instead, we maintain a cycle of:

  1. User interaction triggers POST request to backend
  2. Backend state changes
  3. State change emits update on SSE feed
  4. Event on SSE triggers re-render in application

As a result of this invariant, we can be sure that any state change is accurately reflected in the frontend, regardless of how it was triggered. It also makes the frontend very thin and therefore more predictable.

The state changes triggered by the POST requests should still be synchronous and may return errors to the frontend if the action within the backend fails. This makes it easy to achieve things like disabling a button while an action is being performed or triggering a toast with an error message.

Update local state first

To keep our UI stateless and responsive, we always update the local state of our daemon first (and emit an event for it). Only once the local state is updated, we engage with other systems like the maker/taker daemon.

Applying state changes locally first allows us to record the user's intention and provide instant (UI) feedback that we are working on making it happen. Even if the user restarts the entire application, we can pick up where we left of and finish what we were meant to be doing.

Testing the actor system

A downside of an actor system is that it is composed at runtime rather than compile-time. In other words, it is fairly easy to write code that compiles and sends messages to actors but does not actually work at runtime because the required actor is not alive. Thus, the primary risk we need to eliminate with our tests is the wiring of our actors, i.e. do all the individual actors send the correct messages at the right point in time? We deliberately write these tests against the actor system. Any component not tested as part of this (like projections for the frontend) either need to be so simple that they don't require testing or should be moved into the actor system.

Append only database

The database structure is append only. We never update / overwrite existing state to make sure we don't ever lose data due to bugs in state transitions.

Library only exposes pure transition functions rather than a state machine

The protocol implemented in the library can be thought of as a state machine that is pushed forward by each party. To remain flexible in how the protocol is used, the library MUST only expose pure functions to go from one state to the other rather than representing the actual states itself. This allows applications on top to shape their states and messages as they wish, only reaching into the library for doing the heavy lifting of cryptography and other protocol-specific functionality.