<?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: Sidharth Yadav</title>
    <description>The latest articles on DEV Community by Sidharth Yadav (@sidharth_yadav_8d2e3d8a4e).</description>
    <link>https://dev.to/sidharth_yadav_8d2e3d8a4e</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%2F4178846%2F1f372da5-01b9-4f77-8ae6-5690cc6bf437.png</url>
      <title>DEV Community: Sidharth Yadav</title>
      <link>https://dev.to/sidharth_yadav_8d2e3d8a4e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sidharth_yadav_8d2e3d8a4e"/>
    <language>en</language>
    <item>
      <title>Why Payments Break When the Network Retries (and How I Stopped It)</title>
      <dc:creator>Sidharth Yadav</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:09:29 +0000</pubDate>
      <link>https://dev.to/sidharth_yadav_8d2e3d8a4e/why-payments-break-when-the-network-retries-and-how-i-stopped-it-6cg</link>
      <guid>https://dev.to/sidharth_yadav_8d2e3d8a4e/why-payments-break-when-the-network-retries-and-how-i-stopped-it-6cg</guid>
      <description>&lt;p&gt;A customer clicks “Pay”. The screen freezes, so they click again. Or the app sends the request, the network drops before the answer arrives, and the app sends it again.&lt;/p&gt;

&lt;p&gt;Did the customer just pay twice?&lt;/p&gt;

&lt;p&gt;In a financial system, that question matters. If the same payment gets recorded twice, the ledger is wrong. A customer paid $100, but the books say $200. Someone has to find out why, and fix it by hand.&lt;/p&gt;

&lt;p&gt;I ran into this while building remitlytics-core, a backend I wrote with Java 21, Spring Boot 3 and PostgreSQL. It keeps a double-entry ledger, which means every transaction has a debit and a credit that must match. Here’s how I handle retries, and what testing showed me about my own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What idempotency means
&lt;/h2&gt;

&lt;p&gt;An operation is idempotent if doing it twice gives the same result as doing it once.&lt;/p&gt;

&lt;p&gt;Think of the call button on a lift. Press it once or six times, and one lift still arrives. A payment API should behave the same way: send the same payment twice, and it’s recorded once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idempotency key
&lt;/h2&gt;

&lt;p&gt;Every ledger transaction in my project carries a key. The key is built from what happened and which invoice it was for:&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"PAYMENT_SETTLEMENT_"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The same invoice and the same event always produce the same key. A retry can’t sneak in under a new one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two layers of protection
&lt;/h2&gt;

&lt;p&gt;The first layer is a check in the code. Before recording anything, the service asks whether this key already exists. If it does, it logs a warning and returns:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transactionRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ledger transaction already processed ..."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The second layer is the database. The ledger_transactions table has a unique constraint on the key (simplified here):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ledger_transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The debit and credit entries are saved in the same database transaction as the parent row. So if the parent insert is rejected, the entries never get saved either.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the first layer isn’t enough
&lt;/h2&gt;

&lt;p&gt;The code check has a gap. If two requests arrive at the same moment, both can ask “does this key exist?” before either has saved anything. Both get “no”, and both go ahead.&lt;/p&gt;

&lt;p&gt;That’s why the constraint is the real protection. Only one insert can win, and Postgres rejects the other. Code can have timing gaps, but the database can’t be talked into storing the same key twice.&lt;/p&gt;
&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;I wanted to check this properly, so I wrote two tests against a real PostgreSQL 16 instance with my Flyway migrations applied. I didn’t use an in-memory database, because concurrent inserts behave differently there.&lt;/p&gt;

&lt;p&gt;Test 1: the same payment, recorded twice, one after the other. The second call hit the code check and returned early. Result: 1 transaction, 2 entries.&lt;/p&gt;

&lt;p&gt;Test 2: the same payment, recorded by two threads at once. Both threads passed the code check. Both logged that they recorded the transaction. Then Postgres rejected one of them:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: duplicate key value violates unique constraint
"ledger_transactions_idempotency_key_key"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The final data was still correct: 1 transaction, 2 entries. The database did its job.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the test caught
&lt;/h2&gt;

&lt;p&gt;My code has a catch block for exactly this error, meant to log it and treat the request as “already processed”. It never ran.&lt;/p&gt;

&lt;p&gt;Hibernate delays the insert until the transaction commits, which happens after my try/catch has finished. So the exception escaped to the caller, and my “Successfully recorded” log line was printed before the insert had really happened.&lt;/p&gt;

&lt;p&gt;The data was safe, but the behaviour wasn’t what I’d written the code to do. I would never have noticed without a concurrent test.&lt;/p&gt;
&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;Don’t rely only on application code to catch duplicates. Check in code for the fast, common case, but let the database enforce the rule. It’s the only part of the system that sees every write.&lt;/p&gt;

&lt;p&gt;I haven’t fixed this yet. The options I’m looking at are catching the exception in the caller, outside the transactional method, or using INSERT ... ON CONFLICT DO NOTHING and checking whether a row was actually inserted. I’ll write a follow-up once I’ve tested one.&lt;/p&gt;

