Skip to content
16px
Your balance is probably the wrong abstraction
PaymentsLedgersBackendSystem DesignPostgreSQL

Your balance is probably the wrong abstraction

Why financial systems should treat immutable double-entry ledger entries as the source of truth and balances as disposable projections.

August 15, 202611 min read

The first time you build a wallet service, the schema more or less writes itself:

accounts

id
user_id
balance
currency

Alice sends Bob $100, so you subtract 100 from one row and add 100 to another, wrap it in a transaction, and ship it. Nothing about that is unreasonable. It's what the domain looks like from the outside, and for a side project it's fine.

It stops being fine the moment the numbers belong to someone.

Ledger systems at places like Stripe, Square and Modern Treasury don't treat the current balance as the thing being stored. They store the movement, as immutable entries:

Alice    -$100
Bob      +$100
----------------
Net        $0

Those rows never get touched again. The balance is whatever you get when you add them up. That sounds like a small change and it isn't.

What breaks with a mutable balance

Say Alice has $1,000 and sends Bob $100:

sql
1UPDATE accounts
2SET balance = balance - 100
3WHERE id = 'alice';

She's at $900. Three days later she emails support asking why.

Now go look. The database says 900. That's it. That's the whole answer it can give you. It doesn't tell you whether there was one $100 transfer or two $50 charges, whether a retry double-processed a payment, whether someone ran a manual UPDATE at 2am during an incident, or whether a migration mangled the column. The state is there. The provenance isn't.

Current state tells you where the system ended up. A ledger tells you how it got there. With money, the second question is usually the one people are actually asking.

Think in events

The first improvement is obvious once you see the problem. Don't store the balance, store the things that changed it:

+1000   deposit
-100    transfer to Bob

Balance becomes SUM(entries), which for Alice is 900. Better. But real financial systems go one step past this, into double entry.

Double entry

The rule underneath all of it is short: money cannot leave one account without arriving in another. So every transaction carries at least two entries, and instead of writing Alice -100 on its own, you write both halves:

Transaction tx_123

Alice    -100
Bob      +100

and you enforce SUM(entries) = 0.

That single constraint does a lot of work. If some code path tries to post Alice -100, Bob +90, the ledger refuses it, because ten dollars just evaporated and there is no legitimate way for that to happen.

Real accounting systems say debit and credit rather than positive and negative, and what those mean depends on the account type, which is where most engineers get lost on the first pass. You can skip the vocabulary for a while. The property that matters is the invariant:

Transaction
    Entry A
    Entry B
    Entry C
    ...

Sum(entries) == 0

Every unit of value that leaves somewhere has to land somewhere.

Deposits have two sides too

The question that always comes up: if Alice deposits $1,000 from her bank, where does the other entry come from?

From your own internal accounts. A real system has a set of them sitting behind the customer-facing balances:

Customer Wallet
Bank Cash
Payment Processor Clearing
Revenue
Fees
Refunds
Pending Funds

So the deposit looks something like:

Cash Account        -1000
Alice Wallet        +1000

The exact debit/credit shape depends on your accounting model, but the point holds: the ledger never conjures $1,000 out of nothing. It records where the value came from.

A minimal schema

Three tables will get you surprisingly far.

sql
1CREATE TABLE accounts (
2    id UUID PRIMARY KEY,
3    name TEXT NOT NULL,
4    currency TEXT NOT NULL
5);
6
7CREATE TABLE transactions (
8    id UUID PRIMARY KEY,
9    reference TEXT UNIQUE,
10    created_at TIMESTAMP NOT NULL DEFAULT NOW()
11);
12
13CREATE TABLE ledger_entries (
14    id UUID PRIMARY KEY,
15    transaction_id UUID NOT NULL,
16    account_id UUID NOT NULL,
17    amount BIGINT NOT NULL,
18    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
19
20    FOREIGN KEY (transaction_id)
21        REFERENCES transactions(id),
22
23    FOREIGN KEY (account_id)
24        REFERENCES accounts(id)
25);

There's no balance column anywhere, which is the point.

Don't put money in a float

Related rule, learn it early: never represent money as a floating point number. Not 19.99. Store the smallest unit of the currency as an integer, so $100.00 is 10000 cents and the ledger holds:

Alice    -10000
Bob      +10000

In Go:

go
1type Entry struct {
2    AccountID string
3    Amount    int64
4}

In TypeScript:

ts
1type Entry = {
2  accountId: string;
3  amount: bigint;
4};

