<?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: Kazem</title>
    <description>The latest articles on DEV Community by Kazem (@kazemmdev).</description>
    <link>https://dev.to/kazemmdev</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%2F364498%2F589ae8fd-fd0c-4c55-bec2-f339b2dca232.jpg</url>
      <title>DEV Community: Kazem</title>
      <link>https://dev.to/kazemmdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kazemmdev"/>
    <language>en</language>
    <item>
      <title>MCP Doesn't Make Your Agent Smarter: It Just Tells It What's Already There</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 22 Sep 2026 06:12:16 +0000</pubDate>
      <link>https://dev.to/kazemmdev/mcp-doesnt-make-your-agent-smarter-it-just-tells-it-whats-already-there-1235</link>
      <guid>https://dev.to/kazemmdev/mcp-doesnt-make-your-agent-smarter-it-just-tells-it-whats-already-there-1235</guid>
      <description>&lt;p&gt;Every explanation of MCP I've read starts by making it sound like a new kind of intelligence, and that's exactly what trips people up. You read "Model Context Protocol" and your brain fills in something clever happening under the hood: some extra reasoning layer, some new capability the model didn't have before. It isn't that. MCP doesn't make an LLM smarter or an agent more capable. It just standardizes how an agent finds out what tools it has and how to call them. That's the whole thing. Once you see it that way, it stops being a mystery and starts looking like plumbing you've built a version of before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent actually needs
&lt;/h2&gt;

&lt;p&gt;An LLM on its own only produces text. Ask it to book a flight and the best it can do is describe how you'd book one yourself. It can't reach out and click anything. An agent is what you get when you wrap that LLM with tools, memory, and a loop: it can call something, look at the result, decide what to do next, and call something else. That loop is what turns "answer a question" into "go do a thing."&lt;/p&gt;

&lt;p&gt;To go do a thing, the agent needs a way to reach the outside world. That's what a tool is: &lt;code&gt;searchFlights()&lt;/code&gt;, &lt;code&gt;bookFlight()&lt;/code&gt;, whatever. Underneath, those tools usually just call an API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent → Tool → API → External service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of this is new. It's the same shape as any integration you've written. The interesting part is what happens when there isn't just one API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: every service describes itself differently
&lt;/h2&gt;

&lt;p&gt;Say there are a few hundred airlines, each with its own API. One returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"flightNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SFO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JFK"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another returns the same information as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SFO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JFK"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different field names, probably different endpoint names too. If you want one agent that can work with all of them, you end up writing a separate adapter per airline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent
 ├── Airline A Adapter
 ├── Airline B Adapter
 ├── Airline C Adapter
 └── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a service, write another adapter. That's not a hard problem conceptually, it's just tedious in a way that scales badly. This is the exact problem MCP is aimed at, not "agents can't think," but "agents can't discover and use arbitrary services without someone hand-wiring each one."&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP is a way to describe capabilities, not a way to gain them
&lt;/h2&gt;

&lt;p&gt;MCP (Model Context Protocol) gives a service a standard way to say: here's what I can do, here's what input each thing needs, here's what it returns. An MCP server for an airline might expose something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Capabilities:
  searchFlights
  bookFlight
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with a schema attached to each one, roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"searchFlights"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Search available flights"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An MCP client, the thing your agent runs inside, connects to that server and discovers this. The agent no longer needs &lt;code&gt;Airline A: /api/flights&lt;/code&gt;, &lt;code&gt;Airline B: /flights-list&lt;/code&gt;, &lt;code&gt;Airline C: /list-flights&lt;/code&gt; hardcoded anywhere. It asks the server what's available and gets a consistent shape back, regardless of what the underlying API actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP server sits between the agent and your API, not in place of it
&lt;/h2&gt;

&lt;p&gt;This is the part that's easy to miss: the MCP server isn't the business logic. It's a thin layer in front of logic you already have.&lt;/p&gt;

&lt;p&gt;Without MCP, if you wrote the agent yourself, you'd hardcode the API calls directly into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;flights&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;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Flight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"/api/flights?from=SFO&amp;amp;to=JFK"&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;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"/api/flights/book"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;booking&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent has to know the API's shape. With an MCP server in between, you wrap the same calls as tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"searchFlights"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Flight&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SearchFlights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;flightApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&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="nf"&gt;McpTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bookFlight"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BookingResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;BookFlight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;flightId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;flightApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flightId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what's inside those methods: &lt;code&gt;flightApi.Search(...)&lt;/code&gt;, &lt;code&gt;flightApi.Book(...)&lt;/code&gt;. That's still your existing client hitting your existing API. The MCP layer's job is only to describe those two methods to the agent in a shape it can discover and call. The chain is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCP Tool → Flight API Client → Flight Service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're not rebuilding the Flight Service. You're exposing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the round trip looks like
&lt;/h2&gt;

&lt;p&gt;Say the user asks: &lt;em&gt;"Find me the cheapest flight from SFO to JFK."&lt;/em&gt; The LLM looks at the tools it's been given, decides &lt;code&gt;searchFlights&lt;/code&gt; fits, and produces a tool call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"searchFlights"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SFO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JFK"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That goes to the MCP client, which sends it to the MCP server, which turns it into the actual HTTP call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LLM → tool call → MCP Client → MCP Server → HTTP request → Flight API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API responds with something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FL123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"airline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Air A"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FL456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"airline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Air B"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comes back up through the same chain, the agent hands it to the LLM, and the LLM picks FL456 as the cheaper option. If the user then says "book it," the same pattern repeats with &lt;code&gt;bookFlight&lt;/code&gt;, and the API eventually returns a booking reference. Nothing in this sequence required the model to know anything about the Flight API's actual shape. It only needed the tool's name and schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters if you're a backend developer
&lt;/h2&gt;

&lt;p&gt;This is the part that made MCP click for me, because I already have a service with exactly this shape. ProcessHub, the workflow platform I've been building, has modules like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ProcessHub
 ├── Workflow
 ├── Employee
 ├── Loan
 ├── Task
 └── Notification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could expose an MCP server on top of it with tools like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;getEmployee
getPendingTasks
getLoanStatus
getWorkflowStatus
approveTask
rejectTask
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of that is new backend work. It's the same application services that already back the UI. The MCP server just makes them discoverable and callable by an agent instead of only reachable through a browser click.&lt;/p&gt;

&lt;p&gt;Then a request like "check the status of Ali Rezaei's loan request" becomes a sequence the LLM can work out on its own: call &lt;code&gt;getEmployee&lt;/code&gt;, then &lt;code&gt;getLoanStatus&lt;/code&gt;, then &lt;code&gt;getWorkflowStatus&lt;/code&gt;. Or "find the loan requests waiting on my approval" maps straight to &lt;code&gt;getPendingTasks&lt;/code&gt;. The agent isn't reasoning about loans or workflows in some deeper sense, it's matching a request to the tools it was told exist, the same way it matched "find the cheapest flight" to &lt;code&gt;searchFlights&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the actual value for someone in my position: you don't rebuild anything to make your system agent-usable. You write a thin MCP layer over the application services you already have, and the discovery and calling problem, the part that used to mean one bespoke adapter per integration, gets handled by the protocol instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing worth keeping in mind: permission
&lt;/h2&gt;

&lt;p&gt;None of this means the agent should just go do things unsupervised. For anything sensitive, booking something, approving a task, changing data, it makes sense for the agent to ask first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent: I want to book this flight. Approve?
User: Approve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only then does the actual call happen. For something like &lt;code&gt;approveTask&lt;/code&gt; on a loan workflow, that confirmation step isn't optional. It's the difference between a useful assistant and one you can't trust near real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves things
&lt;/h2&gt;

&lt;p&gt;The LLM still does the thinking. The agent still does the looping between the LLM and its tools. What MCP adds is a way for the agent to find out what's callable, without someone hand-coding that discovery for every single service. If you've already got working APIs behind your product, most of the work isn't teaching an agent to be smart about them. It's writing the thin layer that tells the agent they exist.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What Software Engineers Skip Before Building an AI Agent</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 22 Sep 2026 05:16:22 +0000</pubDate>
      <link>https://dev.to/kazemmdev/what-software-engineers-skip-before-building-an-ai-agent-46kg</link>
      <guid>https://dev.to/kazemmdev/what-software-engineers-skip-before-building-an-ai-agent-46kg</guid>
      <description>&lt;p&gt;If you're coming into AI agents from software engineering, you've probably run into LLM, Embedding, Vector Database, RAG, Tool Calling, LangChain, LangGraph, and MCP as separate topics. Different blog posts, different tutorials, different mental models. That's the problem. In a real system, all of these are pieces of one architecture, and until you see how they connect, you're just memorizing framework APIs.&lt;/p&gt;

&lt;p&gt;Here's the piece that matters most: building an agent is not a prompting trick. It's a systems engineering problem. The concerns that already define backend work don't disappear because there's an LLM involved. You just get one more component in the system, and this one is non-deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LLM is a processing service with a limited input
&lt;/h2&gt;

&lt;p&gt;Strip away the hype and an LLM is close to a service call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input → LLM → Output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except the input isn't a plain string. The model works in tokens, and there's a hard limit on how many tokens it can process per request. You can't dump an entire database or every file in a company into a prompt and hope for the best. That's the first real constraint on any AI system's architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application → Context → LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job becomes deciding what context the model actually gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context window is working memory, and managing it is architecture
&lt;/h2&gt;

&lt;p&gt;Think of the context window like a process's working memory. In a real request it usually has to hold the system prompt, conversation history, the user's query, retrieved documents, and tool results, all at once. If that exceeds the model's capacity, you can't send everything. So managing context becomes its own design problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Conversation → Select relevant history → Retrieve relevant documents → Build context → LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the seed of everything that follows. RAG and agents are both, in different ways, answers to "what goes in the context window."&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings and vector search: finding things by meaning
&lt;/h2&gt;

&lt;p&gt;An LLM is good at generating text, but it's not the right tool for searching a large body of information by meaning. That's what embeddings are for: they turn text into a numeric vector. "How do I reset my password?" becomes something like &lt;code&gt;[0.12, -0.44, 0.81, ...]&lt;/code&gt;. Two pieces of text with similar meaning end up with vectors that are close together: "How can I reset my password?" and "I forgot my password, what should I do?" land near each other even though the wording is different. That's the whole basis of semantic search.&lt;/p&gt;

&lt;p&gt;Once you have embeddings for a large set of documents, you need somewhere to store and query them — a vector database. The structure is simple: each document has its text, its metadata, and its embedding. A user query gets embedded the same way, and vector search returns the documents closest to it in that space. You're no longer matching keywords; you're matching meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunking, and why the boundary between chunks matters
&lt;/h2&gt;

&lt;p&gt;You don't embed a 200-page PDF as one vector. You split it into chunks — an employee handbook becomes a leave policy, a remote work policy, a salary policy, a security policy, each with its own embedding.&lt;/p&gt;

&lt;p&gt;The one detail worth remembering: if you cut a document at an arbitrary point, you can lose context right at the boundary. Say one chunk ends with "Employees can request annual leave after completing their probation period..." and the next starts with "The probation period is normally three months..." Split cleanly, and the connection between those two facts disappears. That's why chunks usually overlap a bit at the edges, so information doesn't get lost exactly where you cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG is a pipeline, not a database
&lt;/h2&gt;

