<?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: Anaya Upadhyay</title>
    <description>The latest articles on DEV Community by Anaya Upadhyay (@anayaupadhyay).</description>
    <link>https://dev.to/anayaupadhyay</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%2F1736805%2F1b89cbeb-ce35-4c87-a7a5-a14dfb431e88.png</url>
      <title>DEV Community: Anaya Upadhyay</title>
      <link>https://dev.to/anayaupadhyay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anayaupadhyay"/>
    <language>en</language>
    <item>
      <title>SOLID in real .NET: the S and D that actually change your code</title>
      <dc:creator>Anaya Upadhyay</dc:creator>
      <pubDate>Fri, 24 Jul 2026 06:15:00 +0000</pubDate>
      <link>https://dev.to/anayaupadhyay/solid-in-real-net-the-s-and-d-that-actually-change-your-code-15fn</link>
      <guid>https://dev.to/anayaupadhyay/solid-in-real-net-the-s-and-d-that-actually-change-your-code-15fn</guid>
      <description>&lt;p&gt;Last Monday someone on my feed changed an invoice email template and watched the tax calculation tests fail. Six red tests, none of them about email. If that sequence sounds impossible, you have not met &lt;code&gt;InvoiceService&lt;/code&gt; yet.&lt;/p&gt;

&lt;p&gt;SOLID usually gets taught as five poster definitions, recited once for the interview and never opened again. In day-to-day .NET work, though, two of the letters do almost all of the refactoring: the S and the D. This is the working version of both, with the before and after in full, targeting .NET 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  S is "one reason to change," not "one thing to do"
&lt;/h2&gt;

&lt;p&gt;The Single Responsibility Principle is Robert C. Martin's, and his formulation is precise: a class should have one reason to change. The popular paraphrase, "a class should do one thing," is the version that misleads people, because "one thing" is whatever granularity you feel like defending in review. "This class handles invoices" sounds like one thing. It is not.&lt;/p&gt;

&lt;p&gt;Count reasons to change instead. Better yet, count the people who can demand a change:&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;InvoiceService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// finance changes this one&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="nf"&gt;CalculateTax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&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;rate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ProductCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reduced&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="m"&gt;0.05m&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.13m&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// design changes this one&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;RenderPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&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;PdfDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTotals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;CalculateTax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToBytes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// marketing changes this one&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;SendEmailAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&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;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EmailTemplates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-ready"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice&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;_mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerEmail&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="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;Three stakeholders, one file. Every edit for any of them re-opens code the other two depend on, which is how a template tweak lands in the tax test report. The class compiles, passes review, and quietly taxes every future change with a full regression run.&lt;/p&gt;

&lt;p&gt;The split is not clever. That is the point:&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;TaxCalculator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="nf"&gt;Calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&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;rate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ProductCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reduced&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="m"&gt;0.05m&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.13m&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoicePdfRenderer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&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;PdfDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTotals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tax&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToBytes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoiceMailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IMailer&lt;/span&gt; &lt;span class="n"&gt;mailer&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;Task&lt;/span&gt; &lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&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;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EmailTemplates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-ready"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice&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;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerEmail&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="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;Now the email template edit touches &lt;code&gt;InvoiceMailer&lt;/code&gt;, its tests, and nothing else. The blast radius of a change drops to one file, and the diff a reviewer reads matches the requirement that caused it. Reviewers notice that. So does whoever gets paged.&lt;/p&gt;

&lt;p&gt;One honest caveat: you can over-split. If two pieces of code change for the same reason at the same time, separating them is ceremony, not architecture. The stakeholder count is the test, in both directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  D is about who owns the concrete type
&lt;/h2&gt;

&lt;p&gt;The Dependency Inversion Principle, also Martin's, says high-level policy should not depend on low-level detail; both should depend on abstractions. In C# terms it is more blunt: your business rule should not &lt;code&gt;new&lt;/code&gt; up the infrastructure it talks to.&lt;/p&gt;

