legdger · Data model

Why balances should not be stored

Store a balance as a field and you must maintain it on every change — the single largest source of inconsistency. Double-entry bookkeeping gives us a calmer answer.

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.

LedgerCore — legs must sum to zero
01// 一条账目 = 一组求和为零的 Leg
02// 午饭 38 元,用招行卡支付:
03transaction(term: .expense) {
04 leg(from: .asset(.cmbCard), amount: -38.00)
05 leg(from: .expense(.dining), amount: +38.00)
06} // sum == 0 ✓
07
08// 余额不存库,永远派生:
09// balance = 期初 + Σ 该袋子下所有 Leg

The 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.

More Notes
Get in touch

Hand the complexity of identity, agents and private domain to one governable kernel

Whether you are replacing an existing IAM, building an agent platform, or trying to make private-domain operations actually work — start with a 30-minute architecture call. We will first judge whether this is the kind of problem we are good at, and say so plainly if it is not.