<?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: Sukhpinder Singh</title>
    <description>The latest articles on DEV Community by Sukhpinder Singh (@ssukhpinder).</description>
    <link>https://dev.to/ssukhpinder</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F628027%2F2c6c10d0-b7eb-4faa-8e10-2c51a4127c13.gif</url>
      <title>DEV Community: Sukhpinder Singh</title>
      <link>https://dev.to/ssukhpinder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ssukhpinder"/>
    <language>en</language>
    <item>
      <title>7.8 MB of Keys I Allocated Just to Throw Away</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Tue, 04 Aug 2026 08:09:59 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/78-mb-of-keys-i-allocated-just-to-throw-away-50mn</link>
      <guid>https://dev.to/ssukhpinder/78-mb-of-keys-i-allocated-just-to-throw-away-50mn</guid>
      <description>&lt;p&gt;A profiler trace put me on a code path I'd never once suspected: a request handler that reads a blob of space-separated tokens and sums a weight for each one it recognizes. The logic is boring and correct. What caught my eye was 7.8 MB of &lt;code&gt;string&lt;/code&gt; allocations sitting in the hot loop of something that only ever &lt;em&gt;reads&lt;/em&gt; those tokens. I wasn't keeping any of them. I was allocating a key, doing one dictionary lookup, and dropping it on the floor.&lt;/p&gt;

&lt;p&gt;The keys were substrings. Every token I sliced out of the blob became its own little &lt;code&gt;string&lt;/code&gt; object just so I could hand it to &lt;code&gt;Dictionary.TryGetValue&lt;/code&gt;. So I measured what that habit actually costs, and what the span-based alternate lookup buys back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The input is one string of 200,000 space-separated tokens, about 1.5 MB. Roughly 70% of them are real keys that live in a 5,000-entry &lt;code&gt;Dictionary&amp;lt;string, long&amp;gt;&lt;/code&gt;; the rest are misses. The job walks the blob, pulls out each token, looks it up, and adds the weight. Timings are the median of 9 rounds after a warmup, allocations come from &lt;code&gt;GC.GetAllocatedBytesForCurrentThread&lt;/code&gt;, .NET 10, Release build, small Linux container. Not a lab. I care about the ratios.&lt;/p&gt;

&lt;p&gt;Here's the version I'd been writing forever:&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;int&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&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;i&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="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="sc"&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="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// allocates&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;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&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;out&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&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="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;&lt;code&gt;Substring&lt;/code&gt; is the tell. It reads like exactly what I mean, which is why it never gets flagged in review. And for a handful of tokens it's genuinely fine. Here's the bill for 200,000:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[A: Substring + lookup]  sum=6,963,210  median=23.4 ms  allocated=7,812 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven and a half megabytes of keys, none of which outlived a single &lt;code&gt;if&lt;/code&gt;. On a busy endpoint that's 7.5 MB of garbage per call for the collector to sweep up later, and the collector's bill lands on some &lt;em&gt;other&lt;/em&gt; request's latency, which is what makes this kind of thing so annoying to track down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternate lookup
&lt;/h2&gt;

&lt;p&gt;Since .NET 9, &lt;code&gt;Dictionary&amp;lt;TKey, TValue&amp;gt;&lt;/code&gt; can hand you an &lt;em&gt;alternate lookup&lt;/em&gt; keyed by a different, comparer-compatible type. For a string-keyed dictionary the useful one is &lt;code&gt;ReadOnlySpan&amp;lt;char&amp;gt;&lt;/code&gt;. You ask for it once, then look up spans directly — no substring, no allocation:&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;lookup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetAlternateLookup&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReadOnlySpan&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;();&lt;/span&gt;

&lt;span class="n"&gt;ReadOnlySpan&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&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;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&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;i&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="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ReadOnlySpan&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// no alloc&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;lookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&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;out&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&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="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;&lt;code&gt;Slice&lt;/code&gt; doesn't copy anything — it's a window over the original string's characters. The comparer hashes and compares the span against the stored keys without ever building a temporary string. Same dictionary, same entries, just a second door into it. And the numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[B: span alternate lookup] sum=6,963,210  median=14.9 ms  allocated=0 KB
same result: True
allocation ratio A/B: ~200,000x
time ratio A/B: 1.57x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical sum, so I didn't quietly change behavior. Zero bytes allocated for the keys. And it came out about 1.57x faster too, which I honestly didn't expect to be that pronounced. I went in for the allocations and the wall-clock win was a bonus, mostly from skipping 140,000 string constructions and the memory traffic they drag along.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch nobody mentions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GetAlternateLookup&lt;/code&gt; isn't free to reach for. It only works when the dictionary's comparer implements &lt;code&gt;IAlternateEqualityComparer&amp;lt;ReadOnlySpan&amp;lt;char&amp;gt;, string&amp;gt;&lt;/code&gt;. The good news is that the comparers you'd actually pick for machine keys already do: &lt;code&gt;StringComparer.Ordinal&lt;/code&gt;, &lt;code&gt;StringComparer.OrdinalIgnoreCase&lt;/code&gt;, and the default comparer you get from &lt;code&gt;new Dictionary&amp;lt;string, long&amp;gt;()&lt;/code&gt; all qualify. A custom &lt;code&gt;IEqualityComparer&amp;lt;string&amp;gt;&lt;/code&gt; you wrote yourself will not, and you find out with an exception thrown at the &lt;code&gt;GetAlternateLookup&lt;/code&gt; call, not a compile error. So it's a runtime contract, and that's worth a test.&lt;/p&gt;

&lt;p&gt;My honest opinion: this is a hot-path tool, not a default. If you're looking up a handful of keys, or the strings already exist as &lt;code&gt;string&lt;/code&gt; objects, reach for it and you've added ceremony for nothing. Where it earns its keep is exactly the shape above, where you're carving keys &lt;em&gt;out of&lt;/em&gt; a larger buffer (a parser, a tokenizer, a CSV or header scanner, a log processor) and the substring is pure waste because it dies the instant the lookup returns. That's when 7.8 MB quietly turns into zero.&lt;/p&gt;

&lt;p&gt;I've started grepping my own parsers for &lt;code&gt;Substring(&lt;/code&gt; followed by a &lt;code&gt;TryGetValue&lt;/code&gt; on the next line. It's a small pattern, but it shows up more than you'd think once you know its silhouette.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/023-dictionary-alternate-lookup" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/023-dictionary-alternate-lookup&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have you found a spot in your code where the key was already sitting in a buffer you owned? I'd like to hear where it turned up for you.&lt;/p&gt;

&lt;p&gt;— still benchmarking things nobody asked me to&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Hashed Every Word Twice to Count It Once</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Tue, 04 Aug 2026 07:11:08 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/i-hashed-every-word-twice-to-count-it-once-1mg9</link>
      <guid>https://dev.to/ssukhpinder/i-hashed-every-word-twice-to-count-it-once-1mg9</guid>
      <description>&lt;p&gt;I was profiling a log parser last week. Nothing dramatic, it just counts how often each error code shows up in a day of lines. The hot loop is a dictionary bump: see a code, increment its counter. It's the most boring code in the file. It also sat higher in the profiler than I expected, so I went looking, and the reason turned out to be almost funny.&lt;/p&gt;

&lt;p&gt;Here's the loop, more or less how everyone writes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&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;out&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;counts&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="n"&gt;c&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;else&lt;/span&gt;
    &lt;span class="n"&gt;counts&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="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads exactly like what you mean. And it hashes the key twice. &lt;code&gt;TryGetValue&lt;/code&gt; computes the hash, walks the bucket, finds the entry. Then &lt;code&gt;counts[code] = ...&lt;/code&gt; throws all of that away and does the whole thing over to write. Same key, same hash, same bucket walk, twice per token. On the miss path it's the same story: one lookup to fail, another to insert.&lt;/p&gt;

&lt;p&gt;There's a method that skips the second trip. &lt;code&gt;CollectionsMarshal.GetValueRefOrAddDefault&lt;/code&gt; finds or creates the slot once and hands you a &lt;code&gt;ref&lt;/code&gt; pointing straight into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;CollectionsMarshal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetValueRefOrAddDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&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;out&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hash, one bucket walk, then you mutate the storage in place. If the key was missing it gets added as &lt;code&gt;default(int)&lt;/code&gt;, which is 0, so the first &lt;code&gt;slot++&lt;/code&gt; lands it at 1. The &lt;code&gt;out bool&lt;/code&gt; tells you whether the key already existed. I don't need that here, so I throw it away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;I wanted a real number, so I built a small counter over 5 million tokens drawn from a 20,000-word vocabulary, skewed so a handful of words dominate and there's a long rare tail. Roughly what actual text looks like. Both versions run the same input. I assert the two histograms come out identical first, then time each as the median of 11 runs. Allocations come from &lt;code&gt;GC.GetAllocatedBytesForCurrentThread&lt;/code&gt;. Workstation GC, small Linux container. This isn't a lab and I'm not chasing microseconds, I care about the ratio.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokens: 5,000,000, distinct vocab: 20,000

identical results: True  (distinct keys: 20,000)

TryGetValue + indexer (two lookups)        median   160.0 ms   ~   1,914 KB/run
GetValueRefOrAddDefault (one lookup)       median    95.0 ms   ~   1,914 KB/run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 1.7x faster on the loop, and it held every run I did. The two-lookup version bounced between 157 and 170 ms. The ref version parked around 95.&lt;/p&gt;

&lt;p&gt;Now the part I actually like, because it's the honest part. Look at the allocations column. They're identical. Both build the same dictionary, same 20,000 string keys, same backing arrays. &lt;code&gt;GetValueRefOrAddDefault&lt;/code&gt; doesn't save you a single byte. It's not a memory trick. All it removes is CPU: the redundant hash and probe on every one of those 5 million bumps. If your workload is allocation-bound, this changes nothing for you. If it's counting or aggregating in a tight loop, it's most of the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gap is that big
&lt;/h2&gt;

