<?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.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>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>
