<?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: Zubair Khan</title>
    <description>The latest articles on DEV Community by Zubair Khan (@sunny56).</description>
    <link>https://dev.to/sunny56</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%2F4142742%2F58dea9a7-196a-4c34-b768-5ea7e1949974.png</url>
      <title>DEV Community: Zubair Khan</title>
      <link>https://dev.to/sunny56</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sunny56"/>
    <language>en</language>
    <item>
      <title>Your ledger is losing money and your tests will never tell you</title>
      <dc:creator>Zubair Khan</dc:creator>
      <pubDate>Fri, 25 Sep 2026 10:53:47 +0000</pubDate>
      <link>https://dev.to/sunny56/your-ledger-is-losing-money-and-your-tests-will-never-tell-you-gbo</link>
      <guid>https://dev.to/sunny56/your-ledger-is-losing-money-and-your-tests-will-never-tell-you-gbo</guid>
      <description>&lt;p&gt;Most of the bugs I have chased in payment systems were not logic bugs. The formula was right. The rounding was right. The tests passed. Money still went missing.&lt;/p&gt;

&lt;p&gt;It went missing because two people did something at the same time.&lt;/p&gt;

&lt;p&gt;I have spent the last few years on transaction and settlement systems, and the thing I did not appreciate early on is how quietly this class of bug fails. A null reference throws. A wrong formula shows up in the first test. A lost update just makes the number slightly wrong, on one day, for one account, and nobody notices until reconciliation runs and somebody asks where four hundred dollars went.&lt;/p&gt;

&lt;p&gt;So I built a small repository to make the failure visible: &lt;a href="https://github.com/sunny56/ledger-core" rel="noopener noreferrer"&gt;github.com/sunny56/ledger-core&lt;/a&gt;. It is a double-entry ledger with three concurrency strategies behind one interface, and a test suite that proves which ones conserve money and which one does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one law
&lt;/h2&gt;

&lt;p&gt;A ledger has exactly one rule that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(all balances) after N transfers == sum(all balances) before
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transfers move money. They never create it and never destroy it. Everything else in a ledger is policy. This is physics.&lt;/p&gt;

&lt;p&gt;And it is trivially easy to satisfy with one writer, which is exactly why it is so easy to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code everybody writes first
&lt;/h2&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;balance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Balance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the balance, work out the new one, write it back. It is the obvious thing. It is also broken.&lt;/p&gt;

&lt;p&gt;Two threads read &lt;code&gt;1000&lt;/code&gt; at the same moment. One adds &lt;code&gt;+100&lt;/code&gt; and writes &lt;code&gt;1100&lt;/code&gt;. The other adds &lt;code&gt;-50&lt;/code&gt; and writes &lt;code&gt;950&lt;/code&gt;. The second write lands last, so the first transfer is gone. Not failed, not logged, not retried. Gone.&lt;/p&gt;

&lt;p&gt;In a CRUD app this is a stale profile field and somebody edits it again. In a ledger it is money that existed a second ago and does not exist now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the bug fail loudly
&lt;/h2&gt;

&lt;p&gt;The part of the repository I am most attached to is the strategy I deliberately did not fix.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NaiveStrategy&lt;/code&gt; is the read-modify-write above, kept in the codebase, with this test pointed at it:&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;NaiveStrategy_LosesMoney_UnderConcurrency&lt;/span&gt;&lt;span class="p"&gt;()&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;harness&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LedgerHarness&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;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ServiceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;NaiveStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// 8 threads, 250 transfers each, all hitting the same account pair&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunConcurrently&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Threads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OperationsPerThread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Transfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;$"naive-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;thread&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;op&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;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Failures&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpeningTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClosingTotal&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;Read that assertion again. It asserts the total &lt;strong&gt;changed&lt;/strong&gt;. The test passes when money is lost.&lt;/p&gt;

&lt;p&gt;Two things make this work reliably rather than one run in fifty. The harness releases every thread from a &lt;code&gt;Barrier&lt;/code&gt;, so they all start at the same instant instead of staggering. And the naive strategy has a &lt;code&gt;Thread.SpinWait(50)&lt;/code&gt; between the read and the write, which widens the race window on purpose.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;SpinWait&lt;/code&gt; is the honest part. The window exists in real code too. It is just narrower, which means you hit it on a Tuesday in production instead of every time in CI.&lt;/p&gt;

&lt;p&gt;If that test ever starts conserving, I do not want somebody deleting it. I want them widening the window, because the test has stopped proving anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to actually fix it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Lock everything first
&lt;/h3&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;accounts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TouchedAccounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take an exclusive lock on every account the entry touches, then do the read-modify-write inside it. This is the &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; shape and it is correct and easy to explain to anyone.&lt;/p&gt;

&lt;p&gt;It also has a trap in it. Look at the store:&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;public&lt;/span&gt; &lt;span class="n"&gt;IDisposable&lt;/span&gt; &lt;span class="nf"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyCollection&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AccountId&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;)&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;gates&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Distinct&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RowFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Gate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ToArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MultiLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gates&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 &lt;code&gt;OrderBy&lt;/code&gt; is the entire reason this does not deadlock. Without it, a transfer from A to B takes A then B, a transfer from B to A takes B then A, and if both start together they sit there forever holding half of what the other one needs.&lt;/p&gt;

