DEV Community

Cover image for A Banking API Is Not Just CRUD: What Building a Money-Movement Ledger Taught Me
Anish Basnet
Anish Basnet

Posted on

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 that working in a weekend with Spring Boot and Postgres, and for a moment I figured the project was basically done.

Then I tried to actually move money from one account to another, and the easy part was over.

This post is about the gap between storing data and moving money safely. That gap is the whole reason I built a personal project called Ledger-Core, a money-movement REST API I made to learn backend correctness properly. It's not production software and I'm not going to pretend it is. But I wanted it to behave the way a real system has to, because that's where the interesting problems showed up.

I'm a junior developer. I didn't know most of this when I started. Here are the problems that taught me, roughly in the order they punched me in the face.

Problem 1: Money and floating point don't mix

The first thing I got wrong was the type I used for money.

If you've never hit this, open a REPL in almost any language and try 0.1 + 0.2. You get 0.30000000000000004.

That's not a bug. Floating point numbers (float, double) are just binary approximations, and a lot of normal decimal values can't be stored exactly. The errors are tiny. But they pile up, and "tiny error on someone's balance" is not a sentence you want to be explaining later.

In Java the fix is BigDecimal with a fixed scale. I store money as NUMERIC(19,2) in Postgres and BigDecimal with scale 2 in Java, the same way everywhere.

@Column(name = "balance", nullable = false, precision = 19, scale = 2)
private BigDecimal balance;
Enter fullscreen mode Exit fullscreen mode

Two things bit me here.

First, you compare with compareTo, not equals. new BigDecimal("1.0").equals(new BigDecimal("1.00")) is false, because equals cares about scale. compareTo returns 0. That one cost me a confusing afternoon staring at a failing test before I understood why.

Second, pick your scale once and pin it. I call .setScale(2) after every add and subtract, so a balance can't quietly drift to some other scale behind my back.

Small stuff. But this was the first moment Ledger-Core stopped feeling like a CRUD app and started feeling like a system with rules.

Problem 2: Two requests at once can make money disappear

This one humbled me, because every test I'd written was green.

Picture an account with $100 in it, and two withdrawals of $80 landing at the exact same moment.

With no concurrency control, both transactions read the balance as 100. Both ask "is 100 at least 80?" and both say yes. Both subtract and write back 20. One whole withdrawal just vanished, the account is now wrong, and $80 left the system that nothing can account for.

This is the classic lost update problem. The reason my tests never caught it is a little embarrassing: I was only ever sending one request at a time. Green tests told me nothing about what happens under pressure, because I'd never written the test that mattered.

The fix I went with is optimistic locking, and in JPA it's almost too easy to turn on. You add a version column to the entity:

@Version
private Long version;
Enter fullscreen mode Exit fullscreen mode

Now every update checks the version. When Hibernate writes, it basically runs UPDATE account SET balance = ?, version = version + 1 WHERE id = ? AND version = ?, where that last version is the one the transaction first read. If another transaction already changed the row, this update matches zero rows, and Hibernate throws an optimistic lock exception. The stale write gets thrown out instead of silently winning.

What clicked for me is that there's no clever Java doing the detection. It's just SQL. An update with an old version matches nothing, and "zero rows updated" is the whole signal. The database does the work.

I picked optimistic over pessimistic locking (SELECT ... FOR UPDATE) on purpose. Optimistic assumes conflicts are rare and only costs you something when one actually happens, which fits a system that isn't getting hammered. Pessimistic holds a lock for the whole transaction, which is safer when one row is under constant fire but slows everything down. For this project, optimistic was the lighter choice.

Then I wrote the test I should've written from day one: ten withdrawals fired at the same instant at an account that can only cover a few of them, lined up with a latch so they genuinely race instead of going one by one. When I ran it, exactly one succeeded, the rest got rejected, and the balance never went negative. Watching that pass taught me more than any blog post about isolation levels ever did.

A green test is only as good as the cases it covers. Obvious when you write it down. Not obvious to me while I was happily watching my happy-path tests go green.

