<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anish Basnet</title>
    <description>The latest articles on DEV Community by Anish Basnet (@anishbasnetab).</description>
    <link>https://dev.to/anishbasnetab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4006981%2F9b686216-859e-4427-8fea-2db3ab54cd85.jpg</url>
      <title>DEV Community: Anish Basnet</title>
      <link>https://dev.to/anishbasnetab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anishbasnetab"/>
    <language>en</language>
    <item>
      <title>A Banking API Is Not Just CRUD: What Building a Money-Movement Ledger Taught Me</title>
      <dc:creator>Anish Basnet</dc:creator>
      <pubDate>Sun, 28 Jun 2026 21:00:49 +0000</pubDate>
      <link>https://dev.to/anishbasnetab/a-banking-api-is-not-just-crud-what-building-a-money-movement-ledger-taught-me-4f7d</link>
      <guid>https://dev.to/anishbasnetab/a-banking-api-is-not-just-crud-what-building-a-money-movement-ledger-taught-me-4f7d</guid>
      <description>&lt;p&gt;I thought a banking API would be mostly CRUD.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then I tried to actually move money from one account to another, and the easy part was over.&lt;/p&gt;

&lt;p&gt;This post is about the gap between &lt;em&gt;storing data&lt;/em&gt; and &lt;em&gt;moving money safely&lt;/em&gt;. That gap is the whole reason I built a personal project called &lt;strong&gt;Ledger-Core&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Money and floating point don't mix
&lt;/h2&gt;

&lt;p&gt;The first thing I got wrong was the type I used for money.&lt;/p&gt;

&lt;p&gt;If you've never hit this, open a REPL in almost any language and try &lt;code&gt;0.1 + 0.2&lt;/code&gt;. You get &lt;code&gt;0.30000000000000004&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's not a bug. Floating point numbers (&lt;code&gt;float&lt;/code&gt;, &lt;code&gt;double&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;In Java the fix is &lt;code&gt;BigDecimal&lt;/code&gt; with a fixed scale. I store money as &lt;code&gt;NUMERIC(19,2)&lt;/code&gt; in Postgres and &lt;code&gt;BigDecimal&lt;/code&gt; with scale 2 in Java, the same way everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"balance"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things bit me here.&lt;/p&gt;

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

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

&lt;p&gt;Small stuff. But this was the first moment Ledger-Core stopped feeling like a CRUD app and started feeling like a system with rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Two requests at once can make money disappear
&lt;/h2&gt;

&lt;p&gt;This one humbled me, because every test I'd written was green.&lt;/p&gt;

&lt;p&gt;Picture an account with $100 in it, and two withdrawals of $80 landing at the exact same moment.&lt;/p&gt;

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

&lt;p&gt;This is the classic &lt;strong&gt;lost update&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The fix I went with is &lt;strong&gt;optimistic locking&lt;/strong&gt;, and in JPA it's almost too easy to turn on. You add a version column to the entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Version&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every update checks the version. When Hibernate writes, it basically runs &lt;code&gt;UPDATE account SET balance = ?, version = version + 1 WHERE id = ? AND version = ?&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I picked optimistic over pessimistic locking (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: A retry can run the same transfer twice
&lt;/h2&gt;

&lt;p&gt;Here's a sequence that looks harmless until you actually think about it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This part surprised me the most. On a network, a request arriving &lt;em&gt;more than once&lt;/em&gt; 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."&lt;/p&gt;

&lt;p&gt;The pattern is an &lt;strong&gt;idempotency key&lt;/strong&gt;. The client makes up a unique key and sends it as a header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/transfers
Idempotency-Key: 9f1c0a3e-1b77-4f2a-9b1e-2c4d5e6f7a8b
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two details I didn't get right the first time.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;different&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: A &lt;code&gt;balance&lt;/code&gt; column can't tell you the truth
&lt;/h2&gt;

&lt;p&gt;My first instinct was one &lt;code&gt;balance&lt;/code&gt; 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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So I moved Ledger-Core to an &lt;strong&gt;append-only, double-entry ledger&lt;/strong&gt;, and it's the decision I'm most glad I made.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;DEBIT&lt;/code&gt; of 50 against A and a &lt;code&gt;CREDIT&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The answer real ledgers use is a &lt;strong&gt;system settlement account&lt;/strong&gt;. A deposit is really a transfer &lt;em&gt;from&lt;/em&gt; the bank's settlement account &lt;em&gt;to&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;To actually make the ledger untouchable, I dropped below the application. I added a Postgres trigger that rejects any &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; on the ledger table. Inserts still work, so history can grow, but nothing can be changed or erased, not from a raw &lt;code&gt;psql&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: How do you actually know the books are right?
&lt;/h2&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;So I built a &lt;strong&gt;reconciliation job&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;And then it caught something real.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That was the moment the whole project clicked for me. I'd built the thing that catches wrong numbers, and it caught &lt;em&gt;my&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually ties all of this together: invariants
&lt;/h2&gt;

&lt;p&gt;Looking back, every one of these was the same problem in a different outfit.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'm at, and what's next
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;There's stuff I've deliberately left for later, and knowing what you &lt;em&gt;haven't&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>java</category>
      <category>fintech</category>
      <category>springboot</category>
    </item>
  </channel>
</rss>