&lt;p&gt;Here is the weld:&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;OrderService&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;PlaceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// the high-level rule owns a wire-level detail&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mailer&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;SmtpMailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"smtp.internal"&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;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Receipt&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;Why this is wrong, concretely and not philosophically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The order rule is welded to SMTP. Moving to SendGrid, or to a queue, means editing checkout logic that has nothing to do with either.&lt;/li&gt;
&lt;li&gt;It cannot be tested in isolation. Run this test suite and a real mail server answers, or the test fails for network reasons your logic does not have.&lt;/li&gt;
&lt;li&gt;The dependency is invisible. Nothing in the constructor tells a reader that placing an order sends mail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is constructor injection with the concrete type owned by the composition root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the abstraction the rule depends on&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IMailer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SendAsync&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="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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// C# 12 primary constructor, current on .NET 10&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IMailer&lt;/span&gt; &lt;span class="n"&gt;mailer&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;PlaceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// rule stays pure; delivery is a detail&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Receipt&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Program.cs, the one place that knows the concrete type&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IMailer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SmtpMailer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderService&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;Swap SMTP for SendGrid and the diff is one registration line. Test the order rule and the fake is three lines. The constructor now tells the truth about what the class needs, which is the property everything else hangs off.&lt;/p&gt;

&lt;p&gt;A registration note, since it comes up: &lt;code&gt;AddScoped&lt;/code&gt; gives you one instance per request in ASP.NET Core, which is the safe default for anything holding request-shaped state. A stateless mailer could be a singleton. What matters for the principle is where the concrete type lives, not which lifetime you pick, and lifetimes deserve their own article anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why S and D need each other
&lt;/h2&gt;

&lt;p&gt;Split a class without inverting its dependencies and you own small classes you still cannot fake in a test. Invert dependencies on a class with five responsibilities and you get a constructor with nine parameters shouting at you, which is at least useful information.&lt;/p&gt;

&lt;p&gt;Do both and every seam becomes two things at once: a swap point and a test point. &lt;code&gt;TaxCalculator&lt;/code&gt; behind &lt;code&gt;ITaxCalculator&lt;/code&gt; can be replaced the day tax rules move to a rules engine, and faked the day you want a checkout test that does not care about VAT. The S made the seam small enough to be honest; the D made it loose enough to be useful.&lt;/p&gt;

&lt;p&gt;This is also where Open/Closed quietly shows up for free. Once the rule depends on &lt;code&gt;IMailer&lt;/code&gt;, extending behavior means writing a new implementation rather than editing a working one. You did not set out to satisfy O. It fell out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other three, honestly
&lt;/h2&gt;

&lt;p&gt;Liskov Substitution earns its keep in inheritance-heavy trees, and modern .NET leans composition hard enough that many teams meet L rarely, mostly in older code. Interface Segregation is S applied to interfaces: if a fake has to implement six members to test one, the interface is hoarding. Fine principles, both. They just drive fewer of the refactors you will actually perform this quarter, which is why this article gave them a paragraph and gave S and D the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-PR checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One reason to change per class. Count stakeholders, not methods.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;new&lt;/code&gt; for anything you would ever swap or fake in a test.&lt;/li&gt;
&lt;li&gt;The constructor names every dependency. Injecting &lt;code&gt;IServiceProvider&lt;/code&gt; is a service locator wearing a lanyard, and it moves failures to runtime.&lt;/li&gt;
&lt;li&gt;Interfaces stay small. A six-member fake for a one-member test is a smell.&lt;/li&gt;
&lt;li&gt;Concrete types live in &lt;code&gt;Program.cs&lt;/code&gt; registrations, nowhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Version note: the samples above use C# 12 primary constructors and target .NET 10, the current LTS. Everything conceptual applies to any supported .NET version; only the constructor syntax is version-flavored, and the classic constructor form works identically.&lt;/p&gt;

&lt;p&gt;If you prefer this material as a visual walkthrough, the 9-slide breakdown of this exact refactor lives at @thesharpfuture, where a new one lands every week.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>architecture</category>
      <category>cleancode</category>
    </item>
    <item>
      <title>JWT Auth in .NET 8: The Validation Settings People Skip</title>
      <dc:creator>Anaya Upadhyay</dc:creator>
      <pubDate>Fri, 10 Jul 2026 06:15:00 +0000</pubDate>
      <link>https://dev.to/anayaupadhyay/jwt-auth-in-net-8-the-validation-settings-people-skip-3nip</link>
      <guid>https://dev.to/anayaupadhyay/jwt-auth-in-net-8-the-validation-settings-people-skip-3nip</guid>
      <description>&lt;p&gt;There is a category of security bug that produces no error, no log entry, and no failing test. A token that should have been rejected gets a 200 instead, and the only way anyone finds out is by reading the auth configuration line by line, usually after something has already gone wrong.&lt;/p&gt;

