rgoussu@goussu: ~/library/applicative-architecture/exercises
~/library/applicative-architecture/exercises cat event-sourced-bank-account-subject.md

Event-sourced bank account — subject

# The self-contained bank-account work statement — commands, invariants, events, projections, and the consistency requirements the event-sourced build must satisfy.

Subjectsaved 2026-08-08 #exercise#event-sourcing#cqrs#ddd#tdd#subject

Brief

You are building the account service for a toy bank, and the ledger is the law: the append-only stream of domain events is the only source of truth. Current balances, statements — everything readable — must be derivable from the events, and re-derivable from scratch at any time. You will build the write side (aggregate + event store), the read side (projections), and then confront the consistency gap between them.

Instructions

Commands (the write API)

  • OpenAccount(accountId, owner) — creates the account.
  • Deposit(accountId, amount) — credits it.
  • Withdraw(accountId, amount) — debits it.

Invariants (the aggregate enforces these; violations reject the command)

  • An account can be opened only once; deposits and withdrawals require an opened account.
  • Amounts are strictly positive.
  • No overdraft: a withdrawal that would take the balance below zero is rejected — no event is emitted for a rejected command.

Events (immutable, past-tense, domain language)

  • AccountOpened { accountId, owner }
  • MoneyDeposited { accountId, amount }
  • MoneyWithdrawn { accountId, amount }

Events record facts, not intentions; once appended they are never edited or deleted.

The build

  1. Model the three events and a given/when/then test helper: given a history of events, when a command, then expect new events — or a rejection.
  2. The BankAccount aggregate rebuilds its state as a left-fold over its events; command methods check the invariants and emit events. Pure in-memory, TDD'd, no persistence.
  3. The event store: append-only, one stream per account, optimistic concurrency — every append states the stream version it expects, and a mismatch fails the append. In-memory first, then file- or SQLite-backed behind the same interface.
  4. Command handlers: load stream → fold to state → decide → append, retrying the whole cycle on a concurrency conflict.
  5. Projections: a current-balance read model and an account-statement read model (chronological entries with running balance), updated from events. Handlers must be idempotent — the same event may be delivered twice — and a full rebuild from event zero must reproduce both models exactly.
  6. Consistency in the UX: after a deposit, the depositor's next balance read must reflect it (read-your-own-writes). Then add snapshots to the aggregate load path and measure what they buy at this scale.

Examples

Command sequence and resulting stream for account acc-1:

OpenAccount(acc-1, "R. Goussu")   → 1: AccountOpened  { acc-1, "R. Goussu" }
Deposit(acc-1, 100)               → 2: MoneyDeposited { acc-1, 100 }
Withdraw(acc-1, 30)               → 3: MoneyWithdrawn { acc-1, 30 }
Withdraw(acc-1, 100)              → rejected (overdraft) — stream unchanged
Deposit(acc-1, 10)                → 4: MoneyDeposited { acc-1, 10 }

Projections over that stream:

  • Current balance: acc-1 → 80
  • Statement:
# Operation Amount Balance
1 opened 0
2 deposit +100 100
3 withdraw −30 70
4 deposit +10 80

Concurrency: two handlers both load version 3 and try to withdraw 50; the first append (expected version 3) succeeds, the second fails its version check, reloads the stream — and now rejects for overdraft.

Constraints

  • The event log is the only source of truth: no ORM-style current-state table that writes go through. Balance tables exist only as projections, disposable and rebuildable.
  • Every behavior is written test-first in the given-events / when-command / then-events template — including the rejections.
  • The domain stays pure: the event store and projections sit behind driven ports; no persistence types inside the aggregate.
  • Events are append-only forever: fixing a mistake means a new (compensating) event, never editing history.

Acceptance

Mapped to the exercise's milestones:

  1. The test helper reads as given/when/then, and a first trivial behavior (opening an account) is specified with it.
  2. The aggregate passes tests for every invariant — double open, non-positive amounts, overdraft — each rejection emitting nothing, entirely in memory.
  3. The store rejects an append whose expected version is stale; the in-memory and durable implementations pass the same test suite through the same interface.
  4. Two concurrent withdrawals against one account never overdraw it: one retries and is then correctly accepted or rejected against the fresh state.
  5. Dropping both read models and replaying from event zero reproduces the example projections exactly; delivering an event twice changes nothing.
  6. A deposit followed immediately by a balance read shows the new balance; snapshot on/off timings for aggregate load are recorded.

Related