Multi-currency systems need a real monetary model, since not every currency has two decimal places (JPY has zero, KWD has three). The principle survives: integers only.

Posting a transfer

Say the request comes in as:

json
1{
2  "from": "alice",
3  "to": "bob",
4  "amount": 10000
5}

You build the entries:

ts
1const entries = [
2  { accountId: "alice", amount: -10000n },
3  { accountId: "bob",   amount:  10000n },
4];

and before anything touches the database:

ts
1const total = entries.reduce(
2  (sum, entry) => sum + entry.amount,
3  0n,
4);
5
6if (total !== 0n) {
7  throw new Error("Unbalanced transaction");
8}

Four lines, and it's probably the most load-bearing check in the system. Then both entries go in inside one database transaction:

BEGIN;

INSERT transaction;

INSERT Alice -10000;
INSERT Bob   +10000;

COMMIT;

Either the whole movement exists or none of it does. You never want to be in a state where Alice's debit committed and Bob's credit didn't because the connection dropped between two inserts.

Getting a balance out

The naive version:

sql
1SELECT SUM(amount)
2FROM ledger_entries
3WHERE account_id = 'alice';

Entries of +1000, -100, -50, +200 give you 1050. The ledger holds the truth and the balance is a projection of it.

Summing millions of rows will be slow

It will, and this is where the idea usually gets misread. Deriving balance from entries doesn't mean scanning 500 million rows every time someone opens the app. You keep a derived table:

ledger_entries        <- source of truth
account_balances      <- derived projection

with something like account_id, available_balance, pending_balance, version. Maintain it incrementally, read from it constantly. It just isn't authoritative. If it drifts, or a bug corrupts it, or you need to prove a number to an auditor, you rebuild it from the entries. Immutable ledger for correctness, cached balance for speed.

If you've worked with event sourcing this is the same shape you already know: an append-only log, a projection built off it, fast reads on top. Projections are disposable. History isn't.

Never edit an entry

Suppose tx_001 posted Alice -100, Bob +100 and it turns out to have been wrong. The tempting fix is an UPDATE. Don't. Write a second transaction instead:

tx_001

Alice    -100
Bob      +100


tx_002 REVERSAL

Alice    +100
Bob      -100

Net movement for both parties is zero, the same as if you'd edited the row, except now the history says two things happened: the transfer, and someone reversing it. For a support ticket or a reconciliation break or a regulator asking questions, those are completely different facts and you want both of them on record.

The audit trail comes free

Here's what a support engineer sees on a mutable-balance system:

Alice balance: $932.17

and here's the same account with a ledger:

09:12  +$1000   ACH deposit
11:46  -$42.83  Card payment
13:02  -$25.00  Transfer
14:15  +$25.00  Transfer reversal
15:31  -$100.00 Withdrawal

One of those you can explain to a customer. Everyone ends up asking a version of the same question anyway. Customers want to know where their money went. Finance wants to know why the bank statement doesn't match the database. Compliance wants to know what happened to a specific transaction, and engineering wants to know which deploy created the bad entry. A ledger gives you evidence for all four. A balance column gives you a number and a shrug.

Idempotency stops being optional

Client posts a transfer. Your server writes it. The connection dies before the response gets back. The client has no idea whether it worked, so it retries, and now Alice has sent $200.

This is why every payment API you've ever integrated with wants an idempotency key:

http
1Idempotency-Key: transfer_839238

Store the key alongside the transaction, and when the same key shows up again, return the original result instead of posting a second one. At the schema level that's just:

sql
1CREATE UNIQUE INDEX transaction_reference_unique
2ON transactions(reference);

Let the database enforce it. Application-level checks race against themselves.

Concurrency is where it gets interesting

Alice has $100 and fires off two transfers at nearly the same moment, one for $80 and one for $70. Both requests read a balance of 100, both decide there are sufficient funds, both post. Alice is now at -$50.

Your ledger did nothing wrong here. Every transaction balanced, nothing was mutated, the invariant held perfectly, and the business outcome is still garbage. Overdraft protection is a concurrency-control problem wearing an accounting costume, and you solve it with the usual tools: row-level locking, serializable isolation, an account version column, a reservation step, or routing all writes for an account through a single writer. The crude version being:

sql
1SELECT id
2FROM accounts
3WHERE id = 'alice'
4FOR UPDATE;

then compute the available balance and post while holding the lock. Most of financial backend work is this: finding the invariants and then making them structurally hard to break.