&lt;p&gt;Most of these live inside one class: &lt;code&gt;TokenValidationParameters&lt;/code&gt;. Its defaults are good. The trouble is that its defaults are also easy to turn off, and the moments when people turn them off tend to be the moments they are trying to make an error message go away.&lt;/p&gt;

&lt;p&gt;This article walks the five settings that get skipped most, what each one actually checks, what turning it off silently allows, and what the correct .NET 8 configuration looks like. At the end there is a checklist you can run against your own codebase in about two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline
&lt;/h2&gt;

&lt;p&gt;Here is a complete, correct JWT bearer setup for a .NET 8 minimal API. Everything below refers back to 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;using&lt;/span&gt; &lt;span class="nn"&gt;System.Text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.AspNetCore.Authentication.JwtBearer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.IdentityModel.Tokens&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;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenValidationParameters&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;TokenValidationParameters&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;ValidIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://auth.example.com"&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;ValidAudience&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"orders-api"&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;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;RequireExpirationTime&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;ValidateIssuerSigningKey&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;IssuerSigningKey&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;SymmetricSecurityKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&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;"Jwt:Key"&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="nf"&gt;AddAuthorization&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;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthentication&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;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"order-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"order-2"&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="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One note before the flags: if you point the handler at an identity provider via &lt;code&gt;options.Authority&lt;/code&gt;, the issuer and signing keys come from OIDC discovery metadata and much of this configures itself. The bugs below live almost entirely in hand-rolled setups, which is exactly where copied snippets end up.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. ValidateLifetime
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Default: true.&lt;/strong&gt; Lifetime validation checks the token's &lt;code&gt;nbf&lt;/code&gt; (not before) and &lt;code&gt;exp&lt;/code&gt; (expires) claims against the current UTC time.&lt;/p&gt;

&lt;p&gt;The bug is not the default. The bug is this line, which shows up in copied configuration with remarkable consistency:&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;ValidateLifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It usually gets added to silence a lifetime validation error during local testing, often when someone's test token has expired and regenerating it feels like friction. The error goes away. The line stays. It ships.&lt;/p&gt;

&lt;p&gt;From that point on, an expired token validates. The signature still checks out, so nothing looks wrong from the outside: the request carries a structurally valid, correctly signed token whose expiry date has simply stopped mattering. Picture a leaked token from six months ago. Your API would still honor it.&lt;/p&gt;

&lt;p&gt;The fix is deletion. The default is already &lt;code&gt;true&lt;/code&gt;, so the safest version of this configuration is the one with less code in it. If a flag has to be &lt;code&gt;false&lt;/code&gt; for a test to pass, that belongs in the test project's configuration, not in &lt;code&gt;Program.cs&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. ClockSkew
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Default: five minutes.&lt;/strong&gt; &lt;code&gt;TokenValidationParameters.DefaultClockSkew&lt;/code&gt; is &lt;code&gt;TimeSpan.FromMinutes(5)&lt;/code&gt; in the Microsoft.IdentityModel reference documentation, and it applies as tolerance on both ends of the lifetime check: a token is accepted when &lt;code&gt;nbf - skew &amp;lt;= now &amp;lt;= exp + skew&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This one is not a flag someone flipped. It is a default nobody tuned, and it means every token in your system is accepted for five minutes past its printed expiry.&lt;/p&gt;

&lt;p&gt;For hour-long tokens, that grace window is noise. For short-lived access tokens the math changes: picture a five minute access token, which now validates for close to ten. Whatever reasoning went into choosing that five minute lifetime, the effective lifetime is quietly double 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;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;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temptation is to set it to &lt;code&gt;TimeSpan.Zero&lt;/code&gt;, and some teams do. Before you follow them, remember what the skew is for: real machines drift, and a token minted by an identity server whose clock runs twenty seconds ahead of yours would be rejected as "not yet valid" with zero tolerance. Size the skew to your infrastructure and your token lifetime rather than deleting it on principle.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. ValidateIssuer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Default: true.&lt;/strong&gt; Issuer validation checks the token's &lt;code&gt;iss&lt;/code&gt; claim against &lt;code&gt;ValidIssuer&lt;/code&gt; or &lt;code&gt;ValidIssuers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is how it gets disabled in the wild. Someone wires up validation, runs the app, and gets an &lt;code&gt;IDX10204&lt;/code&gt; or &lt;code&gt;IDX10205&lt;/code&gt; error from the issuer check, typically because &lt;code&gt;ValidIssuer&lt;/code&gt; was never set or does not match what the identity server actually mints. The fastest way to make that exception disappear is:&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;ValidateIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it does disappear. Along with the question "who minted this token?"&lt;/p&gt;