&lt;p&gt;The code is here: &lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Sidharth-Async" rel="noopener noreferrer"&gt;
        Sidharth-Async
      &lt;/a&gt; / &lt;a href="https://github.com/Sidharth-Async/remitlytics-core" rel="noopener noreferrer"&gt;
        remitlytics-core
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Remitlytics isn't just another CRUD API. It is a strictly structured financial engine designed to handle invoice lifecycles, guarantee transaction integrity via a double-entry ledger, and communicate with the outside world through a fully decoupled, async webhook pipeline.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🚀 Remitlytics — Core Engine &amp;amp; Dashboard&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/1a028660cfaf73a528ebb8f135f287a3899f646d7f6919689ba7a11dd2eec1b7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32312d6f72616e67653f7374796c653d666f722d7468652d6261646765266c6f676f3d6a617661"&gt;&lt;img src="https://camo.githubusercontent.com/1a028660cfaf73a528ebb8f135f287a3899f646d7f6919689ba7a11dd2eec1b7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32312d6f72616e67653f7374796c653d666f722d7468652d6261646765266c6f676f3d6a617661" alt="Java 21"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/33e3f712ddea558aa283267fa8baaafdf2a2bc1182ba0bd8967613f47c3bdc59/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f537072696e675f426f6f742d332e332e342d627269676874677265656e3f7374796c653d666f722d7468652d6261646765266c6f676f3d737072696e67"&gt;&lt;img src="https://camo.githubusercontent.com/33e3f712ddea558aa283267fa8baaafdf2a2bc1182ba0bd8967613f47c3bdc59/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f537072696e675f426f6f742d332e332e342d627269676874677265656e3f7374796c653d666f722d7468652d6261646765266c6f676f3d737072696e67" alt="Spring Boot"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/8f7328df45a6589681ca273d12b591bcf167d226efc27a8a53d179d1a8090c4b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4e6578742e6a732d31362e332d626c61636b3f7374796c653d666f722d7468652d6261646765266c6f676f3d6e6578742e6a73"&gt;&lt;img src="https://camo.githubusercontent.com/8f7328df45a6589681ca273d12b591bcf167d226efc27a8a53d179d1a8090c4b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4e6578742e6a732d31362e332d626c61636b3f7374796c653d666f722d7468652d6261646765266c6f676f3d6e6578742e6a73" alt="Next.js"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/82748535962c7886703d27706bc3ba37d6e1c7e34625776d919695a5df466db2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f506f737467726553514c2d31362d626c75653f7374796c653d666f722d7468652d6261646765266c6f676f3d706f737467726573716c"&gt;&lt;img src="https://camo.githubusercontent.com/82748535962c7886703d27706bc3ba37d6e1c7e34625776d919695a5df466db2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f506f737467726553514c2d31362d626c75653f7374796c653d666f722d7468652d6261646765266c6f676f3d706f737467726573716c" alt="PostgreSQL"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/2d70b1e775b29203156fb0366f6c7fb0bba48a815e43c3f40b8bf8dad1e5f134/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f446f636b65722d52656164792d626c75653f7374796c653d666f722d7468652d6261646765266c6f676f3d646f636b6572"&gt;&lt;img src="https://camo.githubusercontent.com/2d70b1e775b29203156fb0366f6c7fb0bba48a815e43c3f40b8bf8dad1e5f134/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f446f636b65722d52656164792d626c75653f7374796c653d666f722d7468652d6261646765266c6f676f3d646f636b6572" alt="Docker"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An event-driven, fault-tolerant financial backend and dashboard built for resilience and data integrity.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Remitlytics isn't just another CRUD API. It's a strictly structured financial engine designed to handle multi-tenant invoice lifecycles, guarantee transaction integrity via a double-entry ledger, enforce strict API quotas, and communicate with the outside world through a fully decoupled, async webhook pipeline.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🧠 Architecture &amp;amp; Engineering Decisions&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Here's what actually matters — the hard systems problems this project solves.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;1. Immutable Multi-Tenant Double-Entry Ledger&lt;/h3&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; Storing balances as mutable integers (&lt;code&gt;balance = balance + x&lt;/code&gt;) leads to race conditions, lost updates, and zero auditability in financial systems.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The solution:&lt;/strong&gt; An append-only double-entry ledger. Every financial event generates immutable, balanced &lt;code&gt;CREDIT&lt;/code&gt; and &lt;code&gt;DEBIT&lt;/code&gt; entries bounded by a strict PostgreSQL transaction. Balances are derived, never overwritten. Strict &lt;code&gt;tenant_id&lt;/code&gt; isolation guarantees zero cross-contamination of ledger state.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;2. Zero-Bypass Rate Limiting&lt;/h3&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Sidharth-Async/remitlytics-core" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>architecture</category>
      <category>backend</category>
      <category>fintech</category>
      <category>java</category>
    </item>
  </channel>
</rss>
