<?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: scubaDEV</title>
    <description>The latest articles on DEV Community by scubaDEV (@mattia_armas).</description>
    <link>https://dev.to/mattia_armas</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3985738%2F40260faf-dc7b-4d02-95f1-f1a56e96afc7.png</url>
      <title>DEV Community: scubaDEV</title>
      <link>https://dev.to/mattia_armas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mattia_armas"/>
    <language>en</language>
    <item>
      <title>Your Hand-Rolled Two-Phase Commit Between Two Databases Isn't Atomic</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:37:46 +0000</pubDate>
      <link>https://dev.to/mattia_armas/your-hand-rolled-two-phase-commit-between-two-databases-isnt-atomic-3dhe</link>
      <guid>https://dev.to/mattia_armas/your-hand-rolled-two-phase-commit-between-two-databases-isnt-atomic-3dhe</guid>
      <description>&lt;p&gt;I had a write that spanned two physically separate databases. A rename that had to propagate across several tables in one database and a couple of tables in another, and the two had to stay consistent. No distributed transaction coordinator was available to me. So I did the obvious thing: opened a transaction on each, did the work, and committed them one after the other inside a &lt;code&gt;try/catch&lt;/code&gt; with rollbacks on both sides. It felt safe. It compiled. It passed tests.&lt;/p&gt;

&lt;p&gt;Then I drew the failure on a whiteboard, and the safety evaporated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window that ruins everything
&lt;/h2&gt;

&lt;p&gt;Here's the structure, simplified:&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;await&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;txA&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;dbA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&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;txB&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;dbB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;DoWorkOnA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;DoWorkOnB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbB&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;txA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- succeeds&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;txB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- what if this throws?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two transactions do not make one atomic operation. &lt;code&gt;CommitAsync&lt;/code&gt; is a point of no return, and there are &lt;em&gt;two&lt;/em&gt; of them. Between the first commit returning and the second one starting, there is a window. If &lt;code&gt;txB&lt;/code&gt; fails in that window — the connection drops, the process is killed, the database hiccups — then A is permanently committed and B never happens. Your rollback in the &lt;code&gt;catch&lt;/code&gt; block is useless: you can't roll back &lt;code&gt;txA&lt;/code&gt;, it's already durable. The two databases now disagree, and nothing in your code will heal that on its own.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;dual-write problem&lt;/strong&gt;, and it's not a bug you can fix by being more careful with try/catch. The atomicity you want simply isn't available from two independent commits. Ordering them, nesting them, wrapping them — none of it closes the window, because the window is &lt;em&gt;inherent&lt;/em&gt; to having two commit points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "it's never failed" isn't reassurance
&lt;/h2&gt;

&lt;p&gt;The seductive thing about this pattern is that the window is small, so in practice it almost never triggers. You can run it for a year and never see an inconsistency. That's exactly what makes it dangerous: it trains you to trust it, and then it fails during the one incident — a deploy, a failover, an OOM kill — when you're least able to notice a quietly desynced record. Rare and catastrophic beats frequent and visible, from the bug's point of view.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually addresses it
&lt;/h2&gt;

&lt;p&gt;None of these are as convenient as two commits in a row. That's the point — the convenience was the illusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make one database the source of truth.&lt;/strong&gt; Write atomically to a single primary, and treat the second database as a projection you update &lt;em&gt;asynchronously&lt;/em&gt; and &lt;em&gt;idempotently&lt;/em&gt; afterward. The primary is always correct; the secondary catches up. You trade instant consistency for the ability to actually guarantee the important write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the outbox pattern.&lt;/strong&gt; In the same transaction as your primary write, insert a row describing the change into an "outbox" table. A separate process reads the outbox and applies the change to the second database, retrying until it succeeds. Because the outbox row is committed atomically &lt;em&gt;with&lt;/em&gt; the real work, you can never lose the intent — you can only delay it. This is the standard answer for a reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detect and reconcile.&lt;/strong&gt; If you truly can't restructure, at least stop pretending the write is atomic. Make the second write idempotent and retryable, and run a reconciliation pass that finds and repairs divergence. It's a patch over the gap, not a closing of it — but an honest patch beats a false guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset shift
&lt;/h2&gt;

&lt;p&gt;The real fix isn't a code change, it's dropping a belief: that committing two transactions back-to-back is "basically" atomic. It isn't, and no arrangement of them will be. The moment a write spans two systems, you've left the world of database transactions and entered distributed systems, where the tools are outboxes, sagas, idempotency, and reconciliation — not a second &lt;code&gt;CommitAsync&lt;/code&gt; and some hope. Recognizing which world you're in is most of the battle. The pseudo-2PC is sometimes &lt;em&gt;good enough&lt;/em&gt;; just never mistake "good enough" for "atomic."&lt;/p&gt;

