A Banking API Is Not Just CRUD: What Building a Money-Movement Ledger Taught Me
I thought a banking API would be mostly CRUD. Make an account. Read a balance. Update a row. I had built dozens of APIs before this one. The pattern felt familiar.
Then I moved real money, and everything I thought I knew started breaking.
The Moment CRUD Thinking Collapses
The first sign of trouble was a race condition I could not reproduce consistently. Two requests hitting the same account at nearly the same time, both reading the same balance, both deciding there were sufficient funds, both completing. The account went negative. No error was thrown. The database was in a perfectly consistent state that was also completely wrong.
A row update is a lie in financial systems. When you UPDATE accounts SET balance = 950 WHERE id = 42, you are destroying information. You are saying "the balance is now 950" and silently discarding the fact that it was 1000, that a debit of 50 occurred, that it happened at a specific timestamp, triggered by a specific transaction initiated by a specific user. That is not a state change. That is an event, and events are facts. Facts do not get overwritten.
A Ledger Is a Stream, Not a Table
The accounting world figured this out centuries ago. A ledger is not a snapshot. It is a sequence of debits and credits, and the balance is just the sum of that sequence at any given point in time. The "current state" is derived, not stored.
This is the mental shift that changes everything. Once you stop thinking about your database as the source of truth and start thinking about your event log as the source of truth, the right architecture becomes obvious.
-- Instead of this:
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
-- You do this:
INSERT INTO ledger_entries (account_id, amount, type, transaction_id, occurred_at)
VALUES (42, -50, 'debit', 'txn_abc123', NOW());
-- Balance is always computed, never stored as mutable state:
SELECT SUM(amount) FROM ledger_entries WHERE account_id = 42;
The ledger entry is immutable. It happened. You can append to the log. You cannot rewrite it. Balances become a read-time projection over an ordered sequence of facts.
Ordering and Atomicity Are Not Optional
Here is where the infrastructure problem gets serious. If your event log is built on top of something that cannot guarantee ordering, you are back to the same race condition problem, just one layer deeper. You need to know that debit A happened before credit B, not just that both happened. The sequence is the data.
Partial failures are equally dangerous. A transfer between two accounts is not one event, it is two: a debit from the source and a credit to the destination. If your system processes the debit and then crashes before the credit, you have destroyed money. Atomicity across events is not an edge case to handle later. It is the entire problem.
This is why event-driven architecture exists for systems like this. The database becomes almost secondary. What matters is the stream: ordered, durable, complete, and replayable. If you can replay your event log from the beginning and arrive at the correct state every time, your system is correct. If you cannot, it is not.
What Real-Time Streaming Changes
When we rebuilt the ledger properly, the most important decision was choosing infrastructure that could guarantee event ordering before any balance was trusted. This is where tools like Turboline matter in practice. Real-time data streaming that delivers financial event sequences ordered and complete changes what you can reliably compute at read time. Without ordering guarantees at the stream level, you are constantly fighting to reconstruct sequence from timestamps, which are unreliable under load.
The balance a user sees should reflect every event that has occurred, in the order it occurred, with no gaps. That sounds simple. Making it true under concurrent load, across distributed services, with real money on the line, is where the engineering actually lives.
What This Changes About How You Build
If you are designing a financial system from scratch, a few things are worth treating as non-negotiable from day one.
Never store balance as a mutable column that gets updated. Derive it from the ledger at read time, or maintain it as a materialized projection that is updated only by appending new events, never by direct mutation.
Treat every transaction as an immutable event with a unique identifier, a timestamp, an amount, a type, and a reference to the initiating request. Idempotency keys on the request side prevent double-processing if a client retries.
Make replayability a first-class concern. If you cannot take your event log, replay it against a fresh database, and arrive at the correct state, your audit trail is fiction.
And get your ordering guarantees at the infrastructure level, not in application code. Application-level ordering logic is fragile. The stream should arrive ordered so your business logic does not have to compensate for infrastructure that is not.
The Concrete Takeaway
CRUD works fine until money moves. The moment it does, the system is no longer managing state. It is recording history. Build your infrastructure to reflect that, and the hard problems become tractable. Ignore it, and you will spend months debugging race conditions that only appear in production under load, at the worst possible time.
Top comments (0)