Pending and posted

Money in real payment systems isn't available the instant it moves. A card payment gets authorized now and settled later, so the ledger needs to distinguish available from pending from posted. At authorization:

Alice Available     -100
Alice Pending       +100

and at settlement:

Alice Pending       -100
Merchant Balance    +97
Platform Fees       +3

Which brings up something worth being explicit about, because the name misleads people. Double entry does not mean two rows. It means the entries in a transaction sum to zero, however many there are:

Customer      -100
Merchant       +97
Platform Fees   +3
------------------
Total            0

Three entries, still balanced, still fine. Fee splits and multi-party payouts routinely produce six or eight.

Keep the ledger ignorant

Draw a hard line between your business domain and the accounting engine:

Payments Service
      |
      | "Payment captured"
      v
Ledger Service
      |
      | create balanced transaction
      v
Journal

The ledger should be boring, and I mean that as praise. It knows about accounts, transactions, entries, currencies, posting rules, idempotency, reversals. The payment service is the one that knows what a checkout session is, what a merchant is, how refunds work, what a card network does. The ledger only knows that value moved from A to B and that the books balance.

Once product concepts start leaking into the ledger you lose the thing you built it for, because now every feature launch is a change to your financial core.

It's a state machine, not CRUD

A useful reframe when you're designing the API. You're not doing create/read/update/delete. You're doing:

Post transaction
Reverse transaction
Read history
Derive state

Update and delete simply aren't in the vocabulary. Both mutate history, which is the one thing the design exists to prevent. The system is append-heavy on purpose.

Verify it independently

Build a command that walks transactions and checks the invariant:

bash
1ledger verify
ts
1async function verifyTransaction(transactionId: string) {
2  const entries = await db.entries.findMany({
3    where: { transactionId },
4  });
5
6  const total = entries.reduce(
7    (sum, entry) => sum + entry.amount,
8    0n,
9  );
10
11  if (total !== 0n) {
12    throw new Error(
13      `Ledger corruption: ${transactionId} is unbalanced`,
14    );
15  }
16}

Ideally your write path makes an unbalanced transaction impossible to create at all, and you should aim for that. Run the checker anyway. It costs almost nothing and it catches the case where the impossible thing turns out to be possible after all. The same job can check that every entry points at a real account, that every reversal points at a transaction that exists, that references are unique, that currencies match across a transaction.

Build one

If you're learning backend or system design, this is a good weekend project. The surface is small:

POST /accounts

POST /transfers

POST /transactions/:id/reverse

GET /accounts/:id/balance

GET /accounts/:id/entries

GET /transactions/:id

Go or TypeScript, Postgres, BIGINT amounts, real database transactions, idempotency keys. Schema roughly:

accounts
    id
    name
    currency

transactions
    id
    reference
    reversed_transaction_id
    created_at

ledger_entries
    id
    transaction_id
    account_id
    amount
    created_at

Four rules to hold: entries are append-only, every transaction sums to zero, posted transactions can't be edited, corrections go through reversals.

Then break it on purpose. Fire 100 concurrent transfers at the same account. Send the identical request twenty times. Kill the process mid-write. Try to post something unbalanced. Reverse the same transfer twice. You'll learn more from the failures than from the happy path, and the failures are the entire reason the design looks like this.

The part that generalizes

The interesting idea here isn't really accounting. It's that when correctness matters more than convenience, storing only the current state throws away the information you'll need later. Store the sequence of facts that produced it.

Instead of balance = 900, store +1000, -100. Instead of quietly fixing a wrong -100, record the -100 and then a +100 reversal next to it. Nothing vanishes, nothing gets rewritten, and every state transition has a reason attached. When the read model breaks, and it will, you can rebuild it.

If you keep one picture from this, keep this one:

Wrong mental model:

Account
└── balance = $900


Better mental model:

Account
├── +$1000
├── -$100
└── balance = SUM(entries)

and for any movement:

Transaction
├── Alice     -$100
└── Bob       +$100

SUM = $0

The thing users care about most, their balance, turns out to be the derived, disposable part. The valuable data is the history underneath it. That's why Square's Books and the ledger products built since all sit on immutable double-entry records: being able to reconstruct exactly what happened matters more than being able to change a number quickly.

It's also roughly the line between building a wallet-shaped CRUD app and building a financial system.

Bhupesh Kumar

Bhupesh Kumar

Backend engineer building scalable APIs and distributed systems with Node.js, TypeScript, and Go.