&lt;p&gt;With issuer validation off, a token signed with any key your API accepts will pass, regardless of where it came from. In a system with one API and one symmetric key that may sound theoretical. In a system where keys get shared across environments, or where a staging identity server signs with the same key as production, it stops being theoretical fast.&lt;/p&gt;

&lt;p&gt;The exception was the security feature. Fix the configuration instead of the symptom:&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;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;ValidIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://auth.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. ValidateAudience
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Default: true.&lt;/strong&gt; Audience validation checks the token's &lt;code&gt;aud&lt;/code&gt; claim against &lt;code&gt;ValidAudience&lt;/code&gt; or &lt;code&gt;ValidAudiences&lt;/code&gt;, answering the question "was this token minted for me, specifically?"&lt;/p&gt;

&lt;p&gt;Same disable story as the issuer, different error code: an &lt;code&gt;IDX10208&lt;/code&gt; or &lt;code&gt;IDX10214&lt;/code&gt; fires because no audience was configured, and &lt;code&gt;ValidateAudience = false&lt;/code&gt; makes it stop. What it also does is make every API in your system interchangeable from a token's point of view.&lt;/p&gt;

&lt;p&gt;Picture two services, &lt;code&gt;billing-api&lt;/code&gt; and &lt;code&gt;orders-api&lt;/code&gt;, validating with the same signing key. A token minted for billing carries &lt;code&gt;aud: "billing-api"&lt;/code&gt;. With audience validation off, orders accepts it too. One leaked or over-scoped token now opens both doors, and any future service that joins the key club gets opened by it as 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;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;ValidAudience&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"orders-api"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One audience per API. The scope of a token should mean something.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. RequireExpirationTime
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Default: true.&lt;/strong&gt; This is the flag behind the lifetime flag, and it covers a case most people have not considered: what happens when a token has no &lt;code&gt;exp&lt;/code&gt; claim at all?&lt;/p&gt;

&lt;p&gt;Lifetime validation checks the expiry when one is present. &lt;code&gt;RequireExpirationTime&lt;/code&gt; is what makes its presence mandatory. Set it to &lt;code&gt;false&lt;/code&gt; and a token minted without an expiry sails through lifetime validation, because there is nothing to evaluate. That token has no end date. It outlives the incident review, the key rotation discussion, and possibly the service itself.&lt;/p&gt;

&lt;p&gt;There is close to no legitimate reason for an access token without an expiry, so this default deserves to stay exactly where it is:&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;RequireExpirationTime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two more worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RequireSignedTokens&lt;/strong&gt; defaults to &lt;code&gt;true&lt;/code&gt; and rejects unsigned tokens. Leave it. An API that accepts &lt;code&gt;alg: none&lt;/code&gt; tokens is an API with optional authentication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ValidateIssuerSigningKey&lt;/strong&gt; defaults to &lt;code&gt;false&lt;/code&gt;, and this one surprises people in the other direction: signatures are verified regardless. What this flag adds is validation of the key material itself, for example rejecting a token signed with an X.509 certificate that has expired. Worth turning on when your signing keys are certificates with real lifetimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the flags don't cover for each other
&lt;/h2&gt;

&lt;p&gt;Validation is a chain of independent gates: signature, issuer, audience, lifetime. Each one answers a different question. Signature asks whether the token is intact. Issuer asks who minted it. Audience asks whether it was minted for you. Lifetime asks whether it is still alive.&lt;/p&gt;

&lt;p&gt;A yes to one answers none of the others, which is why "the signature checks out" is such a dangerous form of reassurance. Disable a gate and it is not weakened, it is gone, and the three remaining gates will keep passing tokens that the fourth would have stopped. No exception fires when a check is skipped. Skipped checks are silent by definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Run this against every &lt;code&gt;TokenValidationParameters&lt;/code&gt; in your codebase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ValidateLifetime&lt;/code&gt; stays true. Delete the override rather than negotiating with it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ClockSkew&lt;/code&gt; sized to your token lifetime, not left at the five minute default for short-lived tokens.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ValidIssuer&lt;/code&gt; named. Tokens from anywhere are not a feature.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ValidAudience&lt;/code&gt; set per API. One token, one door.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RequireExpirationTime&lt;/code&gt; stays true. Every token gets an end date.&lt;/li&gt;
&lt;li&gt;Bonus: &lt;code&gt;RequireSignedTokens&lt;/code&gt; untouched, &lt;code&gt;ValidateIssuerSigningKey&lt;/code&gt; considered if your keys are certificates.
Then the two minute audit: grep your solution for &lt;code&gt;= false&lt;/code&gt; inside any &lt;code&gt;TokenValidationParameters&lt;/code&gt; initializer and make each hit justify itself with a comment naming who approved it and why. In my experience most of them can't.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you prefer this material as visual breakdowns, I publish them as carousels and short reels at &lt;strong&gt;@thesharpfuture&lt;/strong&gt; on Instagram, aimed at junior to mid-level .NET developers. This week's set covers exactly these five flags.&lt;/p&gt;

