<?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: Dhana</title>
    <description>The latest articles on DEV Community by Dhana (@dhanagani_lakshmi_2487ad0).</description>
    <link>https://dev.to/dhanagani_lakshmi_2487ad0</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%2F4114244%2Fe294e2fe-04e7-4575-9208-f6075df3783e.jpg</url>
      <title>DEV Community: Dhana</title>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dhanagani_lakshmi_2487ad0"/>
    <language>en</language>
    <item>
      <title>Why Await Inside a Loop Can Make Your Async Code 5x Slower</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:10:41 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/why-await-inside-a-loop-can-make-your-async-code-5x-slower-3eca</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/why-await-inside-a-loop-can-make-your-async-code-5x-slower-3eca</guid>
      <description>&lt;p&gt;Here is a mistake that compiles, runs, returns correct results, and quietly makes your code about five times slower than it needs to be: calling several independent async methods inside a loop with &lt;code&gt;await&lt;/code&gt; on each one.&lt;/p&gt;

&lt;p&gt;I measured it. Five one-second calls took about five seconds in a loop and about one second with &lt;code&gt;Task.WhenAll&lt;/code&gt;. This article shows the numbers, explains why, and covers the four things that go wrong when you simply "run everything at once".&lt;/p&gt;

&lt;h2&gt;
  
  
  The Experiment
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Task.Delay(1000)&lt;/code&gt; stands in for a database or web call, so each call "takes" one second without using the CPU:&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.Diagnostics&lt;/span&gt;&lt;span class="p"&gt;;&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="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;FakeApiCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="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="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// simulates waiting on a database or web service&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;var&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;Stopwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartNew&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;1&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="m"&gt;5&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;await&lt;/span&gt; &lt;span class="nf"&gt;FakeApiCall&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="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;$"5 calls, one after another:   &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;ElapsedMilliseconds&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms"&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="nf"&gt;Restart&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;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enumerable&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;5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FakeApiCall&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;$"5 calls, Task.WhenAll:        &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;ElapsedMilliseconds&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms"&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="nf"&gt;Restart&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;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enumerable&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="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="m"&gt;1000&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;$"100 x Task.Delay(1000):       &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;ElapsedMilliseconds&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;I ran this twice in a Release build on .NET 8 on one laptop.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Run 1&lt;/th&gt;
&lt;th&gt;Run 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 calls, one after another&lt;/td&gt;
&lt;td&gt;5,102 ms&lt;/td&gt;
&lt;td&gt;5,041 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 calls, &lt;code&gt;Task.WhenAll&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,035 ms&lt;/td&gt;
&lt;td&gt;1,030 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 × &lt;code&gt;Task.Delay(1000)&lt;/code&gt; with &lt;code&gt;Task.WhenAll&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,016 ms&lt;/td&gt;
&lt;td&gt;1,002 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;WhenAll&lt;/code&gt; version was roughly 4.9 times faster. Each version also finished a little above its ideal time, because starting and finishing tasks has a small cost. Two runs are enough to see the pattern, but they are not enough to trust the last few milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Loop Is Slow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmls85ulsjg0imopv6b3l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmls85ulsjg0imopv6b3l.png" alt=" " width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;await&lt;/code&gt; pauses the current method until that call finishes. Inside a loop, that means call 2 doesn't start until call 1 is done, call 3 waits for call 2, and so on, so the waits add up: 1 + 1 + 1 + 1 + 1 = 5 seconds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Task.WhenAll&lt;/code&gt; works differently. &lt;code&gt;Select(FakeApiCall)&lt;/code&gt; starts all five calls straight away, each returning a task that is still running. &lt;code&gt;WhenAll&lt;/code&gt; then waits for the slowest one. The waits overlap, so the total is about the longest single wait, not the sum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Waiting Is Cheap
&lt;/h2&gt;

&lt;p&gt;The third test is the surprising one. One hundred waits finished in about the same time as five. Adding more waiting tasks added almost no time.&lt;/p&gt;

&lt;p&gt;The reason is that a real &lt;code&gt;await&lt;/code&gt; on a database or network call doesn't keep a thread busy while it waits. I measured only elapsed time here, not thread counts, so I won't claim a number, but the timing is consistent with that behavior. If those hundred waits had run one after another, the same arithmetic gives about 100 seconds. I didn't run that version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four Things That Go Wrong With "Run Everything at Once"
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The service on the other end has limits
&lt;/h3&gt;

&lt;p&gt;One hundred simultaneous calls to a real API or database can overwhelm it, or exhaust a connection pool. Limit how many run at once, for example with &lt;code&gt;SemaphoreSlim&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;gate&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;SemaphoreSlim&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="c1"&gt;// at most 10 calls in flight&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="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ThrottledCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="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;FakeApiCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;results&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;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enumerable&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="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ThrottledCall&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with &lt;code&gt;Parallel.ForEachAsync&lt;/code&gt;, available since .NET 6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Parallel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForEachAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enumerable&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="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;ParallelOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;MaxDegreeOfParallelism&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="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;FakeApiCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With ten at a time and one-second calls, I'd expect about ten seconds for a hundred calls, but I haven't measured either version.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Only the first exception reaches your &lt;code&gt;catch&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When several tasks fail, &lt;code&gt;await Task.WhenAll(...)&lt;/code&gt; rethrows just the first exception. The rest are not lost, but they sit on the combined task:&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;all&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;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&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;await&lt;/span&gt; &lt;span class="n"&gt;all&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="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;ex&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;all&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="n"&gt;InnerExceptions&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="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// every failure, not only the first&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. One database connection can't serve concurrent calls
&lt;/h3&gt;

&lt;p&gt;In general, a database connection handles one command at a time. If you start several database calls at once, each needs its own connection (and its own transaction if you use one). Sharing a single connection across them is the same shared-state mistake that loses updates in multithreaded code.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Some calls depend on each other
&lt;/h3&gt;

&lt;p&gt;If call 2 needs the result of call 1, they can't overlap, and the plain &lt;code&gt;await&lt;/code&gt; order is correct. &lt;code&gt;WhenAll&lt;/code&gt; is for independent work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results Keep Their Order
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Task.WhenAll&lt;/code&gt; returns results in the same order as the tasks you passed in, even if they finish in a different order. In the examples above, &lt;code&gt;results[0]&lt;/code&gt; belongs to the first call.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Is the Waiting Half, Not the CPU Half
&lt;/h2&gt;

