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:
sql1UPDATE 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.
sql1CREATE 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:
go1type Entry struct { 2 AccountID string 3 Amount int64 4}
In TypeScript:
ts1type 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:
json1{ 2 "from": "alice", 3 "to": "bob", 4 "amount": 10000 5}
You build the entries:
ts1const entries = [ 2 { accountId: "alice", amount: -10000n }, 3 { accountId: "bob", amount: 10000n }, 4];
and before anything touches the database:
ts1const 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:
sql1SELECT 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:
http1Idempotency-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:
sql1CREATE 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:
sql1SELECT 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:
bash1ledger verify
ts1async 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.

