Tutorial: a bank account on ReckonDB

This tutorial builds a small banking domain on ReckonDB: open an account, deposit and withdraw funds without ever going negative, and guarantee account numbers are unique. We use Track A (the gateway over HTTP/JSON), so it works from any language; the native BEAM version with evoq is noted at the end.

Prerequisites

reckon-gateway running with an embedded store (see Installation), listening on HTTP :8080. A base URL for brevity:

BASE=localhost:8080/v1/stores/bank

The model

Each account is one stream, account-<id>. Three event types:

  • account_opened_v1 { number, owner }
  • funds_deposited_v1 { amount }
  • funds_withdrawn_v1 { amount }

The current balance is a fold over the account stream. There is no balance column to keep in sync; the events are the source of truth.

1. Open an account

Append the opening event only if the stream does not exist yet:

curl -sX POST $BASE/streams/account-1001/events \
  -H 'content-type: application/json' \
  -d '{"expected_version":"no_stream","events":[
        {"event_type":"account_opened_v1","data":{"number":"NL01-1001","owner":"Alice"}}]}'
# => {"version":0,"count":1}

2. Deposit funds

A deposit always succeeds. Append with expected_version:"any" (no concurrency constraint needed for an additive event):

curl -sX POST $BASE/streams/account-1001/events \
  -H 'content-type: application/json' \
  -d '{"expected_version":"any","events":[
        {"event_type":"funds_deposited_v1","data":{"amount":150.00}}]}'
# => {"version":1,"count":1}

3. Withdraw without overdrawing (the decision loop)

A withdrawal must not take the balance below zero. That is a decision over the account’s own history, so read the stream, fold the balance, and append only if the funds are there. Use the current head version as the optimistic-concurrency check so a concurrent withdrawal cannot slip in between your read and your write:

# Read the account history
curl -s "$BASE/streams/account-1001/events?from=0&limit=1000"
# Fold balance client-side: sum deposits minus withdrawals  => 150.00, head version = 1

# Append the withdrawal only if the stream is still at version 1
curl -sX POST $BASE/streams/account-1001/events \
  -H 'content-type: application/json' \
  -d '{"expected_version":1,"events":[
        {"event_type":"funds_withdrawn_v1","data":{"amount":40.00}}]}'
# => {"version":2,"count":1}   (or HTTP 409 if someone else wrote first: re-read and retry)

If the balance were insufficient, your code simply does not append. If two withdrawals race, one gets the 409 version conflict, re-reads the now-updated balance, and re-decides. This is the read, decide, append, retry-on-conflict loop.

4. Unique account numbers (a cross-stream invariant)

“No two accounts share a number” spans every account stream, so per-stream versions cannot express it. A Dynamic Consistency Boundary can: tag the opening event with the number and conditionally append only if no account was already opened with it.

curl -sX POST $BASE/dcb/append \
  -H 'content-type: application/json' \
  -d '{"tag_filter":{"and":[
        {"event_type":"account_opened_v1"},
        {"match_any":["number:NL01-1002"]}]},
       "seq_cutoff":-1,
       "events":[{"event_type":"account_opened_v1",
                  "tags":["number:NL01-1002"],
                  "data":{"number":"NL01-1002","owner":"Bob"}}]}'
# => {"committed":{"last_seq":N}}  or  {"conflict":{"max_seq":N}} if the number is taken

The check and the write share a single Raft log entry, so the number is unique even if two opens race. See the DCB and CCC guide for the full model, including payload-conditioned boundaries (CCC).

5. Read models

To show balances on a dashboard, build a read model: subscribe to the store and fold events into whatever shape your UI needs (a row per account, say). The write side (events) and the read side (projection) stay separate, which is the point of CQRS. The gateway exposes subscriptions over gRPC; on the BEAM, evoq projections do this for you.

Track B: the native BEAM version

On Erlang or Elixir you would not curl the gateway: you would define an evoq_aggregate for the account (the deposit/withdraw command handlers and the balance fold), an evoq_projection for the balance read model, and an evoq_decision for the cross-stream uniqueness boundary, all persisted by the reckon-evoq adapter. See the evoq guides for the aggregate, projection, and decision behaviours.

Next Steps

  1. Read the DCB and CCC guide in depth
  2. Explore Example Applications
  3. Compare ReckonDB with other event stores