
Most financial systems start the same way. You're building a wallet, a user has 30 to a friend, so you write balance = balance - 30 on one row and balance = balance + 30 on the other. The balance becomes just a number you keep on the wallet and update each time, like the total on a gift card. It works, and you push it to prod that way.
Then the company grows, now you have debit/credit cards, crypto rails, fiat payouts, fees, refunds, a few payment providers, a wallet provider. The financial system spread across services and tables owned by different teams. And one quarter, finance tells you $40,000 is unaccounted for, can't be found.
The money isn't technically gone. It's in your bank account, every single cent is accounted for. What's gone is the attribution or citation, the record of who it belongs to. A balance column in your db holds a number but not the whole story behind it, so when the number and reality are in a disagreement, there's no way to replay how it got there. Every engineer who has worked on any key financial system has some version of this horror story, ask!
The fix isn't just a more thoughtful balance column. It's a model that banks and traditional financial systems haved used for years, and what every serious ledger still runs on today: double entry.
Storing money as a mutable number, a single entry fails for three main reasons, and they tend to come up at 2:45 AM midnight.
And you can't trace to understand what happened because there is no clear history. balance = 70 doesn’t say anything about the movements that produced it, it's just a number. When it's wrong, there's nothing to audit your way back through.
There's no second side. Subtracting $30 from a wallet doesn't record where the $30 went. The source and destination of the money exist only in whichever line of application code happened to run, not in the core system data.
More so again, there's zero help against races that will/could happen. Two requests read balance = 100 at the same time, each subtracts 30, and each writes 70. The money moved twice and the column will still looks like it's correct.
A double entry ledger replaces that mutable number with an append only record of movement, built from three objects.
An Account: This is simply an independent pool of value. A user's wallet, the company's cash, the fees you've collected, each one is an account.
An Entry: This is now a single debit or credit against a single account. An entry MUST always be written once and never changed or deleted. Each is a permanent record of value moving into or out of a single account "immutable".
A Transaction: This is a group of entries that move as one, all or nothing. This is that object that holds your entire system together.
You don't write entries directly. You write a transaction, and a transaction carries its entries. The write itself runs inside a single db txn so it's either all or nothing, tx, _ := db.Begin(); tx.Exec(...); tx.Commit(). For example, a transfer of $30 from Alfred to Tobi is one transaction holding two entries, one that takes from Alfred and one that will give to Tobi, and they either both commit or neither of them does. There should never be a valid state where Alfred was debited and Tobi was never credited. That atomic grouping is the assurance.
Now, this is the next part that confuses quite a number of people. A debit is not a subtraction and a credit is not an addition. They are simply the only two directions a movement can happen or take, and whether a given direction increases ↑ or decreases ↓ an account comes down to the type of account It settles on. For clarity you can read again this section "The three core principles simply"
Every account is one of a handful of possible types like we spoke about above, and the type is what fixes its normal balance, the side that makes the balance go up. So you only ever deal with two. First, debit normal accounts, assets and expenses. A debit is what increases them, a credit is what will decrease them. Then, credit normal accounts, liabilities, equity, and revenue. A credit is what increases them, a debit is what will decrease them.
The difference becomes clear the moment you apply it to a wallet example. The $100 a user holds with you is not your money, it's money you owe them, so on your books it's a liability, credit normal. The actual dollars you hold on their behalf are an asset, which is debit normal.
Now to make a deposit makes sense. For example a user deposits 100 USDC, and two things become true at once: you are holding 100 USDC more, an asset increasing, which is a debit, and you owe the user 100 USDC more, a liability increasing, which is a credit. One movement happened, two entries, and they balance.

Debit and credit aren't opposites in stress. They're two views of the exact $100, recorded at the same time, a single transaction.
From a physics analogy there is another way to look at it and understand it all, which is from the popular Newton's third law of Motion "For every action there is an equal and opposite reaction"
In any transaction, the debits and the credits have to come out equal. Every single time, zero exceptions. Σ debits − Σ credits = 0
A force never shows up alone, it always arrives with its equal and opposite twin. Value in this ledger scenario works the exact same way. It never lands on one account without an equal and opposite entry on another, and the two keep the books in balance.
The 100 USDC you debit your custody and the 100 USDC you credit the user are equal in size but they are not the same thing, one is an asset you now hold, the other is a liability you now owe. So nothing moves here, instead both sides grow at the same time, a new asset and an equal new liability come into existence together, and that is exactly why the books stay balanced.
In every transaction, total debits must equal total credits. A transaction that doesn't balance is not allowed to exist anywhere.
This rule is what turns "never lose money" from a hope into a key attribute of the system you are working on. Value can't leave one account without arriving in another, because the transaction won't balance otherwise. Your custody shouldn't drift apart from what you owe your users, because every movement records both sides at once. When something is wrong, the ledger doesn't hand you a quietly incorrect balance, it rejects the write outrightly.
In practice, the core of a ledger is this single check. In this case amounts are stored as non negative integers in minor units (cents, or wei), and direction is carried by a side field rather than by a sign:
type Entry = { accountId: string; side: "DR" | "CR"; amountMinor: bigint; assetId: string };
function assertBalanced(entries: Entry[]) {
if (entries.length === 0) throw new Error("empty txn");
const net = new Map<string, bigint>();
for (const e of entries) {
if (e.amountMinor <= 0n) throw new Error(`bad amount: ${e.accountId} ${e.amountMinor}`);
const delta = e.side === "DR" ? e.amountMinor : -e.amountMinor;
net.set(e.assetId, (net.get(e.assetId) ?? 0n) + delta);
}
for (const [asset, balance] of net) {
if (balance !== 0n) throw new Error(`unbalanced: ${asset} off by ${balance}`);
}
}Two things in there matter more than they appear. The balance is checked per asset, so a transaction touching dollars and bitcoin has to balance in each independently, one currency can never quietly settle another. And amounts are bigint, not floating point, because floating point and money is a separate horror story for another day.
One more thing to notice: nowhere in that snippet is a balance column being touched. The code only checks that the entries balance, it never writes a balance anywhere. And that's the right place to start from, a balance is never a fact you set directly. You don't write balance = 500. You add up the entries, debits - credits, read according to the account's normal side. The entries are the truth. The balance is just a question you ask of it.
Now here's another thing that could be gotten wrong. Defined by their entries doesn't mean you re add them on every read. If you do that, you're simply summing an account's whole history each time, that's O(n), and n only keeps growing. The account every transaction touches, your treasury and so on, would end up the slowest read. So you don't re add. You store the balance, bump it the moment each entry lands, and keep it in sync with the log. The log stays the source of truth. The stored number is really just a cache that has to agree with it. Derived in meaning, stored in practice, both, not one or the other.
The same idea in one picture, a transaction fanning out into two balanced entries that land on two accounts:

A single entry system asks you to trust a number. A double entry system refuses to let a number stand on its own, a balance isn't something you assert, it's something the entries force to be true. It's also the same way a unique index makes duplicates impossible rather than just something you shouldn't do, the balancing rule set makes losing money a write that won't commit rather than a difference you discover years or months later, with your license being questioned at a panel hearing. That's the reason almost every finance system is built this way.
This first part is also only the skeleton. A real ledger has to handle funds that hasn't settled, a crypto deposit awaiting block confirmations, a withdrawal that might fail and need to be reversed to the initiating user. In Part II, we'll look into this model on real movements, and watch a single balance become three.
Get the latest posts delivered to your inbox.