Problem 3: A retry can run the same transfer twice

Here's a sequence that looks harmless until you actually think about it.

A client sends a transfer. The server does the work. But the response gets lost on the way back, maybe a dropped connection or a timeout. The client never hears "success," so it does the reasonable thing and retries. Now the same transfer might run twice.

This part surprised me the most. On a network, a request arriving more than once is the normal case, not the weird edge case. You can't assume something happens exactly once, so the server has to be able to say "I've already done this one."

The pattern is an idempotency key. The client makes up a unique key and sends it as a header:

POST /api/transfers
Idempotency-Key: 9f1c0a3e-1b77-4f2a-9b1e-2c4d5e6f7a8b
Enter fullscreen mode Exit fullscreen mode

First time I see that key, I do the transfer and save the result against it. Next time the same key shows up, I skip the work entirely and just hand back the saved result. Money moves once.

Two details I didn't get right the first time.

The first was the race. Two copies of a retry can land at almost the same instant, both check "seen this key before?", both see no, and both go ahead. I fixed it the same way as the lost update: let the database referee it. The key has a unique constraint, so when two identical requests race, one insert wins and the other blows up on the constraint. Since that insert lives in the same transaction as the money movement, the loser's whole transaction rolls back. The money never moves twice, and I never had to babysit any of it in Java.

The second detail was sneakier, and I'm a little proud I caught it. What if a client reuses the same key for a genuinely different request, same key but a different amount? If I just returned the saved result, I'd be reporting success for a transfer that never happened. So along with the key, I store a hash of the request itself. Same key plus same request means a real retry, so I replay the saved result. Same key plus a different request means something is off, so I reject it loudly instead of guessing. The key on its own isn't enough. The key plus a fingerprint of what it was for is what makes it safe.

Problem 4: A balance column can't tell you the truth

My first instinct was one balance column I could read and update. It's the obvious move, and it works right up until someone asks a question you can't answer: how did this balance get to this number?

A column has no memory. It holds today's value and nothing about how it got there. If a balance looks wrong, there's no trail to follow and nothing to audit.

So I moved Ledger-Core to an append-only, double-entry ledger, and it's the decision I'm most glad I made.

The idea is that money never just "moves." Every transfer is two entries, a debit on one account and a credit on another, that add up to zero. A $50 transfer from A to B writes a DEBIT of 50 against A and a CREDIT of 50 against B. Entries are append-only, so nothing ever gets updated or deleted. A correction is a brand new entry, never an edit.

This caused a problem I didn't see coming, and figuring it out is where I feel like I finally got double-entry. A transfer between two of my accounts balances on its own, one debit and one credit. But what about a deposit? Money shows up from outside, so the customer gets a credit, but where does the matching debit go? If there's no other side, the books don't balance and the whole idea falls apart.

The answer real ledgers use is a system settlement account. A deposit is really a transfer from the bank's settlement account to the customer. The customer is credited, the settlement account is debited, and the books stay balanced. That settlement account is allowed to go negative, because a negative there isn't a bug, it's the bank correctly tracking how much it owes its depositors in total. Modeling that was the moment double-entry stopped being a thing I'd read about and became a thing I understood.

To actually make the ledger untouchable, I dropped below the application. I added a Postgres trigger that rejects any UPDATE or DELETE on the ledger table. Inserts still work, so history can grow, but nothing can be changed or erased, not from a raw psql session, not even by me. Append-only stops being a promise I make and becomes a rule the database enforces on everyone, including its author.

The payoff is auditability. The ledger is the source of truth, and the balance is just a fast cached view of it that I keep in sync inside the same transaction. If the two ever disagree, the ledger wins, and I can rebuild any balance from scratch by replaying its entries.

Problem 5: How do you actually know the books are right?

Keeping a cached balance and a ledger in sync sounds fine until you ask the obvious next question. What if they drift apart anyway, because of a bug I haven't found yet?