&lt;p&gt;Found one of these switched off in a real codebase? I'd genuinely like to hear which one, and what the comment next to it said, if there was one.&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Minimal API vs Controllers in ASP.NET Core: When Each One Actually Wins</title>
      <dc:creator>Anaya Upadhyay</dc:creator>
      <pubDate>Fri, 12 Jun 2026 06:15:00 +0000</pubDate>
      <link>https://dev.to/anayaupadhyay/minimal-api-vs-controllers-in-aspnet-core-when-each-one-actually-wins-n3a</link>
      <guid>https://dev.to/anayaupadhyay/minimal-api-vs-controllers-in-aspnet-core-when-each-one-actually-wins-n3a</guid>
      <description>&lt;p&gt;Somewhere around .NET 6, the Minimal API question stopped being academic. Teams actually started shipping with it. And then some of those teams quietly started adding Controllers back six months later.&lt;/p&gt;

&lt;p&gt;Both choices can be correct. The problem is that the wrong framing "which one is simpler?" or "which one is more modern?" lands you in the wrong answer. Here is the framing that works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real question
&lt;/h2&gt;

&lt;p&gt;Both Minimal API and Controllers ship working APIs. What they optimize for is completely different, and ignoring that difference is how you end up refactoring.&lt;/p&gt;

&lt;p&gt;Start with three questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How complex is your routing logic?&lt;/strong&gt;&lt;br&gt;
A handful of endpoints vs. a full domain model with dozens of grouped resources. That gap matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you need the full action filter pipeline?&lt;/strong&gt;&lt;br&gt;
Controller action filters and DI-bound per-action behavior make unit testing dramatically easier at scale. Minimal API has endpoint filters, they work, but they are not the same abstraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who else touches this codebase?&lt;/strong&gt;&lt;br&gt;
Controllers have 15 years of conventions. A developer who has never seen your project knows where to look. Minimal API is learnable fast, but it is not pre-loaded.&lt;/p&gt;


&lt;h2&gt;
  
  
  When Minimal API wins
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Microservices with a narrow surface area
&lt;/h3&gt;

&lt;p&gt;One service. A handful of well-defined endpoints. You do not need to carry the full MVC stack for a payment webhook receiver or an internal reporting service. &lt;code&gt;MapGet&lt;/code&gt;, &lt;code&gt;MapPost&lt;/code&gt;, done.&lt;/p&gt;
&lt;h3&gt;
  
  
  Internal tooling and prototypes
&lt;/h3&gt;

&lt;p&gt;You want working code in minutes. Controllers impose ceremony that has no return on a one-team service nobody else integrates with. The shorter the expected lifespan, the higher the ceremony cost.&lt;/p&gt;
&lt;h3&gt;
  
  
  HTTP-native patterns
&lt;/h3&gt;

&lt;p&gt;Webhooks, health checks, simple CRUD. Request in, response out. The Minimal API pipeline maps directly to those problems. No inheritance, no attributes, no discovery conventions.&lt;/p&gt;
&lt;h3&gt;
  
  
  Small teams that own the full stack
&lt;/h3&gt;

&lt;p&gt;When the team is small and the scope is bounded, conventions are mostly overhead. Minimal API gives that team agency over their own structure without fighting a framework.&lt;/p&gt;


&lt;h2&gt;
  
  
  What a clean Minimal API endpoint looks like
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// GET /products/{id}&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/products/{id}"&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IProductRepository&lt;/span&gt; &lt;span class="n"&gt;repo&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;product&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;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Results&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="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The whole thing fits. DI works. &lt;code&gt;IProductRepository&lt;/code&gt; is resolved from the container automatically. &lt;code&gt;Results&lt;/code&gt; helpers cover the common response patterns without &lt;code&gt;IActionResult&lt;/code&gt; boilerplate.&lt;/p&gt;


