Digital wallet
How do you build a balance that can always be proved, event by event?
Where did a customer’s ₹180 go?
Customers asked for a prepaid wallet: top up ₹2,000 once, and each lunch is paid from it. The first version is built the usual way. A wallets table with a balance column, and a wallet_transactions table with one row per movement, both written in the same database transaction.
After the first month the accountant does what she does with every book. For each of the 300 wallets she adds up the transaction rows and compares the sum with the balance. Three wallets disagree. One balance says ₹460; its transactions add up to ₹640. One of the two is wrong, and nothing in the database says which.
The cause takes an hour to find. An old code path for cancelled orders took the money out of the balance column and never wrote a transaction row. Every other path wrote both. That one path changed the balance and left no line in the history.
The customer is polite on the phone, and firm: show me where my ₹180 went, line by line. Nobody can. The balance column holds a number with no history; the history table holds lines that no longer add up to it.
Banks keep the transactions and compute the balance. A paper ledger works the same way: it has no balance column, only lines, with a total in pencil at the bottom of each page so nobody has to add everything up from the first page each time. Before reading on, think about what that design gives up. Today the balance row is what stops two phones on one wallet from both spending the same ₹300. A wallet whose balance is only ever derived from its events must still be safe when two devices spend at once.
Events as the truth, balance as a fold
Event sourcing was introduced in lesson 6.6 and the double-entry ledger in lesson 6.7. Here the whole wallet is built on them.
The moving parts
- Commands and events. A command is a request (“place a ₹180 hold for order 4471”) and may be refused. An event is a fact in the past tense (“HoldPlaced ₹180”) and is never refused once written. The wallet checks a command against the current state, then appends the events it produces.
- One stream per wallet. Each wallet’s events form an event stream numbered from 1. The state is
fold(apply, events): start empty, apply each event in order. The balance is computed, never stored as the truth. - The expected version. A writer reads the stream at version 23, decides, and appends “at version 24, only if the stream is still at 23”. If another writer got there first, the append is refused and the writer reads again and decides again. This is the expected version check, optimistic concurrency on a stream (lesson 2.3 met optimistic locking on rows).
- Snapshots and read models. A snapshot stores the folded state every N events, so a rebuild reads at most N − 1 events after it. The app’s wallet screen and the accountant’s daily totals read from read models built from the events, which lag them slightly.
- Expected version (optimistic concurrency on a stream)
- An append that says which version of the stream the writer saw. The store accepts it only if the stream is still at that version; otherwise the writer must read again.
- For example: A phone and a tablet read the same wallet at version 23. The phone appends version 24. The tablet’s append “at 24, expecting 23” is refused; it re-reads, sees ₹60 available, and is told there is not enough money.
Balance column or event stream?
A new way of thinking has to earn its place, and the balance column can be made far safer than the first version was.
The guarded column is sound, and with those guards the ₹180 bug could not have happened. What decides it is what the wallet is for: customers and the accountant care about its history as much as its balance. Every dispute, refund and audit question is about the lines, and a wallet lives for years. For the menu or a cook’s profile the guarded column stays the right design.
What to remember
- In an event-sourced wallet the events are the truth and the balance is a fold over them; there is nothing else to keep in step.
- Commands can be refused; events are facts. Correct a mistake with a new event, never by editing an old one.
- Concurrent writers race on the expected version: a unique (wallet_id, version) key refuses the stale append, and the loser re-reads and decides again.
- A version check stops races. Only a command id stops repeats, so give every command one.
- Snapshots and read models are caches of the fold: disposable, rebuildable, and never a second source of truth.
Store the events and derive the balance; a number with no lines behind it cannot be audited.
In the real world
The toy has one wallet and five kinds of event. A real wallet adds what the toy leaves out. Each event also posts a double-entry journal entry, because the money in customers’ wallets is a liability the business owes them (lesson 6.7). Event formats change, so old events are “upcast” to the new shape as they are read. Read models are rebuilt from scratch when they go wrong. Replays never resend an SMS or charge a card: Fowler’s advice is to switch the gateways to the outside world off during replay.
The law matters too. In India, a wallet that can buy only from the business that issued it is what the Reserve Bank of India calls a closed-system prepaid payment instrument, which its Master Directions do not treat as a payment system needing authorisation. A wallet usable at other merchants is a different, regulated product. Read the current directions before launching either.