So I built a reconciliation job. It's a scheduled task that walks every account, recomputes the real balance from the ledger, compares it to the stored balance, and records a "break" for any account where the two don't match. It doesn't quietly fix anything, because quietly fixing a mismatch would erase the evidence that something went wrong. It detects and it reports. That's what a real end-of-day reconciliation does.

And then it caught something real.

The first time I ran it against my own dev database, it flagged seventeen accounts whose stored balance didn't match their ledger. For a second I thought the job was broken. It wasn't. Those accounts were leftovers from early in the project, before the double-entry refactor, when my deposit code was still writing balances without full ledger entries. The job was correctly catching damage left behind by an older, buggier version of my own code.

That was the moment the whole project clicked for me. I'd built the thing that catches wrong numbers, and it caught my wrong numbers before I even went looking. A break doesn't tell you which number is right, only that two of them disagree, and working out which one to trust is its own little investigation. That's the real job reconciliation does, and I got to do it for real, on my own data.

What actually ties all of this together: invariants

Looking back, every one of these was the same problem in a different outfit.

CRUD is about storing data correctly. Moving money is about protecting invariants when things go wrong: when requests retry, when they land at the same instant, when a connection dies halfway through.

The invariants Ledger-Core protects are small and specific. A balance is always exact, no floating-point drift. The same transfer never applies twice. A withdrawal never reads a stale balance and overdraws. Every transfer's entries sum to zero, the ledger can never be altered, and a separate job keeps checking that the books still balance.

Once I started thinking in invariants instead of features, the design got easier, because every new feature now had to answer one thing: what could go wrong here, and what keeps this true?

Where I'm at, and what's next

I built Ledger-Core slowly, one guarantee at a time, and I wouldn't let myself move on from a piece until I had a test that actually proved the property held. The slow pace was the point. I didn't want to assemble a system I couldn't explain. I wanted to understand one.

There's stuff I've deliberately left for later, and knowing what you haven't done yet feels like part of the job too. The big one is auth. Right now the endpoints are open, which is fine for a demo I label as a demo, but a real money API has to know who you are and stop you from touching accounts that aren't yours. That's the next phase I'm building.

If you've worked on payment, ledger, or banking systems for real, I'd genuinely like to hear how you handle retries and concurrency in production, and anything I got subtly wrong above. I'm a junior dev learning this on purpose, and a correction from someone who's actually shipped it is worth more to me than any tutorial.

This is the kind of backend work I want to grow into. The kind where correctness isn't a nice-to-have. It's the whole product.

Top comments (3)

Collapse
 
johnfrandsen profile image
John Frandsen

Great writeup — the BigDecimal/equals gotcha is a rite of passage. The gap between "store a number" and "move money correctly" is exactly where most banking side projects die.

One thing I'd add from the data-input side: before you even get to the ledger, there's the problem of getting reliable transaction data FROM banks. In Europe, PSD2 mandates that banks expose read-only account access APIs (AIS), which is how you'd pull real transactions instead of manual CSV imports.

The catch: bank PSD2 implementations vary wildly. Some return clean JSON with proper field names. Others bury merchant info in a single "remittance information" string you have to parse heuristically. And consent tokens expire every 90 days — so your data pipeline needs a re-auth flow baked in, or your ledger silently stops updating.

Full disclosure — I'm working on open-banking.io, which is trying to normalize that messy bank data layer for EU developers. The ledger architecture you've built here is exactly the kind of downstream system that benefits from a clean, consistent transaction feed.

Collapse
 
solonjava profile image
Solon Framework

This is a fantastic deep dive into financial ledger design. The invariant-based approach to ensuring data consistency is exactly what most tutorials miss. In the Solon ecosystem, we've implemented similar patterns using our transaction management module, which provides declarative transaction boundaries with minimal boilerplate. The key insight about using double-entry bookkeeping as the foundation rather than an afterthought really resonates. Have you considered how idempotency keys could integrate with your invariant checks?

Collapse
 
anishbasnetab profile image
Anish Basnet