&lt;h2&gt;
  
  
  When Controllers win
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Large domain with many grouped resources
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ProductsController&lt;/code&gt;, &lt;code&gt;OrdersController&lt;/code&gt;, &lt;code&gt;UsersController&lt;/code&gt;. Controllers namespace your API implicitly and make routing intentions explicit without attribute gymnastics. At 40+ endpoints the ergonomics flip decisively.&lt;/p&gt;
&lt;h3&gt;
  
  
  Action filters are doing real work
&lt;/h3&gt;

&lt;p&gt;Audit logging, model validation attributes, custom authorization policies scoped per action. The filter pipeline on Controllers is composable in ways that Minimal API endpoint filters currently are not. If your cross-cutting concerns are complex, go where they fit cleanly.&lt;/p&gt;
&lt;h3&gt;
  
  
  Multiple developers sharing the codebase
&lt;/h3&gt;

&lt;p&gt;This one is underrated. Controllers have 15 years of conventions behind them. A new developer knows where to look without asking anyone. That knowledge is worth something, especially as the team grows.&lt;/p&gt;
&lt;h3&gt;
  
  
  Versioned APIs with complex binding
&lt;/h3&gt;

&lt;p&gt;Route groups help in Minimal API, but versioned controllers with &lt;code&gt;[ApiVersion]&lt;/code&gt; and strongly-typed model binding still carry less ceremony in practice. If you are versioning three major API versions in production, Controllers are the path of least resistance.&lt;/p&gt;


&lt;h2&gt;
  
  
  The same endpoint as a Controller
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ApiController&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api/[controller]"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductsController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ControllerBase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IProductRepository&lt;/span&gt; &lt;span class="n"&gt;_repo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ProductsController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IProductRepository&lt;/span&gt; &lt;span class="n"&gt;repo&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;_repo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// GET api/products/{id}&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{id}"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="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;GetById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;product&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;_repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;()&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;product&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;More structure. More surface area. Worth it when that structure is carrying load.&lt;/p&gt;


&lt;h2&gt;
  
  
  The side-by-side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Minimal API&lt;/th&gt;
&lt;th&gt;Controllers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Boilerplate&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testability&lt;/td&gt;
&lt;td&gt;Good with DI&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action filters&lt;/td&gt;
&lt;td&gt;Endpoint filters only&lt;/td&gt;
&lt;td&gt;Full filter pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;td&gt;Lambda, explicit&lt;/td&gt;
&lt;td&gt;Attribute + convention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team ramp-up&lt;/td&gt;
&lt;td&gt;Fast for new .NET devs&lt;/td&gt;
&lt;td&gt;Familiar to most devs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale target&lt;/td&gt;
&lt;td&gt;Micro to mid&lt;/td&gt;
&lt;td&gt;Mid to large domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swagger / OAS&lt;/td&gt;
&lt;td&gt;Full support&lt;/td&gt;
&lt;td&gt;Full support&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither row is a clear winner on every dimension. That is the point.&lt;/p&gt;


&lt;h2&gt;
  
  
  The hybrid pattern. You do not always have to pick
&lt;/h2&gt;

&lt;p&gt;This is the one that most people miss. You can use both in one solution:&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;// Program.cs&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Minimal API: health + lightweight probes&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="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;"/version"&lt;/span&gt;&lt;span class="p"&gt;,&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;AppInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Controllers: complex domain resources&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="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MapControllers()&lt;/code&gt; and &lt;code&gt;MapGet()&lt;/code&gt; compose cleanly. Route groups and controller routing do not conflict. Use Minimal API for the surface area where it fits, Controllers where the domain needs structure.&lt;/p&gt;

&lt;p&gt;This is not a workaround. It is a supported, intended usage pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision checklist
&lt;/h2&gt;

&lt;p&gt;Save this for the next project kickoff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New microservice or internal tool&lt;/strong&gt; → Minimal API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing codebase already on Controllers&lt;/strong&gt; → Controllers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need action filter pipeline&lt;/strong&gt; → Controllers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prototyping or validating an idea&lt;/strong&gt; → Minimal API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large domain, many grouped routes&lt;/strong&gt; → Controllers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed concerns in one solution&lt;/strong&gt; → Hybrid&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What actually goes wrong
&lt;/h2&gt;

&lt;p&gt;The most common failure mode is not choosing the wrong approach. It is choosing one approach and never questioning it as the project grows. A project that starts as a three-endpoint Minimal API and quietly becomes a 60-endpoint domain model has a debt problem.&lt;/p&gt;