</description>
      <category>database</category>
      <category>backend</category>
      <category>dotnet</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Everything That Breaks When You Generate PDFs with Headless Chrome in a Container</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:34:31 +0000</pubDate>
      <link>https://dev.to/mattia_armas/everything-that-breaks-when-you-generate-pdfs-with-headless-chrome-in-a-container-320f</link>
      <guid>https://dev.to/mattia_armas/everything-that-breaks-when-you-generate-pdfs-with-headless-chrome-in-a-container-320f</guid>
      <description>&lt;p&gt;Server-side PDF generation with headless Chrome is the kind of feature that demos perfectly and then ambushes you in production. The "how to render a PDF with a headless browser" tutorials all stop exactly where the real problems start: the container, the fonts, the hangs, the bill. This is the operational minefield, one mine at a time. Each of these cost me real debugging hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The distro's Chromium package is a trap
&lt;/h2&gt;

&lt;p&gt;On a Debian/Ubuntu container image, installing the &lt;code&gt;chromium&lt;/code&gt; package often gives you a &lt;strong&gt;snap stub&lt;/strong&gt; that simply won't launch in a headless container. The error is unhelpful and the fix is non-obvious: install actual Google Chrome from Google's official apt repository, &lt;strong&gt;at image build time&lt;/strong&gt;, and point your driver at it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# install Chrome from Google's repo at BUILD time, never at runtime&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;wget &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-O&lt;/span&gt; - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/apt/sources.list.d/google-chrome.list &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; google-chrome-stable
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; CHROME_PATH=/usr/bin/google-chrome&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing this at runtime instead means your first request after every cold start pays a download, or fails offline. Bake the browser into the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Fonts aren't ready when you think they are
&lt;/h2&gt;

&lt;p&gt;This one produced wrong output, not a crash, which is worse. If you measure content height to decide page scaling &lt;em&gt;before the fonts have loaded&lt;/em&gt;, you measure against fallback metrics and your scaling is off by 10–15%. The page looks subtly wrong and you blame your CSS.&lt;/p&gt;

&lt;p&gt;The fix is to wait for the browser to tell you fonts are ready before you measure anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// only now is it safe to measure layout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. A hung Chrome must never hold a slot forever
&lt;/h2&gt;

&lt;p&gt;Headless Chrome can hang. When it does, the danger isn't the one stuck render — it's that the stuck render holds a concurrency slot indefinitely and slowly starves everything behind it. You need two guards: a &lt;strong&gt;hard per-render timeout&lt;/strong&gt;, and a &lt;strong&gt;bounded pool&lt;/strong&gt; so a bad render can't take the whole service down.&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;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;SemaphoreSlim&lt;/span&gt; &lt;span class="n"&gt;_renderPool&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// max concurrent&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;RenderTimeoutMs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;60_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                      &lt;span class="c1"&gt;// hard ceiling&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_renderPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;try&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;cts&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;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;RenderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// killed if it overruns&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_renderPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Release&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;h2&gt;
  
  
  4. The same binary path won't exist everywhere
&lt;/h2&gt;

&lt;p&gt;Chrome lives at one path in your container, another on a developer's laptop, and maybe nowhere on a fresh CI box. Hardcoding the path makes the code run in exactly one environment. A three-tier resolution chain lets one codebase run everywhere: honor an explicit environment variable first, auto-detect a known host install second, and fall back to downloading a managed browser only as a last resort.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The cost nobody budgets for
&lt;/h2&gt;

&lt;p&gt;Here's the one that didn't show up in any tutorial: moving rendering from the client's browser to your server means &lt;strong&gt;you&lt;/strong&gt; now run a full browser per render. In our case it roughly tripled the CPU and memory the service needed. The feature was free when the client's machine paid for it. Server-side, it's a line item. Size your pods accordingly &lt;em&gt;before&lt;/em&gt; you ship, not after the autoscaler panics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the lesson
&lt;/h2&gt;

&lt;p&gt;None of these are about Chrome's API. They're about the gap between "renders a PDF on my machine" and "renders PDFs reliably for everyone." Bake the browser into the image, wait for fonts before measuring, never let a hung render hold a slot, resolve the binary by environment, and budget for the compute you just moved onto your own servers. The happy-path code is the easy 20%. This is the other 80%.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>containers</category>
      <category>dotnet</category>
      <category>devops</category>
    </item>
    <item>
      <title>Dynamic Column Updates in EF Core Without Hand-Rolling SQL Injection</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:32:27 +0000</pubDate>
      <link>https://dev.to/mattia_armas/dynamic-column-updates-in-ef-core-without-hand-rolling-sql-injection-mip</link>
      <guid>https://dev.to/mattia_armas/dynamic-column-updates-in-ef-core-without-hand-rolling-sql-injection-mip</guid>
      <description>&lt;p&gt;Sometimes you genuinely need the &lt;em&gt;set of columns to update&lt;/em&gt; to be data, not code. An operator maps configuration fields to database columns, and you want to honor that mapping without redeploying every time it changes. The naive solution — build an &lt;code&gt;UPDATE&lt;/code&gt; string from those column names — is also one of the easiest ways to hand-write a SQL injection vulnerability. This is how to get the flexibility without the hole.&lt;/p&gt;

