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
- 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.
- The
BankAccountaggregate 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. - 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.
- Command handlers: load stream → fold to state → decide → append, retrying the whole cycle on a concurrency conflict.
- 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.
- 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:
- The test helper reads as given/when/then, and a first trivial behavior (opening an account) is specified with it.
- The aggregate passes tests for every invariant — double open, non-positive amounts, overdraft — each rejection emitting nothing, entirely in memory.
- 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.
- Two concurrent withdrawals against one account never overdraw it: one retries and is then correctly accepted or rejected against the fresh state.
- Dropping both read models and replaying from event zero reproduces the example projections exactly; delivering an event twice changes nothing.
- A deposit followed immediately by a balance read shows the new balance; snapshot on/off timings for aggregate load are recorded.
Related
- Event-sourced bank account — the exercise this is the subject of.
- CQRS & event sourcing — the concept note whose costs this build makes concrete.
- Greg Young's SimpleCQRS (m-r) — the reference implementation to compare against once built.