Guide ยท 9 min read
what you have
accounts
id balance_cents
acc_8821 240000
UPDATE accounts
SET balance_cents = balance_cents - 24000
WHERE id = 'acc_8821';what you want
postings
txn account amount
txn_41 acc_8821 -24000
txn_41 acc_merchant_pay +24000
balance = SUM(amount) WHERE account = ?The balance stops being a number you maintain and becomes a number you derive. That is the whole change.
The actual problem
A ledger answers all four by construction, because the balance is derived from an append-only list of movements rather than overwritten in place. The migration below is the safe path between those two worlds.
The migration
Freeze new writers. Every place that touches the balance column gets funnelled through one function, today, before any migration work starts. If you cannot name every writer, the migration will not survive contact with the one you forgot.
The new ledger takes every movement in parallel. Nothing reads it yet, so a bug here costs you nothing. This is the phase where you discover which of your movements were never really balanced.
Pick a timestamp. Everything after it is already dual-written. Everything before becomes one opening balance per account, not a replay of five years of rows you no longer trust.
A job that reads both and reports drift, running for at least a full billing cycle. Month end is where the disagreements show up, because that is when the edge cases run.
Reads move to the ledger. The old column keeps being written for one more cycle so you can roll back without a data recovery exercise. Then you drop it, deliberately, on a day nothing else ships.
Phase two, in code
async function debitCustomer(order, amountCents) {
await db.transaction(async (tx) => {
await tx.decrementBalance(order.customerId, amountCents);
await kordio.transactions.create({
idempotencyKey: `order:${order.id}:debit`,
postings: [
{ accountId: acc(order.customerId), amount: -amountCents, currency: 'EUR' },
{ accountId: acc('merchant_payable'), amount: amountCents, currency: 'EUR' },
],
metadata: { orderId: order.id },
});
});
}What comparison finds
| What drifts | Why it was invisible before |
|---|---|
| A movement with no counterparty | A fee taken out of a balance with nothing credited. It nets in a stored column and is rejected by a ledger, which is the point. |
| Two writers, one balance | A refund path and a payout path that both decrement, discovered only because the ledger makes the second one fail. |
| Rounding taken once per row | Fractions dropped per movement instead of carried. Invisible at ten rows, a real number at a million. |
| A currency assumption | Everything stored in one currency because there was only one, then a second one arrives and the column has no idea. |
| Manual adjustments | The support tooling that writes the balance directly. Always exists, never in the diagram. |
Phase four
Deciding whether to do any of this at all is a fair question, and it has a real answer:build versus buy. The idempotency keys above are load-bearing, and they have their own guide:how idempotency actually works.
for (const account of await activeAccounts()) {
const stored = await db.balanceOf(account.id);
const derived = await kordio.balances.get(acc(account.id));
if (stored !== derived.amount) {
report({
account: account.id,
stored,
derived: derived.amount,
delta: stored - derived.amount,
});
}
}Cut over when this reports zero across a full close cycle. Not when it reports zero once.
Questions
Usually not. One opening balance per account as of a cutoff is enough for correctness going forward, and it is far less risky than replaying years of rows whose semantics you no longer remember. Keep the old table read-only for history.
At least one full close cycle, so month-end processes run against it once. Teams that shorten this are the ones that find a discrepancy after cutover instead of before.
That is the migration working. A disagreement is a bug in the old path roughly as often as in the new one, and finding it while the old column is still authoritative is the cheapest time to find it.
Yes, and that is the easiest way to adopt a ledger. Land the new product, the new rail or the new market on it, leave the running system alone, and let the old balances age out.
If you hold balances for one party, in one currency, at a volume where a discrepancy is a ten-minute query, this is more process than the problem deserves. Keep your column. The guide is written for the point where a mistake is somebody else money.