&lt;p&gt;We'll build it up in three layers: make it work, make it safe, then count the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: The dynamic update, the wrong way
&lt;/h2&gt;

&lt;p&gt;The tempting version concatenates column names into SQL:&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="c1"&gt;// DO NOT do this.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"UPDATE products SET &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;columnName&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="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; WHERE id = &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&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;If &lt;code&gt;columnName&lt;/code&gt; comes from configuration that an operator can edit, you've just made your schema writable by whoever controls that config. A value of &lt;code&gt;name = 'x'; DROP TABLE products; --&lt;/code&gt; is now your problem. Even "trusted" config is an injection surface the moment it flows into a SQL string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: The same feature with EF.Property
&lt;/h2&gt;

&lt;p&gt;EF Core's &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; lets you set a property &lt;em&gt;by name&lt;/em&gt; without ever building SQL yourself. &lt;code&gt;EF.Property&amp;lt;T&amp;gt;&lt;/code&gt; takes the property name as a string, and EF parameterizes the value and validates the property against the model:&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;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;Products&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&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;=&amp;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;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setters&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;setters&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&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;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Property&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;?&amp;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;columnName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is already a different security posture: the value is a parameter, not interpolated text, and EF will throw rather than emit SQL if &lt;code&gt;columnName&lt;/code&gt; isn't a real mapped property. But "EF will throw" is a runtime backstop, not a policy. We want to reject bad names &lt;em&gt;before&lt;/em&gt; they reach the database, fail closed, and control exactly which columns are writable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Reflection as a whitelist
&lt;/h2&gt;

&lt;p&gt;The guard is to validate every incoming column name against the entity's actual properties, using reflection, and to keep an explicit blacklist of fields that must never be touched dynamically:&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;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Forbidden&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CustomerId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CreatedAt"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Allowed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Public&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;)&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;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;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;Forbidden&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToHashSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ApplyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&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;key&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Keys&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;Allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Column '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' is not updatable."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&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;tx&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;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransactionAsync&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;query&lt;/span&gt; &lt;span class="p"&gt;=&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;Products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&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;=&amp;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;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;updates&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;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&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;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Property&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;?&amp;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;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;value&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&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 important properties of this design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whitelist, not blacklist, as the primary control.&lt;/strong&gt; The set of allowed names is &lt;em&gt;derived from the type itself&lt;/em&gt;, so it can't drift out of sync with the schema. The blacklist only subtracts the sensitive few.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed.&lt;/strong&gt; An unknown column raises before any database call, and the transaction means a bad item rolls everything back rather than half-applying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No SQL is ever constructed from input.&lt;/strong&gt; The column name only ever indexes into a validated set and is handed to EF as a model property reference.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The cost, stated honestly
&lt;/h2&gt;

&lt;p&gt;This isn't free. You pay reflection and a dictionary lookup per field, and one &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; per column rather than one combined statement. For a handful of configurable fields on a record, that's nothing. For a hot path updating dozens of columns across millions of rows, you'd cache the allowed set (as above, it's &lt;code&gt;static&lt;/code&gt;) and consider batching. Measure before you optimize, but know the trade is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The principle to carry off
&lt;/h2&gt;

&lt;p&gt;Dynamic &lt;em&gt;behavior&lt;/em&gt; driven by config is fine. Dynamic &lt;em&gt;SQL text&lt;/em&gt; built from config is the danger. The fix isn't to ban the feature — it's to make sure every externally-influenced identifier is validated against a server-side whitelist that the user can't expand, and to let the ORM parameterize values so you never assemble a query string by hand. Reflection over your own type is the cleanest source for that whitelist, because the schema &lt;em&gt;is&lt;/em&gt; the source of truth.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>sql</category>
      <category>api</category>
      <category>database</category>
    </item>
    <item>
      <title>SELECT MAX()+1 Is a Race Condition Waiting to Happen</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:23:47 +0000</pubDate>
      <link>https://dev.to/mattia_armas/select-max1-is-a-race-condition-waiting-to-happen-22m2</link>
      <guid>https://dev.to/mattia_armas/select-max1-is-a-race-condition-waiting-to-happen-22m2</guid>
      <description>&lt;p&gt;This is a story in three acts about one of the most innocent-looking patterns in backend code: generating the next number in a sequence. It passed every test. It ran fine for months. Then a unique-index violation showed up in production logs, and the fix I reached for &lt;em&gt;first&lt;/em&gt; didn't actually fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: The code that looks correct
&lt;/h2&gt;