&lt;p&gt;Now the pieces connect. Retrieval-Augmented Generation means: before the LLM answers, go find relevant information from an external source and add it to the context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Query → Embedding → Vector Search → Relevant Chunks → Prompt → LLM → Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Someone asks "how many days of annual leave do employees get?" The system embeds the question, searches the vector store, finds the leave policy chunk, adds it to the prompt, and the LLM answers from that context instead of guessing.&lt;/p&gt;

&lt;p&gt;It's worth being precise here, because the terms get used interchangeably and they shouldn't be. A vector database is storage plus vector search. Nothing more. RAG is a pipeline: embed, retrieve, build context, prompt, generate. A vector DB can be part of a RAG system, but the two aren't the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt engineering is application logic
&lt;/h2&gt;

&lt;p&gt;Even with the right document retrieved, you still have to hand it to the model correctly — system instructions, retrieved context, then the question. The basic patterns here are zero-shot (no examples, just "classify this ticket as billing, technical, or account"), one-shot (one example first), and few-shot (several examples before the real input). None of this is exotic, but it's worth treating seriously: the prompt isn't a string you write once and forget. It's part of the application's logic, the same way a SQL query or a validation rule is.&lt;/p&gt;

&lt;h2&gt;
  
  
  An LLM alone is not an agent
&lt;/h2&gt;

&lt;p&gt;This is where the real distinction starts. A plain LLM call is input → LLM → output, once. An agent runs a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Request → LLM → Decide what to do → Tool → Tool Result → LLM → Decide again → ... → Final Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent isn't "an LLM with a better prompt." It's a system that decides, based on its current state and goal, what action to take next.&lt;/p&gt;

&lt;p&gt;Take a question like "what's the current price of this product?" The LLM doesn't know that. It has no live access to your database. So you give it a tool: &lt;code&gt;get_product_price(product_id)&lt;/code&gt;, or &lt;code&gt;search_database(query)&lt;/code&gt;, or &lt;code&gt;send_email(...)&lt;/code&gt;. The LLM decides it needs the price, calls the tool, gets a result, and uses that to answer. The LLM is the decision maker; the tool does the actual work.&lt;/p&gt;

&lt;p&gt;Zoomed out, an agent is an observe → reason → act loop. For something simple, like checking the weather in Tehran, that's: understand the request, pick the weather tool, call it, get the result, respond. For something harder, it might be: search a database, analyze the result, call another API, compare results, produce a final answer. That's when "agent" stops being a buzzword and starts being an actual engineering problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangChain, Chains, and LangGraph
&lt;/h2&gt;

&lt;p&gt;As these systems grow, wiring every piece together by hand (LLM, retriever, vector DB, tool, parser, memory) gets unwieldy. LangChain is a set of abstractions for building this kind of application so you're not connecting everything from scratch. It's worth being clear about what it is, though: LangChain isn't AI. It's a framework for building LLM-based applications.&lt;/p&gt;

&lt;p&gt;A Chain is a pipeline of fixed steps — question → prompt → LLM → parser → result, or question → retriever → documents → prompt → LLM → answer. That's fine when the path is mostly known in advance. Agents usually aren't like that; you don't know the next step ahead of time.&lt;/p&gt;

&lt;p&gt;That's what LangGraph is for. Once a workflow branches — analyze, then either search a database or call an API depending on what's needed, then decide, then respond — a simple chain isn't enough. You have state, nodes, and edges. LangGraph models this as something closer to a state machine or a graph: state, a node processes it, the state updates, a conditional edge decides where to go next. Understanding that model matters more than memorizing LangGraph's API surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP: standardizing how an agent reaches tools
&lt;/h2&gt;

&lt;p&gt;If an agent needs to talk to a calculator, a weather API, a database, a filesystem, GitHub, and some internal API, and each one needs its own custom integration, the architecture gets messy fast. The Model Context Protocol standardizes that connection. An MCP server exposes a set of tools — a Weather server might expose &lt;code&gt;get_weather(city)&lt;/code&gt; and &lt;code&gt;get_forecast(city)&lt;/code&gt;; a Company Knowledge server might expose &lt;code&gt;search_documents(query)&lt;/code&gt; and &lt;code&gt;get_document(id)&lt;/code&gt;. The agent doesn't need to know how the tool is implemented internally.&lt;/p&gt;

&lt;p&gt;From an architecture standpoint, MCP is a standard boundary between the AI application and everything external to it. That separation is what lets you add capabilities without touching the agent's core logic: today it's a calculator and a weather API, tomorrow it's GitHub, Postgres, the filesystem, and a handful of internal services, and the reasoning loop itself doesn't have to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: an org knowledge search example
&lt;/h2&gt;

&lt;p&gt;Say a company has 100,000 documents — HR policies, engineering docs, contracts, product docs, security policies. Someone asks: "do I need my manager's approval for this kind of request?"&lt;/p&gt;

&lt;p&gt;The system embeds the question, searches the vector database for the top relevant chunks, builds a prompt from the system instructions, the question, and those chunks, and sends it to the LLM. If answering fully requires something the documents don't have — say, the employee's own approval level — the agent can call a tool, hit an employee service, get the result, and feed it back to the LLM. That's a plain RAG system turning into an agent the moment it needs to act instead of just retrieve.&lt;/p&gt;

&lt;p&gt;The difference between RAG and agent is worth stating plainly. RAG says: find relevant information and hand it to the model. Agent says: based on the goal and current state, decide what to do. RAG can be one of an agent's capabilities — retrieval is just one action among several it might choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  An agent is not a magical entity: it's a small distributed system
&lt;/h2&gt;

&lt;p&gt;It's easy to talk about an agent as if it's an independent thing that "does stuff." It isn't. It's a set of components: LLM, prompt, state, memory/context, tools, retrieval, external services, control flow. An agent is closer to an application architecture than to a model.&lt;/p&gt;

&lt;p&gt;That framing is the useful one for a software engineer, because it means the classic problems show back up: authentication, authorization, retries, timeouts, rate limiting, logging, observability, caching, state management, error handling, idempotency, security, cost control. None of that disappears because there's an LLM in the loop. If anything, it matters more, because now one of your components is non-deterministic.&lt;/p&gt;

&lt;p&gt;This is, honestly, close to what I've spent the last while building at work, minus the LLM. An approval workflow with a real task inbox, an append-only audit log, a scheduler driving both background jobs and workflow timing, human-in-the-loop suspend/resume — that's state management, idempotency, and control flow, the exact same concerns an agent needs, just without a model deciding the next step. Swap a human approver for an LLM making the routing decision and the shape of the problem barely changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reasonable learning path
&lt;/h2&gt;

&lt;p&gt;You don't need to learn every framework at once. A logical order looks something like: LLM → tokens and context window → embeddings → vector search → RAG → prompt engineering → tool calling → agents → LangChain → LangGraph → MCP → production concerns. Build something small at each step — a chat app for the LLM stage, a document search for RAG, a tool-calling agent for a calculator or weather API, a multi-step workflow for LangGraph, the same tools behind an MCP server for the last stage.&lt;/p&gt;

&lt;p&gt;The mental model underneath all of it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;Understands and generates language, decides based on context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context window&lt;/td&gt;
&lt;td&gt;What's actually available to the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding&lt;/td&gt;
&lt;td&gt;Turns meaning into a vector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector DB&lt;/td&gt;
&lt;td&gt;Stores and searches vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG&lt;/td&gt;
&lt;td&gt;Brings relevant information into context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt&lt;/td&gt;
&lt;td&gt;Defines context and instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool&lt;/td&gt;
&lt;td&gt;Performs a specific action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;Decides and executes across multiple steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LangChain&lt;/td&gt;
&lt;td&gt;Abstractions for building LLM applications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;Models workflow and state as a graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;td&gt;Standardizes how the application reaches tools/servers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you skip these concepts, you end up memorizing framework calls instead of understanding what you're building. The real question isn't "how do I build an agent." It's "how do I build a system where an LLM can use real data and real tools in a way that's controllable, observable, and trustworthy." That's the point where AI engineering just becomes software engineering again.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Part That Was Never About Syntax</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Fri, 11 Sep 2026 18:38:19 +0000</pubDate>
      <link>https://dev.to/kazemmdev/the-part-that-was-never-about-syntax-4jfp</link>
      <guid>https://dev.to/kazemmdev/the-part-that-was-never-about-syntax-4jfp</guid>
      <description>&lt;p&gt;Ask an AI to build you an online ordering system and it will. Models, endpoints, a checkout flow, a decent-looking admin page. It works. You can place an order.&lt;/p&gt;

&lt;p&gt;Then the interesting part starts.&lt;/p&gt;

&lt;p&gt;What happens if 100,000 people place orders at the same time? What if the payment succeeds but creating the order fails? What if two people try to buy the last item at exactly the same moment? What if the database goes down?&lt;/p&gt;

&lt;p&gt;None of those questions are about syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distance has been shrinking the entire time
&lt;/h2&gt;

&lt;p&gt;I've been thinking about this lately: maybe the history of programming is, at its core, the history of reducing the distance between humans and machines.&lt;/p&gt;

&lt;p&gt;At first we had to talk to machines in 0s and 1s. Then Assembly. Then C and C++, then Java, Python, JavaScript, and everything after. Each generation took another layer of the machine's complexity away from the programmer.&lt;/p&gt;

&lt;p&gt;Today you can build real software without knowing exactly how a CPU executes every instruction. Nobody treats that as a loss. It's just where the line moved.&lt;/p&gt;

&lt;p&gt;AI moves the line again. Instead of asking "how do I write this code?", you can say "I want this system to do this," and a large part of turning that intention into code gets handled for you.&lt;/p&gt;

&lt;p&gt;Which brings up the obvious question.&lt;/p&gt;

&lt;h2&gt;
  
  
  So are programmers going to disappear?
&lt;/h2&gt;

&lt;p&gt;I don't think so. But the meaning of "programmer" will probably change.&lt;/p&gt;

&lt;p&gt;Because building something that works is different from building something that is well-designed.&lt;/p&gt;

&lt;p&gt;Those two things look identical in a demo. They stop looking identical the first time the system meets real traffic or real money. The ordering system that handles one order at a time and the one that handles a hundred thousand are not the same system, even when the happy-path code is nearly the same.&lt;/p&gt;

&lt;p&gt;So go back to those questions.&lt;/p&gt;

&lt;p&gt;With 100,000 concurrent orders, you're thinking about where the contention is, what's holding a lock, what's queued and what's synchronous, what falls over first.&lt;/p&gt;

&lt;p&gt;When the payment succeeds but the order fails, money left the customer's account and there's nothing to show for it. That's a correctness problem about state living in two systems at once, and clean code inside either one doesn't fix it.&lt;/p&gt;

&lt;p&gt;Two people buying the last item at the same moment is a race. Whether you handle it depends on decisions made well before anyone wrote a line of business logic.&lt;/p&gt;

&lt;p&gt;And if the database goes down: what does the system do, what does the user see, what's recoverable when it comes back?&lt;/p&gt;

&lt;p&gt;These are questions about understanding the system and the limitations of the machine. You can't answer them by knowing the language better. You answer them by knowing what happens when the thing runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the abstraction never removed
&lt;/h2&gt;