&lt;p&gt;The saving scales with how often you land on a key that already exists. In a Zipf-ish corpus most tokens are repeats, so most iterations take the "found it" path, which is the exact path where the naive version pays for two full lookups. Feed it 5 million unique keys instead and the gap shrinks, because the add path does real work either way. The win is proportional to your update-to-insert ratio, and counters live at the far update-heavy end of that. That's why this one shows off so well and a mostly-insert workload wouldn't.&lt;/p&gt;

&lt;p&gt;There's a sharp edge here, and it's worth saying out loud. The &lt;code&gt;ref&lt;/code&gt; you get back points directly into the dictionary's internal storage, and it stays valid only until the next structural change. Add or remove a key while you're still holding that ref and it can dangle. After a resize you might be writing into the wrong slot entirely. So the rule is simple: grab the ref, mutate it, let it go. Don't stash it, don't hold it across another insert into the same dictionary. For a bump-in-place loop that's how you'd naturally write it anyway, which is exactly why counters fit and a dictionary you're rewriting mid-iteration does not.&lt;/p&gt;

&lt;p&gt;One more caution before you sprinkle this everywhere. The namespace is &lt;code&gt;CollectionsMarshal&lt;/code&gt;. That word is telling you it's the low-level door. For a dictionary you touch a few hundred times, &lt;code&gt;TryGetValue&lt;/code&gt; is clearer and nobody will ever measure the difference. My honest take is that the ref version earns its keep only when the counter loop is genuinely hot, which for me means parsing and aggregation. Everywhere else readability wins and I leave the boring version exactly where it is.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/022-dictionary-ref-upsert" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/022-dictionary-ref-upsert&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's the hottest dictionary loop in your codebase, and have you ever actually counted the lookups it does? I'd be curious whether the ratio holds on real data.&lt;/p&gt;

&lt;p&gt;— still timing loops nobody asked me to time&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Feature Flag That Needed a Restart</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:13:49 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/the-feature-flag-that-needed-a-restart-306g</link>
      <guid>https://dev.to/ssukhpinder/the-feature-flag-that-needed-a-restart-306g</guid>
      <description>&lt;p&gt;I flipped a feature flag in &lt;code&gt;appsettings.json&lt;/code&gt; on a running service, curled the endpoint, and got the old behavior back. Flipped it again. Saved harder. Still off. So I did what apparently was tradition on that team: restarted the app, watched the flag come on, and moved on with my day. For months my mental model was "config reload is flaky". The reload was working the entire time. My code was reading the config through an interface that only ever looks once.&lt;/p&gt;

&lt;h2&gt;
  
  
  One section, four readers
&lt;/h2&gt;

&lt;p&gt;To pin down exactly who notices a config edit, I built the smallest ASP.NET Core app that could answer the question. One &lt;code&gt;Features&lt;/code&gt; section, one options class, four endpoints reading it four different ways — three interfaces plus the trap version I'll get to.&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;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&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;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeatureOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;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;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Features"&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;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExportService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&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="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/options"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeatureOptions&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="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"IOptions"&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;Value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/snapshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOptionsSnapshot&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeatureOptions&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="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"IOptionsSnapshot"&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;Value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/monitor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOptionsMonitor&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeatureOptions&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="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"IOptionsMonitor"&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;CurrentValue&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/frozen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ExportService&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"frozen singleton"&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;.&lt;/span&gt;&lt;span class="n"&gt;Frozen&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;app&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="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOptionsMonitor&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeatureOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OnChange&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;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"[reload] Features changed: ExportEnabled=&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;ExportEnabled&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it, hit all four endpoints, and everyone agrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IOptions           ExportEnabled=False MaxPageSize=50
IOptionsSnapshot   ExportEnabled=False MaxPageSize=50
IOptionsMonitor    ExportEnabled=False MaxPageSize=50
frozen singleton   ExportEnabled=False MaxPageSize=50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, with the app still running, I edited &lt;code&gt;appsettings.json&lt;/code&gt; — &lt;code&gt;ExportEnabled&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;MaxPageSize&lt;/code&gt; to &lt;code&gt;200&lt;/code&gt; — and curled again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IOptions           ExportEnabled=False MaxPageSize=50
IOptionsSnapshot   ExportEnabled=True MaxPageSize=200
IOptionsMonitor    ExportEnabled=True MaxPageSize=200
frozen singleton   ExportEnabled=False MaxPageSize=50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same file, same section, same running process. Two readers saw the change, two never will. That split is the entire topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why IOptions never looks twice
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IOptions&amp;lt;T&amp;gt;&lt;/code&gt; is a singleton that binds the section on first use and caches the result for the lifetime of the process. No change tokens, no invalidation, nothing. It's not broken; it's just answering a different question — "what was the config when this process warmed up?" My flag bug was exactly this. The reload pipeline delivered the new value and &lt;code&gt;IOptions&lt;/code&gt; had no reason to care.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IOptionsSnapshot&amp;lt;T&amp;gt;&lt;/code&gt; is scoped, so it re-binds once per request. &lt;code&gt;IOptionsMonitor&amp;lt;T&amp;gt;&lt;/code&gt; is a singleton that subscribes to configuration change tokens, keeps &lt;code&gt;CurrentValue&lt;/code&gt; up to date, and gives you &lt;code&gt;OnChange&lt;/code&gt;. Both picked up my edit without a single line of plumbing, because &lt;code&gt;WebApplication.CreateBuilder&lt;/code&gt; already registers &lt;code&gt;appsettings.json&lt;/code&gt; with &lt;code&gt;reloadOnChange: true&lt;/code&gt;. The machinery you'd think you need to build is on by default; the only decision left is which interface you inject, and that's the one nobody thinks about.&lt;/p&gt;

&lt;p&gt;One caveat from the trenches: reload depends on file-change notifications actually arriving. In my container a plain in-place edit triggered &lt;code&gt;OnChange&lt;/code&gt; exactly once, which is the polite case — editors that save via rename-and-replace can make it fire twice, and on some mounted or network file systems events never arrive and you need &lt;code&gt;DOTNET_USE_POLLING_FILE_WATCHER=1&lt;/code&gt;. Kubernetes ConfigMap symlink swaps are their own adventure. Test the notification path in your real environment before you bet a prod flag flip on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version that actually got me
&lt;/h2&gt;

&lt;p&gt;Here's the part I find sneaky. You can lose the reload while holding the correct interface:&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExportService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOptionsMonitor&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeatureOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Right interface, wrong moment: CurrentValue read once, kept forever.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;FeatureOptions&lt;/span&gt; &lt;span class="n"&gt;Frozen&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="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentValue&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;That's the &lt;code&gt;/frozen&lt;/code&gt; endpoint above, stale forever, with &lt;code&gt;IOptionsMonitor&lt;/code&gt; sitting right there in the constructor. Reading &lt;code&gt;CurrentValue&lt;/code&gt; at construction time turns a live monitor back into &lt;code&gt;IOptions&lt;/code&gt; with extra steps. The fix is boring: keep the monitor in the field and read &lt;code&gt;CurrentValue&lt;/code&gt; where you use it, or subscribe with &lt;code&gt;OnChange&lt;/code&gt; if you need to react. Code review barely catches this one because the injection looks textbook.&lt;/p&gt;

&lt;h2&gt;
  
  
  What snapshot's freshness costs
&lt;/h2&gt;

&lt;p&gt;Snapshot re-binds every request, and binding is reflection over your options type. I wanted a number for that, so the sample has a &lt;code&gt;/bench&lt;/code&gt; endpoint: 100,000 iterations each of creating an empty scope, creating a scope and resolving a snapshot, and reading &lt;code&gt;monitor.CurrentValue&lt;/code&gt;. This is a container, best of three runs, and I care about ratios, not absolutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100,000 iterations:
  empty scope               19.0 ms
  scope + snapshot bind    715.7 ms
  monitor.CurrentValue       2.1 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a scope-plus-bind lands around 7 µs while a &lt;code&gt;CurrentValue&lt;/code&gt; read is around 20 ns — roughly 300× apart, and the empty-scope row shows it's the binding, not the scope, doing the damage. Honest reading: 7 µs per request for one options type will never show up on your dashboard. Multiply by a dozen options classes injected across every request and it's still small. Pick by lifetime semantics, not this benchmark.&lt;/p&gt;

&lt;p&gt;My actual rule, stated as the opinion it is: &lt;code&gt;IOptionsMonitor&lt;/code&gt; in singletons and pretty much everywhere else too, &lt;code&gt;IOptionsSnapshot&lt;/code&gt; only when a request must see one consistent value from the first middleware to the last log line mid-reload, and plain &lt;code&gt;IOptions&lt;/code&gt; when the value is genuinely fixed at boot. And if your config only ever changes at deploy time — env vars, immutable images, the works — skip the whole question. Env var changes don't hot-reload anyway, and &lt;code&gt;IOptions&lt;/code&gt; everywhere is the cheapest and most honest description of how your system behaves.&lt;/p&gt;

&lt;p&gt;Full runnable sample, live-edit demo included: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/021-options-reload-lifetimes" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/021-options-reload-lifetimes&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's your house rule — snapshot everywhere, monitor everywhere, or restart-and-pretend? And has per-request consistency ever actually saved you mid-reload? Tell me in the comments, because I've never caught it in the act.&lt;/p&gt;

&lt;p&gt;— still flipping flags nobody asked me to flip&lt;/p&gt;

</description>
      <category>aspnetcore</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Outage, Four Times the Traffic</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Mon, 03 Aug 2026 07:15:21 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/one-outage-four-times-the-traffic-5374</link>
      <guid>https://dev.to/ssukhpinder/one-outage-four-times-the-traffic-5374</guid>
      <description>&lt;p&gt;The graph that kicked this off: a downstream inventory service wobbled for about two seconds, and our outbound request count to it quadrupled in exactly that window. No user spike. No deploy. The extra traffic was us — my own retry loop, faithfully doing what I told it to do years ago: "try it four times, it's probably transient."&lt;/p&gt;