&lt;p&gt;The requirement was a per-customer progressive number — &lt;code&gt;ORD-1&lt;/code&gt;, &lt;code&gt;ORD-2&lt;/code&gt;, and so on. The implementation read like plain English:&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;existing&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;Orders&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;customerId&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;next&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="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ParseProgressive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DefaultIfEmpty&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;()&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"ORD-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;next&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;CustomerId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerId&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="nf"&gt;SaveChangesAsync&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 current max, add one, insert. There is nothing wrong with this code &lt;em&gt;if only one thing ever runs it at a time&lt;/em&gt;. The problem is that assumption is almost never true, and nothing in the code makes it true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: Why it breaks, and the fix that doesn't
&lt;/h2&gt;

&lt;p&gt;This is a &lt;strong&gt;TOCTOU&lt;/strong&gt; bug — time-of-check to time-of-use. Two requests for the same customer arrive nearly together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request A reads max = 7. Computes next = 8.&lt;/li&gt;
&lt;li&gt;Request B reads max = 7 &lt;em&gt;before A has inserted&lt;/em&gt;. Also computes next = 8.&lt;/li&gt;
&lt;li&gt;Both insert &lt;code&gt;ORD-8&lt;/code&gt;. The unique index rejects the second. Production error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My first instinct was a lock around the method — make the read-and-write atomic &lt;em&gt;in the application&lt;/em&gt;. And in a single-process test, that "works." Here's the trap: the moment you run more than one instance of the service (and you will — that's what horizontal scaling is), an in-process lock protects nothing. Instance 1 and instance 2 each hold their own lock and happily compute the same number. The in-memory fix doesn't close the race; it just shrinks the window enough to pass your tests and lie to you.&lt;/p&gt;

&lt;p&gt;The real lesson arrived here: &lt;strong&gt;uniqueness has to be enforced by the one thing both instances share — the database.&lt;/strong&gt; Not by application code, no matter how clever the locking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: The fixes that actually hold
&lt;/h2&gt;

&lt;p&gt;There are several, and the right one depends on whether the number has to be &lt;em&gt;gapless&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If gaps are acceptable&lt;/strong&gt; (you just need unique and roughly increasing), use the database's own sequence or identity. It's atomic by construction, across any number of instances:&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="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;order_seq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- each insert pulls nextval('order_seq'), no read-then-write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If the number must be per-customer and gapless&lt;/strong&gt;, you can't lean on a global sequence. Then you serialize at the row level. A Postgres advisory lock keyed on the customer turns concurrent inserts for the &lt;em&gt;same&lt;/em&gt; customer into a queue, while leaving different customers fully parallel:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;-- $1 = customer id&lt;/span&gt;
&lt;span class="c1"&gt;-- now the read-max-and-insert is safe within this transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Or let the database be the referee&lt;/strong&gt; with insert-and-retry. Keep the unique index, attempt the insert, and on a conflict recompute and try again. The constraint you were treating as an error becomes your concurrency control:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The thing I'd tell past me
&lt;/h2&gt;

&lt;p&gt;The bug wasn't the arithmetic. It was believing that "read, then write" is a single step. It's two, and anything can happen in between. When correctness depends on two operations being indivisible, the only component that can guarantee that across a scaled-out service is the shared database — through a sequence, a lock, or a constraint you actually let do its job. Application-side locking on a multi-instance service is a comfort blanket, not a fix.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>dotnet</category>
      <category>concurrency</category>
    </item>
    <item>
      <title>The Hidden Math of Mid-Cycle Subscription Upgrades</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:21:51 +0000</pubDate>
      <link>https://dev.to/mattia_armas/the-hidden-math-of-mid-cycle-subscription-upgrades-1h7f</link>
      <guid>https://dev.to/mattia_armas/the-hidden-math-of-mid-cycle-subscription-upgrades-1h7f</guid>
      <description>&lt;p&gt;Here's a question that sounds trivial and isn't. A customer is on &lt;strong&gt;Basic, 3 seats&lt;/strong&gt;. On day 12 of a 30-day billing cycle they switch to &lt;strong&gt;Pro, 2 seats&lt;/strong&gt;. How much do you charge them today, and what do they renew at next month?&lt;/p&gt;

&lt;p&gt;If your first instinct was "new price times new quantity," you've already shipped the bug. That's the renewal amount, not what they owe &lt;em&gt;now&lt;/em&gt;. The amount due today is a proration, and getting it wrong means either robbing the customer or robbing yourself — quietly, on every mid-cycle change, forever.&lt;/p&gt;

&lt;p&gt;I wrote this logic for a real billing integration, covered it in comments, and I'm glad I did, because six months later I couldn't reconstruct it from memory. Here's the part no payment SDK gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What proration actually means
&lt;/h2&gt;