&lt;p&gt;Pay attention to the signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Duplicated per-endpoint middleware logic → Controllers&lt;/li&gt;
&lt;li&gt;Handler functions growing past 30 lines → consider Controllers&lt;/li&gt;
&lt;li&gt;Difficulty testing endpoints in isolation → Controllers&lt;/li&gt;
&lt;li&gt;Ceremony cost outweighing value → reconsider Minimal API
The framework does not enforce a migration path. You have to notice it yourself.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;The full carousel version of this breakdown (with code examples for each scenario) is on Instagram at &lt;a href="https://www.instagram.com/thesharpfuture" rel="noopener noreferrer"&gt;@thesharpfuture&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Log Aggregation in .NET 8: Seq vs ELK vs Loki</title>
      <dc:creator>Anaya Upadhyay</dc:creator>
      <pubDate>Tue, 26 May 2026 09:15:00 +0000</pubDate>
      <link>https://dev.to/anayaupadhyay/log-aggregation-in-net-8-seq-vs-elk-vs-loki-437l</link>
      <guid>https://dev.to/anayaupadhyay/log-aggregation-in-net-8-seq-vs-elk-vs-loki-437l</guid>
      <description>&lt;p&gt;I have seen the same setup at more companies than I can count: every service writing logs to stdout, a few rotating files scattered across VMs, maybe one service sending to Application Insights, and nobody quite sure where the logs from the background jobs go.&lt;/p&gt;

&lt;p&gt;It works fine until something breaks in production. Then you have an error, four services, and zero way to trace a single request across any of them.&lt;/p&gt;

&lt;p&gt;That is the problem log aggregation solves. This article covers what it actually means in a .NET 8 stack, how to set it up with Serilog, and how to pick the right aggregator for where your team is right now.&lt;/p&gt;




&lt;h2&gt;
  
  
  What log aggregation actually means
&lt;/h2&gt;

&lt;p&gt;The idea is simple: every service in your system writes structured log events to a central store, and you query that store instead of grepping files.&lt;/p&gt;

&lt;p&gt;The pipeline has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your app&lt;/strong&gt; - calls &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; with structured message templates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sink&lt;/strong&gt; - Serilog serializes and ships the event to a destination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An aggregator&lt;/strong&gt; - receives, indexes, and stores the events so you can query them by any property&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key word is structured. If your log messages are plain strings, you have traded one grep problem for another. The entire value of aggregation comes from being able to filter on &lt;code&gt;OrderId&lt;/code&gt;, &lt;code&gt;CorrelationId&lt;/code&gt;, &lt;code&gt;CustomerId&lt;/code&gt;, or any other property you attached to the event.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting up Serilog with multiple sinks
&lt;/h2&gt;

&lt;p&gt;Sinks are composable. You can write to Seq locally, Elasticsearch in production, and a rolling file as a fallback, all from the same configuration.&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;// Program.cs&lt;/span&gt;
&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logger&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;LoggerConfiguration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Enrich&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithCorrelationId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinimumLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Override&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Microsoft"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogEventLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinimumLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Override&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"System"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogEventLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Seq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5341"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Elasticsearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IndexFormat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"logs-{0:yyyy.MM}"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AutoRegisterTemplate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"logs/app-.log"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rollingInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RollingInterval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateLogger&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;Host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSerilog&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting here. The &lt;code&gt;MinimumLevel.Override&lt;/code&gt; calls are important in production. Without them, Microsoft framework internals log at &lt;code&gt;Debug&lt;/code&gt; and they will flood your aggregator with noise that buries your actual signals. Set them to &lt;code&gt;Warning&lt;/code&gt; in production environments.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Enrich.WithCorrelationId()&lt;/code&gt; call requires the &lt;code&gt;Serilog.Enrichers.CorrelationId&lt;/code&gt; package. Pair it with a middleware that sets &lt;code&gt;BeginScope&lt;/code&gt; on every request and every log line inside that request will carry the same correlation ID. Without it, log aggregation gives you a better filing cabinet but not a better answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Seq: start here
&lt;/h2&gt;