&lt;p&gt;So I rebuilt the incident in miniature. One self-hosted endpoint that returns 503 while a fake outage is active, twenty concurrent callers, and a stopwatch. One process on localhost in a Linux container, .NET 10, delays shrunk so the whole thing fits in seconds — not a lab, I care about the shapes, not the milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop I used to write
&lt;/h2&gt;

&lt;p&gt;You've seen this loop. Odds are you've written this loop. I've written it more times than I'll admit in public:&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resp&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;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/inventory"&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccessStatusCode&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;true&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;attempt&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;4&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;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// no delay — "it's probably transient"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty concurrent calls, two-second outage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== phase 1: hand-rolled retry x4, no delay (outage 2s) ==
client calls :   20    succeeded:   0    failed: 20
server hits  :   80    during outage: 80    after recovery: 0
hit window   : first at      4 ms, last at     33 ms
wall time    : 0.03 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that hit window again. All four attempts, for all twenty callers, landed within 29 milliseconds. The outage lasted two seconds. So my loop generated four times the traffic, aimed every single request at a service that was down, and bought exactly zero extra successes.&lt;/p&gt;

&lt;p&gt;That's the quiet flaw in naive retries: they finish before the problem does. A dependency that's down for two seconds might as well be down forever if your entire retry budget burns in 30 milliseconds. And you pay twice — once as wasted work on your side, once as extra load on the exact service that's trying to stand back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same budget, actually spread out
&lt;/h2&gt;

&lt;p&gt;.NET ships a proper answer as a NuGet package: &lt;code&gt;Microsoft.Extensions.Http.Resilience&lt;/code&gt;, built on Polly v8. One line on the client registration:&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;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"backoff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&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;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://127.0.0.1:5199"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddStandardResilienceHandler&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="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;Retry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// production default is 2s; shrunk for the demo&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call site collapses to a single &lt;code&gt;await&lt;/code&gt; — the handler owns the retries (three by default), exponential backoff with jitter, per-attempt and total timeouts, and a circuit breaker I'll get to in a minute.&lt;/p&gt;

&lt;p&gt;Same twenty calls, same two-second outage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== phase 2: standard handler, exponential backoff (base 500ms, outage 2s) ==
client calls :   20    succeeded:  13    failed: 7
server hits  :   80    during outage: 67    after recovery: 13
hit window   : first at      1 ms, last at   2741 ms
wall time    : 2.74 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the part I find genuinely elegant: the request budget is identical. Eighty hits, same as the naive loop. The only thing that changed is where those hits landed in time — spread across 2.7 seconds instead of crammed into 29 milliseconds — and that alone pushed 13 of 20 calls through, because their later attempts outlived the outage. Jitter wobbles the exact split; a back-to-back run gave me 12 of 20, and seven callers still spent their last attempt inside the window. Retries don't help because you try harder. They help when an attempt lands after recovery. Backoff is what buys you that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop knocking on a dead door
&lt;/h2&gt;

&lt;p&gt;Backoff handles the two-second blip. But if the outage runs long, even polite retries are pure cost — every attempt burns your threads and their recovery capacity. That's the circuit breaker's job. The standard handler has one on by default; I tuned it down so 40 demo calls can trip it (the default &lt;code&gt;MinimumThroughput&lt;/code&gt; of 100 is sized for real traffic, and rightly so):&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="nf"&gt;AddStandardResilienceHandler&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="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;Retry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&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;AttemptTimeout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&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="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FailureRatio&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0.5&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;CircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinimumThroughput&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;20&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;CircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SamplingDuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BreakDuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty calls into a six-second outage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== phase 3: standard handler + circuit breaker (longer outage: 6s) ==
client calls :   40    succeeded:   0    failed: 40
server hits  :   40    during outage: 40    after recovery: 0
hit window   : first at      6 ms, last at     18 ms
wall time    : 0.29 s
failures     : BrokenCircuit (failed fast) x40

probe after recovery : 200 OK — circuit closed itself, no restarts, no config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive loop would have thrown 160 requests at that outage. The breaker allowed 40 — the first attempts — saw the failure ratio, opened, and every pending retry failed instantly with &lt;code&gt;BrokenCircuitException&lt;/code&gt; instead of queuing up for another knock. Everything resolved in 0.29 seconds. Nothing piled up. The dependency got silence to recover in. And once the break duration passed, a single probe went through half-open, got its 200, and the circuit closed itself.&lt;/p&gt;

&lt;p&gt;My opinion, stated as one: a hand-rolled retry loop in a PR should trigger the same reflex as hand-rolled JSON parsing. Not because the loop is hard to write, but because the loop is easy to write badly in ways that only show up during an incident — which is the one time you really don't want surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd hold back
&lt;/h2&gt;

&lt;p&gt;Retrying a GET is safe. Retrying a POST that charges a card is not "probably transient", it's "possibly twice" — think before wrapping non-idempotent calls, and lean on idempotency keys if you must. Don't copy my demo thresholds to production either; I shrank them so you can watch the breaker trip on localhost, and the real defaults are conservative on purpose. And an open circuit doesn't make failure go away — it makes failure fast. You still owe callers a fallback: a cached value, a sensible default, or an honest 503 of your own. If you don't have one, the breaker just relocates your problem.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/020-httpclient-retry-storm" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/020-httpclient-retry-storm&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's living in your codebase right now — the loop or the handler? If you've survived an actual retry storm (or caused one, no judgment), I'd like to hear how you found it.&lt;/p&gt;

&lt;p&gt;— Sukhpinder, still staging outages nobody asked me to&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Loaded 8,956 Rows to Flip One Boolean</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Sun, 02 Aug 2026 08:13:03 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/i-loaded-8956-rows-to-flip-one-boolean-596e</link>
      <guid>https://dev.to/ssukhpinder/i-loaded-8956-rows-to-flip-one-boolean-596e</guid>
      <description>&lt;p&gt;Every system I've worked on has a nightly job shaped like this: archive delivered orders older than 90 days. Mine was four lines of EF Core that had survived a dozen code reviews, several of them mine. Then I ran it with SQL logging switched on and watched it haul 8,956 complete rows out of the database so it could write 8,956 booleans back. Nobody read those rows. They existed for the length of a &lt;code&gt;foreach&lt;/code&gt; and died.&lt;/p&gt;

&lt;p&gt;I wanted the actual bill, not a feeling, so I measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit
&lt;/h2&gt;

&lt;p&gt;The demo table is 20,000 orders in SQLite, seeded with &lt;code&gt;Random(42)&lt;/code&gt; so every run produces the same data. 8,956 of them match the archive predicate. A &lt;code&gt;DbCommandInterceptor&lt;/code&gt; counts every command EF Core sends, timings are the median of 5 rounds after a warmup, and allocations come from &lt;code&gt;GC.GetAllocatedBytesForCurrentThread&lt;/code&gt;. EF Core 10, Release build, small Linux container, in-process SQLite. Not a lab — I care about the ratios.&lt;/p&gt;

&lt;p&gt;Here's the version of the job I'd been writing since EF Core 1:&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;stale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&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;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delivered&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;PlacedAtUtc&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoffUtc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&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;order&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;)&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;IsArchived&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChanges&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads like exactly what you mean, which is why it survives code review. And for five rows it's fine. Here's what it costs for 8,956:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[A: load entities + SaveChanges]
  rows touched   : 8,956
  SQL commands   : 8,957
  median time    : 355.3 ms
  allocated      : 75,143 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command count isn't a typo. One SELECT to fetch the rows, then one UPDATE per order — the SQLite provider ships each as its own command. SQL Server would batch them into multi-statement round trips, which helps, but EF still generates, ships, and executes one UPDATE statement per row. &lt;code&gt;WHERE "Id" = @p0&lt;/code&gt;, 8,956 times.&lt;/p&gt;

&lt;p&gt;The 75 MB is the part that surprised me more. Materializing 8,956 entities doesn't cost that much on its own. Change tracking does: EF snapshots every property of every tracked entity so &lt;code&gt;SaveChanges&lt;/code&gt; can diff them later. Two full copies of the table's matching slice, held in memory, to change one column whose new value I already knew before the query ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-statement version
&lt;/h2&gt;

&lt;p&gt;EF Core has had a set-based answer since EF 7, and I'd been treating it as an exotic tool instead of the obvious one:&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;archived&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&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;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delivered&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;PlacedAtUtc&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoffUtc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdate&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;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;IsArchived&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[B: ExecuteUpdate]
  rows touched   : 8,956
  SQL commands   : 1
  median time    : 10.6 ms
  allocated      : 68 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same predicate, same 8,956 rows changed, one command. This is the SQL the interceptor caught:&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;UPDATE&lt;/span&gt; &lt;span class="nv"&gt;"Orders"&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"o"&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="nv"&gt;"IsArchived"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"o"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Status"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"o"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"PlacedAtUtc"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;cutoffUtc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;33x faster and roughly 1,100x fewer allocations, and remember my numbers have no network in them — SQLite is in-process. On a real connection, strategy A pays latency per round trip while strategy B still pays it once. The gap you'd see in production is wider than mine, not narrower.&lt;/p&gt;

&lt;p&gt;There's no magic here, and that's sort of the point. The database could always do this in one statement. The load-modify-save pattern was me routing a one-line UPDATE through a full object graph because that's the shape the framework made comfortable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it bites
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ExecuteUpdate&lt;/code&gt; goes around the change tracker entirely. That's the whole trick, and it's also the sharp edge:&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;tracked&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&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;First&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;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delivered&lt;/span&gt;
                                 &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;PlacedAtUtc&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoffUtc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;ctx&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;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delivered&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;PlacedAtUtc&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoffUtc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdate&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;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;IsArchived&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== the stale-tracker gotcha ==
  tracked entity says IsArchived = False
  database says       IsArchived = True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tracked entity never hears about the update. If code later in that request reads &lt;code&gt;tracked.IsArchived&lt;/code&gt;, it gets a confident wrong answer, and if &lt;code&gt;SaveChanges&lt;/code&gt; runs afterwards for an unrelated edit, nothing corrects it. Mixing tracked work and set-based updates on the same data in the same context is where the bugs live.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I still load the entities