&lt;p&gt;The customer already paid for the full cycle up front. When they change plans mid-cycle, two things are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They have &lt;strong&gt;unused credit&lt;/strong&gt; on the old plan for the days remaining.&lt;/li&gt;
&lt;li&gt;They owe the &lt;strong&gt;cost of the new plan&lt;/strong&gt; for those same remaining days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you charge today is the difference between those two. Not the new full price — just the delta for the slice of the cycle they haven't used yet.&lt;/p&gt;

&lt;p&gt;So for our example, with 18 of 30 days remaining (60% of the cycle left):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unused Basic credit ≈ 60% of (Basic price × 3 seats)&lt;/li&gt;
&lt;li&gt;New Pro cost ≈ 60% of (Pro price × 2 seats)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Charge today = new cost − unused credit&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next renewal then bills the full Pro × 2 at the normal cycle boundary. Two completely different numbers, and conflating them is the classic mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four cases, one of which "should never happen"
&lt;/h2&gt;

&lt;p&gt;Once seats enter the picture, a single change can be any of four shapes, and they don't behave the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade + more seats&lt;/strong&gt; — charge the prorated plan delta on existing seats &lt;em&gt;and&lt;/em&gt; the prorated cost of the new seats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade + fewer seats&lt;/strong&gt; — charge the plan delta only on the seats that survive; the removed seats generate credit, not charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downgrade&lt;/strong&gt; — typically costs &lt;strong&gt;0 today&lt;/strong&gt;. You don't refund mid-cycle cash; the lower price takes effect next renewal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First purchase&lt;/strong&gt; — no proration at all, just a clean charge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In my code one branch is commented &lt;em&gt;"this should never happen given the UI… but it's supported if needed."&lt;/em&gt; That comment is doing real work. Defensive billing code that handles the impossible case is cheaper than a support ticket from the one customer who found a way to trigger it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that actually bit me: dates
&lt;/h2&gt;

&lt;p&gt;The subtle bug wasn't the money math. It was making the new cycle start exactly where the old one ended.&lt;/p&gt;

&lt;p&gt;You take the provider's &lt;code&gt;NextBillingTime&lt;/code&gt;, and if you carry the time-of-day along with it, the new cycle and the old one drift apart by hours — enough to double-bill a fraction of a day or leave a gap. The fix was to &lt;strong&gt;collapse to a date&lt;/strong&gt; (drop the time component entirely) so the boundary lines up cleanly. In .NET that's exactly what &lt;code&gt;DateOnly&lt;/code&gt; is for. A whole class of off-by-a-few-hours billing weirdness disappears the moment you stop carrying the clock around.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design call: compute, log, never surprise the user
&lt;/h2&gt;

&lt;p&gt;The last decision was philosophical. Proration is the kind of calculation that, if it's ever wrong, is wrong in a way the customer notices on their card statement. So I didn't expose intermediate failures to the user at all. Every branch produces a &lt;em&gt;computed result object&lt;/em&gt; — the amount, the case it took, the dates it used — and logs the inputs aggressively. If a charge ever looks off, I can replay exactly which path ran and why, instead of guessing from a stack trace.&lt;/p&gt;

&lt;p&gt;That's the real lesson, more than the arithmetic: for money math, &lt;strong&gt;observability is part of the feature&lt;/strong&gt;. The calculation that you can't reconstruct after the fact is the one that costs you a chargeback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Mid-cycle subscription changes aren't "new price × quantity." They're a difference of two prorated amounts, branching into four cases, anchored to a date boundary you have to normalize by hand. Write it once, comment the impossible branch, log the inputs, and let &lt;code&gt;DateOnly&lt;/code&gt; save you from the clock. Then never touch it again if you can help it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>dotnet</category>
      <category>billing</category>
    </item>
    <item>
      <title>Role-Based Access Control in Blazor WebAssembly with Azure AD</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 14:45:46 +0000</pubDate>
      <link>https://dev.to/mattia_armas/role-based-access-control-in-blazor-webassembly-with-azure-ad-5gp5</link>
      <guid>https://dev.to/mattia_armas/role-based-access-control-in-blazor-webassembly-with-azure-ad-5gp5</guid>
      <description>&lt;p&gt;Blazor WebAssembly runs entirely in the browser. That single fact shapes everything about how you implement authorization, because &lt;strong&gt;nothing the client decides can be trusted&lt;/strong&gt;. A user can open dev tools, edit memory, and flip any boolean you use to hide a button.&lt;/p&gt;

&lt;p&gt;So role-based access control in a WASM app is really two separate jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cosmetic&lt;/strong&gt; — show users only the parts of the UI they're allowed to use, so the app feels coherent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforced&lt;/strong&gt; — make sure the API rejects anything a user shouldn't be able to do, regardless of what the client sends.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post covers both, driven from Azure AD app roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define app roles in Azure AD
&lt;/h2&gt;

