Skip to content

CQRS & event sourcing

This page explains how libspiffy stores state, so you know why a reply can wait for a read model, what survives a restart, and why you never write to libspiffy’s storage yourself.

libspiffy separates writes from reads (CQRS). Writes are commands handled by aggregates, which record what happened as events in an append-only event store. Reads come from read models, tables that projections build from those events.

Command (intent) Event (fact) Query (question)
│ │ │
▼ ▼ ▼
Coordinator actor ──▶ Aggregate ──▶ Event store ──▶ ProjectionActor ──▶ Projection ──▶ Read model
(WalletManager, validates, append-only, follows the updates queried by the
InvoiceCoordinator, emits events CBOR event stream, rows coordinator
PaymentChannelMgr) checkpoints

Three aggregate types hold libspiffy’s state. Each is an Eventador AggregateRoot running as its own actor, one per wallet, invoice or channel:

Aggregate Spawned by State
BitcoinWalletAggregate WalletManagerActor WalletState: keys and addresses, UTXOs and reservations, transactions, deferred payments
InvoiceAggregate InvoiceCoordinatorActor InvoiceState: pending, paid, expired or cancelled
PaymentChannelAggregate PaymentChannelManagerActor ChannelState: the channel’s funding, payments and settlement

An aggregate handles a command in two steps:

  1. handleCommand(state, command) checks the business rules against the current state and returns a list of events. It changes nothing. A rule that fails (an invoice that is not pending, a wallet that cannot cover a payment) throws, and no event is stored.
  2. Eventador appends the events to the event store, then applyEvent(state, event) folds each one into a new, immutable state.
// Sketch, simplified from InvoiceAggregate (package:libspiffy/internals.dart).
@override
Future<List<Event>> handleCommand(InvoiceState state, Command command) async {
if (command is MarkInvoicePaidCommand) {
if (state.status != InvoiceStatus.pending) {
throw StateError('Invoice ${command.invoiceId} is not pending');
}
return [InvoicePaidEvent(/* invoiceId, txid, amountReceived, ... */)];
}
// ...
}

Events are facts. They are never changed or deleted; a correction is a new event. That gives you a full history of every wallet.

LibSpiffyActorSystem runs three projections, each inside an Eventador ProjectionActor:

Projection Builds
WalletProjection wallet rows, addresses, UTXOs, transactions, deferred payments
InvoiceProjection invoice rows (InvoiceReadModel)
ChannelProjection payment channel rows (PaymentChannelEntity)

A ProjectionActor subscribes to the event store’s stream, hands each event to its projection’s handle(), and records a checkpoint: the sequence number of the last event applied. A handle() that fails does not advance the checkpoint. The handlers are idempotent, so applying an event twice leaves the same rows.

Queries read only the read models, never the event store. When you send GetBalanceQuery, the coordinator computes the balance from the read model rows.

Projections run asynchronously, so a read model is briefly behind the event store. libspiffy hides that from you where it matters: before the coordinator answers a request, it waits until the projection has applied the event. WalletCreatedEvent arrives once the read model holds the wallet and its root address, InvoiceCreatedEvent once it holds the invoice, and WalletDeletedEvent and UTXOsReleasedEvent once it shows the deletion or the release. BalanceUpdatedEvent, TransactionConfirmedEvent and TransactionConfirmationRevertedEvent are announced from the events the wallet read model has applied.

So when ask() returns a reply, a query you send next sees what the reply reported:

// Sketch: the second ask reads the wallet the first one created.
await coordinator.ask(CreateWalletCommand(
walletId: 'shop',
name: 'Shop',
mnemonic: mnemonic,
));
final balance = await coordinator.ask(GetBalanceQuery(walletId: 'shop'));

The coordinator does this by asking the projection’s actor (AwaitEventApplied); the refs are LibSpiffyActorSystem.walletProjectionRef, invoiceProjectionRef and channelProjectionRef.

Nothing but the event store is needed to rebuild state:

  • Aggregates replay their events when they start. initialize() preloads every wallet aggregate listed in the read model; invoices and channels load when a command first reaches them.
  • Projections resume from their checkpoint. With Isar storage the checkpoints are stored in the same Isar instance as the read models. Where read models and checkpoints are held in memory, the projections replay the store from the start and rebuild them. Storage separation shows which configuration does which.

Every event type must be registered with Eventador’s EventRegistry before the store is read back. libspiffy does this for its own types; see Event type registration.

A snapshot stores an aggregate’s whole state at a sequence number, so recovery replays only the events after it. All three aggregates can restore from a snapshot: the wallet snapshot is WalletState.toMap(), and the wallet’s balances are recomputed from its UTXOs on restore rather than trusted.

If a snapshot cannot be restored, recovery fails with a StateError. Eventador’s default would fall back to an empty state and replay only the events after the snapshot, silently dropping the wallet’s history; libspiffy’s aggregates refuse that.

libspiffy 5.0.0 does not take snapshots automatically. The aggregates are created without an Eventador SnapshotManager, so recovery replays the full event history unless a snapshot was written some other way.

Each event is stored under a name that never changes, not under its Dart class name: wallet.utxo.received for UTXOReceivedEvent, invoice.paid for InvoicePaidEvent, channel.opened for ChannelOpenedEvent. A rename or an --obfuscate build therefore does not break existing journals. Older journals that stored the class name still load through aliases. Event type registration lists more names and explains the aliases.

  • Send commands through the coordinator (libspiffy.coordinator.ask()). It routes them to the right actor and aggregate, and answers each with its own reply.
  • Do not write to libspiffy’s read model tables or event store yourself. Projections own the read models, aggregates own the event store, and the next replay would undo or contradict your write.
  • Treat read models as derived data. They can be rebuilt from the events; the events cannot be rebuilt from them.
  • Register any event type of your own before the store is read.

For the internals of a projection, see projections-guide.md and wallet-architecture.md in the libspiffy repository.