Most bookkeeping apps store the balance in a balance field on the wallet table. Recording an expense means doing two things in code: insert the transaction, then update the balance. Both must succeed together, or the books are wrong.
This is fine on a single machine with a single thread. But once syncing, imports, bulk reconciliation, or concurrent writes enter the picture, maintaining the balance field becomes a perpetual defensive battle — every change forces you to ask: was the balance updated, was it updated correctly, and did some code path get missed?
A different idea: make the balance a derived value
Double-entry bookkeeping works differently: an entry is not a single amount but a group of legs. Each leg is a “bag + amount”. There is exactly one core constraint — within the same transaction, the legs must sum to zero for every currency.
01// 一条账目 = 一组求和为零的 Leg02// 午饭 38 元,用招行卡支付:03transaction(term: .expense) {04 leg(from: .asset(.cmbCard), amount: -38.00)05 leg(from: .expense(.dining), amount: +38.00)06} // sum == 0 ✓0708// 余额不存库,永远派生:09// balance = 期初 + Σ 该袋子下所有 LegThe balance therefore becomes the result of a query, not a piece of state that has to be maintained. Writing a transaction only requires the legs to sum to zero — something the kernel can enforce with a single validation gate.
The bag model: eleven types, enough without bloat
Assets, liabilities, securities, crypto holdings, pending reconciliation, split intermediates… eleven bag types cover the scenarios personal bookkeeping actually runs into. Bag types are a fixed enum and users cannot define their own — a deliberate narrowing that keeps the data model from bloating without limit as requirements grow.
What it costs
- Balance lookups require aggregation and are slower than reading a single field (held up by indexes and caching)
- As data volume grows, aggregation gets more expensive and needs to be partitioned by time window
- Higher demands on developers: every write must construct a valid combination of legs — you cannot just tweak a number
We traded a model that is more awkward to write for the elimination of an entire class of “the balance does not add up” problems. For a bookkeeping tool, that is a very good trade.