&lt;p&gt;In the Azure portal, open your app registration → &lt;strong&gt;App roles&lt;/strong&gt; → create a role. The important field is the &lt;strong&gt;Value&lt;/strong&gt; — that's the string that lands in the token. For example, a role with value &lt;code&gt;BasicUser&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then assign users to that role under &lt;strong&gt;Enterprise applications → your app → Users and groups&lt;/strong&gt;. Azure AD will now include the role in the &lt;code&gt;roles&lt;/code&gt; claim of the access token issued to that user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Map the role claim in the client
&lt;/h2&gt;

&lt;p&gt;Blazor WASM doesn't automatically know that the &lt;code&gt;roles&lt;/code&gt; claim should map to .NET role checks. You tell it during authentication setup:&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;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMsalAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AzureAd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProviderOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authentication&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProviderOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultAccessTokenScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api://your-api-id/access_as_user"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Make the "roles" claim drive IsInRole / [Authorize(Roles = ...)]&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleClaim&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"roles"&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;With that mapping in place, the standard authorization primitives start working off your Azure AD roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Conditional UI with AuthorizeView
&lt;/h2&gt;

&lt;p&gt;For showing and hiding pieces of UI, &lt;code&gt;AuthorizeView&lt;/code&gt; is the cleanest tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;

        Run report


        You don't have access to this feature.


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the cosmetic layer. It's genuinely useful — it stops users from being confused by controls they can't use — but on its own it secures nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Restrict navigation
&lt;/h2&gt;

&lt;p&gt;A common pattern is to hide whole sections of the nav menu. You can check roles imperatively by injecting the authentication state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@inject AuthenticationStateProvider AuthState

@if (_isBasicUser)
{
    Reports
}