&lt;p&gt;If your team is not running a local aggregator today, start with Seq. It is free for individual use, has first-class support for structured .NET logs, and takes about two minutes to set up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;ACCEPT_EULA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Y &lt;span class="nt"&gt;-p&lt;/span&gt; 5341:5341 &lt;span class="nt"&gt;-p&lt;/span&gt; 80:80 datalust/seq:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;appsettings.Development.json&lt;/code&gt;:&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="nl"&gt;"Serilog"&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;"WriteTo"&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;"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;"Seq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Args"&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;"serverUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:5341"&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="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;Open &lt;code&gt;http://localhost&lt;/code&gt; and you will see every structured event your application emits. Filter by &lt;code&gt;@Level = 'Error'&lt;/code&gt;, or &lt;code&gt;OrderId = '123'&lt;/code&gt;, or &lt;code&gt;CorrelationId = 'abc'&lt;/code&gt; - all of it works immediately because the properties are indexed, not buried inside a string.&lt;/p&gt;

&lt;p&gt;The query experience alone is worth it for local development. You stop reading raw console output and start asking questions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Picking a production aggregator
&lt;/h2&gt;

&lt;p&gt;Here is a direct comparison of the three options most .NET teams evaluate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Seq
&lt;/h3&gt;

&lt;p&gt;Good for teams up to maybe 20 engineers with moderate log volume. The single-node architecture is a real ceiling but it is not a problem until it is a problem. Reasonably priced for small teams and the operational overhead is low. If you are a startup or a small product team, Seq in production is a completely legitimate choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  ELK Stack (Elasticsearch, Logstash, Kibana)
&lt;/h3&gt;

&lt;p&gt;The right choice when you have high volume, need full-text search across log content, or have an ops team that can own the infrastructure. Kibana dashboards are genuinely good for sharing observability across engineering and operations. The tradeoff is real though: you are running three services, Elasticsearch is resource-hungry, and the licensing situation has changed a few times in recent years - worth reviewing before you commit. Not a good first production choice for a small team.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grafana Loki
&lt;/h3&gt;

&lt;p&gt;Loki takes a different approach. Instead of indexing the full content of every log line, it indexes labels only. This makes it significantly cheaper at scale. The tradeoff is that you cannot do full-text search across log content by default - you query by labels and then filter within results.&lt;/p&gt;

&lt;p&gt;If your team is already running Grafana and Prometheus, Loki is the natural addition. It integrates tightly with both and keeps your observability stack in one place. On Kubernetes it scales well horizontally. The LogQL query language takes getting used to, but it is not complex.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three mistakes that break aggregation in production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;String concatenation in message templates.&lt;/strong&gt; The moment you write &lt;code&gt;_logger.LogInformation("Order " + id + " placed")&lt;/code&gt;, the structured property is gone. The aggregator receives a plain string. You cannot filter on &lt;code&gt;OrderId&lt;/code&gt; in Seq or Kibana because it does not exist as a property. Always use message templates: &lt;code&gt;_logger.LogInformation("Order {OrderId} placed", id)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console.WriteLine anywhere in your codebase.&lt;/strong&gt; It bypasses every sink. It is unstructured, and in a containerised environment it is lost on restart. Any &lt;code&gt;Console.WriteLine&lt;/code&gt; in application code should be replaced with &lt;code&gt;ILogger&lt;/code&gt;. This includes library code you own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No minimum level override per environment.&lt;/strong&gt; Framework-level components log a lot at &lt;code&gt;Debug&lt;/code&gt;. In production, shipping those events to your aggregator wastes storage and makes real errors harder to find. A &lt;code&gt;MinimumLevel.Override&lt;/code&gt; for &lt;code&gt;Microsoft.*&lt;/code&gt; and &lt;code&gt;System.*&lt;/code&gt; set to &lt;code&gt;Warning&lt;/code&gt; is one configuration line that keeps your signal-to-noise ratio sane.&lt;/p&gt;




&lt;h2&gt;
  
  
  Before you configure anything
&lt;/h2&gt;

&lt;p&gt;The aggregator is the last decision, not the first.&lt;/p&gt;

&lt;p&gt;Get structured message templates right across your codebase. Set up a correlation ID scope in middleware so every log line in a request shares an ID. Pick the minimum level that makes sense for each environment.&lt;/p&gt;

&lt;p&gt;Once those are in place, the aggregator almost does not matter. Seq, ELK, Loki - they are all just different query UIs sitting on top of the structured events your app already emits correctly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of Logging in .NET - Series 2. The full carousel version with sink configuration code and a setup checklist is on Instagram at &lt;a href="https://www.instagram.com/thesharpfuture" rel="noopener noreferrer"&gt;@thesharpfuture&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>serilog</category>
      <category>aspdotnet</category>
    </item>
  </channel>
</rss>
