I came into fintech from multi-tenant SaaS and enterprise software. Those systems care about correctness too, but there is a different level of seriousness in financial systems. The stakes for a wrong number are not a stale UI state or a broken export. They are real money, real regulatory exposure, and potentially real legal consequences.
One of the first things that shifted my thinking was understanding why financial systems use a double-entry ledger instead of just tracking account balances.
The problem with just storing balances
When I first looked at the system, I asked a reasonable question: why do we need a full transaction history? Why not just keep a balance field on each account and update it when money moves?
The answer is that a balance is a derived value. It tells you where things stand right now, but it cannot tell you how you got there. And in a financial system, "how you got there" is the entire audit trail. It is what you show regulators, what you use to debug disputes, and what proves the system has not silently dropped or duplicated money somewhere.
A balance field also gives you no protection if something goes wrong mid-transaction. If a transfer fails halfway through, the source account has been debited but the destination has not been credited. The money has disappeared from the perspective of the balance, and there is no record explaining why.
The ledger solves this. Every event is recorded, immutably, as a row. The balance is computed from those rows. If the balance looks wrong, you trace it back through the ledger entries.
What double-entry actually means
The accounting principle is simple: every transaction has two sides, and they must balance.
A $100 transfer from Account A to Account B produces exactly two ledger entries:
- Debit $100 from Account A
- Credit $100 to Account B
Total debits must equal total credits. Always. Not most of the time. Not usually. Always.
In practice this means the ledger has a hard mathematical invariant baked into it. If you insert a debit without a matching credit, the invariant is broken and the records are incorrect. Good systems enforce this at multiple levels so a bug in one layer cannot silently violate it.
How we enforced it
We enforced the debit/credit balance constraint at two levels: application logic and the database itself.
At the application level, every transfer was wrapped in a service function that always wrote both entries together. The function took the amount, source account, and destination account, constructed both ledger rows, and inserted them in a single operation. There was no code path where you could write one without the other.
At the database level, we used PostgreSQL transactions to ensure atomicity. Both inserts happened inside a single transaction block. If anything failed, the entire transaction rolled back and neither entry was persisted.
BEGIN;
INSERT INTO ledger_entries (account_id, type, amount, transaction_ref, created_at)
VALUES ($1, 'debit', $3, $5, NOW());
INSERT INTO ledger_entries (account_id, type, amount, transaction_ref, created_at)
VALUES ($2, 'credit', $3, $5, NOW());
-- Verify the transaction balances before committing
DO $$
DECLARE
net NUMERIC;
BEGIN
SELECT COALESCE(SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END), 0)
INTO net
FROM ledger_entries
WHERE transaction_ref = $5;
IF net <> 0 THEN
RAISE EXCEPTION 'Transaction does not balance: net = %', net;
END IF;
END $$;
COMMIT;
We also added a database constraint that checked the sum of all entries for a given transaction_ref equaled zero (credits positive, debits negative). This meant even if a future developer wrote code that bypassed the service layer and inserted directly into the table, the constraint would reject it.
That layered approach is the point. Application logic can have bugs. Developers can write direct SQL in migrations or scripts. The database constraint is the last line of defense that does not care about any of that.
The balance table and what it is actually for
We also maintained a separate account_balances table. A row per account, a single balance column, updated whenever a ledger entry was written.
That might sound like exactly what I criticized earlier. But there is an important distinction.
The balance table exists for read performance. Looking up whether Account A has enough funds to cover a payment, for a high-volume system, you cannot afford to run SUM(amount) over thousands of ledger entries on every request. The pre-computed balance is a cache. It makes reads fast.
But we never treated that cached balance as the source of truth for financial decisions. For anything that mattered, like authorizing a high-value transfer or running end-of-day reconciliation, we queried the ledger directly. The cached balance could theoretically be out of sync due to a bug, a failed update, or a race condition. The ledger could not lie in the same way because every entry was atomic and immutable.
The architecture principle here is: know which data is authoritative and which is derived. Using derived data for convenience is fine. Making critical decisions based on derived data without verifying it against the source is where things go wrong.
Concurrency and locking
Financial systems get concurrent. Multiple payment requests for the same account can arrive at nearly the same time, which creates a classic race condition if you are not careful.
Consider two simultaneous transfers both reading an account balance of $500. Both see enough funds. Both proceed. Both debit $400. The account ends up at $-300 when it should have bounced the second transfer.
PostgreSQL's locking mechanisms are how you prevent this. We used SELECT ... FOR UPDATE to lock the relevant account row before checking the balance and inserting the ledger entries. While one transaction held the lock, any other transaction trying to touch the same account had to wait.
BEGIN;
-- Lock the account row so no other transaction can modify it concurrently
SELECT balance FROM account_balances
WHERE account_id = $1
FOR UPDATE;
-- Now safely check balance and proceed with ledger insertions
-- ...
COMMIT;
This serializes access to each account. Only one transaction can be in the critical section for a given account at a time. You pay a small throughput cost for this, but for financial operations that is the right trade.
What this changed for me
Before this work, I thought about data correctness mostly in terms of validation: make sure the input is the right shape, make sure required fields are present, make sure foreign keys exist.
Fintech pushed me toward a different framing. The question is not just whether the data is valid. The question is whether the data is truthful and whether you can prove it.
The ledger gives you that proof. Every number has a lineage. Every balance can be derived from first principles by replaying the entries. If a discrepancy appears, you have the information to trace exactly where it came from.
Accounting developed these principles over centuries for exactly this reason. Database transactions and constraints are the engineering implementation of the same fundamental problem: how do you keep financial records consistent when many things are happening at once and any of them could fail?
The interesting part is that they fit together cleanly. The accounting model tells you what invariants must hold. The database gives you the tools to enforce them. You are not fighting the system when you use both together.
I am still learning a lot about financial systems. But this experience has genuinely changed how I think about what data correctness means in backend engineering, and I expect that will carry into a lot of future work.