&lt;p&gt;Every layer so far took away a machine detail, and once it was gone we stopped thinking about it.&lt;/p&gt;

&lt;p&gt;AI takes away the code-writing layer. But the questions above were never in that layer. They sat on top of it the whole time, and they're still sitting there after the layer underneath gets automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code writer to problem solver
&lt;/h2&gt;

&lt;p&gt;I think the future won't belong to the person who can write code faster. It'll belong more to the person who better understands:&lt;/p&gt;

&lt;p&gt;what needs to be built,&lt;br&gt;
why it should be built this way,&lt;br&gt;
what trade-offs are involved,&lt;br&gt;
and how to know whether what we built actually works correctly.&lt;/p&gt;

&lt;p&gt;That last one is worth sitting with. When you write the code yourself, you carry a rough model of it in your head, and that model is part of how you judge whether it's right. When you didn't write it, you don't get that for free. Knowing whether a system works becomes something you do deliberately instead of a byproduct of having typed it.&lt;/p&gt;

&lt;p&gt;So the role shifts. Code writer to problem solver, or system designer, or whatever name ends up sticking. The label matters less than where the work goes.&lt;/p&gt;

&lt;p&gt;And maybe AI isn't just another new tool. Maybe it's the next stage of the same journey that started with machine code and has always had one goal: to reduce the distance between what humans want and what machines can do.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>discuss</category>
    </item>
    <item>
      <title>ABP and Elsa Both Want to Own Your API: Pick One</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Wed, 26 Aug 2026 03:38:51 +0000</pubDate>
      <link>https://dev.to/kazemmdev/abp-and-elsa-both-want-to-own-your-api-pick-one-cc7</link>
      <guid>https://dev.to/kazemmdev/abp-and-elsa-both-want-to-own-your-api-pick-one-cc7</guid>
      <description>&lt;p&gt;When I scaffolded my project, an enterprise workflow platform on ABP Framework, Elsa 3.x, EF Core, and PostgreSQL, I ran into a problem that never shows up in either framework's docs, because each framework assumes it's the only one in the room.&lt;/p&gt;

&lt;p&gt;ABP wants you to expose functionality through application services. That's the whole point of the framework: you write a C# class, decorate it with a permission attribute, and ABP auto-generates a REST controller for it. No manual routing, no manual DTO wiring if you let AutoMapper profiles do the work.&lt;/p&gt;

&lt;p&gt;Elsa also wants to expose functionality over HTTP. It ships an &lt;code&gt;HttpEndpoint&lt;/code&gt; activity that lets a workflow itself define a route, accept a request, and respond, no ASP.NET controller involved at all. You drop the activity into a workflow definition and Elsa wires up the endpoint for you.&lt;/p&gt;

&lt;p&gt;Put those two together on the same project and you get two frameworks each offering to be your public API. That's not a hypothetical: it's what I had in front of me the day I needed to expose "start an approval workflow" and "complete a human task" to the rest of the system. Elsa would happily let me do it inside the workflow definition. ABP would happily let me do it as an application service. Pick wrong and you end up with two different ways to call what is conceptually the same operation: discoverable through two different mechanisms, secured by two different permission systems, versioned (or not) by two different conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than it sounds
&lt;/h2&gt;

&lt;p&gt;The obvious reaction is "just pick one and move on." The problem is that Elsa's &lt;code&gt;HttpEndpoint&lt;/code&gt; is convenient in a way that's easy to fall for. You're already inside the workflow definition, you already have the business logic for what happens when a request comes in, and Elsa gives you a working route with zero extra files. For a demo, or a single standalone workflow, that's a fine trade.&lt;/p&gt;

&lt;p&gt;But ProcessHub isn't a single workflow. It's a platform where workflows are one implementation detail behind an approval-task inbox, an audit trail, role-based access, and caching. The moment you let Elsa own the public boundary for even one endpoint, you've committed to two rules for how the outside world talks to your system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"For approval submission, call this ABP application service, which is authorized by an ABP permission, logged by the ABP audit system, and documented by the ABP Swagger integration."&lt;/li&gt;
&lt;li&gt;"For task completion, call this Elsa &lt;code&gt;HttpEndpoint&lt;/code&gt;, which is authorized however that workflow definition wired it up, logged wherever Elsa's execution log happens to write it, and documented nowhere in particular."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither rule is wrong in isolation. Together, they mean every new integration has to ask "wait, is this one of the Elsa-exposed operations or an ABP one?" before it can even find the right client to call. That's the kind of inconsistency that looks small in a design doc and turns into a support ticket six months later when someone can't find the endpoint for a feature they know exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I landed on
&lt;/h2&gt;

&lt;p&gt;The public boundary is the application service, not the workflow engine's own endpoints. Elsa runs entirely behind that boundary. No &lt;code&gt;HttpEndpoint&lt;/code&gt; activities, no direct client-to-Elsa communication of any kind.&lt;/p&gt;

&lt;p&gt;Concretely, that means the approval flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkflowTaskAppService&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ApplicationService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IWorkflowTaskAppService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IApprovalTaskRepository&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;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IRunTaskRequestHandler&lt;/span&gt; &lt;span class="n"&gt;_runTaskHandler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessHubPermissions&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="n"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CompleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CompleteTaskInput&lt;/span&gt; &lt;span class="n"&gt;input&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;task&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;_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// ApprovalTask is our own aggregate, not Elsa's execution state.&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;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CurrentUser&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Comment&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;_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UpdateAsync&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="c1"&gt;// Resume the suspended Elsa workflow through our handler,&lt;/span&gt;
        &lt;span class="c1"&gt;// not through any endpoint Elsa exposes itself.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_runTaskHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ResumeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowInstanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow itself contains a &lt;code&gt;RunTask&lt;/code&gt; activity that suspends execution and waits. Nothing about that activity is reachable from outside the process. The only door in is &lt;code&gt;WorkflowTaskAppService&lt;/code&gt;, authorized by an ABP permission (&lt;code&gt;ProcessHubPermissions.Tasks.Complete&lt;/code&gt;). It shows up in the same Swagger document as every other endpoint in the system, and it gets logged by the same audit tables that log everything else: the append-only domain tables I built independent of Elsa's own execution log, specifically so audit history doesn't depend on which engine happens to be running underneath.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ApprovalTask&lt;/code&gt; is worth calling out too. It's a domain aggregate I own, not Elsa's workflow instance state. The application service reads and writes it directly. Elsa's job is to drive the state machine that decides when a task becomes eligible and what happens after it's resolved. It doesn't get to be the system of record for what the task inbox shows a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the "just use both" instinct is wrong here
&lt;/h2&gt;

&lt;p&gt;I get why the instinct is to use &lt;code&gt;HttpEndpoint&lt;/code&gt; for anything workflow-shaped and application services for everything else. It feels like using each tool for what it's good at. But that's a category error. &lt;code&gt;HttpEndpoint&lt;/code&gt; is good at "this workflow needs to react to an inbound webhook from a third party who doesn't know or care about your internal architecture." It is not good at "this is a first-class operation my own frontend calls," because it opts that operation out of every convention ABP gives you for free: permission checks, DTO validation, Swagger docs, the audit trail, the unit-of-work interceptor that wraps &lt;code&gt;SaveChanges&lt;/code&gt; around the request.&lt;/p&gt;

&lt;p&gt;Once I framed it that way, the rule got easy to defend in code review. If the caller is internal, meaning my own UI or my own downstream services, it goes through an application service, full stop. If the caller is a genuinely external system triggering a workflow with no other integration in the codebase (say, a webhook from a payment provider), &lt;code&gt;HttpEndpoint&lt;/code&gt; is fine, because there's no ABP convention being bypassed. There was never an ABP endpoint for that caller anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Native AOT wrinkle, and why it doesn't change the answer
&lt;/h2&gt;

&lt;p&gt;There's a second reason application services stay the boundary, and it's less about design taste and more about what ABP actually costs you. ABP builds application services on dynamic proxies and interceptors: the unit-of-work wrapper, the auto-validation, the auditing all run through interception at runtime. That's convenient, but it means Native AOT is off the table for this codebase. If you're chasing trimmed, ahead-of-time-compiled binaries, ABP's application service layer isn't going to get you there.&lt;/p&gt;

&lt;p&gt;It also means unit-testing an application service in true isolation is awkward. Mock the repository, call the method directly, and you've bypassed the interceptor pipeline that does half the actual work (the unit-of-work commit, the auditing, the authorization check). I stopped fighting that and write integration tests through the HTTP boundary instead, hitting the real controller ABP generates. Slower than a pure unit test, but it's testing the thing that's actually going to run in production, interceptors included.&lt;/p&gt;

&lt;p&gt;None of that pushed me toward letting Elsa own more of the surface. If anything it reinforced the opposite: if application services are going to cost me AOT and force integration-style testing, I want that cost paid once, at one consistent boundary, not paid partially while a second boundary quietly exists elsewhere with different rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general version of this
&lt;/h2&gt;

&lt;p&gt;If you're bringing ABP into a project that already has an opinionated engine underneath it, whether that's a workflow engine, a job scheduler, or a message bus with its own HTTP hooks, check whether that engine wants to expose its own API surface. A lot of them do, because it's a nice feature to advertise in isolation. Almost none of them coordinate with whatever else in your stack is also trying to be "the" API.&lt;/p&gt;

&lt;p&gt;The fix isn't a framework setting. It's a decision you make explicitly and write down: one thing owns the public boundary, and everything else, no matter how convenient its shortcut looks, runs behind it. On ProcessHub that's the ABP application service layer: modules instead of &lt;code&gt;Program.cs&lt;/code&gt; registrations, permission attributes instead of manual &lt;code&gt;IAuthorizationService&lt;/code&gt; checks, with Elsa treated as an internal state machine and nothing more. It's a small rule. It's also the one I've had to re-explain the most, because the alternative always looks like less code in the moment you're writing it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>The .NET Production Checklist Is Really a List of Places the Framework Lies to You</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Wed, 26 Aug 2026 03:37:41 +0000</pubDate>
      <link>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-2lkc</link>
      <guid>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-2lkc</guid>
      <description>&lt;p&gt;Most of this checklist isn't about .NET at all. It's a list of every place the framework's defaults will quietly hurt you in production, because the happy path was built to make &lt;code&gt;dotnet run&lt;/code&gt; work on your laptop, not to survive three replicas behind a load balancer with a database that has a connection limit.&lt;/p&gt;

&lt;p&gt;None of these are exotic problems. They're the kind of thing you only learn by getting paged for them once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations: the happy path runs them on every replica
&lt;/h2&gt;

&lt;p&gt;The default story is simple: your app starts up, EF Core applies pending migrations, you're live. It works great with one instance.&lt;/p&gt;

&lt;p&gt;It falls apart the moment you scale past one. Three replicas start at roughly the same time, all three try to apply the same migration, and now you're debugging a race condition in your schema instead of your code. Sometimes it's harmless: the migration is idempotent, one replica wins, the others fail quietly and move on. Sometimes it isn't, and you get a half-applied schema or a deadlock on the migrations history table.&lt;/p&gt;

