<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://softvasco.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://softvasco.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-06T07:23:14+00:00</updated><id>https://softvasco.github.io/feed.xml</id><title type="html">Vasco Silva</title><subtitle>Notes on .NET backend engineering (APIs, event-driven systems, legacy modernisation), with real code from the open-source projects I am building.</subtitle><author><name>Vasco Silva</name></author><entry><title type="html">Snapshots in an event-sourced ledger are a cache</title><link href="https://softvasco.github.io/2026/10/snapshots-are-a-cache/" rel="alternate" type="text/html" title="Snapshots in an event-sourced ledger are a cache" /><published>2026-10-06T00:00:00+00:00</published><updated>2026-10-06T00:00:00+00:00</updated><id>https://softvasco.github.io/2026/10/snapshots-are-a-cache</id><content type="html" xml:base="https://softvasco.github.io/2026/10/snapshots-are-a-cache/"><![CDATA[<p>Loading an account in <a href="https://github.com/softvasco/ledger-core">ledger-core</a> means reading its events and replaying them. On the benchmark laptop a 1,000-event stream reads in about 1.8 ms, so this isn’t urgent, but an account that has been open for years will have far more than a thousand events and I didn’t want the load time to grow with the account’s age. Last week the repo got snapshots. The one rule I set before writing any of it: a snapshot is a cache. The events alone must always give the same state, and anything that goes wrong with a snapshot must look like a cache miss, never like an error.</p>

<p>That rule decided most of the design, so this post goes through the decisions it made for me.</p>

<h2 id="what-gets-stored">What gets stored</h2>

<p>A snapshot is the account’s state at a version:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">sealed</span> <span class="n">record</span> <span class="nf">AccountSnapshot</span><span class="p">(</span>
    <span class="n">AccountId</span> <span class="n">AccountId</span><span class="p">,</span>
    <span class="n">Iban</span> <span class="n">Iban</span><span class="p">,</span>
    <span class="n">Currency</span> <span class="n">Currency</span><span class="p">,</span>
    <span class="n">AccountStatus</span> <span class="n">Status</span><span class="p">,</span>
    <span class="n">FreezeReason</span><span class="p">?</span> <span class="n">FreezeReason</span><span class="p">,</span>
    <span class="kt">long</span> <span class="n">Version</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Version</code> is the number of events it covers. Loading reads the snapshot, then the events with a higher version, and replays only those. The aggregate base class has one method for this, <code class="language-plaintext highlighter-rouge">RestoreVersion</code>, and it refuses to run on an aggregate that already has events, so a snapshot can only start a fresh instance.</p>

<p>The account refuses to produce a snapshot while it has unsaved events:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// a snapshot of unsaved changes could outlive a failed append and claim events that never got stored</span>
<span class="k">public</span> <span class="n">AccountSnapshot</span> <span class="nf">ToSnapshot</span><span class="p">()</span> <span class="p">=&gt;</span>
    <span class="n">PendingEvents</span><span class="p">.</span><span class="n">Count</span> <span class="p">==</span> <span class="m">0</span>
        <span class="p">?</span> <span class="k">new</span> <span class="nf">AccountSnapshot</span><span class="p">(</span><span class="n">Id</span><span class="p">,</span> <span class="n">Iban</span><span class="p">,</span> <span class="n">Currency</span><span class="p">,</span> <span class="n">Status</span><span class="p">,</span> <span class="n">FreezeReason</span><span class="p">,</span> <span class="n">Version</span><span class="p">)</span>
        <span class="p">:</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span><span class="s">"Save the pending events before taking a snapshot."</span><span class="p">);</span>
</code></pre></div></div>

<p>If the append fails and the snapshot had already been written, the next load would start from a state that includes events the store never got. The cache would be ahead of the source of truth, which is the one thing a cache must never be.</p>

<h2 id="when-it-is-taken">When it is taken</h2>