&lt;/h2&gt;

&lt;p&gt;There are real reasons to keep the old pattern, and they're worth naming rather than waving at. If flipping the flag triggers per-entity behavior — domain events, an audit interceptor on &lt;code&gt;SaveChanges&lt;/code&gt;, business rules that can veto individual rows — &lt;code&gt;ExecuteUpdate&lt;/code&gt; skips all of it. It ignores optimistic concurrency tokens too: last write wins, silently. And each &lt;code&gt;ExecuteUpdate&lt;/code&gt; runs as its own implicit transaction, so if the job needs to be atomic with other changes, you're wrapping it in an explicit one yourself.&lt;/p&gt;

&lt;p&gt;My rule after this experiment, stated as the opinion it is: load-modify-save is for entities with behavior, and it earns its cost a handful of rows at a time. Any query that ends in "load them all, set one property, save" is a maintenance job, and maintenance jobs speak SQL. Let them.&lt;/p&gt;

&lt;p&gt;The delete side is the same story with a shorter name. Purging old cancelled orders went from a fetch-and-remove loop to:&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;purged&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&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;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cancelled&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;PlacedAtUtc&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;purgeCutoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2,086 rows gone in 23.7 ms, one command, nothing materialized.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/019-efcore-execute-update" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/019-efcore-execute-update&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Got a nightly job that fetches everything it's about to overwrite? Run it with an interceptor counting commands and tell me your number in the comments — I'll admit mine was embarrassing for years.&lt;/p&gt;

&lt;p&gt;— still benchmarking things nobody asked me to, 8,956 statements at a time&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Factory Class I Finally Deleted</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Sun, 02 Aug 2026 07:14:50 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/the-factory-class-i-finally-deleted-3oap</link>
      <guid>https://dev.to/ssukhpinder/the-factory-class-i-finally-deleted-3oap</guid>
      <description>&lt;p&gt;The PR added a WhatsApp notification channel. Three changes: a new &lt;code&gt;WhatsAppSender&lt;/code&gt; class, fair. Its registration, fair. And &lt;code&gt;NotificationSenderFactory&lt;/code&gt;, where a switch statement quietly grew its fourth arm. I've been approving some version of that third change since 2016 — different codebases, same class, same switch. Keyed services made it deletable back in .NET 8, and this week, on .NET 10, I finally sat down and deleted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The class in question
&lt;/h2&gt;

&lt;p&gt;You've written this class. Maybe you called it a factory, maybe a resolver, maybe a provider. One interface, a few implementations, and a string deciding who does the work:&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationSenderFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IServiceProvider&lt;/span&gt; &lt;span class="n"&gt;sp&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="n"&gt;INotificationSender&lt;/span&gt; &lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmailSender&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sms&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SmsSender&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Push&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PushSender&lt;/span&gt;&lt;span class="p"&gt;&amp;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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Unknown channel '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;channel&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;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&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;There are only two ways to write this class and both are a little embarrassing. Either the factory news up the senders itself, which makes every constructor dependency the senders have the factory's problem too. Or it wraps &lt;code&gt;IServiceProvider&lt;/code&gt; like mine does, which is the service locator pattern wearing a name tag that says "factory". And the registration tax rides along: every concrete type registered so the factory can pull it back out, plus the factory itself.&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="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmailSender&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SmsSender&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PushSender&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NotificationSenderFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four registrations, one extra class, zero business value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three lines instead
&lt;/h2&gt;