&lt;p&gt;The fix is boring: migrations run as a single deploy step, not as part of application startup. One job, one shot, before any replica of the new version starts serving traffic. It's less convenient than "it just happens," but "it just happens" is exactly the kind of behavior that's fine until it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks: "alive" and "ready" are not the same question
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/health&lt;/code&gt; as a single endpoint answers one question badly. What you actually need are two different questions with two different consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the process alive? (Should Kubernetes restart the pod?)&lt;/li&gt;
&lt;li&gt;Is the process ready to take traffic? (Should the load balancer route to it?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A service can be alive and not ready — it's up, but its database connection is down, or it's still warming a cache. If your only health check conflates the two, either you route traffic to a pod that can't serve it, or you restart a pod that's actually fine and just waiting on a dependency to come back.&lt;/p&gt;

&lt;p&gt;Split them: &lt;code&gt;/health/live&lt;/code&gt; checks that the process itself is up. &lt;code&gt;/health/ready&lt;/code&gt; checks that its dependencies are reachable. Only &lt;code&gt;/health/ready&lt;/code&gt; gates traffic. This is a small amount of extra wiring for a failure mode that otherwise shows up as "why did we get 500s during a routine dependency blip."&lt;/p&gt;

&lt;h2&gt;
  
  
  Shutdown: three timeouts, each one has to be bigger than the last
&lt;/h2&gt;

&lt;p&gt;Graceful shutdown looks like it should be free — the framework gives you &lt;code&gt;IHostApplicationLifetime&lt;/code&gt;, you hook &lt;code&gt;ApplicationStopping&lt;/code&gt;, done. But shutdown in a container orchestrator is actually a chain of three separate timeouts, and if you only configure the first one, the other two will cut you off anyway:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;IHostApplicationLifetime&lt;/code&gt;: your code's chance to stop accepting new work and finish what's in flight.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShutdownTimeout&lt;/code&gt;: how long the host waits for that to happen before it forces termination. This has to be set above your longest in-flight request, or the host kills requests that were about to finish cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;: how long Kubernetes waits before sending SIGKILL. This has to be set above &lt;code&gt;ShutdownTimeout&lt;/code&gt;, or Kubernetes kills the process before your own shutdown timeout even gets a chance to fire.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get the ordering wrong and you'll see intermittent failed requests during every deploy, and they'll look like application bugs. They're not. They're a shutdown grace period that's smaller than the request it was supposed to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection pools: the number that has to be true across every replica
&lt;/h2&gt;

&lt;p&gt;This one is a simple formula that's easy to forget applies at all once you're running more than one instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Max Pool Size × replicas &amp;lt; DB max_connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pool size of 100 is fine for one replica against a database that allows 200 connections. Scale to three replicas and you're asking for 300 connections against a limit of 200. That shows up as intermittent connection exhaustion under load, usually during a traffic spike, which is exactly when you have the least patience for debugging it.&lt;/p&gt;

&lt;p&gt;Pair this with actual resiliency: &lt;code&gt;EnableRetryOnFailure()&lt;/code&gt; for transient database errors, and a standard resilience handler on your outbound HTTP clients. Networks fail. The question is whether your service treats that as routine or as an unhandled exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runtime image: no SDK, no root
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; out .&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; $APP_UID&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["./Sample.Api"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things happen here that are easy to skip if you just want a working Dockerfile. This is a chiseled/distroless image: no shell, no package manager, no SDK, nothing beyond what the app needs to run. If someone gets code execution inside the container, there isn't much there to pivot with. On top of that, &lt;code&gt;USER $APP_UID&lt;/code&gt; runs the process as a non-root user instead of the container default.&lt;/p&gt;

&lt;p&gt;Neither of these makes the app faster or fixes a bug you'll notice in staging. They're the difference between a compromised container being a dead end and being a foothold. The publish step that gets you here is also worth keeping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet publish src/Sample.Api -c Release -o out /p:PublishReadyToRun=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PublishReadyToRun=true&lt;/code&gt; precompiles a chunk of the IL to native code ahead of time, which matters for the next item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warm-up: the first requests are the slowest ones
&lt;/h2&gt;

&lt;p&gt;.NET JITs your code as it runs: tier-0 first, optimizing later tiers as methods get hot. That's a reasonable tradeoff for a long-running process. It's a bad one for the first few requests a brand-new pod receives, because those requests get compiled cold, right when they're also the ones deciding whether your rollout looks healthy.&lt;/p&gt;

&lt;p&gt;The fix is to send a warm-up request after deploy, before the pod joins the load balancer. It costs a few seconds. What it buys you is not showing your actual users the slowest version of every code path, right as a new deploy goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything else on the list is the same pattern
&lt;/h2&gt;

&lt;p&gt;Structured logging to stdout, so your log aggregator doesn't have to parse whatever format someone chose two years ago. OpenTelemetry traces and metrics with a correlation ID that survives a hop across services, so a slow request can actually be traced instead of guessed at. Response compression and output caching where they pay off, not everywhere, just where the numbers say so. Resource limits and &lt;code&gt;DOTNET_GCHeapHardLimitPercent&lt;/code&gt; set explicitly, because an unbounded GC heap in a container with a memory limit is just a slower way of getting OOMKilled. Backups verified by an actual restore, because a backup nobody has restored is a hope, not a backup.&lt;/p&gt;

&lt;p&gt;None of these are interesting individually. What they have in common is that they're all invisible until the day they aren't — and by then it's an incident, not a checklist item.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI gate that keeps this from being a one-time exercise
&lt;/h2&gt;

&lt;p&gt;The checklist part is only half of it. None of it stays true unless CI enforces it on every change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet format --verify-no-changes → dotnet build -warnaserror → dotnet test → vulnerability scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Format check first, so style drift doesn't show up as noise in review. Build with warnings as errors, so a warning today doesn't become a bug next quarter because everyone got used to ignoring the build output. Tests. Then a vulnerability scan against your dependencies, because &lt;code&gt;dotnet list package --vulnerable&lt;/code&gt; catching something in CI is a much better day than catching it after it's in production.&lt;/p&gt;

&lt;p&gt;The tooling underneath is less important than the discipline of running it on every PR instead of remembering to run it manually before a release: CSharpier or &lt;code&gt;dotnet format&lt;/code&gt; for formatting, Roslyn analyzers and SonarAnalyzer for static analysis, NetArchTest or ArchUnitNET if you want architecture rules enforced by a test rather than a code review comment, and Husky.NET for pre-commit hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this checklist reads the way it does
&lt;/h2&gt;

&lt;p&gt;Every item here comes from the same root cause: the defaults optimize for one instance on your machine, not for several instances that all have to agree on the same schema, the same connection budget, and the same shutdown sequence at once. Add an orchestrator with its own lifecycle rules on top, and that gap becomes the whole checklist.&lt;/p&gt;

&lt;p&gt;None of this is specific to .NET's quirks. It's what "production" means once more than one thing has to agree on the state of the world at the same time.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>dotnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The .NET Production Checklist Is Really a List of Places the Framework Lies to You</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:13:17 +0000</pubDate>
      <link>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-f00</link>
      <guid>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-f00</guid>
      <description>&lt;p&gt;Most of this checklist isn't about .NET at all. It's a list of every place the framework's defaults will quietly hurt you in production, because the happy path was built to make &lt;code&gt;dotnet run&lt;/code&gt; work on your laptop, not to survive three replicas behind a load balancer with a database that has a connection limit.&lt;/p&gt;

&lt;p&gt;None of these are exotic problems. They're the kind of thing you only learn by getting paged for them once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations: the happy path runs them on every replica
&lt;/h2&gt;

&lt;p&gt;The default story is simple: your app starts up, EF Core applies pending migrations, you're live. It works great with one instance.&lt;/p&gt;

&lt;p&gt;It falls apart the moment you scale past one. Three replicas start at roughly the same time, all three try to apply the same migration, and now you're debugging a race condition in your schema instead of your code. Sometimes it's harmless: the migration is idempotent, one replica wins, the others fail quietly and move on. Sometimes it isn't, and you get a half-applied schema or a deadlock on the migrations history table.&lt;/p&gt;

&lt;p&gt;The fix is boring: migrations run as a single deploy step, not as part of application startup. One job, one shot, before any replica of the new version starts serving traffic. It's less convenient than "it just happens," but "it just happens" is exactly the kind of behavior that's fine until it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks: "alive" and "ready" are not the same question
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/health&lt;/code&gt; as a single endpoint answers one question badly. What you actually need are two different questions with two different consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the process alive? (Should Kubernetes restart the pod?)&lt;/li&gt;
&lt;li&gt;Is the process ready to take traffic? (Should the load balancer route to it?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A service can be alive and not ready — it's up, but its database connection is down, or it's still warming a cache. If your only health check conflates the two, either you route traffic to a pod that can't serve it, or you restart a pod that's actually fine and just waiting on a dependency to come back.&lt;/p&gt;

&lt;p&gt;Split them: &lt;code&gt;/health/live&lt;/code&gt; checks that the process itself is up. &lt;code&gt;/health/ready&lt;/code&gt; checks that its dependencies are reachable. Only &lt;code&gt;/health/ready&lt;/code&gt; gates traffic. This is a small amount of extra wiring for a failure mode that otherwise shows up as "why did we get 500s during a routine dependency blip."&lt;/p&gt;

&lt;h2&gt;
  
  
  Shutdown: three timeouts, each one has to be bigger than the last
&lt;/h2&gt;

&lt;p&gt;Graceful shutdown looks like it should be free — the framework gives you &lt;code&gt;IHostApplicationLifetime&lt;/code&gt;, you hook &lt;code&gt;ApplicationStopping&lt;/code&gt;, done. But shutdown in a container orchestrator is actually a chain of three separate timeouts, and if you only configure the first one, the other two will cut you off anyway:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;IHostApplicationLifetime&lt;/code&gt;: your code's chance to stop accepting new work and finish what's in flight.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShutdownTimeout&lt;/code&gt;: how long the host waits for that to happen before it forces termination. This has to be set above your longest in-flight request, or the host kills requests that were about to finish cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;: how long Kubernetes waits before sending SIGKILL. This has to be set above &lt;code&gt;ShutdownTimeout&lt;/code&gt;, or Kubernetes kills the process before your own shutdown timeout even gets a chance to fire.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get the ordering wrong and you'll see intermittent failed requests during every deploy, and they'll look like application bugs. They're not. They're a shutdown grace period that's smaller than the request it was supposed to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection pools: the number that has to be true across every replica
&lt;/h2&gt;

&lt;p&gt;This one is a simple formula that's easy to forget applies at all once you're running more than one instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Max Pool Size × replicas &amp;lt; DB max_connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pool size of 100 is fine for one replica against a database that allows 200 connections. Scale to three replicas and you're asking for 300 connections against a limit of 200. That shows up as intermittent connection exhaustion under load, usually during a traffic spike, which is exactly when you have the least patience for debugging it.&lt;/p&gt;

&lt;p&gt;Pair this with actual resiliency: &lt;code&gt;EnableRetryOnFailure()&lt;/code&gt; for transient database errors, and a standard resilience handler on your outbound HTTP clients. Networks fail. The question is whether your service treats that as routine or as an unhandled exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runtime image: no SDK, no root
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; out .&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; $APP_UID&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["./Sample.Api"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things happen here that are easy to skip if you just want a working Dockerfile. This is a chiseled/distroless image: no shell, no package manager, no SDK, nothing beyond what the app needs to run. If someone gets code execution inside the container, there isn't much there to pivot with. On top of that, &lt;code&gt;USER $APP_UID&lt;/code&gt; runs the process as a non-root user instead of the container default.&lt;/p&gt;

&lt;p&gt;Neither of these makes the app faster or fixes a bug you'll notice in staging. They're the difference between a compromised container being a dead end and being a foothold. The publish step that gets you here is also worth keeping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet publish src/Sample.Api -c Release -o out /p:PublishReadyToRun=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PublishReadyToRun=true&lt;/code&gt; precompiles a chunk of the IL to native code ahead of time, which matters for the next item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warm-up: the first requests are the slowest ones
&lt;/h2&gt;

&lt;p&gt;.NET JITs your code as it runs: tier-0 first, optimizing later tiers as methods get hot. That's a reasonable tradeoff for a long-running process. It's a bad one for the first few requests a brand-new pod receives, because those requests get compiled cold, right when they're also the ones deciding whether your rollout looks healthy.&lt;/p&gt;

&lt;p&gt;The fix is to send a warm-up request after deploy, before the pod joins the load balancer. It costs a few seconds. What it buys you is not showing your actual users the slowest version of every code path, right as a new deploy goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything else on the list is the same pattern
&lt;/h2&gt;

&lt;p&gt;Structured logging to stdout, so your log aggregator doesn't have to parse whatever format someone chose two years ago. OpenTelemetry traces and metrics with a correlation ID that survives a hop across services, so a slow request can actually be traced instead of guessed at. Response compression and output caching where they pay off, not everywhere, just where the numbers say so. Resource limits and &lt;code&gt;DOTNET_GCHeapHardLimitPercent&lt;/code&gt; set explicitly, because an unbounded GC heap in a container with a memory limit is just a slower way of getting OOMKilled. Backups verified by an actual restore, because a backup nobody has restored is a hope, not a backup.&lt;/p&gt;

&lt;p&gt;None of these are interesting individually. What they have in common is that they're all invisible until the day they aren't — and by then it's an incident, not a checklist item.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI gate that keeps this from being a one-time exercise
&lt;/h2&gt;

&lt;p&gt;The checklist part is only half of it. None of it stays true unless CI enforces it on every change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet format --verify-no-changes → dotnet build -warnaserror → dotnet test → vulnerability scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Format check first, so style drift doesn't show up as noise in review. Build with warnings as errors, so a warning today doesn't become a bug next quarter because everyone got used to ignoring the build output. Tests. Then a vulnerability scan against your dependencies, because &lt;code&gt;dotnet list package --vulnerable&lt;/code&gt; catching something in CI is a much better day than catching it after it's in production.&lt;/p&gt;

&lt;p&gt;The tooling underneath is less important than the discipline of running it on every PR instead of remembering to run it manually before a release: CSharpier or &lt;code&gt;dotnet format&lt;/code&gt; for formatting, Roslyn analyzers and SonarAnalyzer for static analysis, NetArchTest or ArchUnitNET if you want architecture rules enforced by a test rather than a code review comment, and Husky.NET for pre-commit hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this checklist reads the way it does
&lt;/h2&gt;

&lt;p&gt;Every item here comes from the same root cause: the defaults optimize for one instance on your machine, not for several instances that all have to agree on the same schema, the same connection budget, and the same shutdown sequence at once. Add an orchestrator with its own lifecycle rules on top, and that gap becomes the whole checklist.&lt;/p&gt;

&lt;p&gt;None of this is specific to .NET's quirks. It's what "production" means once more than one thing has to agree on the state of the world at the same time.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>dotnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Never Use UseInMemoryDatabase in ASP.NET Tests</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:04:25 +0000</pubDate>
      <link>https://dev.to/kazemmdev/never-use-useinmemorydatabase-in-aspnet-tests-1356</link>
      <guid>https://dev.to/kazemmdev/never-use-useinmemorydatabase-in-aspnet-tests-1356</guid>
      <description>&lt;p&gt;A test suite can pass completely against &lt;code&gt;UseInMemoryDatabase&lt;/code&gt; and still break the moment the same code runs against real Postgres. Green checkmarks, CI happy, PR merged — and then a query that relied on a foreign key constraint does the wrong thing in production, because nothing in the test run had a foreign key to enforce.&lt;/p&gt;

&lt;p&gt;That's the one rule in ASP.NET testing I don't compromise on: never test against &lt;code&gt;UseInMemoryDatabase&lt;/code&gt;. It's not a relational database. No constraints, no transactions, and its LINQ translation doesn't match what EF Core actually generates against Postgres or SQL Server. You get green tests that lie to you about what happens in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the in-memory provider fails you
&lt;/h2&gt;

&lt;p&gt;EF Core's in-memory provider was built for quick prototyping, not for verifying behavior. It stores your entities in a plain in-memory collection and evaluates LINQ against that collection using regular .NET semantics. That sounds convenient until you remember that a real database doesn't work that way.&lt;/p&gt;

&lt;p&gt;A unique index violation, a cascade delete, a check constraint, a &lt;code&gt;NOT NULL&lt;/code&gt; column — none of that exists in the in-memory provider. Your test can happily insert two rows that would collide on a unique constraint in Postgres, and it'll pass. A query that translates to a specific SQL expression against Npgsql might translate completely differently against the in-memory LINQ provider, or not translate at all. A bug in your query gets masked because the fake provider is more forgiving than the real one.&lt;/p&gt;

&lt;p&gt;Transactions are the other big gap. If your code relies on transactional rollback, or on isolation between concurrent operations, the in-memory provider gives you none of that. So you end up testing a database that behaves nothing like the one your users hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to use instead
&lt;/h2&gt;

&lt;p&gt;Testcontainers. Spin up a real Postgres instance in a container for the test run, and point EF Core at it like you would in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Real Postgres per test run — not an in-memory provider&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiFactory&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;IAsyncLifetime&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;PostgreSqlContainer&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PostgreSqlBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:17-alpine"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;ConfigureWebHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IWebHostBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureTestServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoveAll&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DbContextOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;();&lt;/span&gt;
            &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseNpgsql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
            &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TimeProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;FakeTimeProvider&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InitializeAsync&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;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="cm"&gt;/* apply migrations */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ApiFactory&lt;/code&gt; boots the real ASP.NET pipeline through &lt;code&gt;WebApplicationFactory&amp;lt;Program&amp;gt;&lt;/code&gt;, but swaps the database connection to point at a containerized Postgres instead of whatever's configured for the app normally. Same constraints, same transaction behavior, same SQL your production database will actually run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pyramid, ASP.NET-flavored
&lt;/h2&gt;

&lt;p&gt;Once you accept that integration tests need a real database, the shape of your test suite follows from that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests (~60%)&lt;/strong&gt; — this is where most of the value is. &lt;code&gt;WebApplicationFactory&amp;lt;Program&amp;gt;&lt;/code&gt; boots the real pipeline in memory, but against a real database in a container. You're exercising the actual HTTP pipeline, the actual EF Core mappings, the actual constraints. Highest value per line, and it's basically the same idea as Laravel feature tests, if you're coming from that world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests (~35%)&lt;/strong&gt; — domain rules, calculators, validators, mappers. No I/O, so they're fast. This is where you test the logic that doesn't need a database or an HTTP request to verify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;E2E (~5%)&lt;/strong&gt; — Playwright, reserved for the handful of flows that must never break. Login, checkout, whatever your equivalent is. E2E tests are slow and brittle by nature, so you don't want your whole suite living here.&lt;/p&gt;

&lt;p&gt;Here's what a couple of integration tests look like against &lt;code&gt;ApiFactory&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostApiTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApiFactory&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IClassFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApiFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Creates_a_post_for_an_authenticated_user&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;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAuthenticatedClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TestUsers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Author&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;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/posts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hello"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"World"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShouldBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Created&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;dto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShouldBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hello"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Forbids_editing_someone_elses_post&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;post&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;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SeedAsync&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;PostBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;WithAuthor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TestUsers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Other&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;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAuthenticatedClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TestUsers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Author&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;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PutAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/api/posts/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hacked"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShouldBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Forbidden&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what these tests actually assert on: HTTP status codes and response bodies. Not internal method calls, not whether some service was invoked with the right arguments. You go through the HTTP boundary the same way a real client would. If you start asserting on internal call sequences instead, your tests become coupled to implementation details, and refactoring turns into a chore even when behavior hasn't changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The supporting rules
&lt;/h2&gt;

&lt;p&gt;A few things make this pyramid work in practice, beyond just using Testcontainers.&lt;/p&gt;

&lt;p&gt;Test data should come from builders, not raw object initializers scattered through every test file. &lt;code&gt;new PostBuilder().WithAuthor(TestUsers.Other)&lt;/code&gt; in the second test above is the ASP.NET equivalent of a Laravel factory: a readable way to construct exactly the entity state a test needs.&lt;/p&gt;

&lt;p&gt;Isolation matters just as much. Wrap each test in a transaction and roll it back, or reset the database between runs with something like Respawn. What you don't want is shared mutable seed data that one test can quietly corrupt for the next one, because that's how you end up with tests that pass individually and fail when run together, one of the more annoying categories of flakiness to debug.&lt;/p&gt;

&lt;p&gt;Time-dependent code needs a &lt;code&gt;FakeTimeProvider&lt;/code&gt; instead of assertions built around &lt;code&gt;DateTime.UtcNow&lt;/code&gt;. If your code branches on expiry windows or scheduling, you want that time to be a value you control in the test, not whatever the clock happens to say when CI runs. &lt;code&gt;ApiFactory&lt;/code&gt; above registers &lt;code&gt;FakeTimeProvider&lt;/code&gt; as a singleton for exactly this reason.&lt;/p&gt;

&lt;p&gt;And outbound HTTP calls should be faked with a stub &lt;code&gt;HttpMessageHandler&lt;/code&gt; or WireMock.NET. A test suite that hits the real internet is a flaky suite, full stop. Any external call, whether a third-party API or a webhook, should be intercepted at the HTTP client level so your tests aren't at the mercy of some other service's uptime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolchain
&lt;/h2&gt;

&lt;p&gt;xUnit is the default runner choice, though NUnit and MSTest work fine if that's what a codebase already uses. Shouldly or FluentAssertions handle assertions. Worth checking FluentAssertions' license terms before using it commercially, since those changed. Mocking is NSubstitute or Moq, take your pick.&lt;/p&gt;

&lt;p&gt;None of these choices matter as much as the database decision. You can swap test runners and assertion libraries without much consequence. But if your integration tests run against &lt;code&gt;UseInMemoryDatabase&lt;/code&gt;, you don't actually know what your code does against Postgres — you know what it does against a LINQ-over-a-list emulation of a database, which is a different thing that happens to share an interface.&lt;/p&gt;

&lt;p&gt;The fix isn't complicated. Testcontainers spins up and tears down fast enough that it doesn't meaningfully slow your suite down, and the confidence it buys you is the whole point of writing integration tests in the first place. If your tests aren't telling you the truth about production, they're not saving you the time debugging in production later — they're just moving the debugging to a worse moment.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>webdev</category>
      <category>programming</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Async in .NET Has Traps Laravel Never Prepared Me For</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 11:48:06 +0000</pubDate>
      <link>https://dev.to/kazemmdev/async-in-net-has-traps-laravel-never-prepared-me-for-590f</link>
      <guid>https://dev.to/kazemmdev/async-in-net-has-traps-laravel-never-prepared-me-for-590f</guid>
      <description>&lt;p&gt;Most of the move from Laravel to .NET has been mechanical: different syntax, same instincts. Controllers are controllers, dependency injection is dependency injection, an ORM is an ORM. Async is where that stops working. It has no Laravel equivalent, and it's where most of the incidents I've seen actually come from.&lt;/p&gt;

&lt;p&gt;PHP doesn't have this problem because PHP doesn't really have this concept. A request comes in, runs on its own process or thread, and there's no shared thread pool to starve. .NET's async model is built around a shared thread pool, and if you get async wrong, you're not just writing slower code. You can deadlock the request, swallow an exception the process never tells you about, or run the server out of sockets under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocking on async code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ deadlock / thread-pool starvation under load&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserAsync&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;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This line looks harmless. It compiles, it works in dev, it might even work in production for a while under light load. Then traffic goes up and requests start hanging.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.Result&lt;/code&gt; blocks the calling thread until the async call finishes. Under load, the thread pool is busy running other requests, and the continuation for &lt;code&gt;GetUserAsync&lt;/code&gt; is waiting for a free thread to resume on. If the pool is saturated, that continuation never gets scheduled, the blocked thread never unblocks, and you've got a deadlock. At minimum you get thread-pool starvation, where things slow down instead of failing outright, which is actually worse to diagnose.&lt;/p&gt;

&lt;p&gt;The fix is not clever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&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;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserAsync&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Async all the way down. The moment you mix in one blocking call, you've undone the point of using async in the first place. It's not a style preference. A single &lt;code&gt;.Result&lt;/code&gt; or &lt;code&gt;.Wait()&lt;/code&gt; buried three layers deep in an otherwise-async call chain is enough to bring a whole request path down under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;async void&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ exception is unobservable, process may crash&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;async void&lt;/code&gt; exists for one legitimate case: UI event handlers, where the framework calls you and there's nowhere to &lt;code&gt;await&lt;/code&gt;. Everywhere else it's a trap. An exception thrown inside an &lt;code&gt;async void&lt;/code&gt; method doesn't propagate the way you'd expect. It doesn't get captured by the caller's try/catch. It gets rethrown on the synchronization context, and for a lot of hosting models that means it can crash the process outright.&lt;/p&gt;

&lt;p&gt;Compare that to an &lt;code&gt;async Task&lt;/code&gt; method: exceptions get captured in the returned &lt;code&gt;Task&lt;/code&gt;, and the caller can &lt;code&gt;await&lt;/code&gt; it, catch it, log it, whatever. An &lt;code&gt;async void&lt;/code&gt; method just fires the exception into the void.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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;Same shape, completely different failure behavior. If you're wiring up an event handler and it's not a UI callback, it should return &lt;code&gt;Task&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new &lt;code&gt;HttpClient&lt;/code&gt; per call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ new HttpClient per call → socket exhaustion&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;http&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;HttpClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one's sneaky. &lt;code&gt;HttpClient&lt;/code&gt; implements &lt;code&gt;IDisposable&lt;/code&gt;, so the &lt;code&gt;using&lt;/code&gt; block looks like correct, responsible code. It isn't. Disposing an &lt;code&gt;HttpClient&lt;/code&gt; doesn't immediately release the underlying socket. It goes into a &lt;code&gt;TIME_WAIT&lt;/code&gt; state. Do this on every request and under enough load you exhaust the available sockets on the box, and now everything making outbound HTTP calls starts failing, not just this one client.&lt;/p&gt;

&lt;p&gt;The usual next move people make is to fix that by making the &lt;code&gt;HttpClient&lt;/code&gt; &lt;code&gt;static&lt;/code&gt; and reusing it forever. That solves socket exhaustion but introduces a different bug: a static &lt;code&gt;HttpClient&lt;/code&gt; doesn't respect DNS changes, because the connection stays pinned to whatever IP it resolved first. If the downstream service moves (a failover, a DNS update, a container restart behind a load balancer), your static client keeps hammering the old address.&lt;/p&gt;

&lt;p&gt;The actual fix is &lt;code&gt;IHttpClientFactory&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="c1"&gt;// ✅&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPaymentClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PaymentClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddStandardResilienceHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// retry + circuit breaker + timeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It manages the connection pool for you, rotates handlers on a schedule so DNS changes get picked up, and &lt;code&gt;AddStandardResilienceHandler()&lt;/code&gt; gives you retry, circuit breaker, and timeout behavior in one line. There's no manual &lt;code&gt;HttpClient&lt;/code&gt; lifecycle to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules that fall out of this
&lt;/h2&gt;

&lt;p&gt;Once you've been burned by these a couple of times, they collapse into a short list I now just follow by default:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async all the way down.&lt;/strong&gt; One blocking call anywhere in the chain undoes the benefit of everything above it and can deadlock the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take a &lt;code&gt;CancellationToken&lt;/code&gt; in every async method and pass it on.&lt;/strong&gt; If the client disconnects, that should stop costing you a database connection. Threading the token through every layer is tedious the first few times and then it's just muscle memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;IHttpClientFactory&lt;/code&gt;, always.&lt;/strong&gt; Manual &lt;code&gt;HttpClient&lt;/code&gt; instances exhaust sockets. A static one misses DNS changes. There isn't a third option that's actually safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing mutable in a singleton without a real concurrency primitive.&lt;/strong&gt; &lt;code&gt;ConcurrentDictionary&lt;/code&gt;, &lt;code&gt;SemaphoreSlim&lt;/code&gt;, &lt;code&gt;Channel&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;Interlocked&lt;/code&gt;: pick the one that fits, but don't put a plain &lt;code&gt;Dictionary&lt;/code&gt; or a counter on a singleton and assume it'll be fine because it worked in local testing with one user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background work belongs in &lt;code&gt;BackgroundService&lt;/code&gt;/&lt;code&gt;IHostedService&lt;/code&gt;, or better, a durable queue.&lt;/strong&gt; Hangfire, Quartz, a real broker. &lt;code&gt;Task.Run(...)&lt;/code&gt; and forget feels fine in dev and then dies with the pod on a rolling deploy, taking whatever it was doing with it. If the work needs to actually complete, it needs to survive a restart, and &lt;code&gt;Task.Run&lt;/code&gt; doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For caching&lt;/strong&gt;, &lt;code&gt;HybridCache&lt;/code&gt;, in-process plus distributed with stampede protection, is the current default answer. &lt;code&gt;IDistributedCache&lt;/code&gt; with Redis covers cross-instance state when you need something simpler or already have Redis in the stack.&lt;/p&gt;

&lt;p&gt;None of these are exotic. They're all things the framework already gives you a correct way to do. The trap isn't that .NET makes async hard, it's that the wrong version of each of these compiles, runs, and looks identical to the right version until the thread pool is under real load. Laravel never put me in that position, because it never had a shared thread pool to starve.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>dotnet</category>
      <category>async</category>
    </item>
    <item>
      <title>Authorize Can't See Your Data</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 11:18:48 +0000</pubDate>
      <link>https://dev.to/kazemmdev/authorize-cant-see-your-data-47pc</link>
      <guid>https://dev.to/kazemmdev/authorize-cant-see-your-data-47pc</guid>
      <description>&lt;p&gt;The first authorization bug I ever shipped wasn't a missing &lt;code&gt;[Authorize]&lt;/code&gt; attribute. It was the opposite — everything had &lt;code&gt;[Authorize]&lt;/code&gt;, the tests were green, and it still let one user edit another user's stuff. The attribute was doing exactly what it promised. I just wanted it to promise something else.&lt;/p&gt;

&lt;p&gt;That's the gap that trips people up: &lt;code&gt;[Authorize]&lt;/code&gt; answers "is this user allowed to hit this endpoint." It has no idea "is this user allowed to touch this specific row." Those are two different questions, and ASP.NET Core gives you two different tools for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication first, briefly
&lt;/h2&gt;

&lt;p&gt;Before authorization, there's the "who are you" question, and the answer depends on who's asking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Internal API behind corporate SSO&lt;/td&gt;
&lt;td&gt;OIDC via Entra ID / Keycloak, JWT bearer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public API for third parties&lt;/td&gt;
&lt;td&gt;JWT bearer + a proper identity provider (Duende, Keycloak, Auth0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-rendered app&lt;/td&gt;
&lt;td&gt;Cookie authentication + ASP.NET Core Identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service-to-service&lt;/td&gt;
&lt;td&gt;Client credentials flow, or mTLS inside the cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JwtBearerDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJwtBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Auth:Authority"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Audience&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"sample-api"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenValidationParameters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;ValidateIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateAudience&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateLifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ClockSkew&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// default 5 min is too generous&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;Notice the &lt;code&gt;ClockSkew&lt;/code&gt;. The default is five minutes, which is generous enough that an expired token can still get through for a while after it should have died. Worth tightening on anything that matters.&lt;/p&gt;

&lt;p&gt;And don't hand-roll token issuance or password hashing. Use an identity provider, or ASP.NET Core Identity, which already does PBKDF2/Argon2-grade hashing, lockout, and 2FA. This isn't the place to prove you can implement crypto yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where policy-based authorization stops
&lt;/h2&gt;

&lt;p&gt;Once you know who the user is, &lt;code&gt;[Authorize]&lt;/code&gt; policies handle the "what role can hit what endpoint" layer well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CanPublish"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"permission"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"posts.publish"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Adults"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requirements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MinimumAgeRequirement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FallbackPolicy&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;AuthorizationPolicyBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthenticatedUser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"CanPublish"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&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="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;This works fine for "can this role publish posts at all." It falls apart the moment the question becomes "can &lt;em&gt;this&lt;/em&gt; user publish &lt;em&gt;this&lt;/em&gt; post." &lt;code&gt;[Authorize]&lt;/code&gt; runs before the action method does anything. It never loaded the post, so it has nothing to check the user against — it can only see claims and roles, not data.&lt;/p&gt;

&lt;p&gt;This is the same shape of problem as a Laravel policy, if you've worked in that world. A policy method takes the model instance and the user and decides together. &lt;code&gt;[Authorize]&lt;/code&gt; alone can't do that, because by the time it runs, there's no instance yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resource-based authorization
&lt;/h2&gt;

&lt;p&gt;The fix is &lt;code&gt;IAuthorizationService&lt;/code&gt;, called after the resource is loaded, with the resource passed in as an argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostAuthorizationHandler&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AuthorizationHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OperationAuthorizationRequirement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Post&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;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleRequirementAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;AuthorizationHandlerContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;OperationAuthorizationRequirement&lt;/span&gt; &lt;span class="n"&gt;requirement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Post&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthorId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindFirstValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NameIdentifier&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Succeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requirement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&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="c1"&gt;// In the handler, once the resource is loaded:&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AuthorizeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Forbid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[Authorize]&lt;/code&gt; alone cannot express "the owner of this post," because it doesn't have the post. Anything instance-specific goes through &lt;code&gt;IAuthorizationService&lt;/code&gt;: load the resource first, then ask the authorization service whether this user can do this operation on this specific instance. It's a deliberate two-step. The decision genuinely depends on data that only exists once you've queried it, so there's no way to collapse it into a single attribute.&lt;/p&gt;

&lt;p&gt;I ran into this exact shape while building the approval workflow core for on my current project. Tasks get completed through a &lt;code&gt;WorkflowTaskAppService&lt;/code&gt;, and the access check there has to look past the role claim: is this specific task assigned to this user, or does their role let them act on it. That can't happen at the attribute level. It happens after the &lt;code&gt;ApprovalTask&lt;/code&gt; is loaded, against that instance. same reasoning as the &lt;code&gt;Post&lt;/code&gt; example, different domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anonymous should be the exception, not the default
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;FallbackPolicy&lt;/code&gt; line in the snippet above is easy to skip past, but it's doing real work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FallbackPolicy&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;AuthorizationPolicyBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthenticatedUser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, any endpoint without an explicit &lt;code&gt;[Authorize]&lt;/code&gt; is open by default. With it, the default flips: everything requires authentication unless you explicitly mark it &lt;code&gt;[AllowAnonymous]&lt;/code&gt;. Forgetting &lt;code&gt;[Authorize]&lt;/code&gt; on one new controller is the classic way to ship an open endpoint. A fallback policy turns that mistake from silent to loud — a forgotten attribute now means "user gets a 401," not "endpoint quietly accepts anyone."&lt;/p&gt;

&lt;p&gt;It's a small config change, but it changes what kind of mistake is possible. Instead of relying on everyone remembering to lock every new controller, you're relying on someone remembering to unlock the few that should be public. That second failure mode is much easier to catch in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist worth keeping around
&lt;/h2&gt;

&lt;p&gt;A few of these are directly related to the resource-based point above, others are just things that are cheap to get right and expensive to get wrong later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTPS + HSTS; &lt;code&gt;UseHttpsRedirection&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Fallback authorization policy; anonymous is opt-in&lt;/li&gt;
&lt;li&gt;Rate limiting (&lt;code&gt;AddRateLimiter&lt;/code&gt;) on auth and expensive endpoints&lt;/li&gt;
&lt;li&gt;Secrets from a vault, never from &lt;code&gt;appsettings.json&lt;/code&gt; or the repo&lt;/li&gt;
&lt;li&gt;Parameterized queries only — &lt;code&gt;FromSqlInterpolated&lt;/code&gt;, never string concatenation into &lt;code&gt;FromSqlRaw&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Don't log tokens, PII, or full request bodies; scrub structured log properties&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.UseCors()&lt;/code&gt; with an explicit origin list, never &lt;code&gt;AllowAnyOrigin&lt;/code&gt; + credentials&lt;/li&gt;
&lt;li&gt;Dependency scanning: &lt;code&gt;dotnet list package --vulnerable --include-transitive&lt;/code&gt; in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. Most authorization bugs I've seen didn't come from someone doing something clever wrong — they came from reaching for &lt;code&gt;[Authorize]&lt;/code&gt; when the actual question needed a resource loaded first.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>webdev</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Getting Request Handling Right in .NET APIs</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:29:30 +0000</pubDate>
      <link>https://dev.to/kazemmdev/getting-request-handling-right-in-net-apis-4j08</link>
      <guid>https://dev.to/kazemmdev/getting-request-handling-right-in-net-apis-4j08</guid>
      <description>&lt;p&gt;Every request handler answers three questions: where does this data come from, is it well-formed, and is it legal? Most bugs I see in API code come from answering these in the wrong place: validating something in the controller that belongs in the domain, or trying to bind data that should've been rejected by a route constraint before your code even ran. .NET already gives you the tools to keep these separate. The trick is using them for what they're actually for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the data come from
&lt;/h2&gt;

&lt;p&gt;Take a single action signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{id:guid}/comments"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromRoute&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;Guid&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="n"&gt;FromQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="n"&gt;CommentRequest&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;FromHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"X-Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromServices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;ICommentService&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five different binding sources, five different jobs. &lt;code&gt;id&lt;/code&gt; comes from the route because it identifies the resource. &lt;code&gt;notify&lt;/code&gt; sits in the query string since it's an optional modifier, and &lt;code&gt;body&lt;/code&gt;, the actual payload, belongs in the request body. The idempotency key is transport-level metadata rather than part of the resource, so it comes from a header. The service isn't part of the request at all; it comes from DI.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's just naming things by where they belong instead of dumping everything into one bag and sorting it out in code. When you see a handler that manually reads &lt;code&gt;Request.Query["notify"]&lt;/code&gt; inside the method body, that's usually a sign someone skipped past the binding attributes rather than a sign they weren't available.&lt;/p&gt;

&lt;p&gt;Route constraints do part of this job before your action even runs. &lt;code&gt;{id:guid}&lt;/code&gt; rejects anything that isn't a valid GUID at the routing layer. The request never reaches your handler, so you don't write an &lt;code&gt;if (!Guid.TryParse(...))&lt;/code&gt; check that's really just working around a mistake. &lt;code&gt;{page:int:min(1)}&lt;/code&gt; does the same thing for pagination: no page zero, no negative pages, no need for a manual bounds check in every paginated endpoint. Use them. They're free correctness you don't have to write yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the input well-formed
&lt;/h2&gt;

&lt;p&gt;Once the request is bound, the next question is whether the values make sense on their own: not whether the operation is allowed, just whether the shape is right.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;CreatePostRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;StringLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MinimumLength&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;[ApiController]&lt;/code&gt; on the controller, a failing model state returns 400 plus a &lt;code&gt;ValidationProblemDetails&lt;/code&gt; body automatically. You don't write that branch yourself. And as of .NET 10, minimal APIs get the same thing without a hand-rolled filter: &lt;code&gt;AddValidation()&lt;/code&gt; wires up DataAnnotations validation directly, so the gap between controllers and minimal APIs on this point mostly closes.&lt;/p&gt;

&lt;p&gt;For anything past "is this field required and how long," DataAnnotations starts to strain. Conditional rules, cross-field checks, async checks like uniqueness: that's FluentValidation territory. I keep it in the Application layer, not on the entity. The reason is the question it's answering: request validation asks "is this input well-formed?" The domain asks "is this operation legal?" Those are different questions with different answers depending on context, and collapsing them into one validation step is where things get messy. A title being too short is always wrong. A post being published while the author is suspended is only wrong because of state the entity itself owns. Mixing those means either the entity ends up depending on request shapes it shouldn't know about, or the request validator starts reaching into business rules it has no business enforcing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shaping the response
&lt;/h2&gt;

&lt;p&gt;On the way out, prefer &lt;code&gt;TypedResults&lt;/code&gt; over &lt;code&gt;Results&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                  &lt;span class="c1"&gt;// 200&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/api/posts/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 201 + Location&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NoContent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                              &lt;span class="c1"&gt;// 204&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                               &lt;span class="c1"&gt;// 404&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidationProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// 400&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Conflict"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"text/csv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"export.csv"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference isn't cosmetic. The concrete type flows into OpenAPI generation and is assertable in tests: you can check that an endpoint returns &lt;code&gt;Created&amp;lt;PostDto&amp;gt;&lt;/code&gt; instead of asserting on a generic &lt;code&gt;IActionResult&lt;/code&gt; and hoping the status code lines up. &lt;code&gt;Results&lt;/code&gt; still works, but you lose that.&lt;/p&gt;

&lt;p&gt;For large collections, don't buffer the whole thing into a list before returning it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/export"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsNoTracking&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;AsAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// IAsyncEnumerable → streamed JSON&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IAsyncEnumerable&lt;/code&gt; streams the JSON as rows come off the database instead of materializing everything in memory first. For an export endpoint especially, that's the difference between a response that starts immediately and one that hangs while the server loads ten thousand rows before writing a single byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract hygiene
&lt;/h2&gt;

&lt;p&gt;A few rules I hold to regardless of the endpoint:&lt;/p&gt;

&lt;p&gt;DTOs both ways, always. Never accept or return entities directly. Over-posting (a client sending fields it shouldn't be able to set, which then get bound straight onto your entity) is a real vulnerability, not a theoretical one. And returning entities means every change to your domain model is also a silent breaking change to your API clients, whether you meant it to be or not.&lt;/p&gt;

&lt;p&gt;Version from day one, either &lt;code&gt;/api/v1/...&lt;/code&gt; or a header. Retro-fitting versioning onto an API that's already live and already has consumers is painful in a way that's hard to appreciate until you're the one doing it.&lt;/p&gt;

&lt;p&gt;For docs, &lt;code&gt;AddOpenApi()&lt;/code&gt; / &lt;code&gt;MapOpenApi()&lt;/code&gt; generates the OpenAPI document now. Swashbuckle's templates left as of .NET 9. Pair it with Scalar or Swagger UI for the browsable page.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ProblemDetails&lt;/code&gt; everywhere, including on 500s. One error shape across the whole API means clients write one error-handling path instead of special-casing whatever shape each endpoint happened to return.&lt;/p&gt;

&lt;p&gt;And status codes should mean what they say: 401 is "who are you," 403 is "I know who you are and no," 409 is a concurrency conflict, 422 is semantically invalid input: the request was well-formed but the operation itself can't happen. Getting these right isn't pedantry. It's the difference between a client that can programmatically decide whether to retry, prompt for re-authentication, or just fail, versus a client that has to parse your error message to figure out what happened.&lt;/p&gt;

&lt;p&gt;What trips people up isn't any single piece of this. It's blending the layers: validating business rules in a request DTO, or handling routing concerns inside a service method. Keep each question where it belongs, and the framework does most of the work for you.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>backend</category>
      <category>dotnet</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Controllers vs Minimal APIs: Stop Picking a Winner</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:04:42 +0000</pubDate>
      <link>https://dev.to/kazemmdev/controllers-vs-minimal-apis-stop-picking-a-winner-452g</link>
      <guid>https://dev.to/kazemmdev/controllers-vs-minimal-apis-stop-picking-a-winner-452g</guid>
      <description>&lt;p&gt;Every .NET 10 project I start now has the same five-minute argument with myself: Controllers or Minimal APIs?&lt;/p&gt;

&lt;p&gt;Both are first-class citizens in .NET 10, with the same routing, DI, filters, and model binding under the hood. So the question isn't which one is "better." It's which shape fits the API you're actually building. And that decision matters a lot less than what you do once you've made it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual tradeoff
&lt;/h2&gt;

&lt;p&gt;Here's how I think about it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Controllers (MVC)&lt;/th&gt;
&lt;th&gt;Minimal APIs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large APIs, convention-heavy teams, model binding edge cases, existing MVC codebases, framework integration (ABP, OData)&lt;/td&gt;
&lt;td&gt;Small/medium services, vertical slices, high-throughput endpoints, AOT scenarios&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Discovery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Attribute routing, conventions&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;Map*&lt;/code&gt; calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Slightly more per request&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Risk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Fat controller" gravity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Program.cs&lt;/code&gt; becomes a 900-line wall unless you split into endpoint modules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the risk column. Both approaches share the same failure mode: everything ends up in one place because nobody stopped it early. Controllers accrete logic because it's easy to add "just one more method" to an existing class. Minimal APIs accrete &lt;code&gt;Map*&lt;/code&gt; calls in &lt;code&gt;Program.cs&lt;/code&gt; for the same reason: the file's already open, the pattern's already there, why not.&lt;/p&gt;

&lt;p&gt;What matters is keeping either one from collapsing into a dumping ground, not which style you start with.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that looks like for Minimal APIs
&lt;/h2&gt;

&lt;p&gt;If you're going with Minimal APIs, don't stack your endpoints in &lt;code&gt;Program.cs&lt;/code&gt;. Pull them into modules and register them with an extension method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Minimal API, organized as a module — not dumped in Program.cs&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostEndpoints&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IEndpointRouteBuilder&lt;/span&gt; &lt;span class="nf"&gt;MapPosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;IEndpointRouteBuilder&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/posts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Posts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddEndpointFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ValidationFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="k"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&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;IPostService&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AsParameters&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;PostQuery&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

        &lt;span class="k"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&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="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationProblem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreatePostRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IPostService&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dto&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;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/api/posts/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;app&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="c1"&gt;// Program.cs&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPosts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MapGroup&lt;/code&gt; gives you a single place to attach tags, auth, and filters for a whole slice of routes, instead of repeating &lt;code&gt;.RequireAuthorization()&lt;/code&gt; on every individual &lt;code&gt;Map*&lt;/code&gt; call. &lt;code&gt;Program.cs&lt;/code&gt; stays one line per feature. That's the whole trick, and it only works if you do it from the start, before you have twenty endpoints to retrofit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that looks like for Controllers
&lt;/h2&gt;

&lt;p&gt;The controller side has its own version of the same discipline, and it's basically the same spirit I'd apply in Laravel: keep the controller thin and let it do exactly one job, translating an HTTP request into a call on something else.&lt;/p&gt;

&lt;p&gt;A few things I hold to on every controller:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thin.&lt;/strong&gt; Bind → delegate → shape a response. No EF queries in a controller action, no business rules. If you're writing a &lt;code&gt;Where&lt;/code&gt; clause in a controller, that logic belongs in a service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always &lt;code&gt;[ApiController]&lt;/code&gt;.&lt;/strong&gt; It gives you automatic 400s on model-binding failures and infers binding sources for you. There's no good reason to opt out of this on a real API controller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never return a raw entity.&lt;/strong&gt; Return &lt;code&gt;IActionResult&lt;/code&gt; or &lt;code&gt;TypedResults&lt;/code&gt;, and shape the response explicitly. Your database schema is not your API contract. The moment you return an EF entity directly, every column you add to that table becomes a payload change for every client, whether you meant it to or not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always accept and forward &lt;code&gt;CancellationToken&lt;/code&gt;.&lt;/strong&gt; And never &lt;code&gt;async void&lt;/code&gt;, never &lt;code&gt;.Result&lt;/code&gt;, never &lt;code&gt;.Wait()&lt;/code&gt;. Under load, blocking on an async call like that is a thread-pool starvation deadlock waiting to happen, the kind of bug that shows up in production instead of dev, under exactly the traffic you were trying to handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one do I pick
&lt;/h2&gt;

&lt;p&gt;It depends on the shape of what I'm building. That's not a cop-out; it's the actual answer. A handful of high-throughput, independently-scaled endpoints with no shared model-binding complexity? Minimal APIs, one module per feature. A large API integrating with something like ABP or OData, where framework conventions are doing real work for me? Controllers.&lt;/p&gt;

&lt;p&gt;What I've stopped doing is treating the choice as the interesting part. What matters is whether, six months in, the routing layer is still organized the way it was on day one: whether &lt;code&gt;Program.cs&lt;/code&gt; is still readable, whether controllers are still thin. That's a discipline problem, not an API-style problem.&lt;/p&gt;

&lt;p&gt;Pick the shape that fits, then actually enforce the guardrails that keep it from turning into the thing you were trying to avoid in the other approach.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Middleware Order Is Behavior, Not Style</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:04:01 +0000</pubDate>
      <link>https://dev.to/kazemmdev/middleware-order-is-behavior-not-style-13ni</link>
      <guid>https://dev.to/kazemmdev/middleware-order-is-behavior-not-style-13ni</guid>
      <description>&lt;p&gt;Middleware ordering looks like a formatting preference, like where you put your using statements. It isn't. Put &lt;code&gt;UseAuthorization()&lt;/code&gt; before &lt;code&gt;UseAuthentication()&lt;/code&gt; in a pipeline and every request sails through as anonymous. No error, no warning, nothing in the logs to flag it. The app just quietly stops checking who anyone is.&lt;/p&gt;

&lt;p&gt;That's the thing about ASP.NET Core middleware: it's a pipeline, and pipelines are order-sensitive by nature. Each component gets the &lt;code&gt;HttpContext&lt;/code&gt;, can act before and after the next one runs, and can short-circuit the whole thing. Swap two lines and you haven't changed how the code looks: you've changed what "authenticated" or "handled" means for every request that comes through. And the framework won't tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short-circuit and what runs after it
&lt;/h2&gt;

&lt;p&gt;Here's a tenant-resolution middleware that shows most of the moving parts at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RequestDelegate&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ITenantStore&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// scoped svc injected per-call&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;tenantId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-Tenant"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status400BadRequest&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAsJsonAsync&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;ProblemDetails&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Missing X-Tenant header."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                     &lt;span class="c1"&gt;// short-circuit: next is never called&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Tenant"&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenantId&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;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                            &lt;span class="c1"&gt;// everything after this runs on the way out&lt;/span&gt;

        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-Tenant"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ⚠ too late if the response already started&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseMiddleware&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the header is missing, we return a 400 and never call &lt;code&gt;next(context)&lt;/code&gt;. That's the short-circuit: nothing further down the pipeline runs, including routing, auth, or the endpoint itself. It's a deliberate gate, and it's the same mechanism that makes ordering matter everywhere else in the pipeline: whatever runs before &lt;code&gt;next()&lt;/code&gt; sees the request on the way in, and whatever runs after sees it on the way out, only if something further down didn't already short-circuit it.&lt;/p&gt;

&lt;p&gt;That last line is worth pausing on: setting a response header after &lt;code&gt;await next(context)&lt;/code&gt; assumes the response hasn't started yet. If anything downstream has already begun streaming the response, which is common the moment you're dealing with larger payloads or certain content types, that header write does nothing. No exception, no warning. It just silently fails to take effect.&lt;/p&gt;

&lt;p&gt;There's a second trap in that same snippet that has nothing to do with ordering: &lt;code&gt;ITenantStore store&lt;/code&gt; is injected as a parameter on &lt;code&gt;InvokeAsync&lt;/code&gt;, not through the constructor. That's not a style choice either. Convention-based middleware like this is instantiated once, at startup, and reused as a singleton for the lifetime of the app. If you constructor-inject a scoped service, like most &lt;code&gt;DbContext&lt;/code&gt;-backed stores, you're capturing it once and reusing that same instance across every request, which is exactly the kind of bug that only shows up under concurrent load, well after you've stopped looking at this file. Injecting scoped services as &lt;code&gt;InvokeAsync&lt;/code&gt; parameters sidesteps that, because the DI container resolves them fresh per request. The other option is to implement &lt;code&gt;IMiddleware&lt;/code&gt; and register it explicitly, which gives you per-request instantiation instead of the singleton convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline as a dependency graph
&lt;/h2&gt;

&lt;p&gt;Once you've internalized that middleware runs in a strict, request-in/response-out sequence, the rest of a typical pipeline reads less like a checklist and more like a dependency graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// outermost — must wrap everything to catch it&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseHsts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseHttpsRedirection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseStaticFiles&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRouting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// decides WHICH endpoint&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRateLimiter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseCors&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthentication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;        &lt;span class="c1"&gt;// WHO are you        — must be after UseRouting&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;         &lt;span class="c1"&gt;// are you ALLOWED    — must be after authentication&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOutputCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapControllers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// executes the endpoint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UseExceptionHandler()&lt;/code&gt; goes first because it needs to wrap everything else. Register it last and it's inside the pipeline instead of around it, so it won't catch exceptions thrown by the middleware registered before it. That's easy to miss because a handler like this can look correct in isolation and pass a test built around a deliberately-thrown exception in a controller action, while still doing nothing the one time an earlier middleware fails.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;UseAuthentication()&lt;/code&gt; has to come after &lt;code&gt;UseRouting()&lt;/code&gt;, and &lt;code&gt;UseAuthorization()&lt;/code&gt; has to come after &lt;code&gt;UseAuthentication()&lt;/code&gt;. Flip that last pair, authorization before authentication, and you get the anonymous-request problem: every request is evaluated as anonymous because the authorization middleware runs before anything has had a chance to establish who the caller is. The request doesn't error. It just gets treated as if no one is logged in, which in a lot of setups means either "reject everyone" or, worse, "allow everyone" depending on how your policies are written.&lt;/p&gt;

&lt;p&gt;Neither mistake throws a compile-time error or a runtime warning. The pipeline builds fine. Requests come through. The behavior is just wrong, and the only way to catch it is to actually exercise the auth path or read the pipeline order carefully enough to reason through what each stage assumes about the ones before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle exceptions once, centrally
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;UseExceptionHandler()&lt;/code&gt; line pairs with an &lt;code&gt;IExceptionHandler&lt;/code&gt; implementation, which is where cross-cutting exception handling actually lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DomainExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IProblemDetailsService&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IExceptionHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;ValueTask&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TryHandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Exception&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;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;DomainException&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// let the next handler try&lt;/span&gt;

        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status422UnprocessableEntity&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="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryWriteAsync&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;ProblemDetailsContext&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ProblemDetails&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Domain rule violated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Detail&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&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="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DomainExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddProblemDetails&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find yourself wrapping every controller action in &lt;code&gt;try/catch&lt;/code&gt;, that's usually a sign the exception handling belongs here instead. Registering multiple &lt;code&gt;IExceptionHandler&lt;/code&gt; implementations lets you handle different exception types separately (&lt;code&gt;TryHandleAsync&lt;/code&gt; returning &lt;code&gt;false&lt;/code&gt; just passes the exception to the next registered handler), while every response still comes back as a consistent RFC 9457 &lt;code&gt;ProblemDetails&lt;/code&gt; payload instead of whatever ad hoc shape each action happened to return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Middleware or endpoint filter?
&lt;/h2&gt;

&lt;p&gt;Not everything belongs in the middleware pipeline just because it can go there. The distinction that's actually useful:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use middleware for&lt;/th&gt;
&lt;th&gt;Use an endpoint filter / action filter for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anything cross-cutting on every request (logging, auth, correlation IDs, compression)&lt;/td&gt;
&lt;td&gt;Concerns tied to a specific endpoint or group (validation, idempotency keys, resource authorization)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Work that must run before routing&lt;/td&gt;
&lt;td&gt;Work that needs the bound arguments / model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Middleware runs before the framework has matched a route or bound any parameters, so it's the right place for things that apply uniformly: logging every request, stamping a correlation ID, checking who the caller is. The moment you need the actual bound arguments of a specific endpoint (validating a request body, checking an idempotency key against a specific resource), you're past what middleware can see cleanly, and an endpoint filter is the better fit. Trying to do argument-level validation in middleware usually means re-parsing or re-binding something the framework is about to do anyway.&lt;/p&gt;

&lt;p&gt;None of this is complicated once you see the pipeline for what it is: a strict, ordered sequence where each stage's correctness depends on assumptions about what already ran. The bugs aren't exotic. They're one-line reorderings that compile clean, run clean, and quietly change what your API does.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>backend</category>
      <category>dotnet</category>
    </item>
  </channel>
</rss>
