The balance projection
Cache the balance as a projection, never as truth. The ledger always wins.
By Solomon Ajayi · Free to read, no signup
Lesson 24 said never cache running_balance. That's true for STATEMENT generation, recompute on read. But for the LIVE wallet balance the user sees every time they open the app, computing sum(debits) - sum(credits) over 5 years of entries is unviable. Real systems cache the live balance ON the wallet row, updated atomically inside the same transaction as each journal entry. The cache is a PROJECTION of the ledger, not its source of truth. If the cache ever drifts, the ledger wins and the cache is rebuilt (Lesson 40). Apply the entries below, then switch to Statement view: the running balance IS what production caches.
There is a real tension between correctness and speed here. The ledger is the truth, and the only fully correct way to know a wallet's balance is to sum every debit and credit ever posted to it. That sum is cheap on day one and ruinous after five years of entries, and the user expects their balance to render the instant they open the app. So you cache: you store a balance number on the wallet row and read that instead of replaying history on every request.
The discipline that keeps this safe is atomicity. The cached balance is updated in the exact same database transaction as the journal entry that changed it, never before, never after, never in a separate write. Deposit ₦10,000 and the INSERT into journal_line and the UPDATE that bumps wallet.balance commit together or not at all. Because they commit together, the cache and the ledger can never disagree by the width of a single failed write.
This is the inverse of the wallet-balance-as-truth model the early lessons warned about. There the number on the row was the only record and the ledger, if it existed, was a side log. Here the ledger is primary and the number is derived, which is exactly why corruption is survivable: a projection can be rebuilt from its source, but a source that was only ever a single mutable number cannot be reconstructed from anything.
Worked example, step by step
Deposit ₦10,000
Standard funding entry. In production, the code does BOTH operations in the same DB transaction: INSERT journal_entry + UPDATE wallet SET balance = balance + 10000 WHERE id = X.
| Account | Debit | Credit |
|---|---|---|
| FBO at Sponsor Bank (1300) | ₦10,000.00 | |
| User Wallet (2000) | ₦10,000.00 |
FBO UP ₦10,000. User Wallet UP ₦10,000. Switch to Statement view and pick User Wallet, the running balance shows ₦10,000 credit. THAT NUMBER is what production caches on wallet.balance. The cache and the ledger agree because both updates committed atomically.
Entry insert AND cache update commit together. Atomic.
BEGIN;
INSERT INTO journal_entry (description) VALUES ('Deposit NGN10,000');
INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
(currval('journal_entry_id_seq'), '1300', 1000000, 0),
(currval('journal_entry_id_seq'), '2000', 0, 1000000);
-- The cache. Same transaction. If either side fails, both roll back.
UPDATE wallet
SET balance = balance + 10000,
updated_at = now()
WHERE user_id = $1;
COMMIT;Plain pg. The discipline: never separate the two writes.
import { Client } from "pg";
export async function deposit(
client: Client,
args: { userId: string; amount: bigint },
): Promise<void> {
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string }>(
`INSERT INTO journal_entry (description) VALUES ($1) RETURNING id`,
[`Deposit NGN${args.amount}`],
);
const entryId = rows[0].id;
await client.query(
`INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
($1, '1300', $2, 0),
($1, '2000', 0, $2)`,
[entryId, args.amount],
);
// Same transaction. The cache walks with the ledger.
await client.query(
`UPDATE wallet SET balance = balance + $1, updated_at = now() WHERE user_id = $2`,
[args.amount, args.userId],
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
}
}Deposit ₦5,000, cache walks with the ledger
Another deposit. Same atomic pattern: entry insert + balance update in one transaction.
| Account | Debit | Credit |
|---|---|---|
| FBO at Sponsor Bank (1300) | ₦5,000.00 | |
| User Wallet (2000) | ₦5,000.00 |
Statement view now shows ₦15,000 running balance. wallet.balance in production now says 15000. They agree because every entry committed AS PART OF a transaction that also updated the cache. Skip the cache update once (left it out of a code path, a migration missed it) and the cache lies FOREVER, every subsequent transaction widens the gap.
Service charge ₦500, same atomic update
A service charge ₦500 from the user's wallet. Same atomic pattern, this time decrementing the cache.
| Account | Debit | Credit |
|---|---|---|
| User Wallet (2000) | ₦500.00 | |
| FBO at Sponsor Bank (1300) | ₦500.00 |
Statement running balance: ₦14,500. Production wallet.balance: 14500. Agreement maintained because the debit-the-wallet entry committed alongside UPDATE wallet SET balance = balance - 500. The discipline is: NEVER update wallet.balance outside a journal-entry transaction. NEVER post a journal entry without the matching balance update. One or the other alone IS the bug.
Takeaway
Cache the live wallet balance for read performance. The cache is a PROJECTION, the ledger is the truth. Update the cache in the SAME transaction as the journal entry, every time, no exceptions. The cache must be REBUILDABLE from the ledger at any moment (Lesson 40 covers the rebuild). Storing balance as truth (with the ledger as 'audit log' on the side) is the architecture that gets fintechs in trouble, they can't recover from corruption because the audit log was never the source of truth.
The code behind it
Reference solution
CREATE TABLE wallet (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
balance bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- UNIQUE on user_id: turns the hot lookup into a 1-row index seek AND
-- enforces the invariant that each user owns exactly one wallet row.
CREATE UNIQUE INDEX wallet_user_id_uniq ON wallet (user_id);Practice this on a real ledger
Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.