<p>The policy is one line, and the line is not the obvious one:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// crossing a multiple, not landing on it, since one append can add several events</span>
<span class="k">public</span> <span class="kt">bool</span> <span class="nf">IsDue</span><span class="p">(</span><span class="kt">long</span> <span class="n">versionBefore</span><span class="p">,</span> <span class="kt">long</span> <span class="n">versionAfter</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="n">versionAfter</span> <span class="p">/</span> <span class="n">Every</span> <span class="p">&gt;</span> <span class="n">versionBefore</span> <span class="p">/</span> <span class="n">Every</span><span class="p">;</span>
</code></pre></div></div>

<p>My first version was <code class="language-plaintext highlighter-rouge">versionAfter % Every == 0</code>. A save can append several events in one call, so a stream can go from version 98 to 103 and never land on 100. With the modulo check that account would skip its snapshot and the next chance is version 200, if that one is hit. Dividing both versions and comparing catches every crossing. The test table says it in numbers, with <code class="language-plaintext highlighter-rouge">Every</code> set to 5:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">before</th>
      <th style="text-align: right">after</th>
      <th style="text-align: center">due</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">0</td>
      <td style="text-align: right">3</td>
      <td style="text-align: center">no</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td style="text-align: right">5</td>
      <td style="text-align: center">yes</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td style="text-align: right">6</td>
      <td style="text-align: center">no</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td style="text-align: right">7</td>
      <td style="text-align: center">yes</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td style="text-align: right">12</td>
      <td style="text-align: center">yes</td>
    </tr>
    <tr>
      <td style="text-align: right">10</td>
      <td style="text-align: right">14</td>
      <td style="text-align: center">no</td>
    </tr>
  </tbody>
</table>

<p>The default interval is 100. Reading 99 events after a snapshot costs well under a millisecond on the benchmark numbers, and a smaller interval would just mean more snapshot writes.</p>

<h2 id="where-it-lives">Where it lives</h2>

<p>The snapshot goes into a second PostgreSQL table next to the events, one row per stream, with the aggregate serialized as <code class="language-plaintext highlighter-rouge">jsonb</code>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">insert</span> <span class="k">into</span> <span class="n">snapshots</span> <span class="p">(</span><span class="n">stream_category</span><span class="p">,</span> <span class="n">stream_id</span><span class="p">,</span> <span class="k">version</span><span class="p">,</span> <span class="k">data</span><span class="p">)</span>
<span class="k">values</span> <span class="p">(</span><span class="o">@</span><span class="n">category</span><span class="p">,</span> <span class="o">@</span><span class="n">id</span><span class="p">,</span> <span class="o">@</span><span class="k">version</span><span class="p">,</span> <span class="o">@</span><span class="k">data</span><span class="p">)</span>
<span class="k">on</span> <span class="n">conflict</span> <span class="p">(</span><span class="n">stream_category</span><span class="p">,</span> <span class="n">stream_id</span><span class="p">)</span> <span class="k">do</span> <span class="k">update</span>
<span class="k">set</span> <span class="k">version</span> <span class="o">=</span> <span class="n">excluded</span><span class="p">.</span><span class="k">version</span><span class="p">,</span> <span class="k">data</span> <span class="o">=</span> <span class="n">excluded</span><span class="p">.</span><span class="k">data</span><span class="p">,</span> <span class="n">taken_at</span> <span class="o">=</span> <span class="n">now</span><span class="p">()</span>
<span class="k">where</span> <span class="n">snapshots</span><span class="p">.</span><span class="k">version</span> <span class="o">&lt;</span> <span class="n">excluded</span><span class="p">.</span><span class="k">version</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">where</code> on the update is the part that matters. Two saves of the same account can run close together, and the slower one may carry the older state. Without the guard, the older snapshot would overwrite the newer one. The result would still be correct, because the events after version 2 get replayed either way, but the cache would get worse for no reason, and it is cheap to prevent. The contract test for it saves version 5, then version 2, and checks that version 5 is still there.</p>

<p>The in-memory store, used by the unit tests, has the same rule in <code class="language-plaintext highlighter-rouge">AddOrUpdate</code>, and both stores run the same abstract test class.</p>

<h2 id="when-it-fails">When it fails</h2>

<p>Three things can go wrong with a cache, and each one gets the cache-miss treatment.</p>

<p>A stored snapshot that no longer deserializes, because the snapshot shape changed in a release:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// a snapshot from an older shape is just a cache miss, the events still rebuild the state</span>
<span class="k">try</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Deserialize</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">_json</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">JsonException</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="k">null</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">null</code> here means “no snapshot”, and the repository replays from the first event. The next save past a threshold writes a fresh snapshot in the new shape, and the stream heals on its own. Events need upcasters when their shape changes, because they are forever. Snapshots don’t, because they can be thrown away.</p>

<p>A snapshot write that fails after the events were stored:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// the events are stored by now, so a failed snapshot must not look like a failed save</span>
<span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">TrySnapshotAsync</span><span class="p">(</span><span class="n">StreamId</span> <span class="n">stream</span><span class="p">,</span> <span class="n">Account</span> <span class="n">account</span><span class="p">,</span> <span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">try</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">snapshots</span><span class="p">.</span><span class="nf">SaveAsync</span><span class="p">(</span><span class="n">stream</span><span class="p">,</span> <span class="n">account</span><span class="p">.</span><span class="n">Version</span><span class="p">,</span> <span class="n">account</span><span class="p">.</span><span class="nf">ToSnapshot</span><span class="p">(),</span> <span class="n">cancellationToken</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">e</span><span class="p">)</span> <span class="nf">when</span> <span class="p">(</span><span class="n">e</span> <span class="k">is</span> <span class="n">not</span> <span class="n">OperationCanceledException</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="nf">LogSnapshotFailed</span><span class="p">(</span><span class="n">logger</span><span class="p">,</span> <span class="n">e</span><span class="p">,</span> <span class="n">stream</span><span class="p">,</span> <span class="n">account</span><span class="p">.</span><span class="n">Version</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>By the time this runs, the append has committed. If the snapshot threw, the caller would see the save as failed and retry a command that has already succeeded, and the account would get a second deposit. So it logs a warning and moves on. Cancellation is the exception to the exception: if the caller cancelled, it should hear about it.</p>

<p>And a snapshot store that is down entirely: <code class="language-plaintext highlighter-rouge">LoadAsync</code> on the Postgres store will throw in that case, and I left it that way. The events table is in the same database, so if one is down the other is too. A separate cache server would be a different conversation.</p>

<h2 id="the-test-that-pins-the-rule">The test that pins the rule</h2>

<p>The one test I’d keep if I had to delete the others loads an account with snapshots on, wipes the snapshot store, loads it again, and compares:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="n">Fact</span><span class="p">]</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Loading_with_or_without_snapshots_gives_the_same_account</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">repository</span> <span class="p">=</span> <span class="nf">Repository</span><span class="p">(</span><span class="n">every</span><span class="p">:</span> <span class="m">2</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">account</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">OpenAndFlip</span><span class="p">(</span><span class="n">repository</span><span class="p">,</span> <span class="n">flips</span><span class="p">:</span> <span class="m">5</span><span class="p">);</span>

    <span class="kt">var</span> <span class="n">withSnapshots</span> <span class="p">=</span> <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">LoadAsync</span><span class="p">(</span><span class="n">account</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span> <span class="n">Token</span><span class="p">);</span>
    <span class="n">_snapshots</span><span class="p">.</span><span class="nf">Clear</span><span class="p">();</span>
    <span class="kt">var</span> <span class="n">fromEventsOnly</span> <span class="p">=</span> <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">LoadAsync</span><span class="p">(</span><span class="n">account</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span> <span class="n">Token</span><span class="p">);</span>

    <span class="n">Assert</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="n">fromEventsOnly</span><span class="p">!.</span><span class="nf">ToSnapshot</span><span class="p">(),</span> <span class="n">withSnapshots</span><span class="p">!.</span><span class="nf">ToSnapshot</span><span class="p">());</span>
    <span class="n">Assert</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="m">6</span><span class="p">,</span> <span class="n">withSnapshots</span><span class="p">.</span><span class="n">Version</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With an interval of 2 and six events, the loaded account went through snapshots at versions 2, 4 and 6. If <code class="language-plaintext highlighter-rouge">FromSnapshot</code> restored a field wrongly, or <code class="language-plaintext highlighter-rouge">Apply</code> and the snapshot disagreed on what a freeze does to the state, this test fails. Everything else about snapshots is an optimisation; this test is the correctness.</p>