&lt;p&gt;Keyed services collapse the whole arrangement into registrations that carry the key themselves. (Autofac folks, I know, you've had named services since forever. The rest of us stayed on the built-in container and waited.)&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="n"&gt;AddKeyedScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;INotificationSender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EmailSender&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&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;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddKeyedScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;INotificationSender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SmsSender&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sms&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;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddKeyedScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;INotificationSender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PushSender&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Push&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consumption has two flavors. When the call site already knows which implementation it wants, an attribute says so. When the key only exists at runtime — a route value, a message header — you ask by key:&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;// the endpoint declares which implementation it wants&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/notify/email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;FromKeyedServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;INotificationSender&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&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;channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"build is green"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// or the route decides at runtime&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/notify/{channel}"&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="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IServiceProvider&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&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;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredKeyedService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;INotificationSender&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"build is green"&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;Yes, the runtime-key endpoint still touches &lt;code&gt;IServiceProvider&lt;/code&gt;. When the key arrives at runtime, somebody has to do a lookup. The difference is that the mapping now lives in the registrations instead of a second class that can drift away from them.&lt;/p&gt;

&lt;p&gt;My demo app probes itself with &lt;code&gt;HttpClient&lt;/code&gt; (.NET 10, small Linux container — this is behavior, not benchmarks) and the exchanges are exactly what you'd hope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== keyed services ==
POST /notify/email      -&amp;gt; 200 {"channel":"email","result":"email queued: \"build is green\""}
POST /notify/push       -&amp;gt; 200 {"channel":"push","result":"push queued: \"build is green\""}
POST /digest            -&amp;gt; 200 {"result":"email queued: \"your daily digest\""}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;/digest&lt;/code&gt; line matters more than it looks. Behind it sits a plain class taking one specific keyed implementation through its constructor. No factory, no locator, and the class states its actual dependency instead of hiding it behind a resolver:&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DailyDigestService&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;FromKeyedServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;INotificationSender&lt;/span&gt; &lt;span class="n"&gt;email&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="nf"&gt;SendDigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"your daily digest"&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 part that bit me
&lt;/h2&gt;

&lt;p&gt;I added a diagnostics block to the demo because two behaviors surprised me the first time I hit them. Real output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== what the container actually sees ==
GetServices&amp;lt;INotificationSender&amp;gt;()          -&amp;gt; 0 implementations
GetKeyedServices(KeyedService.AnyKey)       -&amp;gt; EmailSender, SmsSender, PushSender
GetRequiredKeyedService("fax")              -&amp;gt; InvalidOperationException: No keyed service for type 'INotificationSender' using key type 'System.String' has been registered.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First: keyed registrations are invisible to plain enumeration. Inject &lt;code&gt;IEnumerable&amp;lt;INotificationSender&amp;gt;&lt;/code&gt; for some broadcast-to-every-channel feature and you get an empty sequence. No exception, no warning, just a loop that iterates nothing. &lt;code&gt;GetKeyedServices&amp;lt;T&amp;gt;(KeyedService.AnyKey)&lt;/code&gt; is the escape hatch that actually sees them all.&lt;/p&gt;

&lt;p&gt;Second, read that exception message closely. It names the service type and the key's &lt;em&gt;type&lt;/em&gt;. It does not name the key. "fax" appears nowhere in it. My old factory's default arm was my code, so the message included the offending value. Now I log the key at the call site before resolving, because "using key type 'System.String'" narrows the suspect list down to every string in the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the factory stays
&lt;/h2&gt;

&lt;p&gt;Keyed services map a key to a registration, and that's the entire trick. If your factory contains actual logic — construction that depends on config, parameters passed at creation time, pooling — it's earning its keep. Leave it alone. Same if you have one implementation chosen once at startup by environment: register the right one conditionally and skip keys entirely.&lt;/p&gt;

&lt;p&gt;And it's still runtime resolution. A typo'd key compiles clean and fails as a 500 in production, exactly like the factory's default arm did. My rule now, stated as opinion: keys live in a consts class (&lt;code&gt;Channels.Email&lt;/code&gt;, never &lt;code&gt;"email"&lt;/code&gt; sprinkled around), and one test walks every routable key and resolves it. Costs three minutes to write, catches the dumb thing forever.&lt;/p&gt;

&lt;p&gt;The switch statement was never evil. It just lived in a class whose only job was to exist between my endpoints and my registrations, and the built-in container has been willing to do that job since .NET 8. Deleting a whole class in a PR feels better than adding one. Every time.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/018-keyed-di-services" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/018-keyed-di-services&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's the ceremony class you keep rewriting in every codebase? I'll go first, obviously — factories. Tell me yours in the comments.&lt;/p&gt;

&lt;p&gt;— still deleting classes nobody asked me to&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Shell Scripts Speak C# Now</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:22:49 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/my-shell-scripts-speak-c-now-hka</link>
      <guid>https://dev.to/ssukhpinder/my-shell-scripts-speak-c-now-hka</guid>
      <description>&lt;p&gt;Every couple of weeks I need a twenty-line program. Find what's bloating a build agent's disk, dedupe a CSV, hash-check a folder. For fifteen years the honest answer to "which language?" was &lt;em&gt;not C#&lt;/em&gt; — by the time I'd done &lt;code&gt;mkdir&lt;/code&gt;, &lt;code&gt;dotnet new console&lt;/code&gt;, and named yet another throwaway csproj, the moment had passed. So those little jobs went to bash or Python, and I grumbled quietly every time.&lt;/p&gt;

&lt;p&gt;.NET 10 removed the ritual. You write one &lt;code&gt;.cs&lt;/code&gt; file and run it. I'd been meaning to check how well this actually holds up for real scripts, so this week I did — nothing fancy, one Linux container and a stopwatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file, no project
&lt;/h2&gt;

&lt;p&gt;Here's &lt;code&gt;biggest.cs&lt;/code&gt;, a small utility that lists the largest files under a directory. The whole program is this one file — no csproj anywhere:&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="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;!/&lt;/span&gt;&lt;span class="n"&gt;usr&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="n"&gt;dotnet&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;package&lt;/span&gt; &lt;span class="n"&gt;Humanizer&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="m"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Humanizer&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;root&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;args&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="s"&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;top&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&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;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;files&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;DirectoryInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnumerateFiles&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;new&lt;/span&gt; &lt;span class="n"&gt;EnumerationOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;RecurseSubdirectories&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;IgnoreInaccessible&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;AttributesToSkip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FileAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReparsePoint&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&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;f&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;files&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;size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Humanize&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&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="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastWriteTimeUtc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Humanize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&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;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FullName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  (modified &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ago)"&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 lines are new. &lt;code&gt;#:package Humanizer@3.0.10&lt;/code&gt; is a NuGet reference written as a directive, right in the source. The shebang we'll get to in a minute. Everything else is the C# you already write, top-level statements and all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dotnet run biggest.cs -- ~/.dotnet 5

Top 5 files under /root/.dotnet:

   37.6 MB  .../FSharp.Compiler.Service.dll  (modified 46 seconds ago)
   18.7 MB  .../Microsoft.CodeAnalysis.CSharp.dll  (modified 46 seconds ago)
   18.7 MB  .../Roslyn/bincore/Microsoft.CodeAnalysis.CSharp.dll  (modified 45 seconds ago)
   14.9 MB  .../System.Private.CoreLib.dll  (modified 45 seconds ago)
   11.4 MB  .../native/singlefilehost  (modified 47 seconds ago)

Total: 101.1 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, the .NET SDK's biggest file is the F# compiler. I enjoyed that more than I should have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stopwatch part
&lt;/h2&gt;

&lt;p&gt;The obvious worry: is this Python-fast to iterate on, or does every run pay the compiler tax? I timed it on SDK 10.0.302 in a Linux container — not a lab, I care about the ratios, and warm numbers are best of three.&lt;/p&gt;

&lt;p&gt;The first ever run took 10.5 seconds. That's the worst case by design: it restored Humanizer, compiled, and even sat through the CLI's first-run welcome banner. After that, an unchanged script runs in about 0.3 seconds, and rerunning right after an edit lands just under half a second. Half a second from save to output is firmly in scripting territory. I stopped noticing the compiler was there, which is the whole point.&lt;/p&gt;

&lt;p&gt;The other thing I checked: my folder still contains exactly one file. No &lt;code&gt;bin/&lt;/code&gt;, no &lt;code&gt;obj/&lt;/code&gt;. The build products get squirreled away under &lt;code&gt;~/.local/share/dotnet/runfile/&lt;/code&gt;, one content-hashed folder per script, so the place where your script lives stays as clean as a bash script's folder would. &lt;code&gt;dotnet biggest.cs&lt;/code&gt; works too, if typing &lt;code&gt;run&lt;/code&gt; offends you.&lt;/p&gt;

&lt;h2&gt;
  
  
  chmod +x, because why not
&lt;/h2&gt;

&lt;p&gt;That shebang line isn't decoration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ chmod +x biggest.cs
$ ./biggest.cs /tmp 3

   22.3 MB  /tmp/phantomjs/phantomjs-2.1.1-linux-x86_64.tar.bz2  (modified 12 weeks ago)
   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A C# file behaving like a first-class shell script, cron-able and CI-able. My opinion, stated as such: for anything past one pipe and a grep, I'd now rather have this than the pile of bash I used to babysit. String handling in bash is where my weekends go to die, and every one of those scripts is one &lt;code&gt;#:package&lt;/code&gt; away from a real JSON parser or a real HTTP client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the party ends
&lt;/h2&gt;

&lt;p&gt;It's genuinely single-file, at least on the SDK I tested. I dropped a &lt;code&gt;Helper&lt;/code&gt; class into a &lt;code&gt;util.cs&lt;/code&gt; next to my script and got &lt;code&gt;error CS0103: The name 'Helper' does not exist in the current context&lt;/code&gt;. Running a file compiles that file, full stop.&lt;/p&gt;

&lt;p&gt;I've decided I like the bluntness, because wanting a second file is exactly the signal that your script stopped being a script. The graduation path is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dotnet project convert biggest.cs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That generates a &lt;code&gt;biggest/&lt;/code&gt; folder with the source and a proper csproj, shebang and directives stripped, the &lt;code&gt;#:package&lt;/code&gt; line turned into a real &lt;code&gt;PackageReference&lt;/code&gt;. Funny detail: the generated project came with &lt;code&gt;PublishAot&lt;/code&gt; and &lt;code&gt;PackAsTool&lt;/code&gt; already set to true — the SDK has strong opinions about what your script wants to grow into.&lt;/p&gt;

&lt;p&gt;When not to use this: anything a teammate maintains with you, anything that needs tests, anything with more than one file's worth of ideas. And remember that cold start — 10 seconds of restore on first run means ephemeral CI containers pay the tax every time unless you cache the NuGet folder.&lt;/p&gt;

&lt;p&gt;But for the twenty-line jobs? I've stopped reaching for Python. Ten seconds once, a third of a second forever after, and it's the language I actually think in.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/016-file-based-csharp" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/016-file-based-csharp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's the smallest job you've ever created an entire solution for? Mine involved three lines and a &lt;code&gt;.sln&lt;/code&gt; file I'm still ashamed of. Tell me yours in the comments.&lt;/p&gt;

&lt;p&gt;— still timing things nobody asked me to time&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Upgrade .NET 8 to .NET 10 Without Breaking Your API Contract</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Sat, 01 Aug 2026 03:04:43 +0000</pubDate>
      <link>https://dev.to/csharp-programming/upgrade-net-8-to-net-10-without-breaking-your-api-contract-51h</link>
      <guid>https://dev.to/csharp-programming/upgrade-net-8-to-net-10-without-breaking-your-api-contract-51h</guid>
      <description>&lt;p&gt;If I need to &lt;strong&gt;upgrade .NET 8 to .NET 10&lt;/strong&gt;, I treat the work as an API contract migration, not a project-file edit. A service can compile, pass unit tests, and still surprise consumers with a changed JSON shape, status code, authentication response, or OpenAPI document.&lt;/p&gt;

&lt;p&gt;That risk matters now because Microsoft has confirmed that &lt;a href="https://devblogs.microsoft.com/dotnet/dotnet-8-9-end-of-support/" rel="noopener noreferrer"&gt;.NET 8 and .NET 9 reach end of support on November 10, 2026&lt;/a&gt;. .NET 10 and C# 14 are the current stable releases, and .NET 10 is the supported LTS destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the deadline changes my upgrade order
&lt;/h2&gt;

&lt;p&gt;My first step is inventory, not retargeting. I list every deployable project, test project, &lt;code&gt;global.json&lt;/code&gt;, container base image, CI SDK pin, and Microsoft package reference. &lt;code&gt;dotnet --list-sdks&lt;/code&gt; shows what a machine can build; &lt;code&gt;dotnet --info&lt;/code&gt; shows what the current environment actually resolves.&lt;/p&gt;

&lt;p&gt;If that inventory needs more detail, my older guide to &lt;a href="https://dev.to/ssukhpinder/tracking-net-sdk-and-runtime-versions-with-dotnet-sdk-check-3f25"&gt;&lt;code&gt;dotnet sdk check&lt;/code&gt;&lt;/a&gt; is a useful starting point. For APIs still on .NET 8, the broader &lt;a href="https://dev.to/ssukhpinder/mastering-net-8-web-api-from-setup-to-security-50-tips-guide-for-developers-n40"&gt;Web API setup and security checklist&lt;/a&gt; can help identify behavior worth protecting before the move.&lt;/p&gt;

&lt;p&gt;I then separate the migration into three changes: SDK and target framework, NuGet dependencies, and runtime infrastructure. Keeping those changes visible makes a failure easier to locate. A giant dependency-refresh commit may be quick to create, but it is hard to diagnose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrade .NET 8 to .NET 10 behind contract tests
&lt;/h2&gt;

&lt;p&gt;Before changing &lt;code&gt;net8.0&lt;/code&gt;, I add a small set of tests around the endpoints consumers cannot tolerate changing. I care about observable behavior: status codes, content types, required JSON names, and authentication boundaries. I avoid asserting an entire serialized string because harmless property ordering can make that test noisy.&lt;/p&gt;

&lt;p&gt;Here is a focused xUnit test for a Minimal API:&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;using&lt;/span&gt; &lt;span class="nn"&gt;System.Net&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;System.Text.Json&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;Microsoft.AspNetCore.Mvc.Testing&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;Xunit&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductContractTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IClassFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;GetProduct_keeps_the_public_contract&lt;/span&gt;&lt;span class="p"&gt;()&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;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&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="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;BaseAddress&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;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://localhost"&lt;/span&gt;&lt;span class="p"&gt;)&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;response&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/products/42"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;MediaType&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;stream&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAsStreamAsync&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;json&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;JsonDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ParseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RootElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&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="nf"&gt;GetInt32&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RootElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;_&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;The test project references &lt;code&gt;Microsoft.AspNetCore.Mvc.Testing&lt;/code&gt; in the same 10.0 servicing line as the application. This one test is not a complete compatibility suite. It is a template for the small number of contracts that matter most: a successful read, a validation failure, an unauthorized request, and a not-found response. Those checks catch behavioral changes that a unit test below the HTTP pipeline cannot see.&lt;/p&gt;

&lt;p&gt;For a top-level Minimal API, I also expose the entry point to the test project:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&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;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Program&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;Microsoft maintains a complete &lt;a href="https://github.com/dotnet/AspNetCore.Docs.Samples/tree/main/test/integration-tests/10.x/IntegrationTestsSample" rel="noopener noreferrer"&gt;.NET 10 &lt;code&gt;WebApplicationFactory&lt;/code&gt; sample&lt;/a&gt;. I use that as the reference for test-host setup rather than creating custom server plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move the runtime, packages, and pipeline together
&lt;/h2&gt;

