<?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: Harut Petrosyan</title>
    <description>The latest articles on DEV Community by Harut Petrosyan (@harut_petrosyan).</description>
    <link>https://dev.to/harut_petrosyan</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%2F4209941%2F3f87e6d6-186d-44a6-96cc-b25fad3d9ceb.jpg</url>
      <title>DEV Community: Harut Petrosyan</title>
      <link>https://dev.to/harut_petrosyan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/harut_petrosyan"/>
    <language>en</language>
    <item>
      <title>How I Handle Concurrency-Safe Wallets, Deadlock-Free Transfers, and Idempotency in .NET + PostgreSQL</title>
      <dc:creator>Harut Petrosyan</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:10:57 +0000</pubDate>
      <link>https://dev.to/harut_petrosyan/how-i-handle-concurrency-safe-wallets-deadlock-free-transfers-and-idempotency-in-net-postgresql-500g</link>
      <guid>https://dev.to/harut_petrosyan/how-i-handle-concurrency-safe-wallets-deadlock-free-transfers-and-idempotency-in-net-postgresql-500g</guid>
      <description>&lt;p&gt;Whenever a project needs an internal wallet, SaaS credit system, or user balance, doing a naive &lt;code&gt;wallet.Balance -= amount&lt;/code&gt; in EF Core is a recipe for race conditions and negative balances under load.&lt;/p&gt;

&lt;p&gt;I put together a minimal reference architecture in &lt;strong&gt;.NET + EF Core + PostgreSQL&lt;/strong&gt; to solve the four main headaches of balance management without introducing heavy distributed locks (like Redis Redlock).&lt;/p&gt;




&lt;h3&gt;
  
  
  1. Preventing Double-Spending (Pessimistic + Optimistic Locking)
&lt;/h3&gt;

&lt;p&gt;Instead of relying solely on optimistic concurrency (which throws &lt;code&gt;DbUpdateConcurrencyException&lt;/code&gt; and forces clients to retry under high contention), we lock the wallet row inside the transaction using PostgreSQL's &lt;code&gt;FOR UPDATE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lockedWallets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wallets&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSqlInterpolated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"SELECT *, xmin FROM \"Wallets\" WHERE \"Id\" = &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;walletId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; FOR UPDATE"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 Important EF Core + Npgsql Gotcha:&lt;/strong&gt; &lt;br&gt;
If you map PostgreSQL's &lt;code&gt;xmin&lt;/code&gt; system column as a concurrency token, doing a standard &lt;code&gt;SELECT *&lt;/code&gt; inside a subquery strips system columns, causing Npgsql to throw &lt;code&gt;42703: column w.xmin does not exist&lt;/code&gt;. Explicitly selecting &lt;code&gt;SELECT *, xmin&lt;/code&gt; and materializing with &lt;code&gt;ToListAsync&lt;/code&gt; keeps EF Core's change tracker happy while acquiring the row-level lock. We also back this up with a DB-level &lt;code&gt;CHECK ("Balance" &amp;gt;= 0)&lt;/code&gt; constraint.&lt;/p&gt;


&lt;h3&gt;
  
  
  2. Preventing A -&amp;gt; B and B -&amp;gt; A Deadlocks
&lt;/h3&gt;

&lt;p&gt;If User A transfers to User B at the exact same millisecond User B transfers to User A, locking &lt;code&gt;FromWallet&lt;/code&gt; then &lt;code&gt;ToWallet&lt;/code&gt; will deadlock the database. &lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;deterministic lock ordering&lt;/strong&gt;—always sort the GUIDs in memory before executing the &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;firstId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromWalletId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToWalletId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromWalletId&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToWalletId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;secondId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firstId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromWalletId&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToWalletId&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromWalletId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lockedWallets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wallets&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSqlInterpolated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"SELECT *, xmin FROM \"Wallets\" WHERE \"Id\" IN (&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;firstId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;secondId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) ORDER BY \"Id\" FOR UPDATE"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3. Handling Network Retries (&lt;code&gt;Idempotency-Key&lt;/code&gt; Endpoint Filter)
&lt;/h3&gt;

&lt;p&gt;Mobile and frontend clients retry on timeouts. By implementing a lightweight &lt;code&gt;IEndpointFilter&lt;/code&gt;, we hash the incoming request payload (&lt;code&gt;SHA-256&lt;/code&gt;) alongside the &lt;code&gt;Idempotency-Key&lt;/code&gt; header, insert an in-flight lock record in Postgres, and replay the exact status code and JSON body (&lt;code&gt;X-Idempotent-Replayed: true&lt;/code&gt;) if the client repeats the call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseBody&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;httpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Idempotent-Replayed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&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;h3&gt;
  
  
  4. The 50-Thread Concurrency Stress Test
&lt;/h3&gt;

&lt;p&gt;To verify it, I built an endpoint (&lt;code&gt;/api/wallets/stress-test&lt;/code&gt;) that seeds a wallet with &lt;strong&gt;$100&lt;/strong&gt; and fires &lt;code&gt;Task.WhenAll&lt;/code&gt; with &lt;strong&gt;50 parallel scopes&lt;/strong&gt; simultaneously trying to withdraw &lt;strong&gt;$10&lt;/strong&gt; at the exact same millisecond. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Result:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"testSummary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"50 parallel threads tried to withdraw $10 simultaneously from a $100 wallet."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expectedSuccesses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actualSuccesses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expectedFailures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actualFailures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"finalWalletBalance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalLedgerEntries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"passed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly 10 succeed, 40 fail cleanly, 11 immutable double-entry ledger records are written, and the balance lands on &lt;code&gt;0.0000&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  📦 Get the Full Source Code Micro-Kit
&lt;/h3&gt;

&lt;p&gt;If you are building fintech features, SaaS credits, or internal wallets right now and want the full plug-and-play solution (with the &lt;code&gt;WalletEngine&lt;/code&gt;, &lt;code&gt;IdempotencyFilter&lt;/code&gt;, append-only &lt;code&gt;LedgerEntry&lt;/code&gt; audit trail, stress-test endpoint, and Docker setup), I packaged it into a clean micro-kit here:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://harutpetrosyan.gumroad.com/l/dotnet-wallet-kit" rel="noopener noreferrer"&gt;Download the Bulletproof .NET Wallet &amp;amp; Idempotent Ledger Micro-Kit&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer any questions about PostgreSQL locking or EF Core concurrency gotchas in the comments!&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>postgres</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