@code {
    private bool _isBasicUser;

    protected override async Task OnInitializedAsync()
    {
        var state = await AuthState.GetAuthenticationStateAsync();
        _isBasicUser = state.User.IsInRole("BasicUser");
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also protect the routed pages themselves with an attribute, so that even a user who types the URL directly gets bounced to the "not authorized" view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@page "/reports"
@attribute [Authorize(Roles = "BasicUser")]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again — useful, but still client-side. A determined user can bypass all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: The part that actually matters — enforce on the server
&lt;/h2&gt;

&lt;p&gt;Every endpoint behind the UI must independently check the role. The browser-side checks are a convenience; the API is the boundary that counts.&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;ApiController&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api/reports"&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;class&lt;/span&gt; &lt;span class="nc"&gt;ReportsController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ControllerBase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Roles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"BasicUser"&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;RunReport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Only reachable by a token that actually carries the role.&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;();&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;Because the same Azure AD token carries the same &lt;code&gt;roles&lt;/code&gt; claim to the API, the server validates the role from a source the client can't forge. If someone strips the client-side checks and calls the endpoint directly, the &lt;code&gt;[Authorize]&lt;/code&gt; attribute rejects them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model to take away
&lt;/h2&gt;

&lt;p&gt;Think of it as &lt;strong&gt;defense in two layers with very different jobs&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Blazor WASM layer makes the app &lt;em&gt;pleasant and coherent&lt;/em&gt; — users see what's relevant to them.&lt;/li&gt;
&lt;li&gt;The API layer makes the app &lt;em&gt;secure&lt;/em&gt; — it assumes the client is hostile and validates every role on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only do the client side, you have a UI that &lt;em&gt;looks&lt;/em&gt; locked down and an API that's wide open. If you only do the server side, you have a secure app with a confusing UI full of buttons that error out. You want both, and it's worth being explicit about which layer you're working on at any given moment — because they're easy to conflate, and conflating them is exactly how WASM apps end up insecure.&lt;/p&gt;

</description>
      <category>blazor</category>
      <category>dotnet</category>
      <category>azure</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Permanent Tokens to a Server-Authoritative JWT Model in .NET</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 14:41:41 +0000</pubDate>
      <link>https://dev.to/mattia_armas/from-permanent-tokens-to-a-server-authoritative-jwt-model-in-net-18kn</link>
      <guid>https://dev.to/mattia_armas/from-permanent-tokens-to-a-server-authoritative-jwt-model-in-net-18kn</guid>
      <description>&lt;p&gt;A lot of integrations start the same way: you get an access token from a third-party API, you store it somewhere, and you keep using it. It works on day one. The trouble shows up later — the token can't be revoked without a redeploy, every instance of your app trusts it blindly, and when several requests try to refresh it at the same time you get a small storm of duplicate refresh calls.&lt;/p&gt;

&lt;p&gt;This post walks through replacing that pattern with a &lt;strong&gt;server-authoritative&lt;/strong&gt; model: the server is the single source of truth for token state, tokens are short-lived, and revocation is a database write instead of a deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "permanent" tokens
&lt;/h2&gt;

&lt;p&gt;A permanent or very long-lived token has three properties you don't want in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It can't be revoked cheaply.&lt;/strong&gt; If it leaks, your only real lever is rotating it manually and pushing config everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust is implicit.&lt;/strong&gt; Any holder of the token is authorized, and the application has no internal record of whether that token &lt;em&gt;should&lt;/em&gt; still be valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refreshes race.&lt;/strong&gt; Under load, multiple callers notice the token is expired at the same instant and all trigger a refresh.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix for all three is to stop treating the token as a static secret and start treating it as &lt;strong&gt;state your server owns&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the solution
&lt;/h2&gt;

&lt;p&gt;The model has three moving parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;database table&lt;/strong&gt; that holds the current token, its expiry, and a validity flag.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;token manager&lt;/strong&gt; that is the only component allowed to read, refresh, or invalidate the token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant concurrency control&lt;/strong&gt; so that only one refresh happens at a time, even with many concurrent callers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's a minimal version of the persisted state:&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccessTokenRecord&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;CompanyId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsValid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&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;h2&gt;
  
  
  The token manager
&lt;/h2&gt;

&lt;p&gt;The manager centralizes everything. Nothing else in the codebase talks to the token table directly — that single rule is what makes the model "authoritative."&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenManager&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;AppDbContext&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IExternalAuthClient&lt;/span&gt; &lt;span class="n"&gt;_auth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// One semaphore per company so refreshes don't stampede,&lt;/span&gt;
    &lt;span class="c1"&gt;// but different companies don't block each other.&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ConcurrentDictionary&lt;/span&gt; &lt;span class="n"&gt;_locks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;TokenManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&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;IExternalAuthClient&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&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;db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_auth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;;&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;GetValidTokenAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&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="k"&gt;record&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nc"&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;AccessTokens&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefaultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompanyId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;companyId&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;IsValid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMinutes&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccessToken&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;await&lt;/span&gt; &lt;span class="nf"&gt;RefreshAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;companyId&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;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;RefreshAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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;gate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOrAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SemaphoreSlim&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;1&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;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAsync&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;span class="k"&gt;try&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Re-check inside the lock: another caller may have just refreshed.&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nc"&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;AccessTokens&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefaultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompanyId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;companyId&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;IsValid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMinutes&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccessToken&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;fresh&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;_auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequestTokenAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;companyId&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;span class="k"&gt;record&lt;/span&gt; &lt;span class="err"&gt;??=&lt;/span&gt; &lt;span class="nc"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AccessTokenRecord&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;CompanyId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;companyId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccessToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpiresAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IsValid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&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;AccessTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;);&lt;/span&gt;
            &lt;span class="nc"&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="nf"&gt;SaveChangesAsync&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;finally&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;Release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InvalidateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&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="k"&gt;record&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nc"&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;AccessTokens&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefaultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompanyId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;companyId&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IsValid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&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="nf"&gt;SaveChangesAsync&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;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;Two details are doing the heavy lifting here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The double-check inside the lock.&lt;/strong&gt; The first caller to find an expired token enters the semaphore and refreshes. Every other caller that was waiting then re-reads the record &lt;em&gt;after&lt;/em&gt; acquiring the lock, finds it's already fresh, and returns immediately. You pay for exactly one refresh, not one per waiting request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One semaphore per tenant.&lt;/strong&gt; A single global lock would serialize refreshes across &lt;em&gt;all&lt;/em&gt; companies, turning an unrelated tenant's slow auth call into everyone's problem. Keying the semaphore by &lt;code&gt;companyId&lt;/code&gt; isolates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why short-lived beats permanent
&lt;/h2&gt;

&lt;p&gt;Once token state lives in the database, expiry stops being scary. A token that lives 30 minutes and refreshes automatically is strictly safer than one that lives forever, because the blast radius of a leak is now measured in minutes. And revocation becomes trivial: flipping &lt;code&gt;IsValid&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; means the &lt;em&gt;next&lt;/em&gt; call refreshes, no redeploy required.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on invalidation triggers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;InvalidateAsync&lt;/code&gt; is worth wiring to real events rather than calling it ad hoc. Good triggers include the external API returning a &lt;code&gt;401&lt;/code&gt;, an admin disconnecting an integration, or a credential rotation. The pattern is always the same: mark the record invalid, let the next &lt;code&gt;GetValidTokenAsync&lt;/code&gt; transparently fetch a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The shift here is conceptual more than technical. You stop thinking of the token as a secret you hold and start thinking of it as &lt;strong&gt;state your server is responsible for&lt;/strong&gt;. Once you make that move, short lifetimes, clean revocation, and safe concurrency all fall out of the same small design — a database record, one manager, and a per-tenant lock.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>oauth</category>
      <category>api</category>
    </item>
    <item>
      <title>Postgres or ClickHouse? Row vs Column Storage, and When Each Wins</title>
      <dc:creator>scubaDEV</dc:creator>
      <pubDate>Mon, 15 Jun 2026 14:40:08 +0000</pubDate>
      <link>https://dev.to/mattia_armas/postgres-or-clickhouse-row-vs-column-storage-and-when-each-wins-14f0</link>
      <guid>https://dev.to/mattia_armas/postgres-or-clickhouse-row-vs-column-storage-and-when-each-wins-14f0</guid>
      <description>&lt;p&gt;PostgreSQL and ClickHouse both speak SQL, both call themselves databases, and both will happily store your data. That surface similarity gets teams into trouble, because under the hood they're built for opposite jobs. Picking the wrong one doesn't show up on day one — it shows up six months later when a query that should take 50ms takes 40 seconds.&lt;/p&gt;

&lt;p&gt;The single distinction that explains almost everything is &lt;strong&gt;how rows are laid out on disk&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Row storage vs column storage
&lt;/h2&gt;

&lt;p&gt;PostgreSQL is &lt;strong&gt;row-oriented&lt;/strong&gt;. All the fields of one record sit next to each other on disk:&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-06-15'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'timeout'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'info'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="s1"&gt;'2026-06-15'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'login ok'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'warning'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-06-15'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'retry'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ClickHouse is &lt;strong&gt;column-oriented&lt;/strong&gt;. Each field is stored as its own contiguous run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;id:        1, 2, 3
level:     error, info, warning
service:   billing, auth, billing
timestamp: 2026-06-15, 2026-06-15, 2026-06-15
message:   timeout, login ok, retry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Everything else — performance, compression, what each is good and bad at — falls out of this one choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this makes them good at opposite jobs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fetching one whole record&lt;/strong&gt; is a row store's home turf. &lt;code&gt;SELECT * FROM users WHERE id = 42&lt;/code&gt; touches a single contiguous location and pulls the entire row in one read. Updating that user touches the same one place. This is &lt;strong&gt;OLTP&lt;/strong&gt; — many small, targeted reads and writes, with transactions and consistency. Postgres is built for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scanning a few columns across millions of rows&lt;/strong&gt; is the column store's home turf. &lt;code&gt;SELECT service, count(*) FROM logs GROUP BY service&lt;/code&gt; only needs the &lt;code&gt;service&lt;/code&gt; column — so ClickHouse reads &lt;em&gt;only that column&lt;/em&gt; off disk and ignores the rest. A row store has to read every full row just to look at one field. This is &lt;strong&gt;OLAP&lt;/strong&gt; — aggregations and scans over huge datasets. ClickHouse is built for it.&lt;/p&gt;

&lt;p&gt;Columnar layout also compresses far better, because values in one column are similar to each other (lots of repeated &lt;code&gt;service&lt;/code&gt; names, timestamps in a tight range). Better compression means less data read from disk, which compounds the speed advantage. Add vectorized execution — processing whole column blocks at once — and analytical queries that crawl on Postgres fly on ClickHouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs, stated plainly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;PostgreSQL (row)&lt;/th&gt;
&lt;th&gt;ClickHouse (column)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best at&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transactions, point lookups, updates&lt;/td&gt;
&lt;td&gt;Aggregations, scans over big data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Single-row read/write&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Slow / awkward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Updates &amp;amp; deletes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cheap, native&lt;/td&gt;
&lt;td&gt;Heavy "mutations", avoid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inserts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Row-at-a-time is fine&lt;/td&gt;
&lt;td&gt;Wants large batches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Transactions / ACID&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full support&lt;/td&gt;
&lt;td&gt;Not its job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Aggregations on millions of rows&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;Extremely fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Typical use&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;App backend, orders, users&lt;/td&gt;
&lt;td&gt;Analytics, logs, metrics, events&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ClickHouse weaknesses are the mirror image of its strengths. Updating or deleting a single row is genuinely painful — the column layout that makes scans fast makes targeted edits expensive. And it wants inserts in big batches, not one row per request. If your workload is "update this order's status," ClickHouse is the wrong tool. If it's "show me revenue per region for the last year," Postgres will struggle.&lt;/p&gt;

&lt;h2&gt;
  
  
  They're complements, not competitors
&lt;/h2&gt;

&lt;p&gt;The mistake I see most is treating this as a versus. In practice the two sit side by side: Postgres runs the transactional core of the app — the data users create, edit, and rely on being consistent — while ClickHouse holds the high-volume, append-only, analytical data: logs, events, metrics, anything you query in aggregate and never update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heuristic
&lt;/h2&gt;

&lt;p&gt;Forget the feature lists and ask one question about your dominant query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Few rows, many columns&lt;/strong&gt; (fetch or change a specific record) → &lt;strong&gt;row store, use Postgres.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many rows, few columns&lt;/strong&gt; (aggregate across a huge table) → &lt;strong&gt;column store, use ClickHouse.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most teams don't need to choose. They need Postgres for the app and, eventually, something columnar for the analytics — and the failure mode is forcing one engine to do the other's job because adding a second database felt like too much. The two layouts exist precisely because no single one is good at both.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>clickhouse</category>
      <category>database</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