&lt;p&gt;With the baseline green, I change the application and test projects to &lt;code&gt;net10.0&lt;/code&gt;. I update Microsoft.AspNetCore, Microsoft.Extensions, and EF Core packages to compatible 10.0 servicing releases, then inspect the official &lt;a href="https://learn.microsoft.com/en-us/dotnet/core/compatibility/10" rel="noopener noreferrer"&gt;.NET 10 breaking-change catalog&lt;/a&gt; instead of guessing from compiler errors alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;TargetFramework&amp;gt;&lt;/span&gt;net10.0&lt;span class="nt"&gt;&amp;lt;/TargetFramework&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run the same short sequence locally and in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet package list --outdated
dotnet restore
dotnet build --warnaserror
dotnet test
dotnet publish -c Release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline must install a .NET 10 SDK, and containerized services must use matching 10.0 build and runtime images. Updating only the developer machine proves very little about the artifact that reaches production.&lt;/p&gt;

&lt;p&gt;OpenAPI deserves an explicit decision. ASP.NET Core 10's built-in generator emits OpenAPI 3.1 by default. If an existing client generator only understands 3.0, I temporarily pin the format and schedule that contract change separately:&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;AddOpenApi&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenApiVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;Microsoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenApiSpecVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenApi3_0&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;That pin is a compatibility tool, not a reason to avoid OpenAPI 3.1 forever. I keep it only until consumers have been tested and upgraded.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would not use this direct path
&lt;/h2&gt;

&lt;p&gt;A direct retarget is the wrong move when a database provider, authentication component, hosting platform, or commercial dependency does not support .NET 10. In that case, I isolate the blocker first. Moving through .NET 9 can help expose changes in smaller steps, but .NET 9 has the same November 2026 support deadline, so it is not the final destination.&lt;/p&gt;

&lt;p&gt;I also avoid combining the runtime upgrade with an EF model redesign, authentication rewrite, or OpenAPI generator replacement. Those may all be worthwhile, but separate commits and deployments preserve a useful rollback boundary.&lt;/p&gt;

&lt;p&gt;For shared libraries, temporary &lt;code&gt;net8.0;net10.0&lt;/code&gt; multi-targeting can let applications migrate independently. For a deployable API, multi-targeting is not a substitute for choosing and validating the runtime that production will execute.&lt;/p&gt;

&lt;p&gt;Which API contract would you pin down before moving your service to .NET 10?&lt;/p&gt;

&lt;p&gt;Thanks for reading ? see you next time.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Test That Slept and the Test That Never Woke Up</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Thu, 30 Jul 2026 08:24:37 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/the-test-that-slept-and-the-test-that-never-woke-up-1i6j</link>
      <guid>https://dev.to/ssukhpinder/the-test-that-slept-and-the-test-that-never-woke-up-1i6j</guid>
      <description>&lt;p&gt;My retry tests took seven seconds each. Not because they did seven seconds of work — because they did seven seconds of &lt;em&gt;nothing&lt;/em&gt;, waiting out real backoff delays with a real clock. So I did the textbook thing: injected &lt;code&gt;TimeProvider&lt;/code&gt;, swapped in &lt;code&gt;FakeTimeProvider&lt;/code&gt;, felt smug for about ninety seconds. Then my shiny new fast test didn't come back slow. It didn't come back at all.&lt;/p&gt;

&lt;p&gt;This is the story of both tests: the one that slept, and the one that never woke up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven seconds of nothing
&lt;/h2&gt;

&lt;p&gt;The code under test is a bog-standard exponential backoff helper. Three transient failures, then success, with 1s, 2s and 4s waits in between. The honest way to test it against the system clock looks like this:&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;retry&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;TransientRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;calls&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(()&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;calls&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;4&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;TimeoutException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flaky dependency"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attempts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// 1s + 2s + 4s of genuine wall-clock waiting:&lt;/span&gt;
&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Elapsed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Passed Succeeds_after_three_transient_failures_real_clock [7 s]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven seconds, every run, forever. The test is correct and I resent it. Multiply by a give-up-path test, a jitter test, a cancellation test, and one small retry class quietly bills your CI half a minute per run. That's the sleep tax, and most suites pay it in a dozen places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Owning the clock
&lt;/h2&gt;

&lt;p&gt;Since .NET 8 the fix has been in the box. &lt;code&gt;TimeProvider&lt;/code&gt; is the abstraction, and the only change my retry helper needed was accepting one and handing it to &lt;code&gt;Task.Delay&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TransientRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeProvider&lt;/span&gt; &lt;span class="n"&gt;clock&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;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;baseDelay&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;int&lt;/span&gt; &lt;span class="n"&gt;Attempts&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;private&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="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&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;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&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;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;operation&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="n"&gt;delay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseDelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;while&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;{&lt;/span&gt;
            &lt;span class="n"&gt;Attempts&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;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ConfigureAwait&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="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Attempts&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clock&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="nf"&gt;ConfigureAwait&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="n"&gt;delay&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="p"&gt;}&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;Production passes &lt;code&gt;TimeProvider.System&lt;/code&gt;. Tests pass &lt;code&gt;FakeTimeProvider&lt;/code&gt; from the &lt;code&gt;Microsoft.Extensions.TimeProvider.Testing&lt;/code&gt; package and move time by hand:&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;task&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(()&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;calls&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;4&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;TimeoutException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flaky dependency"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;False&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCompleted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// parked on the 1s backoff&lt;/span&gt;
&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&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="c1"&gt;// attempt 2 fails, parks on 2s&lt;/span&gt;
&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&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;// attempt 3 fails, parks on 4s&lt;/span&gt;
&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;False&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCompleted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// mid-backoff assertion. Try writing THIS with Thread.Sleep.&lt;/span&gt;
&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// attempt 4 succeeds&lt;/span&gt;

&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ok"&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;task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven virtual seconds, zero real ones. I ran exactly this in a console app first and it worked beautifully. Then I pasted it into the xunit project and &lt;code&gt;dotnet test&lt;/code&gt; sat there until the hang detector shot the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that never woke up
&lt;/h2&gt;

&lt;p&gt;Same code. Passes in a console app, deadlocks in xunit. I stuck a file-based trace into the test because at that point I trusted nothing, and it told the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[t5] started  calls=1  sc=AsyncTestSyncContext
[t5] after+1  calls=1  completed=False
[t5] after+2  calls=1  completed=False
[t5] after+4  calls=1  completed=False
[t9] attempt 2  sc=null      &amp;lt;- thread pool, AFTER all my advances
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;calls&lt;/code&gt; never moved while I advanced the clock. Attempt 2 eventually ran on a different thread, after all three &lt;code&gt;Advance&lt;/code&gt; calls had already happened.&lt;/p&gt;

&lt;p&gt;Here's the mechanism. When &lt;code&gt;Advance()&lt;/code&gt; fires the timer, the &lt;code&gt;await Task.Delay&lt;/code&gt; continuation is supposed to run right there, synchronously. But the .NET scheduler refuses to inline a continuation onto a thread whose &lt;code&gt;SynchronizationContext.Current&lt;/code&gt; isn't null — and xunit v2 runs every test under its &lt;code&gt;AsyncTestSyncContext&lt;/code&gt;. Yes, even with &lt;code&gt;ConfigureAwait(false)&lt;/code&gt;: that controls where the continuation &lt;em&gt;goes&lt;/em&gt;, not whether the completing thread may run it &lt;em&gt;inline&lt;/em&gt;. So the continuation gets queued to the thread pool, my test thread sprints through the remaining &lt;code&gt;Advance&lt;/code&gt; calls, and only then does attempt 2 register its 2-second delay. Virtual now is already +7s; the new timer is due at +9s; nothing will ever advance the clock again. The test sleeps forever, which is a funny outcome for a test whose entire purpose was to stop sleeping.&lt;/p&gt;

&lt;p&gt;The console app has no synchronization context, so everything inlines and the exact same code is deterministic. That's why my repro "proved" the code was fine.&lt;/p&gt;

&lt;p&gt;The fix is one load-bearing line at the top of the test:&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;SynchronizationContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetSynchronizationContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Passed Same_scenario_fake_clock [36 ms]
Passed Gives_up_after_max_attempts_fake_clock [10 ms]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From 7 seconds to 36 milliseconds, and the mid-backoff &lt;code&gt;Assert.False&lt;/code&gt; is now a real, deterministic assertion instead of a race I'd have lost one Friday a month. (Timings are xunit's own per-test numbers from a small Linux container — I care about seconds-versus-milliseconds, not the exact digits.)&lt;/p&gt;

&lt;p&gt;If nulling the context feels too spicy for your codebase, the alternative is advancing in a loop — &lt;code&gt;while (!task.IsCompleted) { fake.Advance(step); await Task.Yield(); }&lt;/code&gt; — which tolerates the thread hops but gives up the crisp step-by-step assertions. I prefer the one-liner and a loud comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug you can finally write down
&lt;/h2&gt;

&lt;p&gt;The part I ended up liking most isn't the speed. &lt;code&gt;FakeTimeProvider&lt;/code&gt; also lets you set the time zone, which means the whole class of "only fails at midnight in production" bugs becomes a normal, boring test:&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;// 18:20 UTC on July 30 == 23:50 in Kolkata (UTC+05:30)&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fake&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;FakeTimeProvider&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;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;20&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="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetLocalTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeZoneInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindSystemTimeZoneById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Asia/Kolkata"&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;quota&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;DailyQuota&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&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;// ...exhaust the quota at 23:50 local...&lt;/span&gt;
&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// 00:10 local — but still July 30 in UTC&lt;/span&gt;
&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quota&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryConsume&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;         &lt;span class="c1"&gt;// resets at LOCAL midnight, as promised&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key your "daily" reset on &lt;code&gt;GetUtcNow().Date&lt;/code&gt; instead of &lt;code&gt;GetLocalNow()&lt;/code&gt; and this test catches it instantly — no waiting for a user five and a half time zones away to complain that their quota comes back at 5:30 in the morning.&lt;/p&gt;