&lt;p&gt;Everything here applies to code that spends its time waiting. If the work is CPU-bound, such as heavy calculation, &lt;code&gt;async&lt;/code&gt; doesn't make it faster, and you're into the territory of my earlier article on parallel loops, where a plain sequential loop beat every parallel version for tiny work.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Decision Rule
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Calls depend on each other&lt;/td&gt;
&lt;td&gt;Plain &lt;code&gt;await&lt;/code&gt; in order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Independent calls, a few of them&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Task.WhenAll&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Independent calls, many of them&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Task.WhenAll&lt;/code&gt; with a throttle (&lt;code&gt;SemaphoreSlim&lt;/code&gt; or &lt;code&gt;Parallel.ForEachAsync&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy calculation&lt;/td&gt;
&lt;td&gt;Parallel code, and measure against sequential first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Check Your Own Code
&lt;/h2&gt;

&lt;p&gt;Search your project for &lt;code&gt;await&lt;/code&gt; inside &lt;code&gt;for&lt;/code&gt; and &lt;code&gt;foreach&lt;/code&gt; loops. For each one, ask whether the iterations really depend on each other. If they don't, time the loop with a &lt;code&gt;Stopwatch&lt;/code&gt;, change it to &lt;code&gt;Task.WhenAll&lt;/code&gt;, and time it again in Release mode. Run it more than once, since a single timing can mislead.&lt;/p&gt;

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

&lt;p&gt;An &lt;code&gt;await&lt;/code&gt; inside a loop runs independent calls one after another, so the waits add up. Starting the calls first and awaiting them together with &lt;code&gt;Task.WhenAll&lt;/code&gt; made five one-second calls take about one second instead of about five. The speed-up comes with conditions: limit how many calls run at once, expect only the first exception to reach your &lt;code&gt;catch&lt;/code&gt;, give each database call its own connection, and keep dependent calls in order.&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>async</category>
      <category>performance</category>
    </item>
    <item>
      <title>Thirteen Runs, Thirteen Wrong Answers: What a Shared Counter Teaches About lock, Interlocked, and Parallel.For</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Thu, 08 Oct 2026 09:28:54 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/thirteen-runs-thirteen-wrong-answers-what-a-shared-counter-teaches-about-lock-interlocked-and-iol</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/thirteen-runs-thirteen-wrong-answers-what-a-shared-counter-teaches-about-lock-interlocked-and-iol</guid>
      <description>&lt;p&gt;Most concurrency tutorials explain lock and Interlocked in a paragraph and move on. I wanted to see what they actually do, so I ran a deliberately tiny experiment on my laptop: increment a counter 100,000 times using Parallel.For, four different ways. The results contained one expected finding and one that changed how I think about parallel code.&lt;/p&gt;

&lt;p&gt;The Experiment&lt;/p&gt;

&lt;p&gt;The unsafe version looks harmless:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
long counter = 0;&lt;br&gt;
Parallel.For(0, 100_000, i =&amp;gt; { counter++; });&lt;br&gt;
Console.WriteLine(counter);   // expected: 100000&lt;/p&gt;

&lt;p&gt;I then wrote three corrected versions and a plain sequential loop for comparison:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
// 1. lock&lt;br&gt;
object gate = new object();&lt;br&gt;
Parallel.For(0, N, i =&amp;gt; { lock (gate) { counter++; } });&lt;/p&gt;

&lt;p&gt;// 2. Interlocked&lt;br&gt;
Parallel.For(0, N, i =&amp;gt; { Interlocked.Increment(ref counter); });&lt;/p&gt;

&lt;p&gt;// 3. Thread-local accumulation, merged once per task&lt;br&gt;
long total = 0;&lt;br&gt;
Parallel.For(0, N,&lt;br&gt;
    () =&amp;gt; 0L,                                   // each task starts with its own counter&lt;br&gt;
    (i, state, local) =&amp;gt; local + 1,             // count privately, no sharing&lt;br&gt;
    local =&amp;gt; Interlocked.Add(ref total, local)); // merge once at the end&lt;br&gt;
Why counter++ Isn't One Operation&lt;/p&gt;

&lt;p&gt;The line looks atomic, but it compiles to three steps: read the current value, add one, write the result back. Two threads can interleave those steps:&lt;/p&gt;

&lt;p&gt;Time    Thread A    Thread B    Counter in memory&lt;br&gt;
1   reads 5     5&lt;br&gt;
2       reads 5 5&lt;br&gt;
3   writes 6        6&lt;br&gt;
4       writes 6    6&lt;/p&gt;

&lt;p&gt;Two increments happened, but the counter moved by one. Across 100,000 attempts on several threads, most of those collisions go unnoticed, because nothing throws and nothing is logged.&lt;/p&gt;

&lt;p&gt;Result 1: The Unsafe Version Was Wrong Every Time&lt;/p&gt;

&lt;p&gt;Across 13 runs of the unsafe version, spread over three versions of the test program, the final value was never 100,000. The results ranged from 1,020 to 32,690, which means between roughly 67% and 99% of the increments were lost. The answer also changed on every run, which makes this kind of bug hard to catch: a test that passes once proves nothing.&lt;/p&gt;

&lt;p&gt;Result 2: Timings (Release Build, .NET 8)&lt;/p&gt;

&lt;p&gt;The first round of any benchmark includes compilation and thread-pool start-up, so the table below uses rounds 2 and 3 only. Times are in milliseconds.&lt;/p&gt;

&lt;p&gt;Version Result  Time (ms)&lt;br&gt;
Sequential loop 100,000 0.033 – 0.034&lt;br&gt;
Unsafe counter++    17,825 and 22,577 (wrong)   0.751 – 1.093&lt;br&gt;
lock    100,000 9.0 – 22.8&lt;br&gt;
Interlocked 100,000 2.2 – 2.7&lt;br&gt;
Thread-local + merge    100,000 0.277 – 0.287&lt;/p&gt;

&lt;p&gt;These come from one machine and one process, and from Visual Studio, so the ordering and the rough size of the gaps are meaningful, but I wouldn't trust the decimals.&lt;/p&gt;

&lt;p&gt;The Surprise: Parallel Made It Slower&lt;/p&gt;

&lt;p&gt;The plain sequential loop beat every parallel version. Even the best correct parallel version, thread-local accumulation, was roughly eight to nine times slower than a simple for loop. Interlocked was around 65 to 80 times slower, and lock hundreds of times slower.&lt;/p&gt;

&lt;p&gt;The sequential loop is so trivial that the compiler very likely optimized it heavily, so the number is best read as "this work costs almost nothing." That is exactly the point. Splitting work across threads has a fixed cost for scheduling, coordination, and merging results. When each item of work is tiny, that cost dominates, and the single-threaded version wins.&lt;/p&gt;

&lt;p&gt;Parallelism only pays off when each unit of work is heavy enough to outweigh the overhead. Incrementing a number is not. Processing an employee record that involves database round trips might be, but that is something to measure, not assume.&lt;/p&gt;

&lt;p&gt;Second Surprise: Unsafe Was Slower Than Correct&lt;/p&gt;

&lt;p&gt;The unsafe version, which gives wrong answers, was also three to four times slower than the thread-local version, which gives right ones. A plausible explanation is contention: every thread repeatedly reads and writes the same memory location, so processor cores spend time passing it back and forth. I didn't measure the cause, so I'm presenting it as a likely explanation, not a finding. The measured fact is that sharing one variable was slower even before correctness was fixed.&lt;/p&gt;

&lt;p&gt;lock vs. Interlocked&lt;/p&gt;

&lt;p&gt;In these rounds, Interlocked was about 3 to 10 times faster than lock, and lock was the least consistent, ranging from 9 to 51 milliseconds across rounds. A reasonable reading is that Interlocked.Increment typically maps to a single atomic processor instruction, while lock has to coordinate threads that may end up waiting on each other.&lt;/p&gt;

&lt;p&gt;An earlier, Debug-build version of the test showed a much larger gap, around 30 times. That used a different program, so the two aren't directly comparable, and I wouldn't conclude anything about Debug versus Release from them.&lt;/p&gt;

&lt;p&gt;Interlocked has a limit that matters more than speed: it covers a single operation on a single variable. If an update involves several variables that must stay consistent with each other, Interlocked can't protect them, and lock (or another synchronization tool) is the right choice.&lt;/p&gt;

&lt;p&gt;The Best Fix: Don't Share&lt;/p&gt;

&lt;p&gt;The thread-local version was the fastest correct approach, and it works for a structural reason. Each task counts into its own private variable and only combines results once at the end. There is no contention because there is nothing shared to contend over. The same idea applies well beyond counters: summing totals, building per-thread lists, or collecting per-thread results and merging them afterward.&lt;/p&gt;

&lt;p&gt;A Decision Rule&lt;br&gt;
Situation   Reach for&lt;br&gt;
Many threads updating one number    Interlocked&lt;br&gt;
Several related values that must change together    lock&lt;br&gt;
Counting or totaling across a large loop    Thread-local state, merged once&lt;br&gt;
Very small work per item    Check whether sequential is simply faster&lt;br&gt;
What This Means for Batch Processing&lt;/p&gt;

&lt;p&gt;The same reasoning applies to a payroll-style batch. Each employee's work involves database operations, which is far heavier than incrementing a number, so parallel processing may well help. But the earlier lesson holds: each thread needs its own connection and transaction, and the real speed-up depends on how much work each record carries and how the database handles concurrent writes. A four-thread split will not automatically finish in a quarter of the time. Measure sequential first, then measure parallel, and keep whichever is actually faster and correct.&lt;/p&gt;

&lt;p&gt;How to Benchmark Without Fooling Yourself&lt;br&gt;
Warm up first. The first round in my run was noticeably slower for several versions.&lt;br&gt;
Report ranges, not single numbers. The lock timings varied by more than a factor of five.&lt;br&gt;
Use Release builds. Debug builds change what the compiler does.&lt;br&gt;
Include a sequential baseline. Without it, I would never have noticed that parallel was losing.&lt;br&gt;
For precise numbers, use a proper tool such as BenchmarkDotNet, which handles warm-up and statistics for you.&lt;br&gt;
Takeaway&lt;/p&gt;

&lt;p&gt;An unsynchronized shared counter lost up to 99% of its updates, and a different answer every run is the signature of the bug. Of the fixes, Interlocked is the right tool for a single shared number, lock is for updates that span several values, and the fastest option was to avoid sharing at all. The most useful result, though, was that the parallel versions lost to a plain loop. Before parallelizing anything, measure the sequential version first.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>concurrency</category>
      <category>performance</category>
    </item>
    <item>
      <title>Multithreading in .NET for Batch Processing: What Actually Has to Change</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Wed, 07 Oct 2026 09:48:34 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/multithreading-in-net-for-batch-processing-what-actually-has-to-change-2c9k</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/multithreading-in-net-for-batch-processing-what-actually-has-to-change-2c9k</guid>
      <description>&lt;p&gt;A loop that processes 500 employee records one at a time is simple to reason about and easy to get right. Making that same loop run on multiple threads at once can cut processing time substantially, but it isn't a change you can make casually. This article walks through what multithreading actually means, how to apply it to a real batch-processing scenario, and the one structural change that makes it safe instead of dangerous.&lt;/p&gt;

&lt;p&gt;What a Thread Actually Is&lt;/p&gt;

&lt;p&gt;A thread is a single sequence of instructions executing in order, one line at a time. Code written and run normally executes on a single thread: step one happens, then step two, then step three, strictly in sequence. Multithreading means running more than one of these sequences at the same time, so independent pieces of work happen concurrently instead of waiting on each other.&lt;/p&gt;

&lt;p&gt;For a loop processing 500 records sequentially, that's the equivalent of a single queue with one person serving every customer in turn. Splitting that work across four threads is closer to opening four separate queues, each handling its own share of the customers, finishing in roughly a quarter of the time.&lt;/p&gt;

&lt;p&gt;Why This Matters Specifically for a Salary Batch&lt;/p&gt;

&lt;p&gt;A payroll batch has a property that makes speed genuinely valuable: it's usually running against a real deadline, processing hundreds or thousands of employee records that each require their own database work. But payroll also has a property that makes carelessness genuinely costly: the result has to be correct, and a partial, inconsistent, or duplicated save is a real problem, not a minor inconvenience.&lt;/p&gt;

&lt;p&gt;This combination is exactly where multithreading needs the most care. The speed benefit is real, but it has to be built on something that doesn't compromise the correctness guarantees a single-threaded, single-transaction approach already provides.&lt;/p&gt;

&lt;p&gt;The Mistake: Sharing One Connection and Transaction Across Threads&lt;/p&gt;

&lt;p&gt;A naive approach to speeding up the batch might try to process multiple employees at once while reusing the same database connection and transaction across all of them:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
// Risky: one shared connection and transaction across multiple threads&lt;br&gt;
using var connection = new OracleConnection(connectionString);&lt;br&gt;
connection.Open();&lt;br&gt;
using var transaction = connection.BeginTransaction();&lt;/p&gt;

&lt;p&gt;Parallel.ForEach(employees, employee =&amp;gt;&lt;br&gt;
{&lt;br&gt;
    ProcessSalary(employee, connection, transaction); // shared across threads&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;transaction.Commit();&lt;/p&gt;

&lt;p&gt;This is the same category of problem covered in an earlier article about Dependency Injection lifetimes: a single shared resource, accessed by multiple concurrent operations at once, becomes a source of corruption rather than a convenience. Most database connection objects are not safe to use from multiple threads simultaneously. Two threads attempting to write through the same connection at the same moment can produce errors, corrupted data, or a transaction left in an undefined state. The underlying mistake is identical to a Singleton service holding shared mutable state across concurrent HTTP requests — just relocated from request-handling code to batch-processing code.&lt;/p&gt;

&lt;p&gt;The Fix: One Connection and Transaction Per Thread&lt;/p&gt;

&lt;p&gt;The structural change that makes this safe is straightforward to state, even though it requires rethinking the batch's structure: each thread needs its own connection and its own transaction, entirely separate from every other thread's.&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var employeeBatches = SplitIntoBatches(employees, batchCount: 4);&lt;/p&gt;

&lt;p&gt;Parallel.ForEach(employeeBatches, batch =&amp;gt;&lt;br&gt;
{&lt;br&gt;
    using var connection = new OracleConnection(connectionString);&lt;br&gt;
    connection.Open();&lt;br&gt;
    using var transaction = connection.BeginTransaction();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;try
{
    foreach (var employee in batch)
    {
        ProcessSalary(employee, connection, transaction);
    }

    transaction.Commit();
}
catch
{
    transaction.Rollback();
    throw;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;});&lt;/p&gt;

&lt;p&gt;With this structure, each thread processes its own slice of employees against its own isolated connection and transaction. If one thread's batch encounters an error and rolls back, that rollback only affects the records that specific thread was handling. The employees already committed by other threads, running independently, are entirely unaffected. One thread's failure doesn't retroactively undo another thread's completed, correct work, and no two threads are ever contending for the same connection or transaction at once.&lt;/p&gt;

&lt;p&gt;What Still Needs Attention Beyond Connections&lt;/p&gt;

&lt;p&gt;Isolating connections and transactions per thread solves the most dangerous failure mode, but a few other details matter for a batch like this to behave correctly under concurrency:&lt;/p&gt;

&lt;p&gt;Splitting the work evenly. How employees are divided across threads affects how balanced the workload is; an uneven split means some threads finish early while others remain the bottleneck.&lt;br&gt;
Logging and reporting results. If each thread logs its own outcome independently, those results need to be collected and reconciled afterward into one overall batch report, rather than written to a single shared log object from multiple threads without coordination.&lt;br&gt;
Degree of parallelism. Running too many threads at once against the database can overwhelm the connection pool or the database server itself, so the number of threads needs to be chosen deliberately rather than maximized blindly.&lt;br&gt;
A Practical Question for Any Parallelized Batch&lt;/p&gt;

&lt;p&gt;Before parallelizing any existing sequential batch process, it's worth asking directly: does every piece of shared state in this code, connections, transactions, in-memory counters, logging objects, genuinely belong to one unit of work, or is something being reused across what should be independent, isolated operations? If the answer reveals a connection or transaction being shared across threads, that's the one change that needs to happen before anything else, regardless of how much of a speed improvement the rest of the parallelization offers.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;Multithreading a batch process like salary generation can deliver a genuine, substantial speed improvement, but the benefit depends entirely on isolating each thread's database work from every other thread's. Sharing a single connection and transaction across concurrent threads reproduces the same shared-state corruption risk found in other contexts, like a Singleton DI registration holding per-request data. Giving each thread its own connection and its own transaction removes that risk, keeping each thread's success or failure fully independent of every other thread's, which is exactly the guarantee a process like payroll generation can't afford to lose for the sake of speed.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>multithreading</category>
      <category>csharp</category>
      <category>performance</category>
    </item>
    <item>
      <title>Minimal APIs vs. Controllers: It's Not About Which One Is Newer</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:47:04 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/minimal-apis-vs-controllers-its-not-about-which-one-is-newer-4j1a</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/minimal-apis-vs-controllers-its-not-about-which-one-is-newer-4j1a</guid>
      <description>&lt;p&gt;Minimal APIs get discussed as the newer, leaner way to write ASP.NET Core endpoints, and Controllers as the older, more established approach. That framing makes it sound like a question of which one to switch to. In practice, the more useful question is which one fits a given endpoint or project, since the two solve different shaped problems.&lt;/p&gt;

&lt;p&gt;The Same Endpoint, Two Ways&lt;/p&gt;

&lt;p&gt;Consider a simple endpoint that fetches workflow role mappings for an employee, written first the familiar Controller way:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
[ApiController]&lt;br&gt;
[Route("api/[controller]")]&lt;br&gt;
public class WorkFlowController : ControllerBase&lt;br&gt;
{&lt;br&gt;
    private readonly IclsWorkFlow _workFlowDataAccess;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public WorkFlowController(IclsWorkFlow workFlowDataAccess)
{
    _workFlowDataAccess = workFlowDataAccess;
}

[HttpGet("get-add-rolemapping")]
public IActionResult GetAddRoleMapping(int employeeSlno, int createdBy)
{
    var result = _workFlowDataAccess.GetAddWorkFlows(employeeSlno, createdBy);
    return Ok(result);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;And the same behavior as a Minimal API:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
app.MapGet("/api/workflow/get-add-rolemapping",&lt;br&gt;
    (int employeeSlno, int createdBy, IclsWorkFlow workFlowDataAccess) =&amp;gt;&lt;br&gt;
{&lt;br&gt;
    var result = workFlowDataAccess.GetAddWorkFlows(employeeSlno, createdBy);&lt;br&gt;
    return Results.Ok(result);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Both versions do exactly the same thing. The Minimal API version skips the class, the routing attributes, and the constructor, registering the endpoint directly with a single call. The dependency, IclsWorkFlow, is still supplied automatically by the built-in DI container; it's simply passed as a parameter instead of arriving through a constructor.&lt;/p&gt;

&lt;p&gt;Why Minimal APIs Exist at All&lt;/p&gt;

&lt;p&gt;The Controller version carries a certain amount of structure regardless of how small the endpoint actually is: a class, class-level attributes, and a constructor, before a single line of the endpoint's own logic appears. For a genuinely small service, one with a handful of endpoints total, that structure adds more ceremony than the endpoints need. Minimal APIs were introduced specifically to remove that overhead for cases where it isn't earning its keep, such as small microservices, quick internal utilities, or a lightweight health-check endpoint sitting alongside a larger application.&lt;/p&gt;

&lt;p&gt;Where the Trade-off Actually Shows Up&lt;/p&gt;

&lt;p&gt;The comparison changes once there's more than one endpoint to manage. A workflow-related API with four related operations, written as Controller actions, keeps them together in one class:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class WorkFlowController : ControllerBase&lt;br&gt;
{&lt;br&gt;
    [HttpGet("get-add-rolemapping")]&lt;br&gt;
    public IActionResult GetAddRoleMapping(...) { ... }&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HttpGet("get-delete-rolemappings")]
public IActionResult GetDeleteRoleMappings(...) { ... }

[HttpPost("save-add-role-mapping")]
public IActionResult SaveAddRoleMapping(...) { ... }

[HttpPost("save-delete-role-mapping")]
public IActionResult SaveDeleteRoleMapping(...) { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Written as four separate Minimal API registrations, the same four operations would each need their own app.MapGet or app.MapPost call, typically sitting in Program.cs or a similarly central file:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
app.MapGet("/api/workflow/get-add-rolemapping", (...) =&amp;gt; { ... });&lt;br&gt;
app.MapGet("/api/workflow/get-delete-rolemappings", (...) =&amp;gt; { ... });&lt;br&gt;
app.MapPost("/api/workflow/save-add-role-mapping", (...) =&amp;gt; { ... });&lt;br&gt;
app.MapPost("/api/workflow/save-delete-role-mapping", (...) =&amp;gt; { ... });&lt;/p&gt;

&lt;p&gt;As more modules and endpoints accumulate this way, Program.cs grows large and loses the natural grouping a Controller class provides for free. Minimal APIs can be organized into separate extension methods or files to manage this, but that's additional structure being reintroduced to solve a problem Controllers handle inherently, through the class itself acting as the grouping mechanism.&lt;/p&gt;

&lt;p&gt;A Practical Way to Decide&lt;/p&gt;

&lt;p&gt;The deciding question isn't which style is newer or more fashionable, but which shape the API actually has. A small, standalone service with a few endpoints and little shared context between them is a reasonable fit for Minimal APIs, gaining less ceremony without losing much organization, since there isn't much to organize in the first place. A larger, cohesive application with many related endpoints, shared dependencies, and common conventions across them tends to stay easier to navigate as a set of Controllers, where the class itself keeps related operations grouped as the codebase grows.&lt;/p&gt;

&lt;p&gt;The two approaches aren't mutually exclusive within a single application, either. It's common to see Controllers handling the main, complex business logic of a large system, while a Minimal API endpoint or two handles something small and self-contained alongside it, such as a health check or a simple status endpoint that doesn't warrant its own class.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;Choosing between Minimal APIs and Controllers is a question of fit, not of which one is more current. A handful of small, loosely related endpoints tend to suit Minimal APIs well, trading away structure that wasn't providing much value. A larger set of related endpoints, especially ones sharing dependencies and conventions, tends to stay more maintainable as Controllers, where the class itself does the organizational work that would otherwise have to be rebuilt some other way. The right choice depends on how many endpoints there are and how related they are to each other, not on which approach appeared more recently.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webapi</category>
      <category>aspdotnetcore</category>
    </item>
    <item>
      <title>When the Commit Succeeds but the Response Is Lost: Do You Always Need Operation IDs?</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Mon, 28 Sep 2026 05:21:50 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/when-the-commit-succeeds-but-the-response-is-lost-do-you-always-need-operation-ids-l7n</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/when-the-commit-succeeds-but-the-response-is-lost-do-you-always-need-operation-ids-l7n</guid>
      <description>&lt;p&gt;An earlier article looked at the gap between what a UI shows and what a database actually commits: a screen that announces "Step 1 saved" before a later step fails and the whole transaction rolls back. A reader then pointed out the mirror-image problem, which is just as important. The commit can succeed while the response never reaches the user. The user sees a timeout, assumes the save failed, and tries again, even though the database already changed.&lt;/p&gt;

&lt;p&gt;This article works through that scenario, the fix the reader suggested, and what I found when I checked it against a real monthly batch process.&lt;/p&gt;

&lt;p&gt;The Scenario&lt;/p&gt;

&lt;p&gt;A salary batch screen performs its save in three steps:&lt;/p&gt;

&lt;p&gt;Insert the batch record&lt;br&gt;
Update the related records&lt;br&gt;
Commit, which makes everything permanent&lt;/p&gt;

&lt;p&gt;Consider a timeout that happens after step 3:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
transaction.Commit();          // succeeds, data is now permanent&lt;br&gt;
return Ok("Batch processed");  // this response never reaches the user&lt;/p&gt;

&lt;p&gt;The database is correct. The user's screen says "request timed out." Nothing in the transaction logic was wrong, yet the user now believes the save failed, and the natural reaction is to run it again. In payroll, running a batch twice is exactly the kind of thing that is expensive to untangle later.&lt;/p&gt;

&lt;p&gt;The Suggested Fix&lt;/p&gt;

&lt;p&gt;The reader's approach has three parts:&lt;/p&gt;

&lt;p&gt;A stable operation ID. Each save request carries a unique ID generated before sending. A retry carries the same ID.&lt;br&gt;
An idempotent command. Handling the same operation twice has the same effect as handling it once.&lt;br&gt;
A status endpoint backed by durable state. If the user is unsure what happened, the UI asks the server about that operation ID and gets a definite answer instead of guessing.&lt;/p&gt;

&lt;p&gt;The server keeps a record of every operation ID it has handled. When a retry arrives, it checks the record:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var existing = operationStore.Find(operationId);&lt;/p&gt;

&lt;p&gt;if (existing != null)&lt;br&gt;
{&lt;br&gt;
    return Ok(existing.Result); // already handled, return the original outcome&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// otherwise process normally, then record the operation and its result&lt;/p&gt;

&lt;p&gt;The retry after a timeout no longer causes a second run. The user simply receives the result of the first attempt. The record must be stored durably, in a table rather than in memory, because an in-memory record disappears if the server restarts between the first attempt and the retry. For workflows that also publish events or notifications, an outbox table serves a related purpose, recording the intent to notify in the same transaction as the data change so it can be delivered reliably afterward.&lt;/p&gt;

&lt;p&gt;This is a solid design, and for high-volume systems where retries are frequent it is the right answer.&lt;/p&gt;

&lt;p&gt;What My Own System Already Did&lt;/p&gt;

&lt;p&gt;When I walked through the same scenario for our salary generation screen, I found something the general advice doesn't account for. Salary generation runs once a month, by people who plan for it and are careful with it. And after a successful commit, if someone retries, the screen doesn't process the batch again. It shows "no data found."&lt;/p&gt;

&lt;p&gt;The reason is that there is nothing left to process. The first run changed the state of the data, so a second attempt finds no pending records. The saved data itself acts as the guard. That is a legitimate form of idempotency, achieved through state rather than through operation IDs:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var pending = GetPendingRecords(batchPeriod);&lt;/p&gt;

&lt;p&gt;if (pending.Count == 0)&lt;br&gt;
{&lt;br&gt;
    // nothing left to process&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;For the dangerous part of the problem, duplicate processing after a lost response, this design already holds. No operation ID table was needed.&lt;/p&gt;

&lt;p&gt;The Gap That Remained&lt;/p&gt;

&lt;p&gt;The guard prevents duplicates, but it leaves a communication problem. After a timeout, "no data found" is ambiguous. It could mean:&lt;/p&gt;

&lt;p&gt;the batch was already processed successfully and nothing is left to do&lt;br&gt;
there was never anything to process for that period&lt;br&gt;
something went wrong&lt;/p&gt;

&lt;p&gt;A user who retries after a lost response and sees only "no data found" cannot tell which of these is true, and in payroll, doubt about whether a run went through is a real cost.&lt;/p&gt;

&lt;p&gt;The fix requires no new infrastructure. Instead of reporting an empty result, check whether the batch is already marked as processed and say so:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
if (pending.Count == 0)&lt;br&gt;
{&lt;br&gt;
    var processedOn = GetProcessedDate(batchPeriod);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (processedOn != null)
{
    return Ok($"This batch was already processed on {processedOn:d}.");
}

return Ok("No pending records found for this period.");
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Now the two situations produce different messages, and the user who retries after a timeout gets a clear answer to the question they actually have: did it work?&lt;/p&gt;

&lt;p&gt;Choosing Between the Two Approaches&lt;/p&gt;

&lt;p&gt;The lesson is less about which approach is better and more about matching the machinery to the situation.&lt;/p&gt;

&lt;p&gt;Operation IDs, durable operation records, and status endpoints earn their cost when operations are frequent, retries are common, and the data doesn't naturally reveal whether an operation already ran. A state-based guard, combined with a message that reports the true state, can be enough when an operation is infrequent, tightly controlled, and leaves clear evidence in the data once it has run.&lt;/p&gt;

&lt;p&gt;A useful question to ask of any multi-step save is: if the response is lost after the commit, what stops a retry from repeating the work, and what does the user see when they retry? If the data state already answers the first part, the remaining work may be a clearer message rather than a new subsystem.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;A committed transaction and a delivered response are two separate events, and either one can fail without the other. Operation IDs make the retry safe by recognizing repeated requests. In some systems, the saved data already does that job, and the gap that remains is in what the user is told. Checking which case you are in before adding infrastructure is worth the few minutes it takes.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>database</category>
      <category>trans</category>
      <category>csharp</category>
    </item>
    <item>
      <title>The Gap Between What the UI Shows and What the Database Actually Commits</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Sat, 26 Sep 2026 10:02:47 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/the-gap-between-what-the-ui-shows-and-what-the-database-actually-commits-klo</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/the-gap-between-what-the-ui-shows-and-what-the-database-actually-commits-klo</guid>
      <description>&lt;p&gt;A transaction wrapping multiple database operations guarantees that the data ends up consistent — either everything commits, or everything rolls back. What it says nothing about is what the screen shows the person using the application while that transaction is in progress. A reader's question on a previous article about the Unit of Work pattern surfaced exactly this gap, and it's worth examining carefully, because it's a genuinely separate concern from transactional correctness at the database level.&lt;/p&gt;

&lt;p&gt;The Question That Prompted This&lt;/p&gt;

&lt;p&gt;On a salary batch processing screen using a shared transaction across two save steps, a reader asked: does the UI show a single save failure when the second write fails, or can it make the first step look successful before the rollback happens? For anyone reconciling payroll later, that distinction matters enormously.&lt;/p&gt;

&lt;p&gt;Why This Is a Genuinely Different Problem&lt;/p&gt;

&lt;p&gt;The database-level guarantee is straightforward and, if built correctly, reliable:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var batchResult = InsertBatchRecord(batchModel, connection, transaction);&lt;/p&gt;

&lt;p&gt;if (batchResult.Success)&lt;br&gt;
{&lt;br&gt;
    var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (updateResult.Success)
{
    transaction.Commit();
}
else
{
    transaction.Rollback();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;If UpdateRelatedRecords fails, transaction.Rollback() undoes everything, including the batch insert that technically succeeded moments earlier. The database ends up correct either way.&lt;/p&gt;

&lt;p&gt;But consider what happens if the code also updates the UI at each step along the way:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var batchResult = InsertBatchRecord(batchModel, connection, transaction);&lt;/p&gt;

&lt;p&gt;if (batchResult.Success)&lt;br&gt;
{&lt;br&gt;
    DisplayMessage("Step 1: Batch record saved successfully!"); // shown immediately&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);

if (!updateResult.Success)
{
    transaction.Rollback();
    DisplayMessage("Step 2 failed. Transaction rolled back.");
}
else
{
    transaction.Commit();
    DisplayMessage("Both steps completed successfully.");
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The person watching this screen sees "Step 1: Batch record saved successfully!" before Step 2 has even run. If Step 2 then fails and the rollback executes, the database correctly ends up with no batch record at all — but the user already saw, and may have acted on, a success message for data that no longer exists. Even if a second message eventually clarifies that the transaction rolled back, that first message was real and displayed, and if the person only glanced at the screen once, or the message scrolled past, or they're reviewing a screenshot or log entry taken at that moment, they have genuine evidence of a "success" that the database has since erased.&lt;/p&gt;

&lt;p&gt;Why This Matters Specifically for Reconciliation&lt;/p&gt;

&lt;p&gt;The reader's framing — "for whoever is reconciling payroll" — points at exactly why this distinction has real consequences. Reconciliation often happens after the fact, using whatever evidence is available: screenshots, support tickets, log entries, or someone's memory of what they saw on screen. If the UI displayed an intermediate success message that the database later invalidated through a rollback, that piece of evidence is now actively misleading anyone trying to reconstruct what actually happened. A support ticket referencing "the batch was saved, I saw the confirmation" becomes hard to reconcile against a database that shows no such record — not because anyone made an error, but because the UI communicated something that was only momentarily, provisionally true.&lt;/p&gt;

&lt;p&gt;The Safer Pattern: One Final Status, Not Per-Step Messages&lt;/p&gt;

&lt;p&gt;The straightforward fix is to withhold any success messaging until the entire transaction has actually committed:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var batchResult = InsertBatchRecord(batchModel, connection, transaction);&lt;br&gt;
bool overallSuccess = false;&lt;/p&gt;

&lt;p&gt;if (batchResult.Success)&lt;br&gt;
{&lt;br&gt;
    var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (updateResult.Success)
{
    transaction.Commit();
    overallSuccess = true;
}
else
{
    transaction.Rollback();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
else&lt;br&gt;
{&lt;br&gt;
    transaction.Rollback();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;DisplayMessage(overallSuccess&lt;br&gt;
    ? "Batch processed successfully."&lt;br&gt;
    : "Batch processing failed. No changes were saved.");&lt;/p&gt;

&lt;p&gt;No intermediate messages are shown at all. The person only sees one final status, generated only after the commit or rollback has genuinely happened. This removes the possibility of a UI message ever describing a state that a subsequent rollback later invalidates — the message is only ever displayed once the underlying data is in its final, permanent state.&lt;/p&gt;

&lt;p&gt;When Progress Indicators Are Still Needed&lt;/p&gt;

&lt;p&gt;Some workflows genuinely need to show progress during a multi-step operation, particularly if steps take noticeable time. In those cases, progress indicators should communicate activity, not completion — something like "Processing step 1 of 2..." rather than "Step 1 saved successfully," since the former makes no claim about permanence that a rollback could later contradict, while the latter does.&lt;/p&gt;

&lt;p&gt;A Practical Review Question&lt;/p&gt;

&lt;p&gt;When reviewing any multi-step save operation, it's worth asking not only "does the database stay consistent on failure," but separately, "does any user-facing message get shown before the transaction actually resolves, and could that message become misleading if a later step fails?" These are two different checks, and passing the first doesn't guarantee passing the second — a perfectly correct transaction can still sit behind a UI that tells a story the database doesn't ultimately support.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;Transactional correctness at the database level and accurate communication at the UI level are related but distinct problems. A shared transaction with proper commit and rollback logic guarantees the data ends up consistent, but it says nothing about what gets displayed to a user in the moments before that resolution happens. The safer default is to withhold any success messaging until the entire operation has genuinely committed, so that whatever the user sees — and whatever gets captured in a screenshot, log, or support ticket — accurately reflects the database's final, permanent state, rather than a provisional condition a rollback might later erase.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>database</category>
      <category>uidesign</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Recognizing the Unit of Work Pattern in a Simple Multi-Step Save</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Sat, 26 Sep 2026 04:54:29 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/recognizing-the-unit-of-work-pattern-in-a-simple-multi-step-save-lm9</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/recognizing-the-unit-of-work-pattern-in-a-simple-multi-step-save-lm9</guid>
      <description>&lt;p&gt;Some patterns don't announce themselves with an obvious interface or a textbook-matching class name. Sometimes they're just... there, quietly, in code that looks like ordinary sequential logic. This article walks through a real example: a multi-step save operation that turns out to be a working instance of the Unit of Work pattern, even though nothing in the code is labeled that way.&lt;/p&gt;

&lt;p&gt;The Code in Question&lt;/p&gt;

&lt;p&gt;A salary/batch processing screen performs its save in two steps, calling two separate methods:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var batchResult = InsertBatchRecord(batchModel, connection, transaction);&lt;/p&gt;

&lt;p&gt;if (batchResult.Success)&lt;br&gt;
{&lt;br&gt;
    var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Two details stand out immediately: both calls share the exact same connection and transaction objects, and the second call only executes if the first one succeeded.&lt;/p&gt;

&lt;p&gt;What's Actually Happening Here&lt;/p&gt;

&lt;p&gt;Sharing the same connection and transaction across both calls means these two database operations aren't independent of each other — they're bound together as a single logical unit. Neither InsertBatchRecord nor UpdateRelatedRecords opens its own separate connection or starts its own separate transaction; they both operate within one shared transactional context established somewhere earlier in the calling code.&lt;/p&gt;

&lt;p&gt;The conditional check (if (batchResult.Success)) means the second operation is deliberately gated on the first one's outcome. This isn't accidental sequencing — it's a guard against doing the second write if the first one didn't succeed.&lt;/p&gt;

&lt;p&gt;Put together, this is the essential shape of the Unit of Work pattern: treating multiple related database operations as a single, all-or-nothing unit, so that a partial failure doesn't leave the database in an inconsistent state.&lt;/p&gt;

&lt;p&gt;Why This Matters: What Happens on Failure&lt;/p&gt;

&lt;p&gt;The real value of this structure shows up specifically when something goes wrong. Consider what should happen if UpdateRelatedRecords fails after InsertBatchRecord already succeeded within the same transaction:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
try&lt;br&gt;
{&lt;br&gt;
    var batchResult = InsertBatchRecord(batchModel, connection, transaction);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (batchResult.Success)
{
    var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);

    if (updateResult.Success)
    {
        transaction.Commit();
    }
    else
    {
        transaction.Rollback();
    }
}
else
{
    transaction.Rollback();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
catch (Exception)&lt;br&gt;
{&lt;br&gt;
    transaction.Rollback();&lt;br&gt;
    throw;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Because both operations share the same transaction object, a rollback at any point undoes everything done within that transaction so far — including the batch record insert that technically succeeded moments earlier. Without this shared-transaction structure, a failure in the second step would leave the first step's data permanently saved, with no corresponding update — an inconsistent, partially-completed state that could be difficult to detect and even harder to clean up later, especially in a payroll context where a stray batch record without its related update could cause real downstream confusion.&lt;/p&gt;

&lt;p&gt;Why This Is Easy to Overlook as "Just a Pattern"&lt;/p&gt;

&lt;p&gt;Code like this often doesn't get recognized as implementing a named pattern, because it doesn't involve an interface, a dedicated UnitOfWork class, or any explicit pattern-related vocabulary. It's simply two method calls sharing a connection and transaction, with a success check between them. But the underlying intent — grouping related operations so they succeed or fail together — is precisely what the Unit of Work pattern describes, regardless of whether the code uses that terminology or a more formal implementation involving a dedicated coordinating class.&lt;/p&gt;

&lt;p&gt;Formal implementations of Unit of Work often introduce a dedicated class responsible for tracking all pending changes and committing them together:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class UnitOfWork&lt;br&gt;
{&lt;br&gt;
    private readonly OracleTransaction _transaction;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public bool InsertBatchRecord(BatchModel model) { /* uses _transaction */ }
public bool UpdateRelatedRecords(int batchId, RelatedModel model) { /* uses _transaction */ }

public void Commit() =&amp;gt; _transaction.Commit();
public void Rollback() =&amp;gt; _transaction.Rollback();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;This formalized version makes the pattern more explicit and reusable across different save operations, but the simpler version — passing a shared connection and transaction directly into sequential method calls — achieves the same core guarantee for a single, specific save operation. It's a smaller-scale, less formalized instance of the same underlying idea.&lt;/p&gt;

&lt;p&gt;A Practical Question for Reviewing Multi-Step Saves&lt;/p&gt;

&lt;p&gt;When reviewing any code that performs more than one database write as part of a single logical operation, it's worth asking: if the second (or third, or later) write fails, what happens to the writes that already succeeded? If the honest answer is "they stay saved, creating a partial, inconsistent result," that's a signal the operation needs a shared transaction wrapping all the steps together — exactly the structure already present in this example.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;The Unit of Work pattern doesn't require a dedicated class or explicit pattern-vocabulary to be genuinely present in code. Sharing a single connection and transaction across multiple related database operations, combined with a rollback on any failure, is the pattern's essential behavior, however plainly it's written. Recognizing it in ordinary-looking sequential code — rather than only in formalized, textbook implementations — makes it easier to spot both where the pattern is already protecting data consistency, and where a multi-step save might be missing that protection entirely.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>database</category>
      <category>csharp</category>
      <category>designpatterns</category>
    </item>
    <item>
      <title>Interface for Testability? A Reader's Pushback Made Me Reconsider</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Fri, 25 Sep 2026 11:51:01 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/interface-for-testability-a-readers-pushback-made-me-reconsider-1ll4</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/interface-for-testability-a-readers-pushback-made-me-reconsider-1ll4</guid>
      <description>&lt;p&gt;A previous article argued that registering a concrete class instead of an interface in Dependency Injection quietly determines whether a class can be unit tested. A reader, Ivan Rossouw, pushed back with a genuinely useful correction: framing an interface as the condition for testability oversimplifies things. This article works through that correction properly, because it's a distinction worth understanding precisely, not glossing over.&lt;/p&gt;

&lt;p&gt;The Original Claim&lt;/p&gt;

&lt;p&gt;The earlier argument was straightforward: if a Controller depends on a concrete class directly,&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public WorkFlowController(clsWorkFlow workFlowDataAccess)&lt;/p&gt;

&lt;p&gt;there's no way to substitute a fake implementation during testing, because there's no interface for a fake to implement. Register against an interface instead,&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public WorkFlowController(IclsWorkFlow workFlowDataAccess)&lt;/p&gt;

&lt;p&gt;and a fake IclsWorkFlow can stand in for the real, database-backed clsWorkFlow during a unit test.&lt;/p&gt;

&lt;p&gt;This is true as far as it goes. What it leaves out is that "testable" doesn't only mean "mockable through an interface."&lt;/p&gt;

&lt;p&gt;The Correction: A Concrete Class Can Be Tested Directly&lt;/p&gt;

&lt;p&gt;A concrete class, with no interface at all, can still be tested — just differently:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var realService = new clsWorkFlow(testConnectionString);&lt;br&gt;
var result = realService.GetAddWorkFlows(employeeSlno: 5, createdBy: 1);&lt;br&gt;
Assert.Equal(expectedCount, result.Count);&lt;/p&gt;

&lt;p&gt;This test genuinely runs clsWorkFlow's actual logic, including its real database calls. It's a legitimate test — it's simply a different kind of test than a unit test that isolates logic through a fake dependency. This is called an integration test: it verifies that the class works correctly together with a real (or real-like) database, rather than verifying the class's logic in isolation from everything it depends on.&lt;/p&gt;

&lt;p&gt;Why an Integration Test Can Be More Valuable Here&lt;/p&gt;

&lt;p&gt;For a class like clsWorkFlow, whose entire responsibility is talking to Oracle — building commands, passing parameters, mapping results — a mock-based unit test has a real limitation: a mock can't catch a genuine SQL or parameter-binding mistake, because a mock never touches a real database at all. If a stored procedure name is wrong, or a parameter type doesn't match what Oracle expects, a unit test built entirely on a fake IclsWorkFlow will pass without ever discovering the problem — because the fake never runs any real database logic.&lt;/p&gt;

&lt;p&gt;An integration test, run against a disposable database — a temporary, throwaway database set up specifically for testing and torn down afterward, not the real production database — would catch exactly this kind of error, because it actually executes the real SQL against a real (if temporary) target. For a "thin adapter" class whose main job is translating between application code and database calls, this kind of test can surface more genuine, useful failures than a unit test that mocks the very thing it's supposed to be verifying.&lt;/p&gt;

&lt;p&gt;A More Precise Rule of Thumb&lt;/p&gt;

&lt;p&gt;Ivan's suggested approach, worth adopting as a working rule: introduce an interface around volatile or external behavior — code that reaches outside the application's own process, like a database call, an external API, or a file system operation — and keep genuinely simple, self-contained services concrete, without an interface just for the sake of having one.&lt;/p&gt;

&lt;p&gt;The reasoning: an interface's real value isn't testability in the abstract — it's the ability to substitute behavior when that behavior is expensive, slow, unreliable, or external to the code being tested. A trivial service with no external dependencies doesn't need an interface to be tested; it can simply be instantiated directly, the same way clsWorkFlow can be tested directly against a disposable database.&lt;/p&gt;

&lt;p&gt;A Useful Review Question&lt;/p&gt;

&lt;p&gt;A practical check worth applying when reviewing whether an interface actually earns its place: does this interface express a genuine capability, or does it just duplicate every method on one class?&lt;/p&gt;

&lt;p&gt;An interface like IclsWorkFlow, which exists specifically to allow swapping the real Oracle-backed implementation for a fake one during testing (or, in principle, for a different database implementation later), expresses a real capability — "something that can fetch and save workflow data" — independent of any one specific implementation. An interface that exists purely because "best practice says register interfaces," without any real intention of ever substituting a different implementation, is closer to unnecessary duplication than genuine abstraction.&lt;/p&gt;

&lt;p&gt;What This Changes About the Original Argument&lt;/p&gt;

&lt;p&gt;The core point — that concrete-class registration limits how a Controller can be tested through mocking — still holds. What needed correcting was the implication that mocking through an interface is the only legitimate path to testing, or automatically the best one. For a thin, database-facing class, a well-designed integration test against a disposable database can be more informative than a unit test built on a mock that can never fail the way real database code actually fails.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;Testability isn't a single, uniform requirement satisfied only by interfaces and mocks. A concrete class can be genuinely well-tested through integration testing against a disposable resource, and for classes whose entire purpose is interacting with an external system, this can catch real problems that a mock-based unit test structurally cannot. The more precise habit isn't "always use an interface for testability" — it's introducing interfaces deliberately around volatile or external behavior, and asking, for each one, whether it expresses a real, substitutable capability or merely mirrors a single class's method list.&lt;/p&gt;

</description>
      <category>unittest</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>testing</category>
    </item>
    <item>
      <title>Design Patterns in a Real Project: One I Actually Had, and One I Thought I Had But Didn't</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:55:41 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/design-patterns-in-a-real-project-one-i-actually-had-and-one-i-thought-i-had-but-didnt-2kp9</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/design-patterns-in-a-real-project-one-i-actually-had-and-one-i-thought-i-had-but-didnt-2kp9</guid>
      <description>&lt;p&gt;Design pattern articles often present clean, already-perfect examples: here's the pattern, here's the code, here's why it's elegant. Real codebases are messier than that, and recognizing a pattern accurately — including recognizing when something isn't actually a pattern, just plain code that happens to look similar — is a more useful skill than memorizing textbook definitions. This article walks through two patterns I went looking for in an actual enterprise .NET project: one I genuinely had, and one I initially thought I had, until a closer look showed I didn't.&lt;/p&gt;

&lt;p&gt;Pattern One: The Repository Pattern, Already There Without the Name&lt;/p&gt;

&lt;p&gt;The project in question uses a familiar structure: a Controller layer, and a Data Access layer accessed through an interface.&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public interface IclsWorkFlow&lt;br&gt;
{&lt;br&gt;
    List GetAddWorkFlows(int employeeSlno, int createdBy);&lt;br&gt;
    bool SaveAddWorkFlows(List models);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;public class clsWorkFlow : IclsWorkFlow&lt;br&gt;
{&lt;br&gt;
    public List GetAddWorkFlows(int employeeSlno, int createdBy)&lt;br&gt;
    {&lt;br&gt;
        // ADO.NET / Oracle-specific logic lives here&lt;br&gt;
    }&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public bool SaveAddWorkFlows(List&amp;lt;UserRoleMappingModelDto&amp;gt; models)
{
    // ADO.NET / Oracle-specific logic lives here
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The Controller depends only on IclsWorkFlow, never touching clsWorkFlow or any ADO.NET code directly:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class WorkFlowController : ControllerBase&lt;br&gt;
{&lt;br&gt;
    private readonly IclsWorkFlow _workFlowDataAccess;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public WorkFlowController(IclsWorkFlow workFlowDataAccess)
{
    _workFlowDataAccess = workFlowDataAccess;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;This is a Repository Pattern, even though nothing in the codebase is named "Repository." The naming convention here (cls prefix, following the project's existing style) is different from the textbook IEmployeeRepository / EmployeeRepository naming, but the structural intent is identical: hide database-specific implementation details behind a clean interface, so the rest of the application depends on a contract rather than a concrete data-access mechanism. The flow is straightforward — UI, through a thin Controller acting as the business logic layer, calling the IclsWorkFlow interface, which resolves to clsWorkFlow handling the actual ADO.NET calls.&lt;/p&gt;

&lt;p&gt;Recognizing this mattered less for adding anything new, and more for realizing the project already followed a sound, testable structure — the pattern was present in substance, just not in naming convention.&lt;/p&gt;

&lt;p&gt;Pattern Two: Where I Was Wrong About Template Method&lt;/p&gt;

&lt;p&gt;A separate part of the project has a shared base class for different request types — leave requests, advance requests — each inheriting common fields and an approval method:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
class BaseRequest&lt;br&gt;
{&lt;br&gt;
    public string EmpID;&lt;br&gt;
    public int Status;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public void ApproveLevel(int currentSeqNo, int nextSeqNo)
{
    Status = (currentSeqNo == nextSeqNo) ? 1 : 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;class LeaveRequest : BaseRequest&lt;br&gt;
{&lt;br&gt;
    public DateTime FromDate;&lt;br&gt;
    public DateTime ToDate;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;My first instinct was to call this a Template Method Pattern — a base class defining a process, subclasses customizing pieces of it. On closer inspection, that label doesn't actually fit. ApproveLevel() is a single method with fixed logic, not a defined sequence of multiple steps, and LeaveRequest doesn't override or customize any part of it. This is plain Inheritance: shared fields and one shared method, reused by subclasses that add their own unrelated fields. Calling it Template Method would have been applying the label to code that doesn't actually meet the pattern's requirements.&lt;/p&gt;

&lt;p&gt;What Would Actually Make It a Template Method Pattern&lt;/p&gt;

&lt;p&gt;The distinguishing requirement for Template Method is a defined sequence of steps in the base class, where individual steps — not the overall order — are customized by subclasses:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
abstract class BaseRequest&lt;br&gt;
{&lt;br&gt;
    // The "template" - a fixed sequence, defined once, never overridden&lt;br&gt;
    public void ProcessRequest()&lt;br&gt;
    {&lt;br&gt;
        ValidateRequest();&lt;br&gt;
        CheckApprovalLevel();&lt;br&gt;
        SendNotification();&lt;br&gt;
    }&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;protected virtual void ValidateRequest()
{
    // default shared validation, subclasses may override
}

protected void CheckApprovalLevel()
{
    // shared approval logic, same for every request type
}

protected abstract void SendNotification();
// forces each subclass to implement its own version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;class LeaveRequest : BaseRequest&lt;br&gt;
{&lt;br&gt;
    protected override void SendNotification()&lt;br&gt;
    {&lt;br&gt;
        // "Your leave from X to Y is approved"&lt;br&gt;
    }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;class AdvanceRequest : BaseRequest&lt;br&gt;
{&lt;br&gt;
    protected override void SendNotification()&lt;br&gt;
    {&lt;br&gt;
        // "Your advance of X is approved"&lt;br&gt;
    }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;With ProcessRequest() defining the fixed sequence — validate, then check approval, then notify — and only SendNotification() varying per subclass, this genuinely qualifies. The overall algorithm's shape is locked in the base class; only specific steps are customizable. That's the actual requirement, and it's a meaningfully different structure from simply sharing a field and a method across subclasses.&lt;/p&gt;

&lt;p&gt;Why the Distinction Is Worth Making Carefully&lt;/p&gt;

&lt;p&gt;It would have been easy to write "we use the Template Method Pattern here" in a design document or a technical interview answer, based on surface similarity — inheritance, a shared method, subclasses. But applying a pattern name to code that doesn't structurally satisfy the pattern's actual definition creates a false sense of design sophistication, and can mislead a teammate or reviewer who takes the label at face value and expects a defined multi-step algorithm that isn't actually there. Recognizing plain Inheritance as plain Inheritance, rather than reaching for a more impressive-sounding pattern name, is a more honest and more useful skill than pattern-matching on surface features.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;Design patterns are most useful when they accurately describe a structure already serving a real purpose, not when they're retrofitted onto code because the shape looks vaguely similar. A Repository Pattern can exist in a codebase without ever using the word "Repository," as long as the underlying structure — interface-based abstraction over data access — is genuinely there. Conversely, code that merely shares fields and a method through inheritance isn't a Template Method Pattern unless it defines an actual sequence of steps with specific points designed for customization. Being precise about which is which, even when it means concluding "this is just inheritance, not a named pattern," is a more valuable habit than confidently mislabeling code either way.&lt;/p&gt;

</description>
      <category>designpatterns</category>
      <category>csharp</category>
      <category>architecture</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Registering a Concrete Class Instead of an Interface: A Small DI Detail With a Big Testing Cost</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Wed, 23 Sep 2026 04:08:55 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/registering-a-concrete-class-instead-of-an-interface-a-small-di-detail-with-a-big-testing-cost-4da3</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/registering-a-concrete-class-instead-of-an-interface-a-small-di-detail-with-a-big-testing-cost-4da3</guid>
      <description>&lt;p&gt;Dependency Injection registration often gets reviewed for one thing: which lifetime was chosen — Singleton, Scoped, or Transient. A separate, easy-to-miss detail matters just as much for the long-term health of a codebase: whether the registration points to an interface or a concrete class. This distinction rarely causes a visible bug on its own, but it quietly determines whether a class can ever be properly unit tested.&lt;/p&gt;

&lt;p&gt;Two Registrations That Look Similar But Aren't&lt;/p&gt;

&lt;p&gt;Consider these two ways of registering the same underlying class:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
// Registration A - concrete class only&lt;br&gt;
services.AddTransient();&lt;/p&gt;

&lt;p&gt;// Registration B - interface mapped to implementation&lt;br&gt;
services.AddScoped();&lt;/p&gt;

&lt;p&gt;Both successfully let clsWorkFlow be injected somewhere in the application. Both compile without error, and both work correctly at runtime for normal request handling. The difference only becomes visible when you try to do something with the class beyond its default, real-database behavior — specifically, when you try to test it.&lt;/p&gt;

&lt;p&gt;What the Consuming Controller Looks Like in Each Case&lt;/p&gt;

&lt;p&gt;Registration A typically pairs with a Controller like this:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class WorkFlowController : ControllerBase&lt;br&gt;
{&lt;br&gt;
    private readonly clsWorkFlow _workFlowDataAccess;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public WorkFlowController(clsWorkFlow workFlowDataAccess)
{
    _workFlowDataAccess = workFlowDataAccess;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Registration B pairs with this instead:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class WorkFlowController : ControllerBase&lt;br&gt;
{&lt;br&gt;
    private readonly IclsWorkFlow _workFlowDataAccess;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public WorkFlowController(IclsWorkFlow workFlowDataAccess)
{
    _workFlowDataAccess = workFlowDataAccess;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;At a glance, these look almost identical — one word different in two places. But that one word changes what's possible during testing.&lt;/p&gt;

&lt;p&gt;Why the Interface Version Can Be Tested and the Concrete Version Can't&lt;/p&gt;

&lt;p&gt;To unit test a Controller in isolation — without hitting a real Oracle database — you need to supply a fake or mock version of whatever it depends on. This is only possible when the dependency is expressed as an interface:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class FakeWorkFlow : IclsWorkFlow&lt;br&gt;
{&lt;br&gt;
    public List GetAddWorkFlows(int employeeSlno, int createdBy)&lt;br&gt;
    {&lt;br&gt;
        return new List&lt;br&gt;
        {&lt;br&gt;
            new CtrlSlnoName { CtrlSlno = 1, Name = "Test Workflow" }&lt;br&gt;
        };&lt;br&gt;
    }&lt;br&gt;
    // remaining interface methods implemented with fake data&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;With the Controller depending on IclsWorkFlow, a test can substitute FakeWorkFlow in place of the real, database-backed clsWorkFlow:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
var controller = new WorkFlowController(new FakeWorkFlow());&lt;br&gt;
var result = controller.GetAddRoleMapping(employeeSlno: 5, createdBy: 1);&lt;br&gt;
// assert on result, no real database touched&lt;/p&gt;

&lt;p&gt;If the Controller instead depends directly on the concrete clsWorkFlow class, there is no interface to implement a fake version of. FakeWorkFlow can't be substituted in, because the Controller's constructor demands the actual clsWorkFlow type specifically, not anything that merely behaves like it. The only way to test this Controller is to run it against a real (or real-like) Oracle connection — slower, harder to set up reliably, and prone to failing for reasons unrelated to the actual logic being tested, like a database being temporarily unreachable.&lt;/p&gt;

&lt;p&gt;Why This Detail Is Easy to Miss in Practice&lt;/p&gt;

&lt;p&gt;Registering a concrete class directly is often the path of least resistance early in a project — it compiles, it works, and the missing interface doesn't cause any errors or warnings. The cost only becomes apparent later, when someone tries to add tests to a Controller and discovers there's no way to isolate it from the database. At that point, retrofitting an interface onto an already-widely-used concrete class means touching every registration and every constructor that depends on it — a much larger change than defining the interface correctly from the start would have been.&lt;/p&gt;

&lt;p&gt;This is also why interface-based registration is considered a default good practice in ASP.NET Core projects, even when no tests exist yet at the time a class is written. The interface costs very little to add upfront — a few lines defining method signatures — but keeps the door open for testing later, without requiring a disruptive refactor across the codebase.&lt;/p&gt;

&lt;p&gt;A Practical Habit for Reviewing Registrations&lt;/p&gt;

&lt;p&gt;When reviewing or writing a new service registration, it's worth checking two things together rather than just one: not only which lifetime was chosen, but whether the registration maps an interface to an implementation, or registers a concrete type on its own. A registration like services.AddTransient() works today, but it's worth asking whether this class will ever need to be tested in isolation, mocked, or swapped for an alternative implementation later. If the answer is plausibly yes — which is true for most Data Access classes — defining and registering against an interface from the outset avoids a more expensive change down the line.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;The difference between services.AddTransient() and services.AddScoped() looks trivial on the surface, but it determines whether a class can ever be properly unit tested without touching a real database. Interface-based registration doesn't change how the application behaves for a real user, but it directly shapes how maintainable and testable the codebase remains as it grows — a detail worth checking in every registration, not just the lifetime chosen alongside it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>dependencyinversion</category>
      <category>csharp</category>
      <category>unittest</category>
    </item>
    <item>
      <title>Singleton, Scoped, or Transient: Why DI Lifetime Choice Is the Same Bug You Already Know</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Mon, 21 Sep 2026 09:02:35 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/singleton-scoped-or-transient-why-di-lifetime-choice-is-the-same-bug-you-already-know-1b4e</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/singleton-scoped-or-transient-why-di-lifetime-choice-is-the-same-bug-you-already-know-1b4e</guid>
      <description>&lt;p&gt;Dependency Injection in ASP.NET Core is often taught as a simple mechanical pattern: register an interface, request it in a constructor, the framework wires it up. What's frequently glossed over is that choosing the wrong lifetime for a registered service can silently reproduce the exact same class of bug that shows up in completely unrelated contexts — including the classic ASP.NET Session multi-tab problem many developers eventually encounter. Understanding the connection between these two makes both concepts easier to reason about.&lt;/p&gt;

&lt;p&gt;The Three Lifetimes, Briefly&lt;/p&gt;

&lt;p&gt;ASP.NET Core's built-in DI container offers three lifetime options when registering a service:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
// One instance for the entire application's lifetime&lt;br&gt;
services.AddSingleton();&lt;/p&gt;

&lt;p&gt;// One instance per HTTP request&lt;br&gt;
services.AddScoped();&lt;/p&gt;

&lt;p&gt;// A new instance every single time it's requested&lt;br&gt;
services.AddTransient();&lt;/p&gt;

&lt;p&gt;For a Controller like this:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class WorkFlowController : ControllerBase&lt;br&gt;
{&lt;br&gt;
    private readonly IclsWorkFlow _workFlowDataAccess;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public WorkFlowController(IclsWorkFlow workFlowDataAccess)
{
    _workFlowDataAccess = workFlowDataAccess;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The Controller never writes new clsWorkFlow() itself — the DI container creates and supplies the instance automatically, based on how it was registered. But which lifetime was chosen for that registration has real consequences that only surface under concurrent load.&lt;/p&gt;

&lt;p&gt;The Scenario: Two Users, One Instance&lt;/p&gt;

&lt;p&gt;Imagine clsWorkFlow is registered as a Singleton. There is now exactly one instance of this class for the entire running application — shared by every user, on every request, simultaneously.&lt;/p&gt;

&lt;p&gt;Suppose, for the sake of illustration, that this Data Access class holds some piece of state internally during a method call — even something as seemingly harmless as a private field tracking the employee ID currently being processed:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public class clsWorkFlow : IclsWorkFlow&lt;br&gt;
{&lt;br&gt;
    private string _currentEmpId; // dangerous if this class is a Singleton&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public List&amp;lt;CtrlSlnoName&amp;gt; GetAddWorkFlows(int employeeSlno, int createdBy)
{
    _currentEmpId = employeeSlno.ToString();
    // ... fetch data using _currentEmpId ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;If two requests arrive close together — User A's request and User B's request — and both are being served by the same shared Singleton instance, this sequence becomes possible:&lt;/p&gt;

&lt;p&gt;User A's request begins, sets _currentEmpId = "1023"&lt;br&gt;
Before User A's request finishes processing, User B's request arrives and overwrites _currentEmpId = "2045", using the same shared instance&lt;br&gt;
User A's request completes, but by now _currentEmpId has been overwritten — User A's operation may complete using User B's data instead of their own&lt;/p&gt;

&lt;p&gt;No exception is thrown. No error is logged. The application appears to work correctly under light, sequential testing, and the failure only appears under real concurrent traffic — exactly the conditions that are hardest to reproduce in a typical development or QA environment.&lt;/p&gt;

&lt;p&gt;The Same Root Cause as a Familiar Bug&lt;/p&gt;

&lt;p&gt;This is structurally identical to a well-known ASP.NET problem: Session state being overwritten when a user has multiple browser tabs open. In that scenario, two tabs share the same Session object because they share the same session cookie; whichever tab writes last "wins," and the other tab's data is silently corrupted at the moment of save.&lt;/p&gt;

&lt;p&gt;The DI Singleton scenario is the same failure pattern at a different layer: instead of two browser tabs sharing one Session, it's two concurrent HTTP requests sharing one service instance. In both cases, the underlying mistake is the same — treating shared, mutable state as if it were private to a single operation, when in reality it's accessible to multiple operations happening at once.&lt;/p&gt;

&lt;p&gt;Why Scoped Is the Safer Default for Data Access&lt;/p&gt;

&lt;p&gt;Registering clsWorkFlow as Scoped instead of Singleton eliminates this entire class of problem for the common case:&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
services.AddScoped();&lt;/p&gt;

&lt;p&gt;With Scoped lifetime, each HTTP request gets its own fresh instance of clsWorkFlow. User A's request and User B's request, even arriving at the exact same moment, are working with two completely separate objects. There's no shared internal field to overwrite, because there's no sharing happening at all between requests.&lt;/p&gt;

&lt;p&gt;This is why Scoped is the conventional default for Data Access classes and anything tied to per-request context (like a database connection or a current-user identifier): it matches the natural boundary of "one request, one unit of work" without the overhead of creating a brand-new instance multiple times within a single request, which is what Transient would do unnecessarily.&lt;/p&gt;

&lt;p&gt;Why This Isn't About Authorization&lt;/p&gt;

&lt;p&gt;It's worth being precise about what this problem is, and what it isn't. Role-based access control — checking whether a user is authorized to see or modify certain data — is a completely separate concern from thread-safety and instance scoping. A system can have perfectly correct authorization logic and still suffer from this exact bug, because the two problems operate at different layers: authorization determines what a user is allowed to access; scoping determines whether concurrent operations interfere with each other at the object level. Fixing one does nothing to address the other.&lt;/p&gt;

&lt;p&gt;A Practical Check&lt;/p&gt;

&lt;p&gt;A useful habit when registering any service in DependencyInjection.cs: ask whether the class holds any state that changes during a method call, even temporarily. If it does, and it's registered as Singleton, that state is a shared resource across every concurrent user — a strong candidate for the same category of bug described above. Stateless services, or services whose state is fully contained within a single method call and never stored as a field, are safe as Singletons. Anything that stores per-request or per-user data as instance state should almost always be Scoped instead.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;Dependency Injection lifetime selection isn't just a performance or memory optimization detail — choosing Singleton for a service that isn't genuinely safe to share across concurrent requests can silently reintroduce the same class of data-corruption bug found in classic Session-scoping mistakes, just at a different architectural layer. Recognizing the pattern — shared mutable state, accessed by more than one operation at once — makes it possible to spot this risk in code review before it ever reaches production, rather than debugging it after a support ticket reports data that doesn't make sense.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>webapi</category>
      <category>dependencyinversion</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Controller and Data Access: Understanding 2-Layer Architecture Through Abstraction and Encapsulation</title>
      <dc:creator>Dhana</dc:creator>
      <pubDate>Sat, 19 Sep 2026 04:04:07 +0000</pubDate>
      <link>https://dev.to/dhanagani_lakshmi_2487ad0/controller-and-data-access-understanding-2-layer-architecture-through-abstraction-and-encapsulation-42ad</link>
      <guid>https://dev.to/dhanagani_lakshmi_2487ad0/controller-and-data-access-understanding-2-layer-architecture-through-abstraction-and-encapsulation-42ad</guid>
      <description>&lt;p&gt;Not every real-world application needs full Clean Architecture with its multiple layers, dependency inversion, and strict domain boundaries. Many production systems — particularly internal enterprise applications like HRMS and Payroll platforms — run successfully on a simpler, pragmatic 2-layer architecture: a Controller layer and a Data Access layer. Understanding why this simpler structure works, and how it relates to the same OOP principles behind Clean Architecture, makes it easier to work confidently in either kind of codebase.&lt;/p&gt;

&lt;p&gt;The Two Layers&lt;/p&gt;

&lt;p&gt;A 2-layer architecture splits an application into exactly what the name suggests:&lt;/p&gt;

&lt;p&gt;Layer 1 — Controller Layer: handles incoming HTTP requests, and delegates the actual work elsewhere.&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
[ApiController]&lt;br&gt;
[Route("api/[controller]")]&lt;br&gt;
public class LeaveRequestController : ControllerBase&lt;br&gt;
{&lt;br&gt;
    private readonly ILeaveRequestDataAccess _dataAccess;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public LeaveRequestController(ILeaveRequestDataAccess dataAccess)
{
    _dataAccess = dataAccess;
}

[HttpGet("get-emp-details")]
public IActionResult GetEmployeeDetails(string empId)
{
    var result = _dataAccess.GetEmployeeDetails(empId);
    return Ok(result);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Layer 2 — Data Access Layer: handles the actual database interaction — connection strings, parameters, stored procedure or package calls.&lt;/p&gt;

&lt;p&gt;csharp&lt;br&gt;
public interface ILeaveRequestDataAccess&lt;br&gt;
{&lt;br&gt;
    EmployeeDto GetEmployeeDetails(string empId);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;public class LeaveRequestDataAccess : ILeaveRequestDataAccess&lt;br&gt;
{&lt;br&gt;
    public EmployeeDto GetEmployeeDetails(string empId)&lt;br&gt;
    {&lt;br&gt;
        // ADO.NET/Oracle-specific logic lives here:&lt;br&gt;
        // connection string, OracleCommand, package name, parameter types&lt;br&gt;
        return employeeData;&lt;br&gt;
    }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The Controller never touches ADO.NET directly. The Data Access layer never touches HTTP concerns like status codes or routing. Each layer has exactly one job.&lt;/p&gt;

&lt;p&gt;Why This Is Abstraction and Encapsulation in Practice&lt;/p&gt;

&lt;p&gt;This separation isn't just organizational tidiness — it's a direct, practical application of two core OOP principles.&lt;/p&gt;

&lt;p&gt;Abstraction: the Controller calls _dataAccess.GetEmployeeDetails(empId) without knowing or caring how that data actually gets retrieved. It doesn't know whether the underlying call uses a stored procedure, a package function, or raw SQL. It only needs to know that calling this method returns the data it asked for. The internal complexity of connection handling, parameter binding, and query execution is hidden behind a simple method signature.&lt;/p&gt;

&lt;p&gt;Encapsulation: the Data Access layer protects the details of how data is fetched from being scattered across the codebase. If every controller method directly opened its own OracleConnection and wrote its own SQL, any change to the database — a renamed package, a new parameter, a switched provider — would require hunting down and editing code in dozens of places. By encapsulating that logic inside one dedicated layer, a change only needs to happen once.&lt;/p&gt;

&lt;p&gt;The Practical Test: What Happens When the Database Changes&lt;/p&gt;

&lt;p&gt;A useful way to confirm this separation is working correctly is to ask: if the underlying database technology changed — say, from Oracle to SQL Server — which layer would need to change?&lt;/p&gt;

&lt;p&gt;The answer should be: only the Data Access layer. The connection strings, parameter types, and query syntax living inside LeaveRequestDataAccess would need rewriting. The Controller, which only knows about the ILeaveRequestDataAccess interface and calls GetEmployeeDetails(empId), wouldn't need to change at all — it has no idea, and no need to know, what database sits behind that interface.&lt;/p&gt;

&lt;p&gt;If a database change ever required touching Controller code too, that would be a signal the separation isn't clean — some data-access detail has leaked into a layer that shouldn't know about it.&lt;/p&gt;

&lt;p&gt;How This Relates to Clean Architecture&lt;/p&gt;

&lt;p&gt;Clean Architecture takes this same underlying idea — separating concerns so that change in one area doesn't ripple through the whole system — and extends it further, typically into more layers: a Domain layer (core business rules, independent of any framework), an Application layer (use cases, orchestration), an Infrastructure layer (database, external services), and a Presentation layer (API or UI).&lt;/p&gt;

&lt;p&gt;The core principle is identical to the 2-layer setup: outer layers depend on inner layers, never the other way around, and each layer only knows what it strictly needs to know. A 2-layer Controller/Data Access split is, in effect, a simplified version of this same idea — fewer layers, less ceremony, but the same underlying discipline of not letting database-specific details leak into request-handling code, and not letting request-handling concerns leak into data-access code.&lt;/p&gt;

&lt;p&gt;For many internal enterprise applications, a full Clean Architecture setup — with separate Domain, Application, and Infrastructure projects, dependency inversion containers, and strict boundary enforcement — is more structure than the project actually needs. A well-maintained 2-layer architecture, with a clear interface between Controller and Data Access, delivers much of the same practical benefit — testability, easier maintenance, isolated change — without the additional overhead.&lt;/p&gt;

&lt;p&gt;When 2 Layers Stop Being Enough&lt;/p&gt;

&lt;p&gt;The 2-layer pattern works well until business logic starts accumulating inside the Controller itself — validation rules, calculations, workflow decisions that have nothing to do with HTTP handling or database access. When that happens, it's usually a sign that a third layer — often called a Service or Business Logic layer — is needed to hold that logic separately, keeping the Controller thin and focused purely on request/response handling. This is often the natural next step toward something closer to Clean Architecture, added incrementally as a project's complexity genuinely justifies it, rather than adopted wholesale from day one.&lt;/p&gt;

&lt;p&gt;Takeaway&lt;/p&gt;

&lt;p&gt;A 2-layer Controller/Data Access architecture isn't a simplified shortcut that skips "real" architecture — it's a direct, practical application of Abstraction and Encapsulation at the system level, just as those same principles apply within a single class. Understanding it this way makes the reasoning behind Clean Architecture's more elaborate layering easier to grasp too: both are solving the same underlying problem — isolating change, hiding implementation detail, and keeping each part of the system focused on exactly one responsibility — just at different levels of complexity depending on what the project actually needs.&lt;/p&gt;

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