<p>I haven’t benchmarked the snapshot path itself yet. The current numbers are for raw appends and stream reads, and they’re in <a href="https://github.com/softvasco/ledger-core/blob/main/docs/performance.md">docs/performance.md</a>. The snapshot code is in <a href="https://github.com/softvasco/ledger-core/tree/main/src/LedgerCore.Application/Snapshots">LedgerCore.Application/Snapshots</a> and <a href="https://github.com/softvasco/ledger-core/tree/main/src/LedgerCore.Infrastructure/Snapshots">LedgerCore.Infrastructure/Snapshots</a>.</p>]]></content><author><name>Vasco Silva</name></author><summary type="html"><![CDATA[ledger-core takes a snapshot of an account every 100 events. Treating it as a cache decided when it is taken, how it is stored, and what happens when it fails.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://softvasco.github.io/assets/cards/snapshots-are-a-cache.png" /><media:content medium="image" url="https://softvasco.github.io/assets/cards/snapshots-are-a-cache.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">An event store on PostgreSQL: why appends take a lock</title><link href="https://softvasco.github.io/2026/10/event-store-append-order/" rel="alternate" type="text/html" title="An event store on PostgreSQL: why appends take a lock" /><published>2026-10-03T00:00:00+00:00</published><updated>2026-10-03T00:00:00+00:00</updated><id>https://softvasco.github.io/2026/10/event-store-append-order</id><content type="html" xml:base="https://softvasco.github.io/2026/10/event-store-append-order/"><![CDATA[<p>This week <a href="https://github.com/softvasco/ledger-core">ledger-core</a> got its PostgreSQL event store. Most of it is one table and a few queries. The part that took the most thinking was a single line in the append path that takes a lock, and this post is about why it’s there.</p>

<h2 id="what-the-store-has-to-do">What the store has to do</h2>

<p>The interface has three operations:</p>

<ul>
  <li>append events to one stream, but only if the stream is still at the version the caller loaded;</li>
  <li>read one stream, for rebuilding an aggregate;</li>
  <li>read all events from every stream in order, starting after a position, for projections.</li>
</ul>

<p>The first two are the classic event sourcing part. The third one is where the trouble is. A projection (balances, statements) reads everything after the last position it processed, handles it, saves the new position, and repeats. That only works if, once it has seen position 8, nothing with a lower position can show up later.</p>

<h2 id="the-table">The table</h2>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">create</span> <span class="k">table</span> <span class="n">if</span> <span class="k">not</span> <span class="k">exists</span> <span class="n">events</span> <span class="p">(</span>
    <span class="k">position</span> <span class="nb">bigint</span> <span class="k">generated</span> <span class="n">always</span> <span class="k">as</span> <span class="k">identity</span> <span class="k">primary</span> <span class="k">key</span><span class="p">,</span>
    <span class="n">stream_category</span> <span class="nb">text</span> <span class="k">not</span> <span class="k">null</span><span class="p">,</span>
    <span class="n">stream_id</span> <span class="n">uuid</span> <span class="k">not</span> <span class="k">null</span><span class="p">,</span>
    <span class="k">version</span> <span class="nb">bigint</span> <span class="k">not</span> <span class="k">null</span> <span class="k">check</span> <span class="p">(</span><span class="k">version</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">),</span>
    <span class="n">event_type</span> <span class="nb">text</span> <span class="k">not</span> <span class="k">null</span><span class="p">,</span>
    <span class="k">data</span> <span class="n">jsonb</span> <span class="k">not</span> <span class="k">null</span><span class="p">,</span>
    <span class="n">recorded_at</span> <span class="n">timestamptz</span> <span class="k">not</span> <span class="k">null</span> <span class="k">default</span> <span class="n">now</span><span class="p">(),</span>
    <span class="k">constraint</span> <span class="n">events_stream_version_key</span> <span class="k">unique</span> <span class="p">(</span><span class="n">stream_category</span><span class="p">,</span> <span class="n">stream_id</span><span class="p">,</span> <span class="k">version</span><span class="p">)</span>
<span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">version</code> is the event’s place in its own stream, starting at 1. <code class="language-plaintext highlighter-rouge">position</code> is its place in the whole store. The unique key on (category, stream, version) means two writers can never both store version 3 of the same account.</p>

<h2 id="positions-are-handed-out-before-commit">Positions are handed out before commit</h2>

<p>An identity column is backed by a sequence. PostgreSQL takes the next value when the row is inserted, not when the transaction commits, and sequences ignore rollbacks.</p>

<p>Take two appends to different accounts running at the same time:</p>

<table>
  <thead>
    <tr>
      <th>Time</th>
      <th>Transaction A</th>
      <th>Transaction B</th>
      <th>Reader</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>inserts, gets position 7</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>2</td>
      <td> </td>
      <td>inserts, gets position 8</td>
      <td> </td>
    </tr>
    <tr>
      <td>3</td>
      <td> </td>
      <td>commits</td>
      <td> </td>
    </tr>
    <tr>
      <td>4</td>
      <td> </td>
      <td> </td>
      <td>reads after 6, gets 8, saves 8</td>
    </tr>
    <tr>
      <td>5</td>
      <td>commits</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>6</td>
      <td> </td>
      <td> </td>
      <td>reads after 8, gets nothing</td>
    </tr>
  </tbody>
</table>

<p>Position 7 is now in the table, and the projection will never read it. Nothing fails. A balance is just wrong, and the event store itself looks fine if you query it.</p>

<p>Gaps from rollbacks are a separate thing. An append that inserts and then rolls back burns its position, and that hole never fills. That one is harmless: the reader skips a number that will never exist. The interface says so in its comment: positions only grow, may have gaps, and readers keep the last one they saw instead of counting.</p>

<h2 id="the-fix-i-picked">The fix I picked</h2>

<p>Every append takes a transaction-level advisory lock before it does anything else:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// one append at a time, so ReadAll never sees position 8 commit before position 7</span>
<span class="k">await</span> <span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">lockCommand</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">NpgsqlCommand</span><span class="p">(</span><span class="s">"select pg_advisory_xact_lock(@key)"</span><span class="p">,</span> <span class="n">connection</span><span class="p">,</span> <span class="n">transaction</span><span class="p">))</span>
<span class="p">{</span>
    <span class="n">lockCommand</span><span class="p">.</span><span class="n">Parameters</span><span class="p">.</span><span class="nf">AddWithValue</span><span class="p">(</span><span class="s">"key"</span><span class="p">,</span> <span class="n">AppendLockKey</span><span class="p">);</span>
    <span class="k">await</span> <span class="n">lockCommand</span><span class="p">.</span><span class="nf">ExecuteNonQueryAsync</span><span class="p">(</span><span class="n">cancellationToken</span><span class="p">).</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pg_advisory_xact_lock</code> is held until the transaction ends, by commit or rollback, so nobody has to remember to release it. With it, transaction B in the table can’t insert until A has committed. Positions are handed out in commit order, and a reader that sees 8 has already been able to see 7.</p>

<p>The same lock makes the version check simple. Under it, the append reads the current version of the stream and compares it with what the caller expected:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">actualVersion</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">CurrentVersionAsync</span><span class="p">(</span><span class="n">connection</span><span class="p">,</span> <span class="n">transaction</span><span class="p">,</span> <span class="n">stream</span><span class="p">,</span> <span class="n">cancellationToken</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">actualVersion</span> <span class="p">!=</span> <span class="n">expectedVersion</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">ConcurrencyConflictException</span><span class="p">(</span><span class="n">stream</span><span class="p">,</span> <span class="n">expectedVersion</span><span class="p">,</span> <span class="n">actualVersion</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In READ COMMITTED each statement gets a fresh snapshot, so this query sees whatever the previous lock holder committed. The unique key is still there as a backstop if something ever writes to the table without going through this code.</p>

<p>A conflict is an exception and not a <code class="language-plaintext highlighter-rouge">Result</code>, which is what the domain uses for business rule failures. Nobody broke a rule here. The command handler has to reload the account and try again, and that’s a retry loop, not a message for the user.</p>

<h2 id="what-it-costs">What it costs</h2>

<p>All appends now go one at a time, across every stream. Two deposits to two unrelated accounts wait for each other. Each append is short (one lock, one select, one batch of inserts, commit), but it’s still a single queue for the whole ledger.</p>

<p>I haven’t measured it yet. A BenchmarkDotNet run of single appends and small batches is next on the list for this repo, and I’ll put the numbers in the docs.</p>

<p>The other option I looked at lets appends run in parallel and makes the reader careful instead: store the transaction id on each row, and have the global reader only return rows written by transactions older than the oldest one still running (<code class="language-plaintext highlighter-rouge">pg_snapshot_xmin</code>). Writers never wait, but the read side gets more complicated, and one long-running transaction holds back every projection until it ends. For a ledger that isn’t anywhere near its write limits, I’d rather keep the reader simple and change this when a benchmark tells me to.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Lock on append</th>
      <th>Filter on read</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Parallel appends</td>
      <td>no</td>
      <td>yes</td>
    </tr>
    <tr>
      <td>Reader logic</td>
      <td><code class="language-plaintext highlighter-rouge">where position &gt; @after</code></td>
      <td>needs <code class="language-plaintext highlighter-rouge">xid8</code> column and snapshot checks</td>
    </tr>
    <tr>
      <td>Can a reader skip an event</td>
      <td>no</td>
      <td>no, if the filter is right</td>
    </tr>
    <tr>
      <td>Moving parts</td>
      <td>one line</td>
      <td>a column, an index and the filter</td>
    </tr>
  </tbody>
</table>

<h2 id="testing-it">Testing it</h2>

<p>The in-memory store and the PostgreSQL store run the same abstract test class, <code class="language-plaintext highlighter-rouge">EventStoreContract</code>. The PostgreSQL run uses Testcontainers with <code class="language-plaintext highlighter-rouge">postgres:18-alpine</code>, so CI tests the real thing. One of the tests fires ten appends at the same new stream at once:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="n">Fact</span><span class="p">]</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Only_one_of_several_racing_appends_wins</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">stream</span> <span class="p">=</span> <span class="nf">NewStream</span><span class="p">();</span>

    <span class="kt">var</span> <span class="n">attempts</span> <span class="p">=</span> <span class="n">Enumerable</span><span class="p">.</span><span class="nf">Range</span><span class="p">(</span><span class="m">1</span><span class="p">,</span> <span class="m">10</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">n</span> <span class="p">=&gt;</span> <span class="n">Task</span><span class="p">.</span><span class="nf">Run</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="n">_store</span><span class="p">.</span><span class="nf">AppendAsync</span><span class="p">(</span><span class="n">stream</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="nf">Events</span><span class="p">(</span><span class="n">n</span><span class="p">),</span> <span class="n">Token</span><span class="p">),</span> <span class="n">Token</span><span class="p">));</span>
    <span class="kt">var</span> <span class="n">outcome</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Task</span><span class="p">.</span><span class="nf">WhenAll</span><span class="p">(</span><span class="n">attempts</span><span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">Succeeded</span><span class="p">));</span>

    <span class="n">Assert</span><span class="p">.</span><span class="nf">Single</span><span class="p">(</span><span class="n">outcome</span><span class="p">,</span> <span class="n">won</span> <span class="p">=&gt;</span> <span class="n">won</span><span class="p">);</span>
    <span class="n">Assert</span><span class="p">.</span><span class="nf">Single</span><span class="p">(</span><span class="k">await</span> <span class="nf">ReadStream</span><span class="p">(</span><span class="n">stream</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Exactly one wins and the other nine get a <code class="language-plaintext highlighter-rouge">ConcurrencyConflictException</code>.</p>

<p>This test doesn’t prove the read ordering from the table above, because that needs two transactions held open at the right moments. I want a test for that before the projection runner exists, probably by holding a transaction open on a second connection and checking that a ReadAll after it never skips.</p>

<p>The store is in <a href="https://github.com/softvasco/ledger-core/tree/main/src/LedgerCore.Infrastructure/EventStore">LedgerCore.Infrastructure/EventStore</a>, and the design of the ledger’s money movements is in <a href="https://github.com/softvasco/ledger-core/blob/main/docs/adr/0004-double-entry-bookkeeping-model.md">ADR-0004</a>.</p>]]></content><author><name>Vasco Silva</name></author><summary type="html"><![CDATA[Identity values are handed out at insert time, not at commit, so a reader that follows the global position can skip an event for good. Here is how my ledger's event store avoids that, and what it costs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://softvasco.github.io/assets/cards/event-store-append-order.png" /><media:content medium="image" url="https://softvasco.github.io/assets/cards/event-store-append-order.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why 0.05 × 0.5 is 0.02 in my ledger</title><link href="https://softvasco.github.io/2026/09/rounding-in-a-ledger/" rel="alternate" type="text/html" title="Why 0.05 × 0.5 is 0.02 in my ledger" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://softvasco.github.io/2026/09/rounding-in-a-ledger</id><content type="html" xml:base="https://softvasco.github.io/2026/09/rounding-in-a-ledger/"><![CDATA[<p>In <a href="https://github.com/softvasco/ledger-core">ledger-core</a>, <code class="language-plaintext highlighter-rouge">Money.Of(0.05m, Currency.Eur).Multiply(0.5m)</code> returns EUR 0.02. The exact result is 0.025, which can’t be stored in euros, and it rounds down to 0.02 instead of up to 0.03. Here’s why, plus a few related rules in the same type.</p>

<h2 id="decimalround-rounds-half-to-even">decimal.Round rounds half to even</h2>

<p><code class="language-plaintext highlighter-rouge">decimal.Round</code> uses <code class="language-plaintext highlighter-rouge">MidpointRounding.ToEven</code> unless you pass a mode. It’s usually called banker’s rounding: a value exactly halfway between two neighbours goes to the one whose last digit is even.</p>

<table>
  <thead>
    <tr>
      <th>Value</th>
      <th>ToEven</th>
      <th>AwayFromZero</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0.025</td>
      <td>0.02</td>
      <td>0.03</td>
    </tr>
    <tr>
      <td>0.075</td>
      <td>0.08</td>
      <td>0.08</td>
    </tr>
    <tr>
      <td>1.125</td>
      <td>1.12</td>
      <td>1.13</td>
    </tr>
    <tr>
      <td>1.135</td>
      <td>1.14</td>
      <td>1.14</td>
    </tr>
  </tbody>
</table>

<p>With round half up, every midpoint goes up, so over many postings the rounding error builds up in one direction. With half to even, midpoints go up about half the time and down the rest, and the error mostly cancels out.</p>

<h2 id="the-mode-is-a-parameter">The mode is a parameter</h2>

<p>Half to even isn’t right everywhere. Some products and some tax rules specify half up. So <code class="language-plaintext highlighter-rouge">Multiply</code> takes the mode as a parameter, with <code class="language-plaintext highlighter-rouge">ToEven</code> as the default:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="n">Money</span> <span class="nf">Multiply</span><span class="p">(</span><span class="kt">decimal</span> <span class="n">factor</span><span class="p">,</span> <span class="n">MidpointRounding</span> <span class="n">rounding</span> <span class="p">=</span> <span class="n">MidpointRounding</span><span class="p">.</span><span class="n">ToEven</span><span class="p">)</span> <span class="p">=&gt;</span>
    <span class="k">new</span><span class="p">(</span><span class="kt">decimal</span><span class="p">.</span><span class="nf">Round</span><span class="p">(</span><span class="n">Amount</span> <span class="p">*</span> <span class="n">factor</span><span class="p">,</span> <span class="n">Currency</span><span class="p">.</span><span class="n">MinorUnits</span><span class="p">,</span> <span class="n">rounding</span><span class="p">),</span> <span class="n">Currency</span><span class="p">);</span>
</code></pre></div></div>

<p>Code that needs another rule has to say it: <code class="language-plaintext highlighter-rouge">Multiply(0.5m, MidpointRounding.AwayFromZero)</code>. That way the choice shows up in code review.</p>

<p>The number of decimals comes from <code class="language-plaintext highlighter-rouge">Currency.MinorUnits</code>, not a hard-coded 2. The yen has no minor unit and the Bahraini dinar has three.</p>

<h2 id="rejecting-amounts-that-are-too-precise">Rejecting amounts that are too precise</h2>

<p>Rounding after a multiplication can’t be avoided. Input is another matter. If 10.005 EUR arrives, storing it as 10.00 or 10.01 creates half a cent of difference that nobody will be able to trace later. So <code class="language-plaintext highlighter-rouge">Money.Of</code> refuses it:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="n">Money</span> <span class="nf">Of</span><span class="p">(</span><span class="kt">decimal</span> <span class="n">amount</span><span class="p">,</span> <span class="n">Currency</span> <span class="n">currency</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">ArgumentNullException</span><span class="p">.</span><span class="nf">ThrowIfNull</span><span class="p">(</span><span class="n">currency</span><span class="p">);</span>

    <span class="c1">// a ledger that silently rounds on the way in loses cents nobody can explain later</span>
    <span class="k">if</span> <span class="p">(</span><span class="kt">decimal</span><span class="p">.</span><span class="nf">Round</span><span class="p">(</span><span class="n">amount</span><span class="p">,</span> <span class="n">currency</span><span class="p">.</span><span class="n">MinorUnits</span><span class="p">)</span> <span class="p">!=</span> <span class="n">amount</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">ArgumentException</span><span class="p">(</span>
            <span class="s">$"</span><span class="p">{</span><span class="n">amount</span><span class="p">}</span><span class="s"> has more than </span><span class="p">{</span><span class="n">currency</span><span class="p">.</span><span class="n">MinorUnits</span><span class="p">}</span><span class="s"> decimal places for </span><span class="p">{</span><span class="n">currency</span><span class="p">}</span><span class="s">."</span><span class="p">,</span> <span class="k">nameof</span><span class="p">(</span><span class="n">amount</span><span class="p">));</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="k">new</span> <span class="nf">Money</span><span class="p">(</span><span class="n">amount</span><span class="p">,</span> <span class="n">currency</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Money.Of(1.5m, Currency.FromCode("JPY"))</code> and <code class="language-plaintext highlighter-rouge">Money.Of(0.001m, Currency.Eur)</code> both throw. Trailing zeros are accepted: <code class="language-plaintext highlighter-rouge">1.2000m</code> passes, and it compares equal to <code class="language-plaintext highlighter-rouge">1.20m</code>.</p>

<h2 id="other-checks-in-money">Other checks in Money</h2>

<ul>
  <li>Adding EUR to USD throws a <code class="language-plaintext highlighter-rouge">CurrencyMismatchException</code>.</li>
  <li>Comparing amounts in different currencies throws too. Converting needs a rate and a date, and a value object has neither.</li>
  <li>The amount is a <code class="language-plaintext highlighter-rouge">decimal</code>, because <code class="language-plaintext highlighter-rouge">double</code> can’t represent 0.1 exactly.</li>
</ul>

<p>The code and tests are in <a href="https://github.com/softvasco/ledger-core/tree/main/src/LedgerCore.Domain/Monetary">LedgerCore.Domain/Monetary</a>.</p>]]></content><author><name>Vasco Silva</name></author><summary type="html"><![CDATA[decimal.Round uses banker's rounding by default. How ledger-core uses it, and why amounts with too many decimals are rejected instead of rounded.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://softvasco.github.io/assets/cards/rounding-in-a-ledger.png" /><media:content medium="image" url="https://softvasco.github.io/assets/cards/rounding-in-a-ledger.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Normalising payee names: where NFKD stops</title><link href="https://softvasco.github.io/2026/09/where-nfkd-stops/" rel="alternate" type="text/html" title="Normalising payee names: where NFKD stops" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://softvasco.github.io/2026/09/where-nfkd-stops</id><content type="html" xml:base="https://softvasco.github.io/2026/09/where-nfkd-stops/"><![CDATA[<p>Since 9 October 2025, payment providers in the EU have to check the payee name against the IBAN before they send a euro credit transfer. This is Verification of Payee. The payee’s bank answers with one of four codes: match, close match, no match, or check not possible. The EPC rulebook defines the messages between the two banks, but for the name matching it only gives guidelines, so each bank decides how to do it.</p>

<p>I’m writing an open-source matcher for .NET, <a href="https://github.com/softvasco/payee-match">payee-match</a>. Before any fuzzy matching, both names have to be brought to the same form, so that “João Conceição” typed by the payer and “JOAO CONCEICAO” in the bank’s records compare as equal. This post is about that step.</p>

<h2 id="removing-accents">Removing accents</h2>

<p>.NET does most of it. <code class="language-plaintext highlighter-rouge">string.Normalize(NormalizationForm.FormKD)</code> decomposes an accented letter into the base letter followed by a combining mark. Skip everything in the <code class="language-plaintext highlighter-rouge">NonSpacingMark</code> category and the accents are gone:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">c</span> <span class="k">in</span> <span class="n">name</span><span class="p">.</span><span class="nf">Normalize</span><span class="p">(</span><span class="n">NormalizationForm</span><span class="p">.</span><span class="n">FormKD</span><span class="p">))</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">CharUnicodeInfo</span><span class="p">.</span><span class="nf">GetUnicodeCategory</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="p">==</span> <span class="n">UnicodeCategory</span><span class="p">.</span><span class="n">NonSpacingMark</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">continue</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That covers ã, ç, ü, é and ñ, and Czech letters like ř and ť too. “Dvořák Šťastný” becomes “dvorak stastny”.</p>

<p>I used KD rather than D. The compatibility form also turns the <code class="language-plaintext highlighter-rouge">ﬁ</code> ligature into <code class="language-plaintext highlighter-rouge">f</code> and <code class="language-plaintext highlighter-rouge">i</code>, and full-width <code class="language-plaintext highlighter-rouge">ＡＢＣ</code> into <code class="language-plaintext highlighter-rouge">ABC</code>. You won’t see either often in a payment form, but a name pasted from a PDF can bring them in.</p>

<h2 id="letters-with-no-accent-to-remove">Letters with no accent to remove</h2>

<p>The first German, Nordic and Polish names in the tests failed:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>After NFKD</th>
      <th>Expected</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Straße</td>
      <td>Straße</td>
      <td>strasse</td>
    </tr>
    <tr>
      <td>Søren</td>
      <td>Søren</td>
      <td>soren</td>
    </tr>
    <tr>
      <td>Łukasz</td>
      <td>Łukasz</td>
      <td>lukasz</td>
    </tr>
    <tr>
      <td>Ægir</td>
      <td>Ægir</td>
      <td>aegir</td>
    </tr>
    <tr>
      <td>Þórsson</td>
      <td>Þorsson</td>
      <td>thorsson</td>
    </tr>
  </tbody>
</table>

<p>ß, ø, ł, æ and þ are letters of their own, not a base letter with an accent, so Unicode has no decomposition for them. Lower-casing doesn’t touch them either: <code class="language-plaintext highlighter-rouge">"ß".ToLowerInvariant()</code> is still <code class="language-plaintext highlighter-rouge">"ß"</code>.</p>

<p>They go through a lookup table instead:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// letters that NFKD leaves alone because they aren't an accent on a base letter</span>
<span class="k">private</span> <span class="k">static</span> <span class="k">readonly</span> <span class="n">FrozenDictionary</span><span class="p">&lt;</span><span class="kt">char</span><span class="p">,</span> <span class="kt">string</span><span class="p">&gt;</span> <span class="n">Folds</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">char</span><span class="p">,</span> <span class="kt">string</span><span class="p">&gt;</span>
<span class="p">{</span>
    <span class="p">[</span><span class="sc">'ß'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"ss"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'æ'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"ae"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'œ'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"oe"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'ø'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"o"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'ł'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"l"</span><span class="p">,</span>
    <span class="p">[</span><span class="sc">'đ'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"d"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'ð'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"d"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'þ'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"th"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'ı'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"i"</span><span class="p">,</span> <span class="p">[</span><span class="sc">'ŀ'</span><span class="p">]</span> <span class="p">=</span> <span class="s">"l"</span><span class="p">,</span>
<span class="p">}.</span><span class="nf">ToFrozenDictionary</span><span class="p">();</span>
</code></pre></div></div>

<p>I kept it as an explicit list. A VoP result may have to be explained to a customer or an auditor, and with ten entries anyone can check exactly what was replaced.</p>

<h2 id="splitting-into-words">Splitting into words</h2>

<p>Next the name is split into tokens. Most punctuation just ends a word: hyphens, commas, <code class="language-plaintext highlighter-rouge">&amp;</code>, repeated spaces. “Silva-Santos, Ana” becomes <code class="language-plaintext highlighter-rouge">silva santos ana</code>.</p>

<p>Apostrophes are different. O’Neill and D’Almeida should stay one word, and people type them with a straight quote, a curly one or nothing at all. Dropping the apostrophe gives <code class="language-plaintext highlighter-rouge">oneill</code> and <code class="language-plaintext highlighter-rouge">dalmeida</code> in every case.</p>

<p>At first I wanted dots to behave the same way, but then “J.M. Silva” becomes <code class="language-plaintext highlighter-rouge">jm silva</code> and the two initials are lost. A later step compares initials with full given names (“J. M. Silva” against “João Manuel Silva”), so dots split: <code class="language-plaintext highlighter-rouge">j m silva</code>.</p>

<h2 id="titles-and-company-forms">Titles and company forms</h2>

<p>The normaliser knows nothing about names. After it runs, titles like “Dr” and company forms like “Lda” or “GmbH” are still there. A separate step removes them, and it keeps the legal form to one side instead of throwing it away, because “Silva Lda” and “Silva SA” are two different companies. I’ll write about that one when the matcher uses it.</p>

<p>The normaliser and its tests are in <a href="https://github.com/softvasco/payee-match/blob/main/src/PayeeMatch.Core/Names/NameNormaliser.cs">NameNormaliser.cs</a>. If a name in your language comes out wrong, please open an issue with it.</p>]]></content><author><name>Vasco Silva</name></author><summary type="html"><![CDATA[NFKD removes most accents from a name before fuzzy matching. It does nothing for ß, ø or ł, so those need a small table, and dots and apostrophes need their own rules.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://softvasco.github.io/assets/cards/where-nfkd-stops.png" /><media:content medium="image" url="https://softvasco.github.io/assets/cards/where-nfkd-stops.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>