&lt;p&gt;Two honest caveats. This only works if &lt;em&gt;every&lt;/em&gt; delay in the code path takes the provider — one naked &lt;code&gt;Task.Delay(5000)&lt;/code&gt; buried in a helper and your test is back to sleeping for real, with no error to tell you why. And I don't inject &lt;code&gt;TimeProvider&lt;/code&gt; religiously: code that just stamps a log line can keep &lt;code&gt;DateTime.UtcNow&lt;/code&gt;. My rule is that the moment logic branches on time or sleeps on it, the clock is a dependency and should come in through the front door.&lt;/p&gt;

&lt;p&gt;Full runnable sample, deadlock explanation included: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/013-timeprovider-fake-time" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/013-timeprovider-fake-time&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Grep your test folder for &lt;code&gt;Task.Delay&lt;/code&gt; and &lt;code&gt;Thread.Sleep&lt;/code&gt; and tell me the damage in the comments — I'll go first: mine was seven seconds for one class.&lt;/p&gt;

&lt;p&gt;— still deadlocking things nobody asked me to&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>19 Requests Through a 10-Request Limit</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Thu, 30 Jul 2026 07:15:24 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/19-requests-through-a-10-request-limit-59g9</link>
      <guid>https://dev.to/ssukhpinder/19-requests-through-a-10-request-limit-59g9</guid>
      <description>&lt;p&gt;I shipped a rate limiter recently and told the team the endpoint was now capped at 10 requests per 10 seconds. Later I watched a burst test land 19 requests in about a second and a half. All 200s. Nothing was broken — &lt;code&gt;AddFixedWindowLimiter&lt;/code&gt; was doing exactly what it says on the tin. I'd just never read the tin carefully. A fixed window doesn't promise "at most 10 in any 10 seconds". It promises "at most 10 per window", and windows have edges.&lt;/p&gt;

&lt;p&gt;If a client times its bursts to straddle one of those edges, it collects almost two windows' worth of requests at once. That's the kind of claim that's easy to nod at and easy to misjudge, so I built the smallest repro that could settle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two policies, same numbers
&lt;/h2&gt;

&lt;p&gt;A minimal API on .NET 10 with two policies that look interchangeable in a code review:&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;AddRateLimiter&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RejectionStatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status429TooManyRequests&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="nf"&gt;AddFixedWindowLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fixed"&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="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;PermitLimit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Window&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueLimit&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="c1"&gt;// reject instantly, no queueing&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="nf"&gt;AddSlidingWindowLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sliding"&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="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;PermitLimit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Window&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SegmentsPerWindow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 5 x 2-second segments&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;QueueLimit&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The middleware has been in the box since .NET 7; &lt;code&gt;RequireRateLimiting("fixed")&lt;/code&gt; pins a policy to an endpoint.)&lt;/p&gt;

&lt;p&gt;The fixed limiter keeps one counter and zeroes it every ten seconds. The sliding limiter keeps five little counters, one per 2-second segment, and "the window" is always the last five segments. That bookkeeping difference is the entire story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The choreography
&lt;/h2&gt;

&lt;p&gt;The app attacks itself with &lt;code&gt;HttpClient&lt;/code&gt;. Four moves:&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;// 1. burn the current window down to zero&lt;/span&gt;
&lt;span class="c1"&gt;// 2. poll every 200ms until a request succeeds -&amp;gt; that's a replenish boundary&lt;/span&gt;
&lt;span class="c1"&gt;// 3. go quiet, come back 1s BEFORE the next boundary, burst 9&lt;/span&gt;
&lt;span class="c1"&gt;// 4. step just past the boundary, burst 10 more&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Move 2 matters because window boundaries follow the limiter's internal clock, which starts when the first request lazily creates it — not yours. Rather than guessing, exhaust the window once and wait for permits to come back. The flip from 429 to 200 is a boundary announcing itself, and the next one is exactly one window later. Worth noticing: a real abuser gets the same information for free. It's just status codes.&lt;/p&gt;

&lt;p&gt;Every 200 gets timestamped, and at the end the app reports the densest 10-second span it actually got away with. Conditions: Release build, localhost, small Linux container, wall clock. Not a lab, but this experiment isn't about milliseconds. It's about counting to 19.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- attacking /fixed ---
  burn phase: 10 admitted, then 429s — window is empty
  permits came back at t=10.4s — boundary located
  pre-boundary burst  at t=19.4s: 9/9 admitted
  post-boundary burst at t=20.7s: 10/10 admitted
  =&amp;gt; /fixed: densest 10s span held 19 admitted requests (limit says 10);
     those 19 arrived within 1.31s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine requests an instant before the reset, ten more an instant after. The limiter never blinked, because at the moment of reset it forgets everything. 19 requests in 1.31 seconds on a policy I would've sworn meant 10 per 10 seconds — a 1.9x hole in the promise, available at every boundary, forever.&lt;/p&gt;

&lt;p&gt;Same choreography against the sliding window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- attacking /sliding ---
  pre-boundary burst  at t=19.4s: 9/9 admitted
  post-boundary burst at t=20.7s: 1/10 admitted
  =&amp;gt; /sliding: densest 10s span held 10 admitted requests (limit says 10);
     those 10 arrived within 0.01s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pre-boundary nine sail through, and then the second burst gets exactly one request in, because the nine from 1.3 seconds ago still count against the window. There's no moment of amnesia. The densest span it ever allowed was 10. The number on the tin.&lt;/p&gt;

&lt;p&gt;That "10 within 0.01s" line deserves a beat too: any windowed limiter will serve its entire budget instantly to a cold window. If instantaneous burst is what actually hurts you, you want a token bucket with a small bucket, which is a different post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you actually care?
&lt;/h2&gt;

&lt;p&gt;Sometimes no. If the limit is a coarse backstop protecting capacity — no client gets more than a thousand requests a minute against this scraper-magnet endpoint — a brief 2x at a seam is noise, and fixed windows are the cheapest thing you can deploy: one counter per partition, nothing to segment. I'd leave those alone.&lt;/p&gt;

&lt;p&gt;Where I've changed my default is limits that are promises to people. Per-tenant fairness, paid quota tiers, anything a client could deliberately game. A "10 per 10 seconds" that admits 19 is the kind of discovery that ends up in an awkward support thread. Sliding windows cost a little more memory per partition, since the segment counters exist for every tracked client, but with five segments it's small and the promise becomes true. My rule now: fixed for backstops, sliding for promises.&lt;/p&gt;

&lt;p&gt;One caveat before you rip out every fixed window you own: everything above is a single process. The moment you scale out, every in-memory limiter — fixed or sliding — goes approximate anyway, because each node counts alone. At that point the boundary seam is the least of your inaccuracies, and you're shopping for a distributed limiter regardless.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/012-rate-limit-window-burst" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/012-rate-limit-window-burst&lt;/a&gt; — it takes about 45 seconds to run because it has to sit through real windows.&lt;/p&gt;

&lt;p&gt;Is your fixed window a backstop or a promise? If it's a promise, I'd go check what your densest span really is — and I'd like to hear what you find in the comments.&lt;/p&gt;

&lt;p&gt;— still stress-testing promises nobody asked me to verify&lt;/p&gt;

</description>
      <category>aspnetcore</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Freeze Fee: What FrozenDictionary Charges and When It Pays</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Wed, 29 Jul 2026 08:12:44 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/the-freeze-fee-what-frozendictionary-charges-and-when-it-pays-14ih</link>
      <guid>https://dev.to/ssukhpinder/the-freeze-fee-what-frozendictionary-charges-and-when-it-pays-14ih</guid>
      <description>&lt;p&gt;There's a static lookup table in every service I've ever worked on. A thousand-ish feature flags, or route metadata, or country codes — built once at startup, read on every single request, never touched again. For years my fingers have typed &lt;code&gt;ToImmutableDictionary()&lt;/code&gt; for those. Immutable, read-only, sounds correct. Last night I finally asked the question I should've asked in 2023: is "immutable" actually the fast option here, or did I just like the word?&lt;/p&gt;

&lt;p&gt;So I lined up the three containers .NET gives you for a build-once, read-forever table and made each of them do ten million lookups.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contenders
&lt;/h2&gt;

&lt;p&gt;Same 1,000 feature-flag style keys (&lt;code&gt;flags:checkout:variant-0042&lt;/code&gt;), same values, default ordinal comparer for all three. No tricks, just the three ways you'd naturally write it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dictionary&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;Dictionary&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;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&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;immutable&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToImmutableDictionary&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;frozen&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToFrozenDictionary&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// System.Collections.Frozen, .NET 8+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lookup loop cycles through 4,096 pre-shuffled keys so the branch predictor can't memorize the sequence, and sums value lengths so the JIT can't delete the work:&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;long&lt;/span&gt; &lt;span class="n"&gt;checksum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;LookupOps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&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;frozen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hitQueries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;QueryMask&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;checksum&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten million lookups per round, best of five rounds, .NET 10 Release build in a small Linux container. Not a lab — I care about the ratios, not the absolute nanoseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that made me wince
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== lookups, all keys present ==
Dictionary          :   18.0 ns/lookup
ImmutableDictionary :   86.7 ns/lookup
FrozenDictionary    :   11.9 ns/lookup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FrozenDictionary beats plain Dictionary by about 1.5x. Nice. But look at the middle line. ImmutableDictionary — the thing I've been using &lt;em&gt;specifically for read-only data&lt;/em&gt; — is over 7x slower than frozen, and nearly 5x slower than the boring mutable Dictionary it was supposedly an upgrade from.&lt;/p&gt;

