A register of balances is easy to write and hard to get right, and every hard part is a concurrency problem.
Why Go
The register is written in Go, and the reason is that almost everything it does is transactional database work under contention.
- Explicit control of transactions and row locks. The whole design rests on taking specific locks in a specific order at a specific moment. That has to be stated directly, not inferred by a layer that decides when to open a transaction on your behalf.
- Pauses are bounded, and a lock held during a pause is a lock held. Latency inside a locked transaction is not a performance question here; it is how long everyone else waits to post against the same account.
- Per-organisation schema and session state are ordinary operations. Setting the acting organisation on every transaction, and creating structures per organisation, are routine at this layer, not something worked around.
Amounts are integers, and the API refuses to round
An amount is a whole number in the asset's minor unit, with the scale declared once in the asset registry. It is never a floating-point number on the wire - a value arrives as an integer in minor units or as an exact decimal string, and a value carrying more precision than the asset supports is rejected rather than rounded.
That last clause is the one that matters. Rounding on input is how a register acquires a discrepancy nobody can trace: each individual rounding is defensible and the sum is wrong. Refusing is louder and correct.
Locks are taken in a fixed order
Two transfers in opposite directions between the same pair of accounts will deadlock if each takes its accounts in the order it happens to think of them. So accounts are locked ordered by their identifier, always - and a batch pre-collects and sorts every account it will touch before its first write, so a batch and a single posting, or two batches, cannot deadlock against each other either.
This is a small thing that is either present from the first day or discovered in production.
Holds are not postings
A reservation lives in its own table, not as a movement in the journal. Two reasons, both structural:
- The journal stays a pure record of what happened. A cart that was abandoned should not leave two entries in the history of an account cancelling each other out; it should leave nothing, because nothing happened.
available = balance − heldis then a single, obvious subtraction instead of a query that has to know which postings were provisional.
The past is immutable by construction
The posting time is set by the service and is never supplied by the caller. A closed period is therefore closed for good: no posting can appear inside it after the fact, and no period snapshot ever has to be recomputed because something arrived late.
A backdated correction is expressed the way accountants have always expressed it - a reversal of the original plus a fresh posting in the current period - so what happened and what was corrected are both still visible, in the order they actually occurred.
Isolation is enforced by the database
Every table carries a row-level security policy and every transaction sets the organisation it is acting for. Application-level scoping is not treated as sufficient here.
That is a deliberate divergence from how the data engine does it: a scoping slip in a query layer leaks a list of models, and a scoping slip here moves money between organisations. The two do not deserve the same level of defence.
For the same reason the acting person is never taken from the service credential the caller presents. That token identifies the organisation, not the human; the actor arrives separately and is honoured only when the service credential is the expected one.
An asset also carries a scope - belonging to one organisation, or to the platform - and that scope is resolved before authorisation runs, so a tenant-scoped call cannot address a platform-scoped asset even by accident.
Reconciliation reports, and never repairs
On a schedule, the register checks itself: for every asset, the balances across all accounts must sum to exactly zero. Double entry is what makes that check possible without any external source of truth - no bank statement, no stock count, nothing to compare against.
When it finds a divergence it raises it. It does not fix it. A register that quietly corrects itself is a register that hides the bug that caused the divergence, and the second occurrence is then invisible too. The check exists to tell you that something is wrong while the evidence of what did it is still in the journal.
The account key, and the one thing to decide early
An account is identified by its asset, the kind of owner, the owner, and its dimensions - the warehouse, the cost centre, whatever the balance is split by.
Those dimensions are part of the key, which means adding one later is not a settings change: it is new accounts plus transfer postings plus a recorded cut-over date. That is a supported procedure, not a migration nobody has done, but it is the decision worth spending an afternoon on before the first posting, and the only one on this page with that property.
Where it joins the rest of the platform
A register is only useful in company.
The catalogue is master data. A balance is keyed by codes - an item, a warehouse, a counterparty - and the register holds those codes as opaque strings. What the item is called, what category it belongs to, which supplier it comes from lives in master data, as real tables the same query can join. The register never resolves a code, which is why one register serves loyalty points and warehouse stock without needing to know what either of them is.
Prices and rates live in master data too, and are read at the moment of posting. The rate is not kept here; the price actually charged is recorded on the movement itself, because it describes that movement and not the balance. So two different questions get two different owners: what did this cost when it moved is answered from the journal, and what does it cost today from the price list.
Documents - carts, orders, bookings - are declared by the application. Grouping several reservations so they succeed or fail together is a domain idea, and it lives in a document layer above the register. That is what lets a new case arrive without the ledger having to learn a new noun.
Movements are posted from processes. A transition that completes - a shipment confirmed, a return accepted, an approval granted - posts the movement as part of the work, so a balance changes because something happened and not because somebody remembered to record it.
Balances are read by analytics and shown by pages and portals. Movements and balances reach reporting through a dataset bridge, and a balance on a screen is an ordinary widget bound to that data - so a new consumer gets its interface from the same catalogue of blocks as everything else.
Every one of those joins is a place where a register usually starts growing a second product inside itself: a half-built catalogue, a price list nobody else can see, a bespoke screen per use case. Keeping them as joins is what lets one engine serve points, stock, settlements and limits at the same time - and lets each neighbour stay good at the job it already has.