&lt;p&gt;Deterministic ordering is a one-line fix for a class of bug that is otherwise very hard to reproduce. There is a test for exactly that shape, with a join timeout, so a deadlock fails the build instead of hanging it.&lt;/p&gt;

&lt;p&gt;The cost of this strategy is throughput. Every writer queues behind the hottest account. If one account is popular, you have built a single-threaded system with extra steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Assume nobody else is writing, then check
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&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;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&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;snapshots&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDictionary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;All&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// apply every posting, then return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SpinWait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ConcurrencyRetriesExhaustedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read with a version, do your thinking without holding anything, then commit only if no version moved underneath you. Rowversion in SQL Server, xmin in Postgres, an &lt;code&gt;ETag&lt;/code&gt; in a document store, same idea everywhere.&lt;/p&gt;

&lt;p&gt;Two details in there that took me longer than they should have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The commit phase still takes a lock.&lt;/strong&gt; People ask me why, given the whole point is not locking. The answer is that a journal entry must land completely or not at all. If I compare-and-swap account A successfully and then fail on account B, the ledger is now unbalanced, which is worse than the problem I started with. So the CAS is what detects an interleaved writer, and the short commit-phase lock is only there to stop one entry being torn in half across two accounts. It is held for microseconds, not across the think time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backoff has jitter.&lt;/strong&gt; Without it, every writer that loses a round wakes up at the same moment and collides again. You get a thundering herd that looks like a deadlock but is actually just very bad luck, repeatedly.&lt;/p&gt;

&lt;p&gt;The bound on &lt;code&gt;maxAttempts&lt;/code&gt; matters too. When you start hitting it, the right move is to shard the hot account, not to raise the number. Raising it turns a fast failure into a slow one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Let the database do it
&lt;/h3&gt;

&lt;p&gt;The third option is to stop writing concurrency control and open the transaction at &lt;code&gt;SERIALIZABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I have not implemented this one in the repository yet, and I want to be honest about why it is on the roadmap rather than in the code: it behaves so differently across engines that a single implementation would be misleading. Postgres uses SSI and surfaces conflicts as an error at &lt;code&gt;COMMIT&lt;/code&gt; (SQLSTATE 40001), so a retry loop is mandatory, not optional. SQL Server takes range locks instead and blocks. Identical application code, completely different performance profile.&lt;/p&gt;

&lt;p&gt;It is the least code and the most portable correctness argument, which is often the right answer for a team that does not want to own this problem. I just do not want to publish a benchmark of it until I have run it on both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making bad states unrepresentable
&lt;/h2&gt;

&lt;p&gt;Separate from concurrency, one thing I would do again on any ledger: enforce the invariants in the constructor.&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;sum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Aggregate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinorUnits&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UnbalancedEntryException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Postings must sum to zero, summed to &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sum&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postings sum to zero. One currency per entry. At least two postings. Transfers must be positive.&lt;/p&gt;

&lt;p&gt;Validate at construction, not at the API edge, and there is no code path anywhere in the system that can hand an unbalanced entry to anything. Not a controller, not a background job, not a test helper somebody wrote in a hurry.&lt;/p&gt;

&lt;p&gt;The other decision in that direction: money is stored as integer minor units, never &lt;code&gt;decimal&lt;/code&gt;. Conversion happens at the edges. The ledger itself cannot introduce a rounding error because it never divides anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency is not optional
&lt;/h2&gt;

&lt;p&gt;A retry, a queue redelivery, an impatient user clicking twice. Same thing as far as your ledger is concerned, and all three will happen.&lt;/p&gt;

&lt;p&gt;Every entry carries a key, claimed before the strategy runs. The test fires 800 concurrent posts of the same key and asserts exactly one applied:&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="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Applied&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;799&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duplicates&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that a duplicate returns &lt;code&gt;false&lt;/code&gt; rather than throwing. Callers retry a lot, and an exception per retry is noise, not information.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself four years ago
&lt;/h2&gt;

&lt;p&gt;Write the failing test first. Not a test for the fix, a test for the bug. Until you have watched conservation break in CI, you are guessing about whether your mitigation does anything, and you will pick one based on what you read rather than what your workload does.&lt;/p&gt;

&lt;p&gt;Then pick a strategy based on your actual contention, not on which one sounds most senior. Pessimistic locking is fine and boring and correct if your accounts are not hot. Optimistic is better if they are not contended and worse if they are. Serializable is the least code you will write. None of them is the clever answer.&lt;/p&gt;

&lt;p&gt;The clever answer is the test that tells you when you got it wrong.&lt;/p&gt;




&lt;p&gt;The repository is at &lt;a href="https://github.com/sunny56/ledger-core" rel="noopener noreferrer"&gt;github.com/sunny56/ledger-core&lt;/a&gt;. It runs with &lt;code&gt;dotnet test&lt;/code&gt;, no Docker and no database needed. The store is in-memory but uses real locks and a real version counter, so the semantics line up with what a row lock and a rowversion column give you.&lt;/p&gt;

&lt;p&gt;Next on it: a Postgres adapter behind the same interface, the serializable strategy, a single-writer-per-partition strategy, and a BenchmarkDotNet harness so the throughput claims in this post stop being qualitative.&lt;/p&gt;

&lt;p&gt;If you have shipped a settlement system and solved this differently, I would genuinely like to hear it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