&lt;p&gt;The wince is because this isn't a bug, it's a design I'd never bothered to understand. ImmutableDictionary isn't a read-optimized dictionary that happens to be immutable. It's a hash trie — a tree — built so that "copy this dictionary with one extra entry" is cheap and doesn't disturb the original. Every lookup walks pointers through tree nodes. You're paying, on every read, for a copy-with-changes capability my startup table will never use once.&lt;/p&gt;

&lt;p&gt;FrozenDictionary is the opposite trade. &lt;code&gt;ToFrozenDictionary()&lt;/code&gt; takes its time inspecting your actual keys — for strings it can find the few characters that distinguish the whole key set and hash only those — then picks a specialized internal layout for exactly that data. All the cleverness happens once, at construction. Which raises the obvious question: what does that cost?&lt;/p&gt;

&lt;h2&gt;
  
  
  The freeze fee
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== construction, 1,000 entries, avg of 2,000 builds ==
new Dictionary(pairs)  :     21.8 µs/build
ToImmutableDictionary():    211.4 µs/build
ToFrozenDictionary()   :    166.8 µs/build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. Freezing 1,000 entries costs roughly 8x building a plain Dictionary — about 145 µs of surcharge in my runs. That's the fee, and it's real: build a FrozenDictionary per request and you've made everything worse for no reason.&lt;/p&gt;

&lt;p&gt;But here's the twist I didn't see coming: &lt;code&gt;ToImmutableDictionary()&lt;/code&gt; costs &lt;em&gt;more&lt;/em&gt; than the freeze. For a build-once table, ImmutableDictionary loses on both ends — more expensive to construct and 7x slower to read. There's no scenario in that use case where it's the right pick. It still has a real job — when you genuinely need cheap non-destructive updates, like versioned snapshots of a changing set — but "this data never changes" was never that job. I'd been reading "immutable" as "optimized for reading" for two years. It means "optimized for copying."&lt;/p&gt;

&lt;p&gt;My little program also computed the break-even against Dictionary: the ~145 µs surcharge divided by the ~6 ns saved per hit comes out to roughly 24,000 lookups. A singleton flag table consulted a few times per request clears that in the first minute of traffic, then collects the discount forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the win shrinks
&lt;/h2&gt;

&lt;p&gt;Honesty section. Lookups for keys that &lt;em&gt;don't&lt;/em&gt; exist barely moved: 14.0 ns for Dictionary vs 11.8 ns frozen. Dictionary already rejects most misses on a hash mismatch without ever comparing strings, so there's less waste for frozen to reclaim. I expected miss-heavy workloads to be the showcase and they're the weakest result on the table. If your table mostly answers "no", the upgrade is real but small.&lt;/p&gt;

&lt;p&gt;And the usual caveats: these are my ratios on one container with 1,000 string keys. Different key shapes get different specialized strategies, tiny tables are fast in anything, and none of this matters on a dictionary you rebuild often — per-request construction hands the 8x fee back with interest.&lt;/p&gt;

&lt;p&gt;My rule after this experiment, stated as the opinion it is: any &lt;code&gt;static readonly Dictionary&lt;/code&gt; that nothing ever writes to is a FrozenDictionary wearing the wrong coat. And any &lt;code&gt;ToImmutableDictionary()&lt;/code&gt; on data that never changes is a bug report I'm filing against my own muscle memory.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/011-frozen-dictionary-lookup" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/011-frozen-dictionary-lookup&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Got a &lt;code&gt;ToImmutableDictionary()&lt;/code&gt; sitting on startup data somewhere? Go time it — and tell me in the comments if your ratio beats my 7x.&lt;/p&gt;

&lt;p&gt;— still benchmarking things nobody asked me to, and serving the results cold&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The 400 I Didn't Have to Write</title>
      <dc:creator>Sukhpinder Singh</dc:creator>
      <pubDate>Wed, 29 Jul 2026 07:12:10 +0000</pubDate>
      <link>https://dev.to/ssukhpinder/the-400-i-didnt-have-to-write-2gho</link>
      <guid>https://dev.to/ssukhpinder/the-400-i-didnt-have-to-write-2gho</guid>
      <description>&lt;p&gt;Every POST handler I've written in a minimal API for the last four years opens with the same liturgy: null-check the name, range-check the price, build a dictionary of errors, bail with a 400. Controllers got automatic model validation back when &lt;code&gt;[ApiController]&lt;/code&gt; shipped, and then minimal APIs arrived without it and we all quietly went back to writing if-blocks like it was 2010. I counted the validation preamble in the demo handler I wrote for this post: eleven lines before the first line of actual work.&lt;/p&gt;

&lt;p&gt;.NET 10 finally closes that gap, and it's one line. I spent an afternoon poking at it, and the most interesting thing I found wasn't the feature working. It was my own validation code silently becoming unreachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The before picture
&lt;/h2&gt;

&lt;p&gt;Here's the handler shape I mean. One DTO, three rules, all enforced by hand:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/manual/products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateProduct&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;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;errors&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;Dictionary&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;string&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&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="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Name"&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="s"&gt;"Name is required."&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;else&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;dto&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="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Name"&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="s"&gt;"Name must be 80 characters or fewer."&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;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Price&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0.01m&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;10_000m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Price"&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="s"&gt;"Price must be between 0.01 and 10000."&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;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SalePrice&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;sale&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sale&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"SalePrice"&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="s"&gt;"SalePrice must be lower than Price."&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;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidationProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/products/1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dto&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;It works. It also gets copy-pasted into every endpoint that binds a body, drifts a little each time, and keeps the rules as far away from the type as possible. The DTO says nothing about what a valid product is; the truth lives in some handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line and a record
&lt;/h2&gt;

&lt;p&gt;The .NET 10 version. Rules move onto the type as plain DataAnnotations:&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;AddValidation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateProduct&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/products/1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dto&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;record&lt;/span&gt; &lt;span class="nc"&gt;CreateProduct&lt;/span&gt;&lt;span class="p"&gt;(&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;:&lt;/span&gt; &lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;StringLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&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="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;SalePrice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No package reference — it's in the shared framework. I didn't register &lt;code&gt;CreateProduct&lt;/code&gt; anywhere either; binding a parameter of that type is enough. My demo app probes itself with &lt;code&gt;HttpClient&lt;/code&gt; (this is behavior, not benchmarks — .NET 10 in a small Linux container), and here's the real exchange when I feed it garbage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /products         [garbage in: {"name":"","price":-5,"salePrice":3}]
  -&amp;gt; 400 BadRequest
  {"title":"One or more validation errors occurred.","errors":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 10000."]}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler never ran. Validation happens at binding time, before your code, and the response is the same &lt;code&gt;ValidationProblemDetails&lt;/code&gt; shape controllers produce, so clients can't tell which flavor of endpoint rejected them. Query parameters get the same treatment — an attribute directly on the parameter is enough:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;pageSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&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;pageSize&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET  /search?pageSize=0
  -&amp;gt; 400 BadRequest
  {"errors":{"pageSize":["The field pageSize must be between 1 and 100."]}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part that surprised me
&lt;/h2&gt;

&lt;p&gt;First version of my demo kept the manual endpoint exactly as written above, so I could compare the two worlds side by side. Then I ran it, sent the garbage payload to the &lt;em&gt;manual&lt;/em&gt; endpoint, and got back error messages I never wrote: "The Name field is required." That's the framework's wording, not mine.&lt;/p&gt;

&lt;p&gt;Because the DTO now carries attributes, &lt;code&gt;AddValidation&lt;/code&gt; was rejecting the request before my eleven lines ever executed. My checks weren't wrong. They were dead. If your codebase already has attributes on DTOs from an earlier FluentValidation-plus-annotations era, turning this on may start enforcing rules you forgot were declared. To make the manual endpoint behave like the old world for the demo, I had to opt it out explicitly:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/legacy/products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreateProduct&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisableValidation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the receipt, from the same run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /legacy/products  [garbage in, validation off]
  -&amp;gt; 201 Created
  {"name":"","price":-5,"salePrice":3}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A product with no name and a price of minus five, welcomed into the system. That's the world we're leaving behind.&lt;/p&gt;

&lt;p&gt;Cross-field rules work through the &lt;code&gt;IValidatableObject&lt;/code&gt; you already know: my &lt;code&gt;SalePrice &amp;lt; Price&lt;/code&gt; rule lives there, and an invalid pair comes back as a clean 400. One nuance the run exposed: my manual endpoint reported all three errors at once, but the framework reported only the two attribute failures — &lt;code&gt;Validate&lt;/code&gt; doesn't run until attribute validation passes, so cross-field errors arrive in a second wave. Users fix the name and the price, resubmit, and only then learn the sale price is wrong. Mildly annoying, worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd still say no
&lt;/h2&gt;

&lt;p&gt;If your rules are genuinely conditional — this field is required only for that tenant, prices validate against a table in the database — attributes will fight you, and FluentValidation still earns its keep. The stock messages are also flatly English; there are hooks for customizing, but if you need localized error text today, check that story before migrating anything user-facing. And this is minimal-API binding only. Controllers already had it, so there's nothing to migrate there.&lt;/p&gt;

&lt;p&gt;My opinion, stated as one: for the boring 80% of validation — required, range, length, email — attributes on the type beat checks in the handler, because the contract travels with the DTO instead of hiding in endpoint code. This should have shipped in .NET 6. I'll take it in 10.&lt;/p&gt;

&lt;p&gt;Full runnable sample: &lt;a href="https://github.com/ssukhpinder/dev-to-code-samples/tree/main/010-minimal-api-validation" rel="noopener noreferrer"&gt;https://github.com/ssukhpinder/dev-to-code-samples/tree/main/010-minimal-api-validation&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Are you moving these checks onto attributes, or does FluentValidation own your validation layer for good reasons I've skipped? Tell me in the comments.&lt;/p&gt;

&lt;p&gt;— still deleting code nobody asked me to.&lt;/p&gt;

</description>
      <category>aspnetcore</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
