<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Coding Droplets]]></title><description><![CDATA[Coding Droplets is for Developers who want to Build, Launch and Scale Real Products with .NET.
Expect actionable playbooks, architecture patterns, implementation strategies and growth-minded engineering insights you can apply immediately.
If you’re serious about moving from code snippets to production outcomes, you’ll feel at home here.]]></description><link>https://codingdroplets.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1745250426668/5eb293b3-b818-4119-86a2-c3266ccb5cd4.png</url><title>Coding Droplets</title><link>https://codingdroplets.com</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 06 Jun 2026 07:31:40 GMT</lastBuildDate><atom:link href="https://codingdroplets.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Azure Cosmos DB vs MongoDB Atlas in .NET: Which NoSQL Database Should Your Team Use in 2026?]]></title><description><![CDATA[Picking the right NoSQL database for a .NET project in 2026 means navigating two very mature, cloud-native offerings that look similar on the surface but diverge dramatically the moment your workload ]]></description><link>https://codingdroplets.com/cosmos-db-vs-mongodb-atlas-dotnet-2026</link><guid isPermaLink="true">https://codingdroplets.com/cosmos-db-vs-mongodb-atlas-dotnet-2026</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[CosmosDB]]></category><category><![CDATA[MongoDB]]></category><category><![CDATA[NoSQL]]></category><category><![CDATA[database]]></category><category><![CDATA[Azure]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Sat, 06 Jun 2026 03:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Picking the right NoSQL database for a .NET project in 2026 means navigating two very mature, cloud-native offerings that look similar on the surface but diverge dramatically the moment your workload scales. Azure Cosmos DB and MongoDB Atlas are both document databases, both globally distributed, and both well-supported in the ASP.NET Core ecosystem — but they make different bets on your team's priorities: Cosmos DB optimises for SLA predictability and Azure integration, while MongoDB Atlas optimises for developer familiarity, query expressiveness, and multi-cloud portability. If you want the complete implementation — including how to wire up both databases inside a production ASP.NET Core service alongside resilience patterns and test fixtures — that full source is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, where the code walks every trade-off covered here with a real running system.</p>
<p>Understanding where these two databases pull in opposite directions is exactly what the <a href="https://aspnetcoreapi.codingdroplets.com/">ASP.NET Core Web API: Zero to Production course</a> unpacks in its data layer chapters — showing how clean architecture keeps your domain model free of vendor lock-in regardless of which database you choose underneath.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" /></a></p>
<h2>What Each Database Is Trying to Solve</h2>
<p><strong>Azure Cosmos DB</strong> is a multi-model, globally distributed database built entirely inside Microsoft Azure. It was designed for scenarios where predictable single-digit-millisecond latency at any scale matters more than raw query flexibility. It supports multiple APIs — NoSQL, MongoDB, Cassandra, Gremlin, and Table — but the native API (formerly called Core SQL) is the most capable and the one Microsoft actively develops.</p>
<p><strong>MongoDB Atlas</strong> is the managed cloud version of MongoDB, deployable on Azure, AWS, or GCP. It targets teams who already know MongoDB's document model and query language, or who want to stay independent of any single cloud provider. Atlas adds automated backups, scaling, search, and multi-region replication on top of the open-source engine.</p>
<p>For .NET teams, both have well-maintained client SDKs. Cosmos DB ships the <code>Microsoft.Azure.Cosmos</code> SDK, which is deeply integrated with the Azure ecosystem. MongoDB Atlas is consumed via the official <code>MongoDB.Driver</code> NuGet package, which supports the same driver API whether you run self-hosted or cloud.</p>
<h2>How Does Each One Integrate with ASP.NET Core?</h2>
<h3>Azure Cosmos DB in ASP.NET Core</h3>
<p>The <code>Microsoft.Azure.Cosmos</code> SDK is idiomatic for dependency injection. You register a <code>CosmosClient</code> singleton in <code>Program.cs</code> and inject it into repositories. The SDK handles connection pooling, retry logic, and circuit-breaking internally.</p>
<p>EF Core 9+ ships with a Cosmos DB provider (<code>Microsoft.EntityFrameworkCore.Cosmos</code>) that lets you map domain entities to Cosmos containers using the familiar Fluent API. The provider works, but it abstracts away partition keys and RU budgets — which are the two most important performance levers in Cosmos DB. Most experienced teams that go all-in on Cosmos DB drop the EF Core provider and work directly with the Cosmos SDK for performance-sensitive reads, even if they keep EF Core for other persistence concerns.</p>
<h3>MongoDB Atlas in ASP.NET Core</h3>
<p>The <code>MongoDB.Driver</code> SDK integrates with DI via the <code>IMongoClient</code> interface. <code>MongoClient</code> is thread-safe and designed to be a singleton. Unlike Cosmos DB, MongoDB has no concept of throughput units billed separately from storage — the query model is simpler to reason about for teams new to document databases.</p>
<p>EF Core does not have an official MongoDB provider from Microsoft (the <code>MongoDB.EntityFrameworkCore</code> package exists as a community driver, but it is not production-grade for complex scenarios as of 2026). This means MongoDB teams typically use the native driver pattern with repository abstraction, which is actually more aligned with how the Mongo ecosystem works.</p>
<h2>Side-by-Side Comparison</h2>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>Azure Cosmos DB</th>
<th>MongoDB Atlas</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Pricing model</strong></td>
<td>Request Units (RUs) per second, provisioned or serverless</td>
<td>vCPU-based clusters + storage; no throughput units</td>
</tr>
<tr>
<td><strong>Query language</strong></td>
<td>SQL-like (NoSQL API) or MongoDB wire protocol</td>
<td>MongoDB Query Language (MQL)</td>
</tr>
<tr>
<td><strong>.NET EF Core support</strong></td>
<td>Official EF Core provider (limited)</td>
<td>Community provider (limited)</td>
</tr>
<tr>
<td><strong>Native .NET SDK</strong></td>
<td><code>Microsoft.Azure.Cosmos</code> (Microsoft-maintained)</td>
<td><code>MongoDB.Driver</code> (MongoDB Inc.)</td>
</tr>
<tr>
<td><strong>Global distribution</strong></td>
<td>Built-in, configurable per region with one click</td>
<td>Atlas Global Clusters; multi-region writes require zone sharding</td>
</tr>
<tr>
<td><strong>SLA</strong></td>
<td>99.999% availability (multi-region active-active)</td>
<td>99.995% (multi-region)</td>
</tr>
<tr>
<td><strong>Partition strategy</strong></td>
<td>Mandatory partition key; affects every query</td>
<td>Sharding optional; single-server model scales vertically first</td>
</tr>
<tr>
<td><strong>Consistency levels</strong></td>
<td>5 configurable levels (Strong → Eventual)</td>
<td>Single consistency model per operation type</td>
</tr>
<tr>
<td><strong>Multi-cloud</strong></td>
<td>Azure-only</td>
<td>Azure, AWS, GCP</td>
</tr>
<tr>
<td><strong>Local development</strong></td>
<td>Cosmos DB Emulator (Docker-friendly)</td>
<td>MongoDB community edition or Atlas free tier</td>
</tr>
<tr>
<td><strong>Search</strong></td>
<td>Integrated Azure Cognitive Search</td>
<td>Atlas Search (Lucene-based, built-in)</td>
</tr>
<tr>
<td><strong>ACID transactions</strong></td>
<td>Supported (within a single logical partition)</td>
<td>Supported (multi-document, multi-collection)</td>
</tr>
<tr>
<td><strong>Change feed</strong></td>
<td>Native change feed API</td>
<td>Change Streams</td>
</tr>
<tr>
<td><strong>Vendor lock-in risk</strong></td>
<td>High (Azure-only, proprietary RU model)</td>
<td>Low (open-source engine, multi-cloud)</td>
</tr>
</tbody></table>
<h2>When Does Cosmos DB Win?</h2>
<p>Cosmos DB earns its place when your workload has these characteristics:</p>
<p><strong>You are already deep in Azure.</strong> If your team uses Azure App Service, Azure Functions, Azure Service Bus, and Azure Active Directory, Cosmos DB fits without any integration tax. The SDK integrates with Azure Managed Identity for keyless auth, Azure Monitor for diagnostics, and Azure Private Link for network isolation. None of those require any code — they are infrastructure concerns.</p>
<p><strong>You need guaranteed single-digit-millisecond latency at scale.</strong> Cosmos DB's SLA covers P99 latency, not just availability. At high RPS on hot partitions, its in-memory execution tier stays predictable in a way that a provisioned MongoDB Atlas cluster may not.</p>
<p><strong>Your access patterns are well-defined and stable.</strong> Cosmos DB rewards teams who model their data around partition keys and access patterns at design time. If your team knows that 90% of reads are by <code>tenantId</code>, Cosmos DB with a <code>tenantId</code> partition key delivers extremely efficient point lookups. Change those patterns later and you may need to remodel entire containers.</p>
<p><strong>You are building serverless workloads.</strong> Cosmos DB Serverless mode bills per operation, making it cost-effective for APIs with spiky or unpredictable traffic — think a startup that has 100 requests per day during beta and could suddenly receive 100,000 after launch. Serverless scales to zero when idle.</p>
<h2>When Does MongoDB Atlas Win?</h2>
<p>MongoDB Atlas is the pragmatic choice when:</p>
<p><strong>Your team comes from a MongoDB background.</strong> The MongoDB Query Language is richer and more expressive than Cosmos DB's NoSQL API for complex aggregations, text search, and ad-hoc queries. Teams who already know <code>\(lookup</code>, <code>\)group</code>, and the aggregation pipeline will be productive immediately.</p>
<p><strong>Multi-cloud or cloud-agnostic strategy matters.</strong> MongoDB Atlas runs identically on Azure, AWS, and GCP. If your SaaS product serves enterprise customers who mandate data residency on a specific cloud, Atlas handles that without re-architecting your data layer. Cosmos DB cannot move off Azure.</p>
<p><strong>Your data model evolves frequently.</strong> MongoDB's schema flexibility and lack of forced partition key decisions let teams iterate on their data model faster. Adding a new field or restructuring a sub-document does not require a migration plan the way that a partition key change in Cosmos DB does.</p>
<p><strong>You need strong full-text search.</strong> Atlas Search is a mature Lucene-based search engine built into Atlas. It supports fuzzy matching, facets, autocomplete, and vector search with a familiar MongoDB integration. Cosmos DB has improved its search story, but Atlas Search remains more feature-complete for search-heavy workloads.</p>
<p><strong>Cost predictability matters more than raw SLA.</strong> Cosmos DB's RU pricing can surprise teams at scale. A complex aggregation that works fine on a development cluster may consume 50× more RUs than expected in production. MongoDB Atlas's vCPU-based pricing model is easier to budget for.</p>
<h2>What Are the Real Trade-Offs for .NET Teams?</h2>
<h3>The Partition Key Problem in Cosmos DB</h3>
<p>Cosmos DB forces every container to have a partition key. Getting this wrong is expensive — Cosmos DB does not let you change the partition key after creation. For multi-tenant SaaS applications, using <code>tenantId</code> as the partition key is the textbook answer, but it creates a hot partition problem when your largest tenant has 100× the traffic of your smallest. The solution is hierarchical partition keys (supported since 2023), but this adds design complexity that MongoDB Atlas simply does not have.</p>
<h3>The RU Budget Problem in Cosmos DB</h3>
<p>Every operation in Cosmos DB consumes Request Units. A simple document read might cost 1 RU. A fan-out query across multiple partitions might cost 400 RUs. Teams that come from a relational or MongoDB background are often surprised to discover that their application is triggering expensive queries they did not design for. Cosmos DB's query explorer shows RU consumption, but teams need to tune queries at a level of detail most are not used to.</p>
<h3>MongoDB Atlas's Operational Overhead</h3>
<p>MongoDB Atlas handles most operational concerns automatically, but there are edge cases. Automated balancing of shards across a sharded cluster is eventually consistent — during a rebalancing event, some queries may route to slightly stale replicas. For most SaaS workloads this is acceptable, but it is a consideration for systems that require strict read-after-write consistency everywhere.</p>
<h3>.NET SDK Ergonomics</h3>
<p>The <code>Microsoft.Azure.Cosmos</code> SDK is well-designed for asynchronous .NET code. It returns <code>FeedIterator&lt;T&gt;</code> for paginated queries, which pairs naturally with <code>IAsyncEnumerable&lt;T&gt;</code> in C#. The <code>MongoDB.Driver</code> SDK supports LINQ-style queries against <code>IMongoCollection&lt;T&gt;</code>, which is familiar to .NET developers. Neither SDK significantly outperforms the other in developer ergonomics — the decision comes down to operational concerns, not SDK quality.</p>
<h2>Which Database Should Your Team Choose?</h2>
<p>Here is the decision framework for 2026:</p>
<p><strong>Choose Cosmos DB if:</strong></p>
<ul>
<li>Your entire infrastructure is Azure and you want zero cross-cloud management overhead</li>
<li>Your access patterns are well-understood before you design the schema</li>
<li>You need the 99.999% SLA with active-active multi-region writes as a contractual requirement</li>
<li>You are building serverless or event-driven workloads where per-operation billing is cost-effective</li>
</ul>
<p><strong>Choose MongoDB Atlas if:</strong></p>
<ul>
<li>Your team already knows MongoDB or you want to stay cloud-agnostic</li>
<li>Your data model is likely to evolve significantly during the first year</li>
<li>You need rich full-text search without integrating a separate search service</li>
<li>Your cost model works better with predictable vCPU pricing than RU-based billing</li>
</ul>
<p><strong>Choose neither — consider PostgreSQL — if:</strong></p>
<ul>
<li>Your data is mostly relational with a few JSON blobs</li>
<li>Your team is small and the overhead of managing a document database model is not worth the flexibility</li>
<li>You are already using EF Core and want to stay in the familiar migration-based workflow</li>
</ul>
<p>The honest answer for most .NET SaaS teams in 2026: if you are already on Azure and your access patterns are known, Cosmos DB is a defensible choice. If you are not tied to Azure or your product is still evolving, MongoDB Atlas gives you more room to manoeuvre.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<h2>FAQ</h2>
<h3>Is Azure Cosmos DB supported by EF Core?</h3>
<p>Yes, EF Core has an official Cosmos DB provider via the <code>Microsoft.EntityFrameworkCore.Cosmos</code> package. It allows you to map entities to Cosmos DB containers using the familiar Fluent API. However, it abstracts over partition keys and RU consumption in ways that can hide important performance issues. For production workloads that are throughput-sensitive, most teams use the native <code>Microsoft.Azure.Cosmos</code> SDK directly and reserve EF Core for relational data stores.</p>
<h3>Can I use MongoDB Atlas in an ASP.NET Core API without EF Core?</h3>
<p>Yes, and this is actually the common pattern. You use the <code>MongoDB.Driver</code> NuGet package, register a singleton <code>IMongoClient</code> in your DI container, and inject <code>IMongoCollection&lt;T&gt;</code> into your repository classes. The driver supports async/await natively and integrates cleanly with ASP.NET Core's cancellation token model.</p>
<h3>How does Cosmos DB pricing compare to MongoDB Atlas for a typical SaaS application?</h3>
<p>This depends heavily on your access patterns. Cosmos DB Serverless is often cheaper for low-traffic APIs because it charges per operation and scales to zero. For consistently high-traffic APIs, a provisioned Cosmos DB cluster can become expensive relative to a MongoDB Atlas M30 cluster. Before committing, model your expected read/write RPS and use the Cosmos DB pricing calculator alongside Atlas cluster pricing to compare at your expected scale.</p>
<h3>Does MongoDB Atlas support transactions like a relational database?</h3>
<p>MongoDB Atlas supports multi-document ACID transactions across collections and even across shards. Transactions in MongoDB are designed for cases where you need atomic updates spanning multiple documents — for example, updating an order and its corresponding inventory record in a single operation. For the majority of document-level operations, you do not need explicit transactions because MongoDB guarantees atomicity at the document level.</p>
<h3>What is the best local development experience for .NET teams using Cosmos DB?</h3>
<p>Microsoft provides an official <strong>Azure Cosmos DB Emulator</strong> that runs as a Docker container. It supports the NoSQL API and the MongoDB compatibility API. You can add it to a <code>docker-compose.yml</code> alongside your ASP.NET Core application for a fully local dev loop. For integration tests, Testcontainers for .NET has a Cosmos DB module that spins up the emulator automatically in test runs, keeping your tests isolated and reproducible.</p>
<h3>Can Azure Cosmos DB run on AWS or GCP?</h3>
<p>No. Azure Cosmos DB is Azure-exclusive. If your organisation has a multi-cloud mandate or if your customers require data residency on AWS or GCP, Cosmos DB is not viable. MongoDB Atlas is the document database that runs natively and identically on all three major clouds.</p>
<h3>What is the difference between Cosmos DB's NoSQL API and its MongoDB API?</h3>
<p>Cosmos DB's MongoDB API emulates the MongoDB wire protocol, allowing you to connect with a standard <code>MongoDB.Driver</code> client. However, it does not support every MongoDB feature — complex aggregation pipeline operators, certain index types, and change stream behaviours differ from native MongoDB. Microsoft maintains a compatibility matrix on <a href="https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/feature-support-42">learn.microsoft.com</a>. If your application relies on advanced MongoDB features, using native Atlas rather than Cosmos DB's MongoDB emulation API is safer.</p>
]]></content:encoded></item><item><title><![CDATA[Correlation ID in ASP.NET Core: Custom Middleware vs HttpContext.TraceIdentifier vs W3C Trace Context — Enterprise Decision Guide]]></title><description><![CDATA[When a request fails somewhere in a distributed system, the first question is: which log entries belong to this request, and which service did it touch? Correlation IDs are how you answer that questio]]></description><link>https://codingdroplets.com/correlation-id-aspnet-core-enterprise-decision-guide</link><guid isPermaLink="true">https://codingdroplets.com/correlation-id-aspnet-core-enterprise-decision-guide</guid><category><![CDATA[asp.net core]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[distributed tracing]]></category><category><![CDATA[correlation-id]]></category><category><![CDATA[observability]]></category><category><![CDATA[logging]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Web API]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Fri, 05 Jun 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/daba170f-7002-4246-b8c7-16d26b2a1876.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When a request fails somewhere in a distributed system, the first question is: <em>which log entries belong to this request, and which service did it touch?</em> Correlation IDs are how you answer that question reliably. In ASP.NET Core, there are at least three distinct ways to approach correlation ID management — each with different trade-offs that matter significantly in enterprise contexts.</p>
<p>The decision is not as simple as "just add a header." The full implementation — with production-ready propagation across <code>HttpClient</code> calls, background jobs, message queues, and Serilog enrichment with OpenTelemetry integration — is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, including the source code used at the enterprise level to trace requests across microservices reliably.</p>
<p>Understanding how correlation IDs fit into your broader observability strategy is exactly what <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 14 of the ASP.NET Core Web API: Zero to Production course</a> covers — including structured logging, OpenTelemetry, and health checks, all wired together in a single production API codebase.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<h2>What Is a Correlation ID and Why Does It Matter?</h2>
<p>A correlation ID is a unique identifier attached to an incoming request and propagated across every service, log entry, and background operation that request touches. When something goes wrong, you can filter your logs by a single ID and reconstruct the full request journey — across multiple APIs, message queues, and asynchronous jobs.</p>
<p>Without a correlation ID strategy, debugging distributed failures requires correlating timestamps, guessing which log entries are related, and hoping that nothing ran in parallel at the same time. With a solid strategy, you query one field and get the complete picture.</p>
<p>The challenge is that ASP.NET Core offers multiple mechanisms that seem to solve this problem — and teams frequently pick the wrong one for their context, or mix them in ways that create ambiguity rather than clarity.</p>
<h2>The Three Approaches: An Overview</h2>
<h3>Custom Middleware with X-Correlation-ID Header</h3>
<p>The most explicit approach: write middleware that reads an inbound <code>X-Correlation-ID</code> header (or generates a new GUID if none is present), stores it in <code>HttpContext.Items</code>, adds it to <code>ILogger</code> scope, and forwards it on all outbound <code>HttpClient</code> calls via a <code>DelegatingHandler</code>.</p>
<p>This gives you complete control over naming, storage, propagation strategy, and header format. It integrates cleanly with Serilog's <code>LogContext.PushProperty</code> so every log line within that request automatically carries the correlation ID.</p>
<p>The trade-off: it is entirely your responsibility. There is no standard, no automatic tooling support, and you must instrument every outbound call manually if you want propagation to work.</p>
<h3>HttpContext.TraceIdentifier</h3>
<p><code>HttpContext.TraceIdentifier</code> is a built-in property that ASP.NET Core assigns to every request. It is a unique string identifier — in Kestrel it is formatted as <code>{connectionId}:{requestCount}</code>, which makes it deterministic but not human-friendly.</p>
<p>The appeal is obvious: it is always there, requires no middleware, and is already included in ASP.NET Core's default request logging (<code>UseRouting</code>, <code>UseSerilogRequestLogging</code>). You can read it anywhere you have access to <code>IHttpContextAccessor</code>.</p>
<p>The limitation: <code>TraceIdentifier</code> is an internal implementation detail. It is scoped to a single application instance. If you want to track a request <em>across</em> services, you cannot use <code>TraceIdentifier</code> as the propagation ID because it is never forwarded to downstream services by the framework. It is a local ID, not a distributed one.</p>
<h3>W3C Trace Context (traceparent / tracestate)</h3>
<p>W3C Trace Context is an <a href="https://www.w3.org/TR/trace-context/">IETF standard (RFC specification)</a> that defines two headers: <code>traceparent</code> (carrying version, trace ID, parent span ID, and flags) and <code>tracestate</code> (vendor-specific metadata). ASP.NET Core fully supports this standard through <code>System.Diagnostics.Activity</code> and the <code>ActivitySource</code> API.</p>
<p>When OpenTelemetry is enabled, <code>Activity.Current</code> is automatically populated with W3C-compatible IDs. The <code>TraceId</code> on an <code>Activity</code> is a 128-bit identifier that propagates across service boundaries when downstream <code>HttpClient</code> calls are instrumented. Any OpenTelemetry-compatible backend — Jaeger, Zipkin, Grafana Tempo, Azure Monitor — understands this format natively.</p>
<p>The trade-off: the W3C approach is powerful and standards-compliant, but it adds complexity. You need OpenTelemetry instrumentation wired up, and the <code>TraceId</code> is verbose (a 32-character hex string) compared to a GUID. For simple scenarios — a single-service API or a small team — this can be more infrastructure than the problem warrants.</p>
<h2>When to Use Which Approach</h2>
<h3>Use Custom Middleware (X-Correlation-ID) When:</h3>
<ul>
<li><p>Your system is not using OpenTelemetry and you do not plan to introduce it soon</p>
</li>
<li><p>You need a simple, human-readable ID in logs and error responses (e.g., to share with customers for support tickets)</p>
</li>
<li><p>You want clients to be able to pass a correlation ID from the frontend (mobile apps, browser clients) and trace it end-to-end</p>
</li>
<li><p>Your team is small and the infrastructure overhead of W3C trace context is not justified yet</p>
</li>
<li><p>You need the ID to survive message queue boundaries (RabbitMQ messages, Azure Service Bus) where HTTP headers do not naturally propagate</p>
</li>
</ul>
<p>This is the right starting point for most teams building their first distributed system. It is simple, explicit, and easy to reason about.</p>
<h3>Use HttpContext.TraceIdentifier When:</h3>
<ul>
<li><p>You need a quick, no-setup way to correlate logs within a <em>single</em> service</p>
</li>
<li><p>You are not building a distributed system and the request never leaves one application</p>
</li>
<li><p>You want something in logs immediately without writing middleware</p>
</li>
<li><p>You are adding basic observability to a legacy application and cannot introduce new middleware yet</p>
</li>
</ul>
<p>Never use <code>TraceIdentifier</code> as your correlation ID if the request touches more than one service. It will not propagate, and you will end up with per-service IDs that do not connect.</p>
<h3>Use W3C Trace Context When:</h3>
<ul>
<li><p>You are running OpenTelemetry (or plan to)</p>
</li>
<li><p>Your observability tooling supports distributed tracing natively (Azure Application Insights, Grafana Tempo, Jaeger, Honeycomb)</p>
</li>
<li><p>You have multiple services that all speak the same tracing language</p>
</li>
<li><p>You care about span-level granularity, not just request-level correlation</p>
</li>
<li><p>You are building a new system and can establish the right foundation from the start</p>
</li>
</ul>
<p>W3C Trace Context is the right long-term choice for any team that is serious about distributed observability. It is the standard everything converges on.</p>
<h2>Can You Use Both?</h2>
<p>Yes — and in practice, most mature enterprise systems do. The pattern is:</p>
<ul>
<li><p>Use W3C Trace Context (<code>Activity.Current.TraceId</code>) as the primary distributed trace ID, handled by OpenTelemetry</p>
</li>
<li><p>Use a custom <code>X-Correlation-ID</code> header as an <em>application-level</em> correlation ID that clients can pass and that survives non-HTTP boundaries (queues, cron jobs, emails)</p>
</li>
<li><p>Enrich structured logs with <em>both</em> — the W3C trace ID for APM tooling correlation, and the application correlation ID for support ticket lookups</p>
</li>
</ul>
<p>The key insight is that these two IDs serve different audiences: the trace ID serves your observability platform, and the application correlation ID serves your support team and your customers.</p>
<h2>The W3C TraceId vs X-Correlation-ID Decision Matrix</h2>
<table>
<thead>
<tr>
<th>Criterion</th>
<th>Custom X-Correlation-ID</th>
<th>HttpContext.TraceIdentifier</th>
<th>W3C Trace Context</th>
</tr>
</thead>
<tbody><tr>
<td>Cross-service propagation</td>
<td>✅ Manual (DelegatingHandler)</td>
<td>❌ Never propagates</td>
<td>✅ Automatic (OTel)</td>
</tr>
<tr>
<td>Client-supplied ID support</td>
<td>✅ Yes</td>
<td>❌ No</td>
<td>⚠️ Via traceparent header</td>
</tr>
<tr>
<td>Queue/job propagation</td>
<td>✅ Manual</td>
<td>❌ No</td>
<td>⚠️ Manual span propagation</td>
</tr>
<tr>
<td>Human-readable</td>
<td>✅ GUID format</td>
<td>⚠️ conn:count format</td>
<td>❌ 32-char hex</td>
</tr>
<tr>
<td>Setup cost</td>
<td>Low</td>
<td>Zero</td>
<td>Medium-high</td>
</tr>
<tr>
<td>APM tool compatibility</td>
<td>❌ None built-in</td>
<td>❌ None built-in</td>
<td>✅ Native</td>
</tr>
<tr>
<td>Span-level granularity</td>
<td>❌ No</td>
<td>❌ No</td>
<td>✅ Yes</td>
</tr>
</tbody></table>
<h2>Anti-Patterns to Avoid</h2>
<p><strong>Relying on TraceIdentifier for distributed tracing.</strong> This is the most common mistake. Teams log <code>TraceIdentifier</code> everywhere, then realise it changes per service and is completely useless for cross-service correlation.</p>
<p><strong>Using a shared static correlation ID store.</strong> Some teams store the correlation ID in a static variable or a singleton instead of scoped to the request. In async code, this causes correlation IDs from concurrent requests to bleed into each other. Always scope correlation IDs to the request via <code>HttpContext.Items</code> or <code>AsyncLocal&lt;T&gt;</code>.</p>
<p><strong>Not propagating on outbound calls.</strong> Generating a correlation ID on the inbound request but forgetting to add it to outbound <code>HttpClient</code> calls defeats the purpose entirely. Use a <code>DelegatingHandler</code> registered at the factory level so propagation is automatic and cannot be forgotten.</p>
<p><strong>Mixing two different header names.</strong> Using <code>X-Correlation-ID</code> on some services and <code>X-Request-ID</code> on others means logs across services use different fields. Pick one header name and enforce it across the fleet as a standard.</p>
<p><strong>Generating a new ID on every hop.</strong> The ID should only be generated if none arrives with the request. If the client or an upstream service sends a correlation ID, preserve and forward it — do not replace it. This is what allows end-to-end tracing from the client's first request.</p>
<h2>What a Production Correlation ID Strategy Looks Like</h2>
<p>A mature enterprise correlation ID strategy typically combines:</p>
<ol>
<li><p><strong>Inbound middleware</strong> — reads <code>X-Correlation-ID</code> (or generates a new GUID), stores it in <code>HttpContext.Items</code>, and sets it in <code>ILogger</code> scope</p>
</li>
<li><p><strong>Outbound</strong> <code>DelegatingHandler</code> — reads the correlation ID from <code>IHttpContextAccessor</code> and adds it to every outbound request header automatically</p>
</li>
<li><p><strong>Serilog enrichment</strong> — <code>LogContext.PushProperty("CorrelationId", ...)</code> so every log line in the request carries the field without manual effort</p>
</li>
<li><p><strong>OpenTelemetry</strong> <code>ActivitySource</code> <strong>baggage</strong> — for services that use OTel, the correlation ID is added as activity baggage so it flows through the trace graph</p>
</li>
<li><p><strong>Background job propagation</strong> — correlation IDs are embedded in message payloads (not just headers) so they survive queue round-trips and deferred execution</p>
</li>
</ol>
<p>This level of depth — including edge cases like Hangfire jobs, Azure Service Bus handlers, and multi-tenant scenarios — is what separates a demo implementation from a production-ready one.</p>
<p>💻 <strong>Full source code for the correlation ID middleware</strong> — clone it, run it, and adapt it: <a href="https://github.com/codingdroplets/dotnet-request-correlation-middleware">github.com/codingdroplets/dotnet-request-correlation-middleware</a></p>
<h2>Should You Use a NuGet Package?</h2>
<p>There are established NuGet packages for correlation ID management in ASP.NET Core — <code>stevejgordon/CorrelationId</code> and <code>skwasjer/Correlate</code> being the most referenced. These are solid libraries and reasonable choices if you want the basics handled quickly.</p>
<p>The case against a package: correlation ID propagation is not complex to build, and owning the implementation means you understand exactly what is in scope. When you need to extend it — adding to Hangfire jobs, propagating through custom event buses, or integrating with a proprietary APM — a bespoke implementation is far easier to adapt. The package abstraction can become a constraint at the edges.</p>
<p>The case for a package: it handles the edge cases you have not thought of yet. For teams without a dedicated platform engineer, a well-maintained package is a pragmatic choice.</p>
<p>Neither answer is wrong. The trade-off is ownership vs convenience, and both are valid.</p>
<blockquote>
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<h2>FAQ</h2>
<h3>What is the difference between a correlation ID and a trace ID in ASP.NET Core?</h3>
<p>A correlation ID is an application-level identifier — typically a GUID — that you generate and propagate explicitly, often via a custom <code>X-Correlation-ID</code> header. A trace ID (in the W3C sense) is a 128-bit identifier managed by the distributed tracing system (OpenTelemetry, Activity API). They serve overlapping but distinct purposes: the trace ID links spans for APM tooling; the correlation ID links log entries and is human-accessible for support workflows. Enterprise systems often carry both.</p>
<h3>Should I use X-Correlation-ID or X-Request-ID as my header name?</h3>
<p>Either works at the HTTP level, but standardise on one across your entire fleet. <code>X-Correlation-ID</code> is more widely used in .NET ecosystems and library documentation. <code>X-Request-ID</code> is common in Ruby/Rails and some gateway tools (NGINX, Envoy). If you control all services end-to-end, pick <code>X-Correlation-ID</code> and be consistent. If you integrate with third-party gateways or clients, check what they expect.</p>
<h3>Does HttpContext.TraceIdentifier work for distributed tracing?</h3>
<p>No. <code>HttpContext.TraceIdentifier</code> is scoped to a single application instance and is never forwarded to downstream services by ASP.NET Core. It is useful for correlating log entries <em>within</em> a single service, but cannot be used to trace a request across services. Use it only for single-service scenarios or as a supplementary field.</p>
<h3>How do I propagate a correlation ID through Hangfire or Azure Service Bus in ASP.NET Core?</h3>
<p>HTTP headers do not survive the queue boundary. The standard approach is to embed the correlation ID in the message payload or job parameter — as a top-level field, not in the message body. When the consumer processes the message, it reads the correlation ID from the payload and initialises it into the <code>AsyncLocal&lt;T&gt;</code> context or <code>ILogger</code> scope before executing any business logic.</p>
<h3>Is W3C Trace Context the right choice for a single ASP.NET Core API with no downstream services?</h3>
<p>Probably not. W3C Trace Context is designed for distributed systems. For a single-service API, the overhead of configuring OpenTelemetry exporters, managing <code>Activity</code> spans, and reasoning about trace IDs adds complexity without proportional benefit. For a single service, <code>HttpContext.TraceIdentifier</code> enriched into Serilog logs is sufficient. Add W3C Trace Context when you have a second service to connect to.</p>
<h3>How do I add the correlation ID to every Serilog log entry automatically?</h3>
<p>Use <code>LogContext.PushProperty("CorrelationId", correlationId)</code> inside your correlation ID middleware, within a <code>using</code> scope that covers the rest of the request pipeline. This enriches every <code>ILogger.Log*</code> call within that scope with the <code>CorrelationId</code> property, so you never have to pass it manually to individual log statements.</p>
<h3>Can a client send their own correlation ID and have it propagated?</h3>
<p>Yes, and this is often desirable in B2B APIs. Your middleware should check for an inbound <code>X-Correlation-ID</code> header and use that value if present (and validate it as a reasonable length/format), only generating a new ID if none is provided. This allows upstream services or API consumers to set the correlation ID at the point of origin and trace it all the way through your system.</p>
]]></content:encoded></item><item><title><![CDATA[Strategy Pattern vs Switch Expressions vs Dictionary Dispatch in .NET: Which Should Your Team Use?]]></title><description><![CDATA[Every .NET team eventually reaches the same crossroads: a conditional block that dispatches behaviour based on a type, value, or enum — and the growing suspicion that the current approach won't scale.]]></description><link>https://codingdroplets.com/strategy-pattern-vs-switch-expressions-vs-dictionary-dispatch-dotnet</link><guid isPermaLink="true">https://codingdroplets.com/strategy-pattern-vs-switch-expressions-vs-dictionary-dispatch-dotnet</guid><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[design patterns]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[strategy pattern]]></category><category><![CDATA[clean code]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Fri, 05 Jun 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/07c25794-ad7f-48c9-a967-48500c6a0347.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every .NET team eventually reaches the same crossroads: a conditional block that dispatches behaviour based on a type, value, or enum — and the growing suspicion that the current approach won't scale. The Strategy Pattern, C# switch expressions, and dictionary dispatch all solve this problem, but each carries different costs in readability, testability, extensibility, and runtime performance. Choosing the wrong one early means refactoring it away later, often at the worst possible moment. Understanding where each approach belongs — and where it does not — is the kind of judgment that separates maintainable enterprise codebases from ones that quietly accumulate technical debt.</p>
<p>If you want to see these patterns in practice with production-ready code and edge cases covered, the full implementations are available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a> — ready to run and adapt to your real codebase.</p>
<p>Understanding how this decision fits into your service registration strategy is equally important. The <a href="https://codingdroplets.com/keyed-services-vs-factory-pattern-named-services-aspnet-core-2026">Keyed Services vs Factory Pattern vs Named Services in ASP.NET Core</a> guide covers the DI side of this decision — worth reading alongside this article when the dispatch logic lives behind an interface.</p>
<h2>What Problem Are We Actually Solving?</h2>
<p>All three approaches address the same core challenge: selecting and executing the right behaviour at runtime based on a discriminant — a status, a payment method, an event type, a document format. The difference lies in how that selection is encoded, who controls it, and how easy it is to change later.</p>
<p>The naive starting point is an <code>if-else</code> chain or a <code>switch</code> statement. It works until it doesn't — typically when new cases appear, when the logic grows complex enough to demand unit testing at the individual branch level, or when two different parts of the codebase need to resolve the same discriminant independently.</p>
<h2>Option 1: The Strategy Pattern with Dependency Injection</h2>
<p>The Strategy Pattern encodes each branch as a separate class implementing a shared interface. ASP.NET Core's DI container registers all implementations, and a resolver — either a factory, a keyed service lookup, or an <code>IEnumerable&lt;T&gt;</code> filter — selects the right one at runtime.</p>
<p>The key advantage is that each strategy is independently testable, independently deployable in unit tests, and fully decoupled from the resolution logic. Adding a new case means adding a new class and registration, not touching existing code. This is the Open/Closed Principle in its most literal form.</p>
<p>The cost is ceremony. Three strategies mean three classes, three registrations, and a resolution mechanism that itself needs to be correct. For small, stable discriminant sets, this overhead is real.</p>
<p><strong>When to reach for the Strategy Pattern:</strong></p>
<ul>
<li>The number of variants is expected to grow (new payment providers, new notification channels, new document processors)</li>
<li>Each variant has non-trivial logic that needs its own tests</li>
<li>The strategy implementations have their own dependencies that should be injected</li>
<li>The resolution happens across multiple call sites or services</li>
<li>Extensibility via plugin or module registration is a future requirement</li>
</ul>
<p><strong>When to avoid it:</strong></p>
<ul>
<li>The discriminant set is fixed and small (2-3 cases that will never expand)</li>
<li>The logic per branch is a single line or trivially simple</li>
<li>The extra classes add navigation burden without adding testability benefit</li>
</ul>
<h2>Option 2: Switch Expressions (C# 8+)</h2>
<p>C# switch expressions, introduced in C# 8 and extended significantly through C# 13 and 14, bring pattern matching directly into the language. They support type patterns, property patterns, positional patterns, and relational patterns — making them far more powerful than a traditional switch statement.</p>
<p>A switch expression is the right tool when the dispatch logic is local, the result is a value (not a side effect), and the discriminant set is closed — meaning you control all the cases and new ones don't arrive at runtime.</p>
<p>The compiler provides exhaustiveness checking for enums and discriminated types, which means the switch expression fails at compile time if a case is missed — something neither dictionary dispatch nor the Strategy Pattern provides without extra effort.</p>
<p><strong>When to reach for switch expressions:</strong></p>
<ul>
<li>Mapping from one value to another (enum → string, status → HTTP code, tier → discount rate)</li>
<li>The discriminant is a closed set under your control (an enum, a sealed type hierarchy)</li>
<li>The branch logic is a simple expression, not a multi-step operation</li>
<li>The dispatch is local to one method and not shared across services</li>
<li>You want compile-time exhaustiveness guarantees</li>
</ul>
<p><strong>When to avoid them:</strong></p>
<ul>
<li>Branches contain complex, multi-step logic that deserves its own test surface</li>
<li>The discriminant set is open-ended or runtime-provided (plugins, user-configurable processors)</li>
<li>The logic involves dependencies — injected services, async I/O, external calls</li>
</ul>
<h2>Option 3: Dictionary Dispatch</h2>
<p>Dictionary dispatch maps a key to a <code>Func&lt;T&gt;</code>, <code>Action</code>, or pre-constructed handler object stored in a <code>Dictionary&lt;TKey, TValue&gt;</code>. It offers O(1) lookup by design and is the natural fit when the key space is large, when the mapping is built at runtime from dynamic configuration, or when the cost of switch compilation overhead (on very large switch blocks) matters.</p>
<p>In practice, dictionary dispatch is most useful when the mapping comes from data — from a configuration file, a database, or a set of registered plugins — rather than from compile-time case labels. It is also useful when multiple services each need to resolve their own subset of the same key space independently.</p>
<p>The downside is that dictionaries give up exhaustiveness checking entirely. Missing keys are runtime failures. The structure is harder to reason about when reading the codebase, because the reader must trace both the dictionary construction and the invocation site to understand the full dispatch graph.</p>
<p><strong>When to reach for dictionary dispatch:</strong></p>
<ul>
<li>The key-to-handler mapping is built from runtime data (configuration, database, plugin registration)</li>
<li>The key space is large (dozens or more cases) and O(1) lookup is preferable to a long switch</li>
<li>Multiple registration sources need to contribute to the same dispatch map</li>
<li>The handlers are lightweight functions (delegates, lambdas) without DI dependencies</li>
</ul>
<p><strong>When to avoid it:</strong></p>
<ul>
<li>The mapping is static and known at compile time (a switch expression is simpler and safer)</li>
<li>The handlers have injected dependencies (use the Strategy Pattern instead)</li>
<li>You need exhaustiveness guarantees (dictionary dispatch has none)</li>
</ul>
<h2>Side-by-Side Comparison</h2>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>Strategy Pattern</th>
<th>Switch Expression</th>
<th>Dictionary Dispatch</th>
</tr>
</thead>
<tbody><tr>
<td>Extensibility</td>
<td>✅ Open/Closed by design</td>
<td>❌ Requires code change</td>
<td>⚠️ Dynamic if built at runtime</td>
</tr>
<tr>
<td>Exhaustiveness check</td>
<td>❌ Runtime failure on missing key</td>
<td>✅ Compiler-enforced (enums/sealed)</td>
<td>❌ Runtime failure on missing key</td>
</tr>
<tr>
<td>Testability</td>
<td>✅ Each strategy independently testable</td>
<td>⚠️ Tested as part of the calling method</td>
<td>⚠️ Mapping logic needs integration</td>
</tr>
<tr>
<td>DI support</td>
<td>✅ Native — inject into strategies</td>
<td>❌ No DI in branch logic</td>
<td>⚠️ Possible but awkward</td>
</tr>
<tr>
<td>Ceremony</td>
<td>❌ High — multiple classes</td>
<td>✅ Low — inline expression</td>
<td>⚠️ Medium — constructor or setup method</td>
</tr>
<tr>
<td>Lookup performance</td>
<td>⚠️ Depends on resolution</td>
<td>✅ Compiler-optimised jump table</td>
<td>✅ O(1) hash lookup</td>
</tr>
<tr>
<td>Runtime flexibility</td>
<td>⚠️ New registration required</td>
<td>❌ Compile-time only</td>
<td>✅ Fully dynamic</td>
</tr>
<tr>
<td>Best for</td>
<td>Open variant sets with logic</td>
<td>Closed value mappings</td>
<td>Dynamic, data-driven routing</td>
</tr>
</tbody></table>
<h2>Real-World Trade-Offs</h2>
<h3>The Notification Routing Problem</h3>
<p>Consider an ASP.NET Core API that dispatches notifications across email, SMS, push, and webhook channels. New channels will be added as the product grows. Each channel has configuration, retry logic, and templating concerns.</p>
<p>This is the Strategy Pattern problem. Each channel is a strategy, each strategy has dependencies, and the open-ended growth of the channel list is precisely the extensibility pressure the pattern is designed to absorb. Switch expressions and dictionary dispatch both require code changes for every new channel — which means the team that added the new channel has to understand the dispatch site, not just the new handler.</p>
<h3>The HTTP Status Code Mapping Problem</h3>
<p>Consider mapping a domain exception type to the appropriate HTTP status code in a global exception handler. The set of exception types is finite, controlled by the team, and the mapping is a pure value expression: <code>NotFoundException</code> → 404, <code>ConflictException</code> → 409, <code>ValidationException</code> → 422. This is a switch expression problem. Bringing in the Strategy Pattern for this adds four files of ceremony for what should be four lines of code.</p>
<h3>The Payment Method Routing Problem with Runtime Plugins</h3>
<p>Consider a payment processing platform where payment method handlers are registered as plugins from a database table. The handler for each payment method is resolved by a string key that comes from the database at startup. This is a dictionary dispatch problem — the mapping is built at runtime, the keys come from data, and the handlers are lightweight delegates that call into a pre-resolved service. The Strategy Pattern is still viable here but requires a more complex keyed-service setup; dictionary dispatch is simpler when the plugins are data-driven rather than code-driven.</p>
<h2>What About Combining Them?</h2>
<p>In practice, production codebases often combine all three. A common pattern in ASP.NET Core looks like this:</p>
<ul>
<li><strong>Switch expression</strong> at the top of the pipeline to route between two or three broad categories</li>
<li><strong>Strategy Pattern</strong> within each category where the variants are open-ended and DI-managed</li>
<li><strong>Dictionary dispatch</strong> in specific hot paths where lookup performance matters and the keys come from runtime data</li>
</ul>
<p>The mistake to avoid is defaulting to one approach regardless of context. Teams that over-apply the Strategy Pattern end up with an explosion of single-method classes. Teams that over-apply switch expressions end up with unmaintainable blocks that nobody wants to touch. Teams that over-apply dictionary dispatch end up with opaque mapping tables and silent runtime failures.</p>
<h2>How This Connects to Common DI Mistakes</h2>
<p>The Strategy Pattern's biggest failure mode in ASP.NET Core codebases is getting the service lifetime wrong — registering strategies as singletons when they depend on scoped services, or resolving them through a root provider and triggering the captured dependency problem. If your Strategy Pattern resolution involves any service lookup, the guidance in <a href="https://codingdroplets.com/aspnet-core-dependency-injection-mistakes-and-fixes">7 Common ASP.NET Core Dependency Injection Mistakes (And How to Fix Them)</a> directly applies — particularly the sections on captive dependencies and lifetime mismatches.</p>
<h2>Making the Right Call for Your Team</h2>
<p>Three questions guide the decision:</p>
<p><strong>Is the variant set closed or open?</strong> If it is closed (you control all the cases and they are known at compile time), switch expressions give you compiler safety for free. If it is open, the Strategy Pattern earns its ceremony.</p>
<p><strong>Does each variant have its own dependencies or complex logic?</strong> If yes, the Strategy Pattern is the right level of abstraction. If no, the overhead is not worth it.</p>
<p><strong>Is the mapping data-driven or code-driven?</strong> If the key-to-handler mapping comes from runtime data, dictionary dispatch is the natural fit. If it comes from source code, switch expressions or the Strategy Pattern will be safer.</p>
<p>The best teams treat these as tools in a toolbox — not mutually exclusive philosophies. Applying the right tool to the right problem is what keeps .NET codebases readable and changeable over time.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<hr />
<h2>FAQ</h2>
<h3>What Is the Difference Between the Strategy Pattern and a Switch Expression in C#?</h3>
<p>The Strategy Pattern encodes each behaviour variant as a separate class implementing a shared interface, with variant selection handled by a resolver or DI container at runtime. A switch expression is a language construct that selects a value or invokes logic inline based on a pattern match, with all cases defined at compile time. The Strategy Pattern is better for open-ended, dependency-carrying variants; switch expressions are better for closed value mappings where compile-time exhaustiveness checking is valuable.</p>
<h3>When Should I Prefer a Switch Expression over the Strategy Pattern in ASP.NET Core?</h3>
<p>Use a switch expression when the dispatch is mapping from one value to another (for example, status to HTTP code or enum to string), when the set of cases is fixed and compiler-controlled, and when the branch logic is a simple expression rather than a multi-step operation with dependencies. The Strategy Pattern adds a level of indirection that is only justified when the variants are independently testable, have injected dependencies, or are expected to grow over time.</p>
<h3>Is Dictionary Dispatch Faster Than a Switch Expression in .NET?</h3>
<p>For small to medium discriminant sets (up to roughly 6-7 cases), the C# compiler generates optimised jump tables or binary search trees for switch expressions that are typically faster than dictionary hash lookups. For larger sets (dozens or hundreds of keys), dictionary dispatch at O(1) can outperform the switch. In practice, the performance difference is rarely the deciding factor — correctness, testability, and maintainability should drive the choice first.</p>
<h3>Can I Combine the Strategy Pattern with Dictionary Dispatch in ASP.NET Core?</h3>
<p>Yes, and it is a common production pattern. The Strategy Pattern defines the interface and implementations; dictionary dispatch (or keyed services) provides the resolution mechanism that maps from a runtime key to the correct strategy instance. This combination gives you the extensibility and testability of the Strategy Pattern with the O(1) runtime lookup of dictionary dispatch. ASP.NET Core's keyed DI services (<code>AddKeyedSingleton</code>, <code>AddKeyedScoped</code>) are the idiomatic way to achieve this since .NET 8.</p>
<h3>What Are the Risks of Using Dictionary Dispatch for Behaviour Routing in .NET?</h3>
<p>The primary risk is silent runtime failure: if a key is missing from the dictionary, the code throws a <code>KeyNotFoundException</code> at runtime rather than failing at compile time. Unlike a switch expression with an exhaustive pattern match or an enum, the compiler cannot verify that all expected keys are handled. Dictionary dispatch also makes the routing logic harder to trace during a code review or debugging session, because the reader must follow both the dictionary construction and the call site to understand the full dispatch graph. Always include a fallback or explicit null-check for missing keys.</p>
<h3>How Does the Strategy Pattern Interact with Scoped Service Lifetimes in ASP.NET Core?</h3>
<p>Strategy implementations should be registered with the same lifetime as their most restrictive dependency. If a strategy depends on a scoped service (such as a <code>DbContext</code>), the strategy itself must be scoped — not singleton. Resolving scoped strategies through a DI-registered factory is straightforward; the risk appears when teams store strategy instances in a singleton resolver without accounting for lifetime. This is covered in detail in the dependency injection mistake guides, and it is one of the most common production issues with Strategy Pattern implementations in ASP.NET Core services.</p>
<h3>Should I Use the Strategy Pattern for Simple Two-Case Dispatch in .NET?</h3>
<p>Generally, no. For two fixed cases — or even three — the ceremony of the Strategy Pattern (interface, two implementation classes, registration, resolver) is not justified unless the variants are expected to grow or each variant has non-trivial logic requiring independent testing. A switch expression or a simple conditional is more readable and easier to maintain for a small, stable discriminant set. Reserve the Strategy Pattern for situations where extensibility and independent testability are genuine requirements, not theoretical ones.</p>
]]></content:encoded></item><item><title><![CDATA[7 Common ASP.NET Core Dependency Injection Mistakes (And How to Fix Them)]]></title><description><![CDATA[Dependency injection is baked into ASP.NET Core so deeply that most developers start using it before fully understanding it. That's fine — until production. When DI goes wrong, the failures are subtle]]></description><link>https://codingdroplets.com/aspnet-core-dependency-injection-mistakes-and-fixes</link><guid isPermaLink="true">https://codingdroplets.com/aspnet-core-dependency-injection-mistakes-and-fixes</guid><category><![CDATA[asp.net core]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[dependency injection]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[backend]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Thu, 04 Jun 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/79e5f199-c793-4229-9fc4-e75ccfa9e485.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Dependency injection is baked into ASP.NET Core so deeply that most developers start using it before fully understanding it. That's fine — until production. When DI goes wrong, the failures are subtle: memory leaks that grow slowly over days, stale data that appears only under concurrent load, <code>InvalidOperationException</code> crashes that only reproduce in staging, and objects that live far longer than they should. These are not framework bugs. They are configuration mistakes that compound over time.</p>
<p>The full implementations — including lifecycle validation, IServiceScopeFactory patterns, and edge-case handling — are available as annotated, production-ready source code on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, ready to drop into real ASP.NET Core projects.</p>
<p>Understanding these mistakes also matters beyond the immediate fix. DI lifetime decisions are architectural decisions. Getting them right in <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 1 of the Zero to Production course</a> is one of the first things covered — because everything that follows depends on services being registered with the right lifetime.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<p>This article covers seven DI mistakes that appear repeatedly in ASP.NET Core codebases — from startups to enterprise teams — and explains the fix for each one.</p>
<hr />
<h2>Mistake 1: Injecting a Scoped Service Into a Singleton (The Captive Dependency)</h2>
<p>This is the most well-known DI mistake in ASP.NET Core, and it still ships to production regularly. The term "captive dependency," coined by Mark Seemann, describes exactly what happens: a singleton holds a shorter-lived service captive, preventing it from being disposed and reusing stale state across requests.</p>
<p>A typical scenario: a singleton background processor injects <code>AppDbContext</code> directly via constructor injection. <code>AppDbContext</code> is registered as scoped — one per request — but the singleton is created once and never recreated. The captured <code>DbContext</code> instance is now shared across all concurrent operations for the lifetime of the application.</p>
<p><strong>What actually goes wrong:</strong></p>
<ul>
<li><p>EF Core's <code>DbContext</code> is not thread-safe. Sharing it across concurrent requests causes <code>InvalidOperationException: A second operation was started on this context before a previous operation completed.</code></p>
</li>
<li><p>Scoped services that cache per-request data (like a current user resolver or a tenant identifier) will return the data from the first request that initialized the singleton. Every subsequent request gets the wrong context.</p>
</li>
<li><p>ASP.NET Core's scope validation (enabled by default in the Development environment) will throw an <code>InvalidOperationException</code> at startup if it detects this misconfiguration. Production builds often have scope validation disabled, which is why this can silently reach production.</p>
</li>
</ul>
<p><strong>The fix:</strong> When a singleton genuinely needs to access a scoped service, inject <code>IServiceScopeFactory</code> and create an explicit scope for each unit of work. This is the documented pattern for <code>BackgroundService</code> and hosted services that need database access. Creating a scope manually gives you control over the lifetime, and the scope — along with everything it resolves — is disposed correctly when the using block exits.</p>
<p>Alternatively, reconsider whether the service truly needs to be a singleton. Many services registered as singletons for performance reasons are perfectly safe as scoped — and the performance difference is negligible compared to a single database round-trip.</p>
<hr />
<h2>Mistake 2: Using the Service Locator Pattern</h2>
<p>The service locator pattern means calling <code>IServiceProvider.GetService&lt;T&gt;()</code> (or <code>GetRequiredService&lt;T&gt;()</code>) from inside a class to resolve dependencies on demand, rather than declaring them as constructor parameters. It is technically supported, but it is an anti-pattern in ASP.NET Core DI.</p>
<p>The problem is not correctness — it works. The problem is that it hides dependencies. When a class takes <code>IOrderRepository</code>, <code>IEmailSender</code>, and <code>IEventPublisher</code> in its constructor, those dependencies are explicit. Every caller knows what this class needs. When a class takes <code>IServiceProvider</code> and resolves whatever it needs internally, none of that is visible.</p>
<p><strong>Concrete consequences:</strong></p>
<ul>
<li><p>Unit testing becomes painful. Mocking three concrete dependencies is straightforward. Mocking an <code>IServiceProvider</code> that returns the right thing for any type passed to it is not.</p>
</li>
<li><p>Misconfigured registrations fail at runtime, not at construction time. Constructor injection fails fast at startup. Service locator fails at the call site, which may be deep in a request lifecycle.</p>
</li>
<li><p>Code that looks simple acquires hidden coupling to the entire DI container. Refactoring becomes harder as the true dependency graph is obscured.</p>
</li>
</ul>
<p><strong>The fix:</strong> Declare every dependency as a constructor parameter. If a class is growing to 6-8 constructor parameters, that is a signal that the class is doing too much — not a reason to switch to service locator. Split the class first, then inject cleanly.</p>
<p>The only legitimate use case for service locator in ASP.NET Core is infrastructure code that genuinely cannot use constructor injection: middleware classes (where scoped services must be resolved in <code>Invoke</code>/<code>InvokeAsync</code>), factories that create objects based on runtime conditions, and bootstrapping code in <code>Program.cs</code>. Outside of those cases, constructor injection is always the right choice.</p>
<hr />
<h2>Mistake 3: Registering Services With the Wrong Lifetime</h2>
<p>Choosing the wrong service lifetime is a common source of subtle bugs. The three lifetimes have clear meanings:</p>
<ul>
<li><p><strong>Transient</strong> — a new instance every time the service is resolved</p>
</li>
<li><p><strong>Scoped</strong> — one instance per HTTP request (or per manually created scope)</p>
</li>
<li><p><strong>Singleton</strong> — one instance for the entire application lifetime</p>
</li>
</ul>
<p>The mistake is not always injecting scoped into singleton (Mistake 1). There are two other common misregistrations:</p>
<p><strong>Transient services that hold state.</strong> A service registered as transient that holds internal mutable state (a counter, a cache, a buffer) will appear to work correctly in single-threaded tests but will produce incorrect results in production. Each resolution gets a fresh instance — the state is never shared, never accumulated. If you need state that persists across calls within a request, the service should be scoped.</p>
<p><strong>Singletons that use</strong> <code>HttpContext</code><strong>.</strong> A singleton that takes <code>IHttpContextAccessor</code> will compile and run, but <code>IHttpContextAccessor.HttpContext</code> is only valid during an active request on the current thread. A singleton that caches <code>HttpContext</code> properties at construction time, or accesses them from a background thread, will read null or stale data. Services that are inherently per-request must be registered as scoped, full stop.</p>
<p><strong>What is genuinely safe as singleton:</strong> Pure utility services with no mutable state, configuration wrappers, registered <code>IOptions&lt;T&gt;</code> snapshots, clients like <code>HttpClient</code> (managed through <code>IHttpClientFactory</code>), and external SDK clients explicitly designed for thread-safe singleton use.</p>
<p><strong>The fix:</strong> Before registering any service, answer three questions: Does it hold mutable state that should be reset per request? Does it access <code>HttpContext</code> or any request-scoped data? Does it wrap a resource (like a <code>DbContext</code>) that is explicitly not thread-safe? If yes to any of these, the service should be scoped — not singleton, not transient.</p>
<hr />
<h2>Mistake 4: Ignoring Disposable Transient Services</h2>
<p>When a transient service implements <code>IDisposable</code> or <code>IAsyncDisposable</code>, the ASP.NET Core DI container takes ownership of its disposal — but only when it is resolved from a scoped context. The container holds a reference to every disposable transient it creates so it can call <code>Dispose()</code> when the scope ends.</p>
<p>This is the intended behaviour. The problem arises when transient disposables are resolved from the root container — directly from <code>IServiceProvider</code> during application startup, or in singleton services that create their own resolution context. In those cases, the root container holds the disposable for the entire application lifetime. There is no scope end to trigger disposal. The result is a memory leak that grows for as long as the application runs.</p>
<p><strong>Why it is hard to catch:</strong> In development, applications restart frequently. The leak is invisible. In production, behind a load balancer with infrequent restarts, memory grows steadily. By the time it is investigated, the connection between the leak and the DI configuration is not obvious.</p>
<p><strong>The fix:</strong> Avoid <code>IDisposable</code> on transient services unless the service genuinely manages a resource that must be released immediately. If disposal is needed, prefer scoped registration — the scope provides a natural disposal boundary. When resolving transient disposables manually (in tests or factory code), use explicit <code>using</code> blocks with manually created scopes to ensure deterministic disposal.</p>
<hr />
<h2>Mistake 5: Over-Registering Services as Singletons for Performance</h2>
<p>Singleton registration is sometimes chosen for performance reasons: "Why create a new instance on every request if the class is stateless?" This reasoning is often correct, but it leads to a habit of defaulting to singleton for anything that looks lightweight — and that habit eventually produces the captive dependency problem at scale.</p>
<p>There is also a subtler issue: classes that appear stateless sometimes are not. Thread-local state, internal lazy-initialized caches, or dependencies that hold state (registered transitively through constructor injection) can all make a "stateless" class stateful in ways that are not obvious from the registration call.</p>
<p><strong>The fix:</strong> Default to scoped for services that interact with any request-specific data, infrastructure, or external systems. Use singleton deliberately and explicitly only when you have confirmed the service is genuinely stateless and thread-safe. The performance cost of scoped over singleton is negligible for anything that does real work — it is measured in nanoseconds of allocation, while your slowest database query is measured in milliseconds.</p>
<p>When in doubt, profile first. Do not optimise DI lifetime without a measurement that shows lifetime choice is the bottleneck.</p>
<hr />
<h2>Mistake 6: Registering Multiple Implementations and Resolving the Wrong One</h2>
<p>ASP.NET Core allows multiple implementations of the same interface to be registered. When you call <code>services.AddScoped&lt;INotificationService, EmailNotificationService&gt;()</code> and later call <code>services.AddScoped&lt;INotificationService, SmsNotificationService&gt;()</code>, both registrations are valid. Resolving <code>INotificationService</code> from the container returns the last registration — <code>SmsNotificationService</code>. The email implementation is effectively hidden.</p>
<p>This surprises developers because it is different from how most other DI containers behave, and it is different from what you might expect if you have worked with configuration overriding.</p>
<p><strong>Where this bites teams:</strong></p>
<ul>
<li><p>Integration tests that register a mock implementation after the real one (expecting to override) find that the mock is resolved correctly — but only if test setup registers last. If the production registration somehow runs after the test setup, the real implementation wins.</p>
</li>
<li><p>Modules or plugins that add implementations to an existing interface without knowing about prior registrations can silently replace behaviour.</p>
</li>
<li><p>Resolving <code>IEnumerable&lt;INotificationService&gt;</code> correctly returns all implementations in registration order. Teams that expect <code>INotificationService</code> (singular) to give them all implementations are surprised when only the last one is returned.</p>
</li>
</ul>
<p><strong>The fix:</strong> When you intend to have multiple implementations, design for it explicitly. Use <code>IEnumerable&lt;T&gt;</code> injection when a consumer needs all implementations. Use named/keyed services (available natively in .NET 8+ via <code>AddKeyedScoped</code>) when different consumers need different implementations. Never rely on implicit registration order to control which implementation is resolved — make the selection explicit.</p>
<hr />
<h2>Mistake 7: Constructing Dependencies Outside the DI Container</h2>
<p>This is the mistake that looks harmless in isolation: <code>var service = new MyService(new MyRepository(new AppDbContext(options)))</code>. It compiles. It runs. It works in unit tests. But it breaks the DI system in ways that accumulate.</p>
<p><strong>What goes wrong:</strong></p>
<ul>
<li><p>The constructed object and its entire dependency tree are invisible to the DI container. No lifetime management. No scope boundary. No disposal by the framework.</p>
</li>
<li><p>If <code>MyService</code> is later refactored to depend on a new service, every call site where it is manually constructed must be updated. Constructor injection centralises this — <code>new</code> scatters it.</p>
</li>
<li><p>Factories, decorators, and pipeline behaviours registered in the DI container do not apply to manually constructed objects. An object created with <code>new</code> bypasses middleware, logging behaviour, and any cross-cutting concern wired through the container.</p>
</li>
</ul>
<p>In production ASP.NET Core code, manually constructing services is rarely justified. The cases where it is legitimate: creating simple, truly static value objects (records, configuration structs) that have no runtime dependencies, and constructing test doubles in unit test arrangement code where DI adds no value.</p>
<p><strong>The fix:</strong> Register everything in the DI container. If a service needs to be created dynamically at runtime (based on a type identifier, a feature flag, or runtime data), use a factory — but register the factory itself in the container and let it resolve its dependencies through the container.</p>
<hr />
<h2>How to Audit Your Codebase for DI Mistakes</h2>
<p>Running <code>ValidateScopes = true</code> and <code>ValidateOnBuild = true</code> in development (which is the default behaviour in ASP.NET Core's Development environment) will catch captive dependencies at application startup. This is the first and most valuable automated check.</p>
<p>Beyond that, a targeted code review for:</p>
<ul>
<li><p>Any class that takes <code>IServiceProvider</code> as a constructor parameter (service locator)</p>
</li>
<li><p>Any <code>IDisposable</code> service registered as transient</p>
</li>
<li><p>Any singleton that holds a reference to <code>HttpContext</code> or <code>IHttpContextAccessor</code></p>
</li>
<li><p>Any <code>new ServiceType(...)</code> outside of test setup code</p>
</li>
</ul>
<p>This is not a comprehensive list, but it covers the most common sources of DI-related production failures.</p>
<p>DI in ASP.NET Core is powerful precisely because it handles object lifetime, disposal, and dependency resolution automatically — but only for objects registered in the container. Every workaround to that system trades short-term convenience for long-term instability.</p>
<hr />
<blockquote>
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<hr />
<h2>Frequently Asked Questions</h2>
<p><strong>What is a captive dependency in ASP.NET Core DI?</strong> A captive dependency occurs when a longer-lived service (typically a singleton) holds a reference to a shorter-lived service (scoped or transient). The shorter-lived service is never released because the singleton's lifetime controls when it is disposed. This leads to stale data, thread-safety violations, and memory leaks. The standard fix is to inject <code>IServiceScopeFactory</code> into the singleton and create an explicit scope when the scoped service is needed.</p>
<p><strong>Why should I avoid the service locator pattern in ASP.NET Core?</strong> The service locator pattern uses <code>IServiceProvider.GetService&lt;T&gt;()</code> to resolve dependencies at call time rather than declaring them as constructor parameters. While it works, it hides dependencies, makes unit testing significantly harder (you must mock the entire service provider), and causes misconfiguration errors to surface at runtime rather than at startup. Constructor injection is always preferred except in infrastructure code like middleware or application bootstrapping.</p>
<p><strong>Can I register multiple implementations of the same interface in ASP.NET Core DI?</strong> Yes. Registering multiple implementations of the same interface is supported. However, resolving the interface by type (e.g., <code>INotificationService</code>) returns only the last registered implementation. To access all implementations, inject <code>IEnumerable&lt;INotificationService&gt;</code>. To direct different consumers to different implementations, use keyed services (introduced natively in .NET 8 via <code>AddKeyedScoped</code>, <code>AddKeyedSingleton</code>, etc.).</p>
<p><strong>What happens if a transient service implements IDisposable in ASP.NET Core?</strong> The DI container tracks disposable transient services and calls <code>Dispose()</code> on them when the enclosing scope ends. This is correct behaviour within a request scope. The problem arises when disposable transients are resolved from the root container (outside any request scope) — the root container holds them for the application's entire lifetime, causing a memory leak. Disposable transients should always be resolved within a properly scoped context.</p>
<p><strong>How do I validate my DI registrations at startup in ASP.NET Core?</strong> ASP.NET Core validates scope violations and resolves all services on build in the Development environment by default. You can explicitly enable this in any environment by setting <code>ValidateScopes = true</code> and <code>ValidateOnBuild = true</code> on the host builder's <code>UseDefaultServiceProvider</code> call. This catches captive dependencies (scoped services injected into singletons) at startup rather than at runtime during a request.</p>
<p><strong>What is the difference between Scoped and Transient service lifetimes?</strong> Scoped services are created once per HTTP request (or per manually created <code>IServiceScope</code>) and shared across all resolutions within that scope. Transient services are created fresh every time they are resolved — even multiple times within the same request. Scoped is appropriate for services that hold per-request state (like a DbContext or a current user service). Transient is appropriate for lightweight, stateless services where a new instance per use is safe and desirable.</p>
<p><strong>Should I default new services to Scoped, Transient, or Singleton?</strong> For most application services in ASP.NET Core APIs, scoped is the safest default. It gives each request its own instance (avoiding thread-safety concerns) while limiting allocation to once per request (avoiding the overhead of transient). Use singleton only for provably thread-safe, stateless services. Use transient for very lightweight utility objects where per-resolution creation is genuinely needed. When in doubt, start with scoped and optimise with measurement.</p>
]]></content:encoded></item><item><title><![CDATA[ASP.NET Core Logging & Observability Interview Questions for Senior .NET Developers (2026)]]></title><description><![CDATA[Logging and observability have quietly become one of the most reliable differentiators between junior and senior .NET candidates. Most developers can configure Serilog to write to a file — but senior ]]></description><link>https://codingdroplets.com/aspnet-core-logging-observability-interview-questions-senior-dotnet-2026</link><guid isPermaLink="true">https://codingdroplets.com/aspnet-core-logging-observability-interview-questions-senior-dotnet-2026</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[logging]]></category><category><![CDATA[observability]]></category><category><![CDATA[serilog]]></category><category><![CDATA[OpenTelemetry]]></category><category><![CDATA[interview]]></category><category><![CDATA[dotnet-interview]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Thu, 04 Jun 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/b5fbaa21-d07e-40a8-89e5-e0f54a8e2a33.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Logging and observability have quietly become one of the most reliable differentiators between junior and senior .NET candidates. Most developers can configure Serilog to write to a file — but senior engineers are expected to reason about structured logging strategies, OpenTelemetry correlation, health check semantics, and what "production-ready observability" actually means for a distributed system. The interview questions in this guide cover exactly that territory. If you want to go deeper with hands-on code and production-ready patterns beyond the conceptual level, the full annotated implementations are available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a> — wired together in a complete ASP.NET Core API so you can see how each piece fits.</p>
<p>Chapter 14 of the <a href="https://aspnetcoreapi.codingdroplets.com/">ASP.NET Core Web API: Zero to Production course</a> covers structured logging with Serilog, OpenTelemetry traces and metrics, and health check endpoints with liveness and readiness semantics — exactly the topics that come up in senior .NET interviews. It walks through a full production codebase so the context is always clear.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<p>The questions are grouped by difficulty — Basic, Intermediate, and Advanced — so you can start with fundamentals and work toward the senior-level topics that separate candidates in 2026.</p>
<hr />
<h2>Basic Questions</h2>
<h3>What is the difference between <code>ILogger&lt;T&gt;</code> and <code>ILoggerFactory</code> in ASP.NET Core?</h3>
<p><code>ILogger&lt;T&gt;</code> is a strongly-typed logger scoped to a specific class. The generic type argument <code>T</code> becomes the category name, which is typically the fully qualified class name. This makes it easy to filter logs by class in production.</p>
<p><code>ILoggerFactory</code> is a lower-level abstraction used to create loggers with arbitrary category names. It is useful in cases where the category name cannot be determined at compile time — for example, inside a helper class that creates child loggers dynamically.</p>
<p>In practice, inject <code>ILogger&lt;T&gt;</code> in your application classes. Use <code>ILoggerFactory</code> only when you genuinely need to create loggers with dynamic category names, such as inside framework-level infrastructure code.</p>
<hr />
<h3>What are log levels in ASP.NET Core, and when should you use each?</h3>
<p>ASP.NET Core defines six log levels in ascending severity:</p>
<ul>
<li><p><strong>Trace</strong> — the most detailed, typically disabled in production; used for step-by-step flow tracing during development.</p>
</li>
<li><p><strong>Debug</strong> — development-time diagnostics; disabled by default in production.</p>
</li>
<li><p><strong>Information</strong> — significant application events such as startup, request completion, and business-level milestones.</p>
</li>
<li><p><strong>Warning</strong> — unexpected but recoverable situations; for example, a configuration value falling back to a default.</p>
</li>
<li><p><strong>Error</strong> — a failure that affects the current operation but not the overall application; for example, an unhandled exception for a single request.</p>
</li>
<li><p><strong>Critical</strong> — a failure that requires immediate attention and may prevent the application from continuing; for example, a database connection failure at startup.</p>
</li>
</ul>
<p>At a senior level, the key distinction interviewers expect is: use <strong>Warning</strong> for recoverable anomalies that indicate a potential problem worth investigating, and use <strong>Error</strong> only for failures that impact a specific operation without crashing the process. Over-using Error creates alert fatigue in production monitoring systems.</p>
<hr />
<h3>What is structured logging, and why does it matter?</h3>
<p>Structured logging means recording log entries as structured data — typically key-value pairs — rather than plain text strings. Instead of writing <code>"User 42 placed order 99"</code>, you write a log event with discrete properties: <code>userId = 42</code>, <code>orderId = 99</code>, <code>eventType = "OrderPlaced"</code>.</p>
<p>This matters in production because structured logs can be queried, aggregated, and filtered by tools like Seq, Grafana Loki, or Azure Application Insights without parsing text. You can ask: "Show me all requests where <code>orderId = 99</code> completed with a 500 status" and get exact results.</p>
<p>In ASP.NET Core, structured logging is supported through the <code>ILogger</code> message template syntax using named placeholders: <code>_logger.LogInformation("Order {OrderId} placed by user {UserId}", orderId, userId)</code>. The curly-brace syntax binds values to named properties rather than positional string substitution.</p>
<hr />
<h3>How do you configure Serilog in ASP.NET Core?</h3>
<p>Serilog replaces the built-in logging pipeline via <code>UseSerilog()</code> on the host builder. You configure it by defining sinks (outputs) such as Console, File, or Seq, and setting minimum log levels per namespace.</p>
<p>A minimal production-ready setup typically uses at least a Console sink for container environments and a rolling-file sink for local debugging. Enrichers like <code>FromLogContext()</code> and <code>WithMachineName()</code> add contextual properties automatically to every log event.</p>
<p>The recommended pattern is to configure Serilog early in <code>Program.cs</code> — before the host is built — so that startup errors are captured rather than silently lost.</p>
<p>For a detailed comparison of Serilog, NLog, and the built-in <code>ILogger</code> across enterprise scenarios, see <a href="https://codingdroplets.com/aspnet-core-structured-logging-serilog-nlog-ilogger-enterprise-decision-guide">ASP.NET Core Structured Logging: Serilog vs NLog vs ILogger</a>.</p>
<hr />
<h3>What is <code>UseSerilogRequestLogging()</code> and when should you use it?</h3>
<p><code>UseSerilogRequestLogging()</code> is middleware from the <code>Serilog.AspNetCore</code> package that replaces ASP.NET Core's default per-request log output with a single, richer structured event per request.</p>
<p>The default ASP.NET Core logging emits two or more log lines per request across different categories. Serilog's middleware collapses these into a single event that includes the HTTP method, path, status code, elapsed milliseconds, and any properties added to the log context during the request.</p>
<p>Use it in every production ASP.NET Core application. It reduces log volume, improves query performance in log aggregation tools, and makes request-level diagnostics significantly easier.</p>
<hr />
<h2>Intermediate Questions</h2>
<h3>What is the difference between <code>Log.Warning</code> and <code>_logger.LogWarning</code>, and which should you use?</h3>
<p><code>Log.Warning</code> is the static <code>Serilog.Log</code> API — a static facade that writes to the globally-configured Serilog logger. <code>_logger.LogWarning</code> uses the <code>ILogger&lt;T&gt;</code> interface injected via ASP.NET Core's DI system.</p>
<p>Always prefer <code>_logger.LogWarning</code> (the <code>ILogger&lt;T&gt;</code> interface) in application code. It is testable — you can mock <code>ILogger&lt;T&gt;</code> in unit tests and verify log calls. It is also DI-friendly, keeping your code decoupled from the specific logging library.</p>
<p>The static <code>Log</code> facade is acceptable for bootstrapping code (e.g., early <code>Program.cs</code> startup before DI is available), but it should not be used inside services, controllers, or handlers.</p>
<hr />
<h3>What are log scopes in ASP.NET Core, and how do you use them?</h3>
<p>Log scopes allow you to attach contextual properties to all log events emitted within a defined code block. They are created using <code>ILogger.BeginScope()</code>.</p>
<p>A common use case is attaching a correlation ID or tenant ID to every log event produced while processing a specific request or job, without having to pass the value explicitly to every log call. When the scope is disposed (typically at the end of a <code>using</code> block), the properties are automatically removed.</p>
<p>In Serilog, scopes map to enrichment via <code>LogContext.PushProperty()</code>. ASP.NET Core's <code>ILogger</code> interface bridges the two transparently when Serilog is configured as the provider.</p>
<p>Senior candidates are expected to distinguish between log scopes (contextual enrichment within a code block) and log categories (the class-level name on <code>ILogger&lt;T&gt;</code>) — they serve different purposes and are often confused.</p>
<hr />
<h3>What is the three-pillar model of observability, and how does ASP.NET Core support it?</h3>
<p>Observability in distributed systems is built on three signals:</p>
<ul>
<li><p><strong>Traces</strong> — distributed, causally-linked records of work across services; essential for understanding latency and failures in request flows that span multiple components.</p>
</li>
<li><p><strong>Metrics</strong> — numerical measurements over time (counters, gauges, histograms); used for dashboards, SLOs, and alerting.</p>
</li>
<li><p><strong>Logs</strong> — discrete, time-stamped records of events with context; used for debugging and audit.</p>
</li>
</ul>
<p>ASP.NET Core supports all three natively. Structured logging via <code>ILogger</code> covers logs. The <code>System.Diagnostics</code> namespace with <code>Activity</code> and <code>ActivitySource</code> covers traces. <code>System.Diagnostics.Metrics</code> covers metrics.</p>
<p>OpenTelemetry provides the vendor-neutral export layer that connects these signals to backend platforms like Jaeger, Prometheus, Azure Monitor, or Grafana LGTM.</p>
<p>Senior candidates are expected to articulate how all three work together — not treat them as separate tools.</p>
<hr />
<h3>How does OpenTelemetry work in ASP.NET Core, and why is it preferred over direct SDK integrations?</h3>
<p>OpenTelemetry is a vendor-neutral observability framework that provides a single API and SDK for capturing traces, metrics, and logs. In ASP.NET Core, it integrates via the <code>OpenTelemetry.Extensions.Hosting</code> package.</p>
<p>You configure it by adding trace providers (e.g., <code>AddAspNetCoreInstrumentation()</code>, <code>AddHttpClientInstrumentation()</code>), metric providers, and exporters in <code>Program.cs</code>. The exporters send data to your observability backend — OTLP for Jaeger/Tempo/Grafana, Prometheus for metrics, or Azure Monitor for Microsoft-stack shops.</p>
<p>The advantage over direct SDK integrations (such as the Application Insights SDK or Datadog's .NET agent) is portability. With OpenTelemetry, you can switch your observability backend without changing application code. You write instrumentation once; exporters handle the transport.</p>
<p>For a full walkthrough, see <a href="https://codingdroplets.com/opentelemetry-aspnet-core-complete-guide-dotnet-2026">OpenTelemetry in ASP.NET Core: A Complete Guide for .NET Developers</a>.</p>
<hr />
<h3>What is the difference between liveness, readiness, and startup probes in ASP.NET Core health checks?</h3>
<p>These three probe types come from Kubernetes terminology, and ASP.NET Core health checks map directly to them:</p>
<ul>
<li><p><strong>Liveness</strong> — answers "Is this process alive and functional?" If a liveness check fails, Kubernetes restarts the pod. A liveness check should be minimal — typically just verifying that the application is not deadlocked or in an unrecoverable error state. Connecting to a database in a liveness check is an anti-pattern; a flaky DB connection should not restart your pod.</p>
</li>
<li><p><strong>Readiness</strong> — answers "Is this instance ready to receive traffic?" A readiness check failure removes the pod from the load balancer rotation without restarting it. Include dependency checks here (database reachability, downstream service availability) if those dependencies are required to serve requests.</p>
</li>
<li><p><strong>Startup</strong> — answers "Has the application finished initialising?" Relevant for apps with long startup sequences. Kubernetes waits for the startup probe to pass before enabling liveness and readiness probes.</p>
</li>
</ul>
<p>In ASP.NET Core, you register health checks via <code>AddHealthChecks()</code> and tag each check with <code>HealthCheckTags.Live</code> or <code>HealthCheckTags.Ready</code>. Map them to separate endpoints: <code>/health/live</code> for liveness and <code>/health/ready</code> for readiness.</p>
<p>For a deeper comparison of probe semantics, see <a href="https://codingdroplets.com/aspnet-core-health-checks-liveness-readiness-startup-probes">ASP.NET Core Health Checks: Liveness vs Readiness vs Startup Probes</a>.</p>
<p>You can also explore production-ready health check implementations in the <a href="https://github.com/codingdroplets/aspnet-core-health-checks">GitHub repo</a> — with custom <code>IHealthCheck</code> implementations, tagged checks, and Kubernetes-ready endpoints.</p>
<hr />
<h3>What is <code>AddDbContextCheck()</code> and when should you use it?</h3>
<p><code>AddDbContextCheck&lt;TContext&gt;()</code> is a built-in health check extension from <code>Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore</code> that verifies a DbContext can connect to its database. It executes a <code>CanConnectAsync()</code> call against the database under the hood.</p>
<p>Use it in your readiness check, not your liveness check. A database connectivity failure means your application cannot serve requests (readiness concern), but it does not mean the application process itself is unhealthy (liveness concern).</p>
<p>Important caveat: <code>AddDbContextCheck()</code> only verifies connectivity, not schema consistency or query performance. For production scenarios, consider supplementing it with a lightweight query against a known table.</p>
<hr />
<h2>Advanced Questions</h2>
<h3>How do you implement correlation IDs in a distributed ASP.NET Core system, and how do they connect to distributed traces?</h3>
<p>Correlation IDs are unique identifiers attached to a request that flow through all log events and downstream calls produced during that request's lifetime. They enable you to reconstruct the complete execution path for a single user request across multiple services and log aggregation queries.</p>
<p>In ASP.NET Core, the common pattern is middleware that inspects an <code>X-Correlation-ID</code> header on incoming requests. If the header is present, it uses the provided value; if not, it generates a new <code>Guid</code>. The ID is then added to the current <code>ILogger</code> scope via <code>LogContext.PushProperty()</code> (Serilog) or <code>BeginScope()</code> (ILogger), so every log event in that request automatically includes the correlation ID.</p>
<p>For distributed traces, the W3C <code>traceparent</code> header propagation built into <code>System.Diagnostics.Activity</code> and OpenTelemetry is the modern replacement. <code>Activity.TraceId</code> and <code>Activity.SpanId</code> are the trace-native equivalents of manual correlation IDs. The key insight for senior candidates: when using OpenTelemetry, you should correlate log events to trace spans using <code>Activity.Current.TraceId</code> rather than managing a separate correlation ID.</p>
<hr />
<h3>What is <code>LoggerMessage</code> source generation, and when should you use it?</h3>
<p><code>LoggerMessage</code> is a code-generation approach to structured logging that pre-compiles log messages into strongly-typed static delegates, eliminating the allocations and string parsing overhead of the standard <code>ILogger.Log</code> methods.</p>
<p>There are two approaches: the classic <code>LoggerMessage.Define()</code> factory methods (pre-.NET 6) and the newer source-generated approach using <code>[LoggerMessage]</code> attribute on partial methods (available from .NET 6 onwards). The source-generated version is cleaner and recommended for all new code.</p>
<p>Use <code>LoggerMessage</code>-generated methods in hot paths — high-throughput endpoints, inner loops, or request handlers where the logging overhead is measurable. In practice, this means anywhere you might log hundreds of thousands of events per second.</p>
<p>For typical line-of-business APIs, the overhead of regular <code>ILogger.LogInformation()</code> calls is negligible. The cost-benefit of <code>LoggerMessage</code> is only meaningful at high throughput. Senior candidates should know when to use it — not reflexively apply it everywhere.</p>
<hr />
<h3>How do you handle PII (Personally Identifiable Information) in ASP.NET Core logs?</h3>
<p>PII in logs is a compliance and security risk. Strategies for managing it fall into three categories:</p>
<p><strong>1. Destructuring policies (Serilog):</strong> Serilog's <code>IDestructuringPolicy</code> lets you intercept and redact or mask properties on objects before they are serialised into log events. You can register policies that replace email addresses, phone numbers, or credit card fields with masked equivalents.</p>
<p><strong>2. Log templates discipline:</strong> The simplest approach is never logging raw user-supplied data. Pass only identifiers (e.g., <code>userId</code>, <code>orderId</code>) into log messages, never full names, emails, or addresses. This requires consistent code review discipline rather than automated enforcement.</p>
<p><strong>3. Minimum log levels and filter configuration:</strong> Set minimum log levels for namespaces that may produce PII-heavy output (e.g., <code>Microsoft.AspNetCore.HttpLogging</code> logs request/response bodies by default). Disable body logging in production unless you have an explicit, compliant reason to enable it.</p>
<p>The key point for senior interviews: PII handling in logs is not just a technical problem — it is a governance problem. The technical controls enforce what the team decides as policy. Engineers need to understand both layers.</p>
<hr />
<h3>What is the difference between metrics, traces, and logs, and when would you use each for production debugging?</h3>
<p>These three signals serve different diagnostic purposes and are most effective when used together:</p>
<p><strong>Logs</strong> are the first tool for understanding what happened during a specific request or operation. They provide exact event records with context. Use logs when you know the time window and can search by entity identifiers.</p>
<p><strong>Metrics</strong> tell you the shape of a problem across the fleet. High error rate, elevated p99 latency, or a drop in requests per second are all visible in metrics before any individual log is examined. Use metrics for detection — they tell you something is wrong before you know what.</p>
<p><strong>Traces</strong> bridge the gap between "something is slow" (metrics) and "here is exactly where the time went" (logs). A distributed trace shows the causal chain of spans across services, making latency attribution in multi-service architectures possible. Use traces when you need to understand where time is spent or where failures propagate across service boundaries.</p>
<p>The senior-level answer is not to choose one — it is to use all three in the correct sequence: metrics surface the problem, traces isolate the location, logs explain the details.</p>
<hr />
<h3>How do you avoid common structured logging mistakes in production ASP.NET Core APIs?</h3>
<p>The most costly structured logging mistakes in production:</p>
<p><strong>1. Synchronous sinks on hot paths.</strong> File and database sinks should always be configured as asynchronous. Synchronous writes from within request-handling code add measurable latency under load. Serilog's <code>WriteTo.Async()</code> wrapper buffers writes off the request thread.</p>
<p><strong>2. Over-logging at high severity.</strong> Excessive use of <code>LogError</code> for expected, recoverable conditions (e.g., <code>404 Not Found</code>) causes alert noise. <code>LogWarning</code> for 4xx client errors and <code>LogError</code> only for 5xx server-side failures is the correct split.</p>
<p><strong>3. String interpolation instead of structured templates.</strong> <code>_logger.LogInformation($"User {userId} logged in")</code> loses the structured property. <code>_logger.LogInformation("User {UserId} logged in", userId)</code> preserves it. This is a common mistake that turns structured logging back into plain text.</p>
<p><strong>4. Capturing exception messages on 500s.</strong> <code>exception.Message</code> should never appear in 500-level responses. Log the full exception object (via <code>LogError(ex, ...)</code>) for internal diagnostics, but never expose it to API consumers.</p>
<p><strong>5. No minimum log level configuration per namespace.</strong> Logging <code>Microsoft.EntityFrameworkCore</code> at Verbose in production generates enormous SQL query logs. Always configure per-namespace minimum levels in <code>appsettings.json</code>.</p>
<p>For a more detailed treatment of these patterns, see <a href="https://codingdroplets.com/aspnet-core-logging-mistakes">7 Common ASP.NET Core Logging Mistakes (And How to Fix Them)</a>.</p>
<hr />
<blockquote>
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<hr />
<h2>Frequently Asked Questions</h2>
<p><strong>What observability topics are most commonly asked about in senior .NET interviews?</strong> The most frequent topics are structured logging (Serilog configuration, named placeholders, enrichers), OpenTelemetry integration (auto-instrumentation, trace correlation, exporters), health check semantics (liveness vs readiness), and production anti-patterns like logging PII or using synchronous sinks. Interviewers in 2026 also increasingly ask about <code>LoggerMessage</code> source generation for performance-conscious candidates.</p>
<p><strong>Is knowing Serilog required for .NET senior interviews, or is the built-in ILogger enough?</strong> You are expected to understand <code>ILogger&lt;T&gt;</code> deeply — it is the abstraction used in all production code. Serilog knowledge is a strong differentiator. Most production .NET teams use Serilog or NLog as the logging provider behind <code>ILogger</code>, and interviewers at companies running real production systems will often ask about Serilog specifically. Understanding both the abstraction and a concrete provider is the safe baseline.</p>
<p><strong>What is the difference between OpenTelemetry and Application Insights SDK?</strong> Application Insights SDK is a Microsoft-specific, proprietary telemetry solution that sends data directly to Azure Monitor. OpenTelemetry is a vendor-neutral standard that can export to any compatible backend, including Azure Monitor (via the Azure Monitor Exporter), Jaeger, Grafana, or Datadog. OpenTelemetry is the modern, portable choice; the Application Insights SDK ties you to Azure. In interviews, the correct answer is to prefer OpenTelemetry for new projects and use the Azure Monitor Exporter if you need Azure Monitor.</p>
<p><strong>How do I explain health checks to a non-technical interviewer or system design discussion?</strong> Frame it as: "Health checks expose endpoints that tell infrastructure orchestrators — like Kubernetes — whether this instance is ready to serve requests and whether it is functioning correctly." Liveness tells Kubernetes whether to restart the pod. Readiness tells the load balancer whether to route traffic to it. The analogy is a car's dashboard warning lights: one tells you to pull over immediately (liveness), another tells you the engine isn't warm enough yet (readiness).</p>
<p><strong>What is</strong> <code>Activity.TraceId</code><strong>, and how does it relate to distributed tracing in ASP.NET Core?</strong><code>Activity.TraceId</code> is a 16-byte identifier from <code>System.Diagnostics.Activity</code> that uniquely identifies a distributed trace across all services involved in a single request. In ASP.NET Core with OpenTelemetry, the W3C <code>traceparent</code> header propagates this ID between services automatically. Every span in the trace shares the same <code>TraceId</code>, enabling tools like Jaeger or Grafana Tempo to stitch all related spans into a single trace view. <code>Activity.SpanId</code> identifies the individual unit of work within that trace.</p>
<p><strong>Should I log request and response bodies in production for debugging purposes?</strong> Generally, no. Request and response body logging should be disabled in production unless there is a specific, compliance-reviewed need. Body logging at scale adds significant I/O overhead, risks capturing PII or sensitive tokens, and generates enormous log volume. For targeted debugging, prefer structured event logs with entity identifiers and status codes. If body logging is genuinely needed for compliance audit purposes, ensure it is scoped to specific endpoints, PII is masked, and retention is governed by your data policies.</p>
<p><strong>What's the cleanest way to add a correlation ID to every log event in ASP.NET Core?</strong> Register middleware early in the pipeline that extracts or generates the correlation ID, stores it in <code>HttpContext.Items</code>, and pushes it to the <code>ILogger</code> scope with <code>LogContext.PushProperty()</code> (Serilog) or <code>BeginScope()</code> (ILogger). All downstream middleware, controllers, and services then emit log events that automatically include the correlation ID without any additional plumbing. This is preferable to passing the ID as a parameter through every method call.</p>
]]></content:encoded></item><item><title><![CDATA[Audit Logging in ASP.NET Core: Middleware vs EF Core Interceptors vs Domain Events — Enterprise Decision Guide]]></title><description><![CDATA[Audit logging is one of those requirements that sounds simple until you actually sit down to implement it across a production API. Every enterprise team eventually faces the same question: where does ]]></description><link>https://codingdroplets.com/audit-logging-aspnet-core-enterprise-decision-guide</link><guid isPermaLink="true">https://codingdroplets.com/audit-logging-aspnet-core-enterprise-decision-guide</guid><category><![CDATA[Aspnetcore]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[efcore]]></category><category><![CDATA[audit logging]]></category><category><![CDATA[enterprise]]></category><category><![CDATA[Web API]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Wed, 03 Jun 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/7c358ba9-ffa9-4db0-8b45-6971e330b928.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Audit logging is one of those requirements that sounds simple until you actually sit down to implement it across a production API. Every enterprise team eventually faces the same question: where does the audit logic live, and what captures it? The answer affects maintainability, compliance coverage, performance, and how painful debugging becomes when something goes wrong six months later.</p>
<p>Three approaches dominate audit logging strategy in ASP.NET Core APIs: request-level middleware that captures HTTP traffic, EF Core SaveChanges interceptors that capture data mutations at the persistence layer, and domain event handlers that capture business-meaningful state changes from within the domain model. Each has a rightful place — and each is the wrong choice in the wrong context. The full working implementation of each approach, including the audit entity schema, scoped service wiring, and a complete test suite, is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a> for teams who want production-ready code alongside the theory.</p>
<p>Understanding <a href="https://aspnetcoreapi.codingdroplets.com/">Ch 12 of the Zero to Production course</a> walks through audit trail implementation inside a complete ASP.NET Core API, showing how SaveChanges interceptors and domain events work together within the same production codebase — with the infrastructure already wired in.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<hr />
<h2>What Audit Logging Is Actually Solving</h2>
<p>Before selecting an approach, it is worth being precise about what you are trying to capture. Enterprise teams typically need audit logging for at least one of three reasons:</p>
<ul>
<li><p><strong>Compliance and regulatory mandates</strong> — GDPR, HIPAA, SOC 2, PCI-DSS, and similar frameworks require demonstrable records of who accessed or changed what, and when</p>
</li>
<li><p><strong>Operational debugging</strong> — reconstructing what a user did immediately before a data integrity problem surfaced</p>
</li>
<li><p><strong>Security forensics</strong> — detecting and investigating suspicious access patterns or privilege escalation after the fact</p>
</li>
</ul>
<p>The approach you choose should match the audit signal you actually need. HTTP-level signals are fundamentally different from persistence-level signals, which are different again from business-intent signals. Conflating them leads to audit logs that look impressive and help no one.</p>
<hr />
<h2>Approach 1: Request-Level Middleware</h2>
<h3>What It Captures</h3>
<p>ASP.NET Core middleware sits at the HTTP pipeline level. A custom middleware component can intercept every inbound request and outbound response, capturing the actor identity (from <code>HttpContext.User</code>), the endpoint called, the HTTP verb, the request timestamp, the response status code, and optionally the request body.</p>
<p>Middleware audit logging answers: <em>who called what, when, and what did the API say back?</em></p>
<h3>When Middleware Is the Right Choice</h3>
<p>Middleware audit logging is appropriate when:</p>
<ul>
<li><p>You need an HTTP access log independent of whether a database operation succeeded</p>
</li>
<li><p>Your compliance requirement is specifically about API endpoint access, not data mutation (common in healthcare and financial services where access logs are mandated separately from change logs)</p>
</li>
<li><p>You are auditing endpoints that use raw SQL, external service calls, or caching — where no EF Core interaction occurs</p>
</li>
<li><p>You need to capture requests that fail validation and never reach the service layer at all</p>
</li>
<li><p>Your team wants a consistent audit record regardless of which persistence strategy each endpoint uses</p>
</li>
</ul>
<p>A well-implemented middleware audit log captures the full HTTP story. It does not care whether the underlying operation used EF Core, Dapper, or a third-party API.</p>
<h3>When Middleware Is Not the Right Choice</h3>
<p>Middleware becomes the wrong tool when:</p>
<ul>
<li><p>You need field-level change tracking (which property changed from what value to what value)</p>
</li>
<li><p>You are auditing batch operations that issue multiple database writes per HTTP request</p>
</li>
<li><p>You need to distinguish between business operations at a finer grain than HTTP routes provide</p>
</li>
<li><p>Your audit store requires transactional consistency with the data change (middleware writes happen outside your database transaction)</p>
</li>
</ul>
<p>The transactional consistency limitation is the most significant. If a middleware component writes the audit record to its own table and the downstream database write then fails and rolls back, you have an audit log entry for an operation that never actually completed. That kind of phantom audit entry can create compliance problems as serious as the missing records it was meant to prevent.</p>
<hr />
<h2>Approach 2: EF Core SaveChanges Interceptors</h2>
<h3>What It Captures</h3>
<p>EF Core interceptors hook into the SaveChanges pipeline. A <code>SaveChangesInterceptor</code> implementation receives the <code>ChangeTracker</code> before persistence, exposing every entity being inserted, updated, or deleted — including the old and new property values for updates.</p>
<p>The <code>ISaveChangesInterceptor</code> interface provides both synchronous and asynchronous interception points, and because it runs inside the same database transaction as your domain changes, the audit record either commits with the data or rolls back with it.</p>
<p>EF Core interceptors answer: <em>what changed in the database, which entity was affected, and what were the before/after values?</em></p>
<h3>When EF Core Interceptors Are the Right Choice</h3>
<p>Interceptors are the dominant choice for enterprise audit logging when:</p>
<ul>
<li><p>You need field-level change history with old and new values (mandatory for financial systems, healthcare records, and many GDPR scenarios)</p>
</li>
<li><p>Transactional consistency between the audit record and the data change is a hard requirement</p>
</li>
<li><p>Most or all of your persistence goes through EF Core (which is true for the majority of ASP.NET Core APIs)</p>
</li>
<li><p>You want a low-friction, automatic capture that works across all entities without modifying individual service methods</p>
</li>
<li><p>You are implementing a general-purpose audit trail for all data mutations, not a curated set of business events</p>
</li>
</ul>
<p>The <code>ChangeTracker</code> gives interceptors complete visibility into what EF Core is about to commit. Combined with <code>ICurrentUserService</code> (or similar) for resolving the actor identity, an interceptor can populate a normalized <code>AuditLog</code> table with virtually no changes to existing business logic.</p>
<p>One important design detail: interceptors run inside the request scope and have access to scoped DI services — but only if you register them correctly. Registering an interceptor that captures a scoped service as a singleton is one of the most common sources of <code>DbContext</code> disposal errors in production audit implementations.</p>
<h3>When EF Core Interceptors Are Not the Right Choice</h3>
<p>Interceptors fall short when:</p>
<ul>
<li><p>Your API uses Dapper or raw SQL for performance-critical paths — those writes are invisible to the <code>ChangeTracker</code></p>
</li>
<li><p>You need to capture <em>business intent</em>, not just field mutations. A property change from <code>Status = Pending</code> to <code>Status = Approved</code> is captured as a raw field update; the fact that it represents an approval decision by a specific role is not</p>
</li>
<li><p>You use soft deletes widely — the interceptor captures the <code>IsDeleted</code> field flip, not a semantically meaningful "deleted" event</p>
</li>
<li><p>Your audit requirement is for access patterns (who viewed a record), not just mutations — EF Core read operations do not pass through SaveChanges</p>
</li>
</ul>
<p>The coverage gap matters. If a significant portion of your data access bypasses EF Core, interceptors give you a partial audit log that may appear complete but silently misses entire categories of writes.</p>
<hr />
<h2>Approach 3: Domain Event Handlers</h2>
<h3>What It Captures</h3>
<p>Domain events express what happened in terms the business actually cares about: <code>OrderApproved</code>, <code>UserRoleEscalated</code>, <code>PaymentRefunded</code>, <code>AccountSuspended</code>. When these events are raised by domain entities and handled by dedicated audit handlers, the audit log captures business intent rather than raw data mutations.</p>
<p>Domain event audit handlers answer: <em>what business decision was made, by whom, in what context, and what was the outcome?</em></p>
<h3>When Domain Events Are the Right Choice</h3>
<p>Domain events are the superior choice for audit logging when:</p>
<ul>
<li><p>Your compliance requirement demands audit records at the business operation level (common in regulated industries where regulators want to know "who approved this loan" not "who set <code>ApprovalStatus</code> to <code>true</code>")</p>
</li>
<li><p>You are operating in a domain-rich model (DDD) where entities already raise events for state transitions</p>
</li>
<li><p>You need audit entries that are interpretable by non-technical reviewers (compliance officers, auditors, legal teams)</p>
</li>
<li><p>Your audit records feed downstream systems — event sourcing, SIEM platforms, external audit repositories — that expect business-level event payloads</p>
</li>
<li><p>You use <a href="https://codingdroplets.com/cqrs-and-mediatr-in-asp-net-core-enterprise-decision-guide">MediatR pipeline behaviors</a> and already have a cross-cutting audit behavior in place</p>
</li>
</ul>
<p>Domain events decouple the audit concern from both HTTP and persistence infrastructure. The audit handler is just another event consumer — it can write to a dedicated audit store, a separate database, an event stream, or a compliance platform without touching the business logic that raised the event.</p>
<h3>When Domain Events Are Not the Right Choice</h3>
<p>Domain events require investment to be effective. They are not the right primary audit strategy when:</p>
<ul>
<li><p>Your codebase uses an anemic domain model where entities are passive data containers — there are no events to raise</p>
</li>
<li><p>You need comprehensive coverage of all data mutations, not just curated business state transitions</p>
</li>
<li><p>You are working with a legacy codebase where introducing domain events requires broad refactoring</p>
</li>
<li><p>Field-level change history is required — domain events typically carry intent and outcome, not property-level diffs</p>
</li>
</ul>
<p>Relying solely on domain events also creates an inherent coverage risk: a developer adds a new write path without raising the corresponding event, and that write is silently excluded from the audit trail. Interceptors, by contrast, capture everything that flows through EF Core automatically.</p>
<hr />
<h2>Decision Matrix: Which Approach for Which Requirement?</h2>
<table>
<thead>
<tr>
<th>Requirement</th>
<th>Middleware</th>
<th>EF Core Interceptors</th>
<th>Domain Events</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP access log (who called what)</td>
<td>✅ Primary choice</td>
<td>❌ No signal</td>
<td>❌ No signal</td>
</tr>
<tr>
<td>Field-level change tracking</td>
<td>❌ No visibility</td>
<td>✅ Primary choice</td>
<td>⚠️ Only if events carry diffs</td>
</tr>
<tr>
<td>Transactional consistency with data</td>
<td>❌ Outside transaction</td>
<td>✅ Same transaction</td>
<td>⚠️ Depends on handler design</td>
</tr>
<tr>
<td>Business intent / semantics</td>
<td>❌ HTTP-only context</td>
<td>⚠️ Raw field changes only</td>
<td>✅ Primary choice</td>
</tr>
<tr>
<td>Dapper / raw SQL coverage</td>
<td>✅ Always captures HTTP</td>
<td>❌ No coverage</td>
<td>⚠️ Requires manual event raise</td>
</tr>
<tr>
<td>Failed request capture</td>
<td>✅ Always</td>
<td>❌ No SaveChanges</td>
<td>❌ No event raised</td>
</tr>
<tr>
<td>GDPR right-to-erasure support</td>
<td>⚠️ Actor access log only</td>
<td>✅ Full entity coverage</td>
<td>⚠️ Business level only</td>
</tr>
<tr>
<td>Non-technical audit reader</td>
<td>❌ Technical HTTP data</td>
<td>❌ Raw property diffs</td>
<td>✅ Business language</td>
</tr>
<tr>
<td>Low implementation friction</td>
<td>⚠️ Medium</td>
<td>✅ Low</td>
<td>⚠️ High (needs domain model)</td>
</tr>
</tbody></table>
<hr />
<h2>The Layered Strategy: Why Most Production Systems Use Two</h2>
<p>The most robust enterprise audit implementations combine approaches rather than picking one. A practical layered strategy for most ASP.NET Core APIs looks like:</p>
<ol>
<li><p><strong>EF Core interceptor as the default</strong> — captures all data mutations automatically with transactional consistency. This is the always-on safety net.</p>
</li>
<li><p><strong>Domain events for high-value business operations</strong> — <code>OrderPlaced</code>, <code>UserSuspended</code>, <code>PermissionGranted</code> — where compliance or legal teams need interpretable records in business language.</p>
</li>
<li><p><strong>Middleware for access auditing</strong> — separate from data mutation auditing, handles the "who viewed this endpoint" requirement when regulatory mandates require it.</p>
</li>
</ol>
<p>This layered approach means no single audit mechanism is a single point of failure. If a domain event is accidentally omitted from a new write path, the interceptor still captures the underlying data change. If the EF Core path is bypassed, the HTTP access log still records the attempted operation.</p>
<hr />
<h2>Anti-Patterns to Avoid</h2>
<p><strong>Logging audit records outside a transaction.</strong> Writing audit entries to a separate table in a different database context (or a different service call entirely) creates phantom audit entries when the main transaction rolls back. Always co-locate your EF Core audit writes in the same <code>DbContext</code> or use a transactional outbox if the audit store is external.</p>
<p><strong>Logging full request bodies everywhere.</strong> HTTP middleware that captures raw request bodies will capture PII, passwords, and payment card data unless you apply sensitive field masking. GDPR and PCI-DSS make this a compliance liability, not a feature.</p>
<p><strong>Registering scoped services as singletons in interceptors.</strong> EF Core interceptors registered as singletons cannot safely inject scoped services like <code>ICurrentUserService</code>. Use <code>AddInterceptors()</code> at <code>AddDbContext()</code> time with proper scoping, or resolve the current user from <code>IHttpContextAccessor</code> injected at the correct lifetime.</p>
<p><strong>Treating structured logging as an audit log.</strong> Application logs (Serilog, OpenTelemetry traces) are for operational visibility. Audit logs are a legal artifact. Using the same storage, format, and retention policy for both is a governance failure. Structured logs rotate and are pruned; audit records must be immutable and retained for defined regulatory periods.</p>
<hr />
<h2>What to Adopt Right Now</h2>
<p>For teams building a new ASP.NET Core API or auditing an existing one:</p>
<ul>
<li><p>Start with an EF Core SaveChanges interceptor — it gives immediate, broad coverage with minimal code change</p>
</li>
<li><p>Add domain events for the 5–10 business operations your compliance team actually asks about in audit reviews</p>
</li>
<li><p>Add middleware HTTP access logging only if your security or compliance requirement explicitly demands endpoint-level access records</p>
</li>
<li><p>Review your audit record retention policy and make sure audit tables are protected from application-layer deletes</p>
</li>
</ul>
<blockquote>
<p>☕ If this decision guide saved you time, <a href="https://buymeacoffee.com/codingdroplets">buy us a coffee</a> — it helps keep content like this coming.</p>
</blockquote>
<hr />
<h2>FAQ</h2>
<p><strong>What is the difference between audit logging and application logging in ASP.NET Core?</strong> Application logging (via Serilog, ILogger, OpenTelemetry) records operational events for debugging and observability — log levels rotate, data is pruned, and the format is optimised for developer tooling. Audit logging records legally significant changes to data or access by users — records must be immutable, tamper-evident, and retained for defined compliance periods. Storing both in the same sink under the same retention policy is a compliance risk.</p>
<p><strong>Can EF Core interceptors audit Dapper or raw SQL queries?</strong> No. EF Core's <code>SaveChangesInterceptor</code> only observes operations flowing through the EF Core <code>DbContext</code>. Writes executed via Dapper, <code>FormattableString</code> raw SQL outside of EF Core, or direct ADO.NET calls are invisible to the change tracker. If your codebase mixes EF Core with Dapper, your interceptor audit log will be incomplete unless you add explicit audit writes in the Dapper code paths.</p>
<p><strong>Should audit logs be stored in the same database as application data?</strong> For most mid-size APIs, co-locating audit tables in the same database is practical and makes transactional consistency straightforward. For high-compliance scenarios (financial, healthcare), a dedicated audit database with write-only application access — so the application can insert but not update or delete audit rows — provides stronger tamper-evidence. The tradeoff is operational complexity.</p>
<p><strong>How do I capture the current user identity inside an EF Core interceptor?</strong> Inject <code>IHttpContextAccessor</code> into the interceptor (resolving it from the service provider at <code>AddDbContext</code> time, not as a singleton property). Access <code>httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier)</code> to retrieve the authenticated user ID. If the interceptor runs in a background job context where there is no HTTP context, fall back to a dedicated <code>ICurrentUserService</code> that can return a system identity for non-request-scoped operations.</p>
<p><strong>What fields should every audit log record include?</strong> At minimum: entity type, entity primary key, operation type (Insert/Update/Delete), actor identity (user ID or system identity), timestamp (UTC), the changed properties with old and new values (for updates), and a correlation or request ID that ties the audit entry back to the HTTP request. For higher compliance requirements, add the source IP address, session identifier, and a hash of the record for tamper detection.</p>
<p><strong>Is there a performance cost to EF Core SaveChanges interceptors?</strong> Yes, but it is typically small relative to the network round-trip cost of the database write itself. The interceptor traverses the change tracker, which is already populated before SaveChanges. The dominant cost is the additional INSERT into the audit table — keep audit writes in the same transaction and batch them when multiple entities change in a single SaveChanges call. Avoid serialising full object graphs to JSON in the hot path; capture only the changed properties.</p>
<p><strong>Do domain events work for audit logging in a CQRS / MediatR setup?</strong> Yes, and this is one of the cleaner integration points. A MediatR pipeline behavior can intercept all commands, extract the actor and intent, and publish an audit event after the command handler succeeds. This gives you a consistent audit record for every state-changing command without modifying individual handlers. Combine it with an EF Core interceptor for the field-level detail and you have both business-intent and data-level coverage in the same system.</p>
]]></content:encoded></item><item><title><![CDATA[The ASP.NET Core Middleware Pipeline Checklist for .NET Teams]]></title><description><![CDATA[The ASP.NET Core middleware pipeline is one of the highest-leverage configuration decisions your team makes at startup. Get the ordering right, and your application is fast, secure, and easy to debug.]]></description><link>https://codingdroplets.com/aspnet-core-middleware-pipeline-checklist-dotnet-teams</link><guid isPermaLink="true">https://codingdroplets.com/aspnet-core-middleware-pipeline-checklist-dotnet-teams</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[Middleware]]></category><category><![CDATA[webdev]]></category><category><![CDATA[backend]]></category><category><![CDATA[#softwareengineering]]></category><category><![CDATA[General Programming]]></category><category><![CDATA[ASP.NET]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Wed, 03 Jun 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/212f4df5-1284-42cb-abc5-bf94b3259015.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The ASP.NET Core middleware pipeline is one of the highest-leverage configuration decisions your team makes at startup. Get the ordering right, and your application is fast, secure, and easy to debug. Get it wrong, and you quietly ship authorization bypasses, broken error handling, and performance regressions that only surface under production load.</p>
<p>This checklist gives your team a concrete, ordered reference for configuring the middleware pipeline in any ASP.NET Core application — whether you are working on a minimal API, a controller-based Web API, or a modular monolith. If you want to see the middleware pipeline wired correctly inside a complete, production-grade ASP.NET Core API — alongside CQRS, EF Core, validation, and JWT auth — the <a href="https://aspnetcoreapi.codingdroplets.com/">Zero to Production course</a> covers the full <code>Program.cs</code> setup in context, so everything is connected from day one.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<p>The full reference implementation — including annotated <code>Program.cs</code> files for both minimal and controller-based APIs, custom middleware samples, and edge-case handling — is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, with the source code structured to map directly to what enterprise teams actually ship.</p>
<hr />
<h2>Why Middleware Ordering Matters More Than You Think</h2>
<p>Middleware in ASP.NET Core executes as a chain. Each component runs in the order it is registered, processes the request, passes control to the next component, and then runs again on the way back through the response. This bidirectional flow means that a middleware registered <em>after</em> another can never affect how that earlier middleware processes the request — only how it processes the response.</p>
<p>This has real security and correctness consequences:</p>
<ul>
<li><p>Exception handlers registered too late will miss exceptions thrown by earlier middleware</p>
</li>
<li><p>Authentication registered after routing will fail silently on some routes</p>
</li>
<li><p>Rate limiting applied before routing cannot partition by endpoint</p>
</li>
</ul>
<p>The checklist below follows the recommended registration order for a production ASP.NET Core API. Each item includes the reasoning, so your team knows <em>why</em> it goes there — not just that it does.</p>
<hr />
<h2>The 12-Point ASP.NET Core Middleware Pipeline Checklist</h2>
<h3>✅ 1. Exception Handling Goes First — Always</h3>
<p><code>UseExceptionHandler()</code> (or a custom <code>IExceptionHandler</code> implementation for .NET 8+) must be the <strong>first</strong> middleware registered in the pipeline. It can only catch exceptions thrown by middleware that runs <em>after</em> it. Placing it anywhere else creates a silent blind spot.</p>
<p>In development, <code>UseDeveloperExceptionPage()</code> can replace <code>UseExceptionHandler()</code> to surface stack traces — but never ship it to production.</p>
<p><strong>Why it matters:</strong> A middleware registered at position 5 cannot catch an unhandled exception thrown at position 3. The exception propagates past the exception handler entirely.</p>
<p>For a detailed breakdown of how <code>IExceptionHandler</code> differs from traditional exception middleware and when to use each, see <a href="https://codingdroplets.com/aspnet-core-middleware-mistakes-and-fixes">7 Common ASP.NET Core Middleware Mistakes and How to Fix Them</a>.</p>
<hr />
<h3>✅ 2. HSTS and HTTPS Redirection Come Second</h3>
<p><code>UseHsts()</code> and <code>UseHttpsRedirection()</code> belong near the top, after exception handling but before any request-processing middleware. HSTS instructs browsers to only connect over HTTPS for a defined period. HTTPS redirection catches plain HTTP requests and issues a 301 before anything else has a chance to read request data over an unencrypted channel.</p>
<p>Skip <code>UseHsts()</code> in development (it persists in the browser and breaks local HTTP testing). The standard pattern uses an environment check: call <code>UseHsts()</code> only when <code>app.Environment.IsProduction()</code>.</p>
<hr />
<h3>✅ 3. Static Files Before Routing and Auth</h3>
<p><code>UseStaticFiles()</code> short-circuits the pipeline for static asset requests. There is no point running routing, authentication, or authorization for a request that just wants <code>site.css</code>. Register it before the routing middleware to keep those requests cheap.</p>
<p>If your API serves no static files, skip this entirely — unnecessary middleware registrations add measurable overhead at scale.</p>
<hr />
<h3>✅ 4. Routing Before Auth and Rate Limiting</h3>
<p><code>UseRouting()</code> must be registered before any middleware that needs to know which endpoint is being targeted. This includes authentication, authorization, rate limiting, CORS, and output caching when you want endpoint-aware policies.</p>
<p>Without routing resolved, middleware like <code>UseRateLimiter()</code> cannot apply per-endpoint policies — it falls back to global policies only, which is usually not what you want.</p>
<p><strong>Note:</strong> In .NET 6 and later, <code>UseRouting()</code> is often called implicitly when you call <code>app.MapControllers()</code> or <code>app.MapGet(...)</code>. If you rely on implicit routing, be aware that any middleware registered before those calls runs <em>before</em> the route is resolved.</p>
<hr />
<h3>✅ 5. CORS Middleware Before Authentication</h3>
<p><code>UseCors()</code> must be registered before <code>UseAuthentication()</code> and <code>UseAuthorization()</code>. A preflight OPTIONS request from a browser will not carry auth credentials — if your auth middleware runs first and rejects the unauthenticated OPTIONS request with 401, the CORS handshake fails before the browser even sends the real request.</p>
<p>Register the default CORS policy with <code>UseCors()</code> here, or reference a named policy if you have multiple policies configured. Endpoint-level <code>[EnableCors("PolicyName")]</code> attributes still need the global middleware registered in the pipeline.</p>
<hr />
<h3>✅ 6. Authentication Before Authorization — Always</h3>
<p><code>UseAuthentication()</code> populates <code>HttpContext.User</code> from the incoming token or cookie. <code>UseAuthorization()</code> reads <code>HttpContext.User</code> to evaluate policies and roles. If they are swapped, every request arrives at the authorization check with an unauthenticated principal, and <code>[Authorize]</code> attributes silently fail.</p>
<p>This ordering mistake is one of the most common sources of "everything returns 401" bug reports on Stack Overflow. The ASP.NET Core docs are explicit about this ordering, but it is easy to miss in a fast-growing <code>Program.cs</code>.</p>
<hr />
<h3>✅ 7. Rate Limiting After Routing, After Auth (Usually)</h3>
<p><code>UseRateLimiter()</code> should be registered after <code>UseRouting()</code> and typically after <code>UseAuthentication()</code>. This allows you to partition limits by authenticated user identity (e.g., per-user or per-subscription-tier policies) rather than just by IP address — which is far more meaningful for most APIs.</p>
<p>If you only need IP-based rate limiting and want it as a pure traffic filter before any app logic runs, you can move it earlier — but you lose the ability to read user claims for partitioning.</p>
<hr />
<h3>✅ 8. Output Caching After Auth, Before Endpoint Middleware</h3>
<p><code>UseOutputCaching()</code> should run after <code>UseAuthentication()</code> and <code>UseAuthorization()</code>, so that cached responses are only served for requests that have already been authorized. Caching authenticated responses before authorization is checked can serve one user's data to another.</p>
<p>Tag-based invalidation, vary-by-query, and vary-by-header policies should be configured at registration time using <code>AddOutputCache()</code> in the service collection, then referenced by name at the endpoint level via <code>[OutputCache(PolicyName = "...")]</code>.</p>
<hr />
<h3>✅ 9. Custom Middleware in the Right Place</h3>
<p>Custom middleware components — correlation ID injection, request logging, tenant resolution, feature flag evaluation — need to be placed deliberately:</p>
<ul>
<li><p><strong>Correlation ID / request context:</strong> Register early, after exception handling, so the correlation ID is available in all subsequent log entries including error logs</p>
</li>
<li><p><strong>Tenant resolution:</strong> Register after routing (so the route is known) but before any data access middleware that needs the tenant context</p>
</li>
<li><p><strong>Request/response logging:</strong> Register early but after exception handling, so the logger captures both successful and error responses</p>
</li>
<li><p><strong>Feature flags:</strong> Register after routing and auth if the flag check is per-user; register before routing if it is a kill-switch for the entire endpoint</p>
</li>
</ul>
<p>The rule: place custom middleware where it can <em>see</em> everything it needs from earlier middleware and <em>affect</em> everything that runs after it.</p>
<hr />
<h3>✅ 10. Map Endpoints Last</h3>
<p><code>app.MapControllers()</code>, <code>app.MapGet(...)</code>, <code>app.MapHub(...)</code>, and similar calls should come at the end of the pipeline configuration. These define the terminal middleware — the actual endpoints that handle the request. Everything registered before them acts as a pipeline gate or cross-cutting concern.</p>
<p>Adding a middleware <em>after</em> <code>app.MapControllers()</code> has no effect on controller requests because the request has already been handled before the middleware has a chance to run on the inbound path.</p>
<hr />
<h3>✅ 11. Review Your <code>Program.cs</code> After Every Major Dependency Addition</h3>
<p>Every time your team adds a new NuGet package that registers middleware (rate limiting libraries, telemetry SDKs, feature flag providers, API gateway SDKs), the package documentation may specify a required registration order. Blindly appending <code>app.UseNewThing()</code> at the bottom is a common source of subtle bugs.</p>
<p>Make it a habit to review the full middleware registration sequence in code review. If your team uses a <a href="https://codingdroplets.com/aspnet-core-api-code-review-checklist-dotnet-teams">code review checklist</a>, add middleware ordering as a line item.</p>
<hr />
<h3>✅ 12. Audit Against the Recommended Pipeline for Your Scenario</h3>
<p>Microsoft publishes a canonical recommended middleware order for different application types. The most common one for Web APIs is:</p>
<ol>
<li><p><code>UseExceptionHandler</code> / <code>UseDeveloperExceptionPage</code></p>
</li>
<li><p><code>UseHsts</code></p>
</li>
<li><p><code>UseHttpsRedirection</code></p>
</li>
<li><p><code>UseStaticFiles</code></p>
</li>
<li><p><code>UseRouting</code></p>
</li>
<li><p><code>UseCors</code></p>
</li>
<li><p><code>UseAuthentication</code></p>
</li>
<li><p><code>UseAuthorization</code></p>
</li>
<li><p><code>UseRateLimiter</code></p>
</li>
<li><p><code>UseOutputCaching</code></p>
</li>
<li><p><code>MapControllers</code> / <code>MapGet</code> / endpoint mapping</p>
</li>
</ol>
<p>Compare your <code>Program.cs</code> against this sequence regularly — especially after refactoring or upgrading major framework versions. For a broader view of what a production-ready ASP.NET Core API needs beyond middleware, see <a href="https://codingdroplets.com/aspnet-core-production-readiness-checklist-dotnet-teams">The 12-Point ASP.NET Core Production Readiness Checklist for .NET Teams</a>.</p>
<hr />
<h2>The Items Teams Get Wrong Most Often</h2>
<p>Based on the patterns we see repeatedly, these three items cause the most production incidents:</p>
<p><strong>Exception handler registered too late</strong> — Registering <code>UseExceptionHandler()</code> after authentication means auth exceptions (like invalid token signatures or expired tokens from the JWT bearer handler) propagate unhandled and return a plain 500 with no <code>ProblemDetails</code> body.</p>
<p><strong>CORS registered after authentication</strong> — Browser preflight requests fail with 401 before the CORS handshake completes, causing the client to see a confusing CORS error when the real issue is middleware order.</p>
<p><strong>Rate limiting placed before routing</strong> — IP-based limits work, but per-user partitioning silently falls back to anonymous handling because the user identity is not yet available. The limits appear to apply, but the partitioning logic never fires.</p>
<hr />
<h2>What About <code>IMiddleware</code> vs <code>RequestDelegate</code>?</h2>
<p>If your team writes custom middleware, prefer the <code>IMiddleware</code> interface (registered with <code>AddTransient&lt;T&gt;()</code> in the service collection) over the convention-based <code>RequestDelegate</code> approach for any middleware that takes dependencies. <code>IMiddleware</code> gets its dependencies from DI on each request, making it scoped-safe. Convention-based middleware receives its dependencies in the constructor, which means they are resolved once at startup — creating the classic captive dependency problem for scoped services.</p>
<p>For a complete reference on the idempotency middleware pattern in ASP.NET Core — including the full source code — the <a href="https://github.com/codingdroplets/dotnet-api-idempotency-middleware">dotnet-api-idempotency-middleware</a> repo shows how a production-grade custom middleware handles dependency injection, request body hashing, and distributed cache integration.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<hr />
<h2>FAQ</h2>
<h3>What is the correct middleware order in ASP.NET Core?</h3>
<p>The recommended order for a Web API is: exception handling first, then HSTS and HTTPS redirection, static files, routing, CORS, authentication, authorization, rate limiting, output caching, and finally endpoint mapping. This order ensures each component has access to the context it needs and that security gates run before business logic.</p>
<h3>Why must <code>UseAuthentication</code> come before <code>UseAuthorization</code>?</h3>
<p><code>UseAuthentication</code> populates <code>HttpContext.User</code> by validating the incoming token or cookie. <code>UseAuthorization</code> reads <code>HttpContext.User</code> to evaluate policies. If authorization runs first, it reads an empty or unauthenticated principal and rejects the request with 401, regardless of whether the credentials in the request are valid.</p>
<h3>Why does CORS need to be registered before authentication?</h3>
<p>Browser preflight (OPTIONS) requests do not carry credentials. If authentication middleware runs before CORS and rejects the unauthenticated OPTIONS request, the browser never receives the CORS headers it needs to proceed, causing a CORS error on the client — even though the actual request would have been authorised.</p>
<h3>Can I register rate limiting before authentication in ASP.NET Core?</h3>
<p>You can, but with a trade-off. Rate limiting before authentication can only partition by IP address or other request properties. If you need per-user or per-subscription-tier limits, rate limiting must run after authentication so it can read user identity from <code>HttpContext.User</code>.</p>
<h3>Does middleware order matter for output caching?</h3>
<p>Yes. Output caching should run after authentication and authorization to avoid serving a cached authenticated response to a different user. If caching runs before the auth check, a response cached for user A could be returned to user B on the next matching request.</p>
<h3>What happens if I register a middleware after <code>MapControllers()</code>?</h3>
<p>Middleware registered after <code>MapControllers()</code> will never execute on the inbound request path for controller actions, because <code>MapControllers()</code> defines the terminal middleware. The request is handled before the later-registered middleware runs inbound. Any code you intended to run as pre-processing will be silently skipped.</p>
<h3>How do I debug middleware ordering issues in development?</h3>
<p>Use the <code>Microsoft.AspNetCore.MiddlewareAnalysis</code> NuGet package in development. It logs each middleware component in pipeline order with its execution timing, making it easy to see both the ordering and where a request is short-circuited. Pair this with structured logging using Serilog's <code>UseSerilogRequestLogging()</code> to capture the full request lifecycle in your development logs.</p>
]]></content:encoded></item><item><title><![CDATA[Sieve vs Gridify in .NET: Which Filtering Library to Use?]]></title><description><![CDATA[Every production ASP.NET Core API eventually needs the same thing: a way for clients to filter a list of resources, sort the results, and page through them efficiently. The naive approach — hand-rolli]]></description><link>https://codingdroplets.com/sieve-vs-gridify-odata-dotnet-filtering-sorting</link><guid isPermaLink="true">https://codingdroplets.com/sieve-vs-gridify-odata-dotnet-filtering-sorting</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[WebAPI]]></category><category><![CDATA[sieve]]></category><category><![CDATA[Gridify]]></category><category><![CDATA[OData]]></category><category><![CDATA[API Filtering]]></category><category><![CDATA[sorting]]></category><category><![CDATA[Pagination]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Tue, 02 Jun 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/c469b934-9c71-45ae-bfb0-76a9d4c8a56c.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every production ASP.NET Core API eventually needs the same thing: a way for clients to filter a list of resources, sort the results, and page through them efficiently. The naive approach — hand-rolling query string parsing and building <code>IQueryable</code> chains yourself — works once, then becomes a maintenance burden the moment a second endpoint needs the same treatment. That is where libraries like <strong>Sieve</strong>, <strong>Gridify</strong>, and <strong>OData</strong> step in. Choosing between them is not obvious. Each solves the same core problem with different philosophies, trade-offs, and ceilings — and picking the wrong one shows up months later as hard-to-extend query logic or an API surface you did not intend to expose. The full production implementation — with sorting, filtering, and a working <code>PagedResult&lt;T&gt;</code> wired into a real API — is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, ready to drop into your next project.</p>
<p>Seeing filtering and sorting work in isolation is useful. Understanding how they compose with EF Core queries, pagination wrappers, and <code>IQueryable</code> chaining inside a complete production API is what separates a working prototype from a production-ready codebase. <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 4 of the Zero to Production course</a> covers exactly this — filtering, sorting, and cursor-based pagination all wired together in the same codebase.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<h2>What These Libraries Actually Solve</h2>
<p>Before reaching for any library, it helps to understand what problem they are all solving at the same level. A client hits <code>GET /products?name=widget&amp;price&gt;10&amp;sort=name&amp;page=2&amp;pageSize=25</code>. Somewhere in your API, you need to:</p>
<ol>
<li><p>Parse the query string into filtering predicates</p>
</li>
<li><p>Translate those predicates into an <code>IQueryable</code> expression tree</p>
</li>
<li><p>Apply a sort order to the same query</p>
</li>
<li><p>Apply <code>Skip</code> and <code>Take</code> for pagination</p>
</li>
</ol>
<p>All three libraries — Sieve, Gridify, and OData — handle this pipeline. They differ in how much control the client gets, how much configuration the developer must provide, and what the API contract looks like.</p>
<hr />
<h2>Sieve: Attribute-Driven, Explicit Control</h2>
<p>Sieve takes a developer-first approach. You explicitly mark which properties on your domain entities are filterable and sortable using <code>[Sieve(CanFilter = true, CanSort = true)]</code> attributes. Nothing is filterable unless you explicitly opt in.</p>
<p>The query string convention is simple: <code>?filters=Name@=Widget&amp;sorts=Price&amp;page=1&amp;pageSize=20</code>. The <code>@=</code> operator is a case-insensitive contains, <code>==</code> is exact match, <code>&gt;</code> and <code>&lt;</code> are range operators. The client gets a well-defined, intentional surface.</p>
<p><strong>What Sieve does well:</strong></p>
<ul>
<li><p>The attribute approach means the server is always in control. A client cannot filter on a field you have not marked — there is no accidental data leakage through queries.</p>
</li>
<li><p>Works cleanly with EF Core <code>IQueryable</code> — the filtering, sorting, and pagination all run database-side before materialisation.</p>
</li>
<li><p>The operator syntax is readable and easy to document. <code>?filters=Status==Active,CreatedAt&gt;2026-01-01</code> reads clearly.</p>
</li>
<li><p>Custom sort/filter logic is supported via override methods in a custom <code>SieveProcessor</code>.</p>
</li>
<li><p>The library is mature, well-tested, and the Biarity/Sieve GitHub repository has been the standard reference for this pattern in the .NET ecosystem for years.</p>
</li>
</ul>
<p><strong>Where Sieve shows friction:</strong></p>
<ul>
<li><p>The attribute approach has a hidden cost: your domain entities start carrying infrastructure concerns. If you care about keeping your domain layer clean, decorating entity properties with <code>[Sieve]</code> attributes feels like a leak. You can work around this using a fluent configuration override, but it adds setup.</p>
</li>
<li><p>Multi-tenant scenarios where different clients should see different filterable fields require custom processors.</p>
</li>
<li><p>The library's maintainer has had periods of low activity — check the issue tracker before committing to it in a long-lived codebase.</p>
</li>
<li><p>Complex nested filtering (filtering on a child collection's property) requires custom handling.</p>
</li>
</ul>
<p><strong>When to reach for Sieve:</strong></p>
<ul>
<li><p>You want a simple, explicit opt-in model</p>
</li>
<li><p>The team is comfortable decorating entities with attributes</p>
</li>
<li><p>API clients are mostly internal and the query surface is predictable</p>
</li>
<li><p>You want tight control over what is exposed via query parameters</p>
</li>
</ul>
<hr />
<h2>Gridify: Fast, Convention-Based, LINQ-First</h2>
<p>Gridify takes a different stance. It is a lightweight library that parses a filtering string expression into an <code>IQueryable&lt;T&gt;</code> expression tree dynamically, without requiring attribute decorations on your entities. The client sends <code>?filter=name=Widget AND price&gt;10&amp;orderBy=name asc&amp;page=2&amp;pageSize=25</code>.</p>
<p>The parsing is expression-tree based, which keeps it close to native LINQ performance. Independent benchmarks on the Gridify documentation show it operating near the speed of a hand-written LINQ expression for common filter operations — a meaningful advantage over reflection-heavy alternatives.</p>
<p><strong>What Gridify does well:</strong></p>
<ul>
<li><p>Zero attribute decoration required — works against any POCO, DTO, or entity</p>
</li>
<li><p>Clean expression-tree compilation means the filtering translates directly to SQL via EF Core with no N+1 risks</p>
</li>
<li><p>The filter syntax is natural and client-friendly: <code>name=Widget AND price&gt;10 OR category=Electronics</code></p>
</li>
<li><p>Mapper classes (<code>GridifyMapper&lt;T&gt;</code>) let you define which fields are mappable without touching entity classes — keeping domain and API surface separate</p>
</li>
<li><p>Actively maintained (alirezanet/Gridify on GitHub) with regular releases</p>
</li>
</ul>
<p><strong>Where Gridify shows friction:</strong></p>
<ul>
<li><p>The filter syntax (<code>name=Widget AND price&gt;10</code>) is more expressive than Sieve, which is a double-edged sword — more powerful queries also means a larger attack surface if you are not careful about what is exposed through the mapper</p>
</li>
<li><p>Documentation for advanced scenarios (nested relationships, custom type converters) is thinner than Sieve</p>
</li>
<li><p>The default behaviour allows filtering on all public properties of the target type unless you explicitly configure a mapper — the inverse of Sieve's opt-in model</p>
</li>
</ul>
<p><strong>When to reach for Gridify:</strong></p>
<ul>
<li><p>You want clean entity classes without attribute decoration</p>
</li>
<li><p>Performance is a priority and you want expression-tree-compiled filters</p>
</li>
<li><p>API clients need a flexible, expressive filter syntax</p>
</li>
<li><p>The team already works with DTOs as the query surface (not raw entities)</p>
</li>
</ul>
<hr />
<h2>OData: Protocol-Level, Maximum Power, Maximum Overhead</h2>
<p>OData is not a library — it is a standardised protocol for building queryable APIs. The ASP.NET Core implementation (<code>Microsoft.AspNetCore.OData</code>) exposes the full OData query specification: <code>\(filter</code>, <code>\)orderby</code>, <code>\(select</code>, <code>\)expand</code>, <code>\(top</code>, <code>\)skip</code>, <code>$count</code>.</p>
<p>What this buys you is enormous power: clients can select individual fields, expand navigation properties (joins), apply nested filters on related entities, and get precise metadata about the query result. Tooling in Power BI, Excel, and enterprise reporting suites understand OData natively.</p>
<p><strong>What OData does well:</strong></p>
<ul>
<li><p>The most capable query language of the three — clients can express queries that would require multiple round-trips with the other libraries</p>
</li>
<li><p><code>$expand</code> enables client-side relationship traversal without building custom endpoints</p>
</li>
<li><p>Native tooling integration — BI tools, Excel data connections, and enterprise integration platforms understand OData out of the box</p>
</li>
<li><p>Standardised, versioned protocol — behaviour is documented, predictable, and not library-specific</p>
</li>
<li><p>The Microsoft OData library for ASP.NET Core is production-grade and maintained by the OData team</p>
</li>
</ul>
<p><strong>Where OData shows friction:</strong></p>
<ul>
<li><p>Adds significant API surface area. A poorly configured OData endpoint can expose more of your data model than intended.</p>
</li>
<li><p>The <code>$expand</code> feature can generate expensive database queries if Eager Loading is not carefully controlled</p>
</li>
<li><p>OData's EDM (Entity Data Model) setup requires non-trivial configuration and tightly couples your API to your EF Core model</p>
</li>
<li><p>Response envelopes change shape (OData wraps results in <code>@odata.context</code>, <code>value</code>, etc.) — clients and tests must handle this format</p>
</li>
<li><p>For most public REST APIs, OData is far more than what is needed and the overhead shows in onboarding cost</p>
</li>
<li><p>The learning curve is steep. Senior engineers encountering OData for the first time still take meaningful time to understand <code>\(metadata</code>, <code>\)expand</code> semantics, and query validation configuration</p>
</li>
</ul>
<p><strong>When OData makes sense:</strong></p>
<ul>
<li><p>Internal enterprise APIs consumed by BI tools, Power BI, or Excel</p>
</li>
<li><p>APIs that need to support ad-hoc querying from non-developer consumers</p>
</li>
<li><p>Integration scenarios where OData compatibility is a hard requirement</p>
</li>
</ul>
<hr />
<h2>Side-by-Side Comparison</h2>
<table>
<thead>
<tr>
<th></th>
<th><strong>Sieve</strong></th>
<th><strong>Gridify</strong></th>
<th><strong>OData</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Setup effort</strong></td>
<td>Low-medium</td>
<td>Low</td>
<td>High</td>
</tr>
<tr>
<td><strong>Client syntax</strong></td>
<td><code>?filters=Name==x</code></td>
<td><code>?filter=Name=x AND Price&gt;10</code></td>
<td><code>?$filter=Name eq 'x'</code></td>
</tr>
<tr>
<td><strong>Opt-in/out model</strong></td>
<td>Attribute opt-in</td>
<td>Mapper-controlled</td>
<td>EDM-defined</td>
</tr>
<tr>
<td><strong>Entity decoration required</strong></td>
<td>Yes (or custom processor)</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td><strong>Performance</strong></td>
<td>Good (IQueryable)</td>
<td>Excellent (expression tree)</td>
<td>Good (but $expand risks)</td>
</tr>
<tr>
<td><strong>Complex filtering</strong></td>
<td>Limited without custom code</td>
<td>Strong</td>
<td>Very strong</td>
</tr>
<tr>
<td><strong>Client tooling support</strong></td>
<td>None</td>
<td>None</td>
<td>Power BI, Excel, etc.</td>
</tr>
<tr>
<td><strong>Maintenance status</strong></td>
<td>Stable, periodic updates</td>
<td>Active</td>
<td>Active (Microsoft)</td>
</tr>
<tr>
<td><strong>API surface control</strong></td>
<td>High</td>
<td>Medium (with mapper)</td>
<td>Low without careful config</td>
</tr>
<tr>
<td><strong>Best for</strong></td>
<td>Internal APIs, explicit control</td>
<td>REST APIs, clean entities</td>
<td>Enterprise BI / ad-hoc query</td>
</tr>
</tbody></table>
<hr />
<h2>Does Your ASP.NET Core API Need a Library at All?</h2>
<h3>What Is the Best Approach for Filtering in ASP.NET Core?</h3>
<p>For small APIs with three to five filterable endpoints, hand-rolled <code>IQueryable</code> filtering is perfectly reasonable. A typed <code>ProductQueryParams</code> record with explicit filter properties — price range, category, name — gives you full control with zero dependency. The cost shows up at scale: fifteen endpoints, fifty filterable fields, and inconsistent query string conventions across the team.</p>
<p>At that point, a library removes the duplication, standardises the query contract, and keeps individual endpoints focused on authorisation and business logic rather than filter plumbing.</p>
<hr />
<h2>The Clear Winner for Most Teams</h2>
<p><strong>Sieve</strong> is the right default for teams building internal or semi-public REST APIs where you want deliberate, audited control over what is filterable. The attribute model is explicit — every filterable field is a conscious decision.</p>
<p><strong>Gridify</strong> is the better choice when entity cleanliness matters, performance is a priority, or API clients need an expressive filter syntax. The mapper pattern keeps your domain layer free of infrastructure concerns while still giving you tight control.</p>
<p><strong>OData</strong> is the right call only when enterprise tooling integration is a hard requirement or you are building an internal data API consumed by BI tools. For most REST APIs, OData is significant overhead for a problem that Sieve or Gridify solve more cleanly.</p>
<p>The recommendation: start with <strong>Gridify</strong> for new projects. The mapper pattern scales cleanly, the syntax is intuitive, and the near-native performance means you are not paying a penalty as query complexity grows. Switch to Sieve if your team strongly prefers the attribute-based opt-in model. Only reach for OData when the enterprise tooling requirement is explicit.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<hr />
<h2>FAQ</h2>
<p><strong>Is Sieve still actively maintained in 2026?</strong> The core library (Biarity/Sieve) has had periods of reduced activity. The fundamentals are stable but check the GitHub issue tracker for any recent .NET version compatibility concerns before committing to a new project. Community forks are available if the primary repo falls behind.</p>
<p><strong>Can Gridify be used safely with EF Core without causing N+1 queries?</strong> Yes — Gridify generates expression trees that EF Core translates directly to SQL. Filtering, sorting, and pagination all execute server-side before materialisation. The key is to ensure you are passing an <code>IQueryable&lt;T&gt;</code> (not an <code>IEnumerable&lt;T&gt;</code>) to the Gridify processor, so no premature client-side evaluation occurs.</p>
<p><strong>Does OData work with Minimal APIs in ASP.NET Core?</strong> OData support in ASP.NET Core historically required Controllers and was not compatible with Minimal APIs. As of recent Microsoft.AspNetCore.OData releases, partial support has been added, but the integration is not as clean as with controller-based APIs. For OData use cases, Controllers remain the more reliable choice.</p>
<p><strong>What is the difference between Sieve's</strong> <code>@=</code> <strong>operator and</strong> <code>==</code><strong>?</strong><code>==</code> is an exact match (case-sensitive by default, configurable). <code>@=</code> is a case-insensitive contains — equivalent to <code>LIKE '%value%'</code> in SQL. Sieve also supports <code>_=</code> (starts with), <code>=@</code> (ends with), and <code>_-=</code> (case-insensitive starts with), giving clients a useful range of string matching without exposing raw SQL.</p>
<p><strong>Can I use Sieve or Gridify with non-EF Core data sources?</strong> Both libraries work against any <code>IQueryable&lt;T&gt;</code> implementation. In practice, you can use them with Dapper by materialising data to a list first, but you lose the database-side execution benefit — filtering will happen in memory. For non-relational or non-IQueryable data sources, manual filtering logic is often the better trade-off.</p>
<p><strong>Which library handles multi-column sorting best?</strong> All three support multi-column sorting. Sieve uses a comma-separated <code>sorts</code> parameter (<code>?sorts=Name,-Price</code> where <code>-</code> prefix means descending). Gridify uses <code>?orderBy=Name asc, Price desc</code>. OData uses <code>?$orderby=Name asc, Price desc</code>. Gridify's syntax is arguably the most readable for multi-column cases.</p>
<p><strong>Is Gridify's filter syntax safe from injection attacks?</strong> Gridify parses filter strings into expression trees — it does not produce raw SQL strings. The expression tree is passed to the LINQ provider, which handles parameterisation. As long as you are using a mapper to restrict which properties are queryable, there is no SQL injection risk. Always define a <code>GridifyMapper&lt;T&gt;</code> rather than relying on the default open mapping.</p>
]]></content:encoded></item><item><title><![CDATA[The Pipes and Filters Pattern in ASP.NET Core: When to Use It and How]]></title><description><![CDATA[The Pipes and Filters pattern is one of those architectural ideas that shows up everywhere in .NET — in ASP.NET Core's middleware stack, in MediatR's pipeline behaviors, in System.Threading.Channels p]]></description><link>https://codingdroplets.com/pipes-filters-pattern-aspnet-core</link><guid isPermaLink="true">https://codingdroplets.com/pipes-filters-pattern-aspnet-core</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[architecture]]></category><category><![CDATA[design patterns]]></category><category><![CDATA[Pipeline]]></category><category><![CDATA[MediatR]]></category><category><![CDATA[enterprise]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Tue, 02 Jun 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/7c26a5b8-4a55-43c0-8f51-1dc56f820572.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The Pipes and Filters pattern is one of those architectural ideas that shows up everywhere in .NET — in ASP.NET Core's middleware stack, in MediatR's pipeline behaviors, in <code>System.Threading.Channels</code> processing loops — yet most teams never deliberately name it when they use it, or reach for it deliberately when they need it. This guide closes that gap.</p>
<p>If you want to see how these patterns work inside a complete production-grade ASP.NET Core codebase — with pipeline behaviors, background processing stages, and all of it wired together — the full implementation is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, with annotated source code that maps directly to what enterprise teams actually ship.</p>
<p>Understanding processing pipelines in isolation is useful — seeing them as part of a complete production API alongside CQRS, Clean Architecture, and background task orchestration is what makes the design decisions click. That is exactly what Chapter 12 of the <a href="https://aspnetcoreapi.codingdroplets.com/">ASP.NET Core Web API: Zero to Production course</a> covers — with source code you can run immediately.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<h2>What Problem Does the Pipes and Filters Pattern Solve?</h2>
<p>The Pipes and Filters pattern structures a processing task as a sequence of independent, composable stages — each stage (a <em>filter</em>) receives input, transforms or evaluates it, and passes the result to the next stage via a <em>pipe</em>. No stage needs to know about the stages before or after it. Each stage has a single, well-defined responsibility.</p>
<p>The pattern addresses a recurring problem: when a task involves multiple distinct processing steps that can be independently tested, reordered, enabled, or replaced without the other steps knowing. Tangled, monolithic service methods that do validation, enrichment, transformation, and persistence in one continuous flow are the symptom this pattern is designed to fix.</p>
<p>In ASP.NET Core, the middleware pipeline is a first-class, built-in implementation of Pipes and Filters. Every <code>Use</code>, <code>UseWhen</code>, or <code>Map</code> call registers a filter. The request flows through them in registration order. Each middleware calls <code>next()</code> to pass control downstream and can act again on the way back. That is the pattern — applied at the HTTP layer.</p>
<h2>The Two Faces of Pipes and Filters in .NET</h2>
<p>In practice, the pattern shows up in two distinct forms inside .NET applications.</p>
<h3>The Request Pipeline (Framework-Level)</h3>
<p>ASP.NET Core's <code>IMiddleware</code> and <code>RequestDelegate</code> chain are the canonical implementation. The framework owns the pipe; you implement the filters. You add cross-cutting concerns — authentication, rate limiting, correlation ID propagation, exception handling — as discrete middleware components.</p>
<p>The filter in this context is <code>IMiddleware</code> or the convention-based <code>InvokeAsync(HttpContext context, RequestDelegate next)</code> signature. Each component has access to <code>HttpContext</code>, can short-circuit the pipeline (by not calling <code>next</code>), and can observe or modify both the request and the response.</p>
<h3>Custom Domain Pipelines (Application-Level)</h3>
<p>The second form appears when you build your own pipeline for domain-level processing: a document ingestion flow that validates → enriches → classifies → persists; a payment workflow that checks limits → applies fraud rules → calls the processor → emits an event; a data transformation chain that normalises → validates → maps → stores.</p>
<p>Here you implement the pipe yourself, and MediatR's <code>IPipelineBehavior&lt;TRequest, TResponse&gt;</code> is the most natural way to do it in a Clean Architecture + CQRS setup. Each behavior is a filter; MediatR is the pipe. <code>System.Threading.Channels</code> serves the same role in producer-consumer processing loops where you need backpressure, concurrency control, and stage isolation.</p>
<h2>When to Use the Pipes and Filters Pattern</h2>
<p>Apply the pattern when the following conditions hold:</p>
<p><strong>The task has multiple distinct stages.</strong> If a single service method performs validation, enrichment, transformation, and persistence as one sequential block, each step is an implicit filter. Making them explicit — and decoupled — yields components you can test independently and reorder without side effects.</p>
<p><strong>Stages have different operational characteristics.</strong> Some stages are CPU-bound (parsing, hashing), others are I/O-bound (database writes, external API calls), others are stateless (validation). When stages differ in latency profile, error handling strategy, or retry policy, separating them into discrete components lets you apply the right operational model to each.</p>
<p><strong>The pipeline needs to evolve without coordinated changes.</strong> New business requirements often mean new stages — a compliance check, a new enrichment source, an audit event. A Pipes and Filters structure absorbs new stages at the insertion point without modifying the existing stages around them.</p>
<p><strong>Cross-cutting concerns must not bleed into business logic.</strong> Logging, correlation tracking, caching, validation, and performance measurement belong in the pipeline, not in the handlers. Pipeline behaviors (MediatR) and middleware (ASP.NET Core) enforce this boundary structurally — it is architecturally impossible for a handler to skip the logging behavior if the behavior wraps every command.</p>
<p><strong>Stages can be reused across multiple pipelines.</strong> A fraud-check stage that reads from a shared rules engine, or a normalisation stage that applies canonical formatting, should be written once and composed into any pipeline that needs it — not copy-pasted.</p>
<h2>When Not to Use It</h2>
<p>The pattern introduces indirection. When that indirection does not pay off, avoid it.</p>
<p><strong>When the task is genuinely simple.</strong> A CRUD endpoint that reads from the database and returns a DTO does not benefit from a four-stage pipeline. The overhead of designing, naming, and wiring discrete stages is not justified for straightforward transformations.</p>
<p><strong>When stages are tightly coupled by shared state.</strong> Pipes and Filters works because each stage is independent. If your stages share a mutable context object that grows as it passes through, you have a different pattern — the Chain of Responsibility or a workflow engine — and you should name it as such.</p>
<p><strong>When ordering is non-trivial and frequently changes.</strong> If stage ordering has complex conditional logic ("run stage B only after stage A, but only if stage C did not short-circuit"), you are describing an orchestrated workflow, not a linear pipeline. A workflow engine or saga handles that more explicitly.</p>
<p><strong>When you need guaranteed stage isolation in distributed systems.</strong> A single-process MediatR pipeline shares the same process and transaction scope. If stages genuinely need to run in separate services, survive process restarts independently, or scale independently, the Competing Consumers or Saga patterns are more appropriate.</p>
<h2>How the Pattern Is Expressed in ASP.NET Core</h2>
<h3>Middleware as Pipes and Filters</h3>
<p>The simplest expression of the pattern in ASP.NET Core is the middleware pipeline registered in <code>Program.cs</code>. The registration order is the execution order. Short-circuiting is explicit: a middleware that does not call <code>next</code> terminates the pipeline at that stage.</p>
<p>The key design principle here is the single-responsibility constraint on each component. A rate limiting middleware should do exactly that — apply a rate limit — and nothing else. If it starts checking authentication state to decide whether to rate limit differently, it has absorbed a concern that belongs in a dedicated component earlier or later in the pipeline.</p>
<h3>MediatR Pipeline Behaviors</h3>
<p>For application-layer pipelines, <code>IPipelineBehavior&lt;TRequest, TResponse&gt;</code> is the idiomatic filter interface. Validation behavior, logging behavior, caching behavior, and transaction behavior all implement this interface. MediatR resolves them in dependency injection registration order and executes them before invoking the handler.</p>
<p>The important constraint: pipeline behaviors should be generic where possible. A <code>ValidationBehavior&lt;TRequest, TResponse&gt;</code> that invokes all registered <code>IValidator&lt;TRequest&gt;</code> instances from DI is a reusable filter for every command and query in the application. A behavior written to handle only one specific request type is not a pipeline behavior — it is business logic that belongs in the handler.</p>
<h3>System.Threading.Channels for Async Stage Pipelines</h3>
<p>When stages need to process work asynchronously with backpressure, concurrency limits, and stage-local consumers, <code>System.Threading.Channels</code> models the pipe explicitly. Each stage reads from one channel and writes to the next. <code>BoundedChannel</code> provides the backpressure mechanism — if a downstream stage is slow, the bounded buffer prevents unbounded memory growth upstream.</p>
<p>This form is appropriate for document ingestion, event stream processing, or any fan-in/fan-out processing requirement where MediatR's synchronous (per-request) model would create blocking or excessive thread contention.</p>
<h2>Trade-offs and Anti-Patterns</h2>
<h3>The Trade-offs</h3>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Benefit</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Testability</td>
<td>Each stage tests in isolation</td>
<td>More test surface area to maintain</td>
</tr>
<tr>
<td>Extensibility</td>
<td>New stages insert without modifying existing ones</td>
<td>Registration and ordering must be actively managed</td>
</tr>
<tr>
<td>Observability</td>
<td>Each stage can be independently logged and traced</td>
<td>Per-stage telemetry requires explicit instrumentation</td>
</tr>
<tr>
<td>Decoupling</td>
<td>Stages do not depend on each other</td>
<td>Shared context objects can re-introduce coupling</td>
</tr>
</tbody></table>
<h3>Anti-Patterns to Avoid</h3>
<p><strong>The God Filter.</strong> A pipeline behavior or middleware that handles validation, enrichment, caching, and audit logging in a single class. It is indistinguishable from a monolithic service method, just with different plumbing.</p>
<p><strong>Hidden side effects between stages.</strong> Stages that write to ambient state — a shared dictionary on the context, a <code>ThreadLocal</code>, an unscoped service — create invisible ordering dependencies. If stage B fails because stage A did not populate a key on a shared object, the pipeline is coupled in ways the type system cannot express.</p>
<p><strong>Overusing the middleware pipeline for business logic.</strong> <code>UseWhen</code> and conditional middleware are powerful, but complex branching in <code>Program.cs</code> is a sign that the logic belongs in application-layer handlers, not in the HTTP pipeline. The middleware pipeline should deal with infrastructure concerns. Business rules belong in MediatR handlers and domain models.</p>
<p><strong>Ignoring the ordering contract.</strong> <code>AddAuthentication</code> and <code>UseAuthentication</code> must precede <code>UseAuthorization</code>. <code>UseRouting</code> must precede <code>UseEndpoints</code>. The middleware pipeline has implicit ordering contracts that are not enforced by the compiler. Violating them produces bugs that only appear in production under specific conditions. Document the required ordering explicitly in a comment on the registration block.</p>
<h2>Decision Matrix</h2>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Recommended Approach</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP cross-cutting concerns (auth, rate limiting, CORS, logging)</td>
<td>ASP.NET Core middleware pipeline</td>
</tr>
<tr>
<td>Application-layer cross-cutting concerns (validation, caching, transactions)</td>
<td>MediatR <code>IPipelineBehavior&lt;TRequest, TResponse&gt;</code></td>
</tr>
<tr>
<td>Async multi-stage background processing with backpressure</td>
<td><code>System.Threading.Channels</code> stage pipeline</td>
</tr>
<tr>
<td>Distributed multi-service workflows with compensation</td>
<td>Saga pattern</td>
</tr>
<tr>
<td>Simple CRUD endpoint</td>
<td>No pipeline — direct handler</td>
</tr>
</tbody></table>
<h2>What to Do with Existing Code</h2>
<p>If you have a large service class with tangled validation, enrichment, and persistence logic, refactoring to an explicit pipeline is a good investment — but do it in stages.</p>
<p>Start by extracting cross-cutting concerns into MediatR behaviors. Validation is the easiest first step. A <code>ValidationBehavior</code> applied globally immediately removes validation code from every handler and makes the constraint explicit and reusable.</p>
<p>Next, identify the stages hidden inside your handlers. If a handler fetches an entity, applies a business rule, raises a domain event, and saves — those are four stages, three of which (fetch, raise event, save) are infrastructure-layer concerns that belong in behaviors or event handlers, not mixed with the business rule itself.</p>
<p>Use <code>System.Threading.Channels</code> only when you have a genuine async processing requirement with observable backpressure needs. Do not introduce channel-based pipelines as a default architecture — they carry significant operational complexity and are worth it only when the concurrency and backpressure semantics are genuinely needed.</p>
<p>Internal to Coding Droplets, we have written about the related <a href="https://codingdroplets.com/aspnet-core-middleware-mistakes-and-fixes">7 Common ASP.NET Core Middleware Mistakes and How to Fix Them</a> and the <a href="https://codingdroplets.com/cqrs-and-mediatr-in-asp-net-core-enterprise-decision-guide">CQRS and MediatR in ASP.NET Core: Enterprise Decision Guide</a> — both worth reading alongside this guide for a complete picture of pipeline design in .NET.</p>
<p>For the authoritative reference on the pattern's theory, the <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/pipes-and-filters">Pipes and Filters pattern on the Azure Architecture Center</a> is the canonical starting point, and Microsoft's <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/">ASP.NET Core Middleware documentation</a> covers the framework implementation in depth.</p>
<blockquote>
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<h2>FAQ</h2>
<p><strong>What is the Pipes and Filters pattern in ASP.NET Core?</strong> The Pipes and Filters pattern structures a processing task as a sequence of independent stages connected by pipes. In ASP.NET Core, the middleware pipeline is the built-in implementation at the HTTP layer. At the application layer, MediatR pipeline behaviors implement the same pattern for command and query processing.</p>
<p><strong>How is the Pipes and Filters pattern different from the Chain of Responsibility?</strong> Chain of Responsibility passes a request through handlers where each handler decides whether to process it or forward it — typically only one handler acts. Pipes and Filters passes data through all stages sequentially, with each stage performing its own transformation or evaluation. In ASP.NET Core middleware, both handlers and short-circuiting middleware both exist, but the overall structure is Pipes and Filters.</p>
<p><strong>When should I use MediatR pipeline behaviors vs ASP.NET Core middleware?</strong> Use ASP.NET Core middleware for HTTP-level infrastructure concerns — authentication, rate limiting, CORS, exception handling, compression, and request/response logging at the transport level. Use MediatR pipeline behaviors for application-layer cross-cutting concerns — validation, caching, transaction scope, and audit logging at the business operation level. The key signal: if the concern requires knowledge of <code>HttpContext</code>, it belongs in middleware; if it requires knowledge of the command or query type, it belongs in a pipeline behavior.</p>
<p><strong>Can I build custom Pipes and Filters pipelines without MediatR?</strong> Yes. The pattern does not require a framework. A simple interface such as <code>IPipelineFilter&lt;T&gt;</code> with an <code>Execute(T context, Func&lt;T, Task&gt; next)</code> signature, combined with a builder that chains filters in registration order, is sufficient. MediatR provides this out of the box for CQRS workloads, and <code>System.Threading.Channels</code> provides it for async producer-consumer flows. For lightweight scenarios, a hand-rolled pipeline with no third-party dependency is a reasonable choice.</p>
<p><strong>How does the Pipes and Filters pattern affect testability?</strong> It significantly improves testability. Each filter or behavior can be tested in isolation with a unit test. A validation behavior needs only a set of validators and a fake <code>next</code> delegate. A middleware component needs only an <code>HttpContext</code> and a <code>RequestDelegate</code>. There is no need to construct the entire pipeline to test a single stage — which contrasts sharply with monolithic service methods where a test must trigger all logic simultaneously.</p>
<p><strong>What is the right number of stages in a pipeline?</strong> There is no fixed answer, but a useful heuristic: each stage should have a name that clearly describes its single responsibility. If you cannot name a stage without using "and", split it. In practice, MediatR pipelines with three to six behaviors (logging, validation, caching, transaction, error handling, audit) are common in production systems. Middleware pipelines in ASP.NET Core often have ten or more components — each provided either by the framework or as a named, composable component.</p>
<p><strong>Should I use System.Threading.Channels or a message broker for stage pipelines?</strong> Use <code>System.Threading.Channels</code> when all stages run in the same process and you need in-process backpressure, concurrency control, and stage isolation without external dependencies. Use a message broker (RabbitMQ, Azure Service Bus, Kafka) when stages need to run in separate processes or services, survive process restarts independently, or scale horizontally at the stage level. Channels are an in-process primitive; brokers are distributed infrastructure. Do not reach for a broker to solve a problem that <code>BoundedChannel&lt;T&gt;</code> handles in ten lines.</p>
]]></content:encoded></item><item><title><![CDATA[Cache Stampede in ASP.NET Core: IMemoryCache Race Conditions in Production — Root Cause and Fix]]></title><description><![CDATA[Cache stampedes are one of those production problems that look like an infrastructure failure until you dig into the code. Under normal load, your ASP.NET Core API responds fast, the cache does its jo]]></description><link>https://codingdroplets.com/cache-stampede-aspnet-core-imemorycache-production-fix</link><guid isPermaLink="true">https://codingdroplets.com/cache-stampede-aspnet-core-imemorycache-production-fix</guid><category><![CDATA[Aspnetcore]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[performance]]></category><category><![CDATA[caching]]></category><category><![CDATA[webdev]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Mon, 01 Jun 2026 13:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/80092765-258f-48f4-a35f-ae9ece7abf0f.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cache stampedes are one of those production problems that look like an infrastructure failure until you dig into the code. Under normal load, your ASP.NET Core API responds fast, the cache does its job, and everything is fine. Then traffic spikes — or a scheduled cache expiry fires at the wrong time — and your database is suddenly receiving ten times the expected queries in a fraction of a second. Response times climb, CPU usage spikes, and the on-call alert goes off. The cache was supposed to prevent this.</p>
<p>Understanding why <code>IMemoryCache</code> does not protect you from this by default, and knowing the exact fix to apply, is the difference between a cache that helps in production and one that creates a new category of failure. The full working implementation — including the <code>SemaphoreSlim</code> locking pattern, the <code>HybridCache</code> migration, and a load test harness you can run against your own API — is on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, with production-ready source code that maps directly to real enterprise workloads.</p>
<p>If you want to see this problem and its fix in context of a complete production API — alongside rate limiting, EF Core, and authentication all wired together — Chapter 9 of the <a href="https://aspnetcoreapi.codingdroplets.com/">ASP.NET Core Web API: Zero to Production course</a> covers exactly this, including how caching decisions interact with resilience and database access patterns.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<h2>What Is a Cache Stampede?</h2>
<p>A cache stampede — also called a thundering herd or cache miss avalanche — occurs when multiple concurrent requests all find that a cached value has expired at the same time. Because the value is gone, every one of those requests independently concludes that it needs to regenerate it. They all go to the data source simultaneously. The data source (your database, your downstream API) receives a burst of identical queries that far exceeds what the cache was designed to absorb.</p>
<p>The pattern repeats on every expiry cycle. Instead of one database call every five minutes, you get two hundred simultaneous calls every five minutes, each of which takes longer than usual because the database is now under abnormal load.</p>
<p>In ASP.NET Core, this happens specifically because <code>IMemoryCache.GetOrCreateAsync</code> does not serialize concurrent population. When ten requests call it for the same missing key at the same instant, all ten enter the factory delegate. The cache is not wrong — it is doing exactly what it was designed to do. The problem is the assumption that only one caller will ever enter the factory for a given key at a given time.</p>
<h2>Why It Happens in Production but Not Locally</h2>
<p>Local testing rarely reveals this problem because developers typically test with low concurrency. One request runs, populates the cache, and the next request hits the populated entry. The race condition requires multiple concurrent callers and an expired (or absent) cache entry to trigger.</p>
<p>In production, three things combine to make it visible:</p>
<p><strong>Traffic shape.</strong> High-traffic APIs receive tens or hundreds of requests per second. When a popular cache entry expires, many of those in-flight requests will call <code>GetOrCreateAsync</code> before any of them finishes populating it.</p>
<p><strong>Cache TTL alignment.</strong> When all entries for a given category share the same TTL, they expire at the same wall-clock time. A cache warm-up at startup, followed by a fixed absolute expiration, produces synchronized expiry across the cluster.</p>
<p><strong>Slow factory delegates.</strong> The longer the factory takes to run — a complex query, a downstream API call, an EF Core join — the wider the window during which concurrent callers can pile in. A factory that takes 200ms under normal load can collect 40 concurrent callers before the first one finishes.</p>
<h2>How to Diagnose a Cache Stampede</h2>
<p>Before reaching for a fix, confirm you are actually dealing with a stampede rather than a different caching problem.</p>
<p><strong>What to look for in your metrics and logs:</strong></p>
<ul>
<li><p>A periodic spike in database query count that coincides with your cache TTL interval</p>
</li>
<li><p>Response latency spikes that are narrow (seconds, not minutes) and repeat at a predictable interval</p>
</li>
<li><p>Structured log entries showing the same cache key being "populated" by multiple concurrent requests within milliseconds of each other</p>
</li>
<li><p>Database CPU spikes that are correlated with specific cache keys rather than random query patterns</p>
</li>
</ul>
<p><strong>A quick diagnostic approach:</strong> add structured logging inside your factory delegate that includes the cache key and a timestamp. If you see the same key logged multiple times within a single second, you have confirmed a stampede. With Serilog and named placeholders, a single log line is sufficient: <code>_logger.LogInformation("Cache miss for key {CacheKey} at {UtcNow}", key, DateTime.UtcNow)</code>.</p>
<p>If you are already using <code>IDistributedCache</code>, you may not notice the problem at first because the distributed cache serializes writes differently — but the same fundamental issue can occur if your factory is slow enough and traffic is high enough.</p>
<h2>Fix 1: SemaphoreSlim Per-Key Locking</h2>
<p>The most widely used fix for <code>IMemoryCache</code> is a <code>SemaphoreSlim</code> that gates entry into the factory delegate. Only one caller is allowed to populate a given key. All other callers wait, and once the first caller finishes and populates the cache, the waiting callers retrieve the value without calling the factory themselves.</p>
<p>The implementation pattern wraps <code>GetOrCreateAsync</code> inside a lightweight keyed lock. The lock is typically stored in a <code>ConcurrentDictionary&lt;string, SemaphoreSlim&gt;</code> so that different keys can proceed concurrently — only callers requesting the same key are serialized.</p>
<p>This approach is effective and works without any dependency changes. The trade-off is added complexity: the lock dictionary must be managed carefully to avoid memory leaks (remove entries after use), and the per-key semaphore adds a small allocation cost on cache miss.</p>
<p>The pattern is most appropriate when:</p>
<ul>
<li><p>You are on .NET 8 or earlier and cannot yet adopt <code>HybridCache</code></p>
</li>
<li><p>Your cache usage is concentrated on a small, known set of high-contention keys</p>
</li>
<li><p>You want precise control over the locking behaviour</p>
</li>
</ul>
<h2>Fix 2: Staggered TTL to Break Synchronized Expiry</h2>
<p>A simpler partial mitigation is to add a random jitter to the cache TTL. Instead of all entries for a category expiring at exactly the same time, they expire within a window — say, between 4 and 6 minutes for a 5-minute target TTL. This distributes the stampede load over time rather than eliminating it entirely.</p>
<p>Jitter is easy to add: <code>TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(Random.Shared.Next(0, 60))</code>. The <code>Random.Shared</code> instance in .NET 6 and later is thread-safe.</p>
<p>Staggered TTL does not eliminate the problem — it reduces its impact. Under very high concurrency, even a 60-second jitter window will still produce small stampedes. It is best used as a complementary technique alongside one of the serialization approaches.</p>
<h2>Fix 3: HybridCache — Stampede Protection Built In</h2>
<p>The cleanest long-term fix for .NET 9 and .NET 10 projects is <code>HybridCache</code>, which was introduced as GA in .NET 9. <code>HybridCache</code> has stampede protection built into its design: it serializes concurrent requests for the same key using a coalescing mechanism. When multiple requests arrive for a missing key simultaneously, only one factory invocation is triggered. The other callers wait for that single result and share it.</p>
<p>This is a first-class guarantee in the API contract, not a workaround. Switching from <code>IMemoryCache.GetOrCreateAsync</code> to <code>HybridCache.GetOrCreateAsync</code> with the same factory delegate gives you stampede protection without any additional locking code.</p>
<p><code>HybridCache</code> also provides an optional two-layer architecture: a fast in-process L1 cache backed by a distributed L2 (Redis, for example). For multi-instance APIs, the L1+L2 combination means that even after a pod restart, the newly started instance can warm from the shared L2 rather than hitting the database. This directly reduces the window during which a stampede can form.</p>
<p>When to use <code>HybridCache</code> vs the <code>SemaphoreSlim</code> approach:</p>
<ul>
<li><p>New projects on .NET 9 or .NET 10: default to <code>HybridCache</code></p>
</li>
<li><p>Existing projects still on .NET 8: the <code>SemaphoreSlim</code> pattern is the right in-place fix</p>
</li>
<li><p>Multi-instance deployments where L2 (Redis) is already in place: <code>HybridCache</code> provides the most complete solution</p>
</li>
</ul>
<h2>Is There a Question About Which Cache to Use?</h2>
<p>If the stampede is severe enough that you are investigating this as a production incident, the answer for most teams is: migrate the affected endpoints to <code>HybridCache</code> if you are on .NET 9+. The API surface is close enough to <code>IMemoryCache</code> that a targeted migration of just the high-traffic keys is low-risk and can be done without touching the rest of the codebase.</p>
<p>If you are on .NET 8 or need a fix today without a version upgrade, the <code>SemaphoreSlim</code> pattern is production-proven and straightforward. The risk is that you are now maintaining custom locking infrastructure. Keep it simple: one semaphore per key, clean up after use, and log when the lock is contended.</p>
<p>The one pattern to avoid entirely is using a single global lock across all keys. A wide lock serializes all cache operations and eliminates the concurrency benefits of caching. The whole point of the fix is to serialize per-key, not per-cache.</p>
<p>For a full production-ready implementation — including how the lock dictionary is managed, how the HybridCache migration looks in a real API with Redis as L2, and load test results showing the before and after — the complete source code is at <a href="https://github.com/codingdroplets/dotnet-hybridcache-aspnetcore">github.com/codingdroplets/dotnet-hybridcache-aspnetcore</a>.</p>
<h2>Preventing It From Recurring</h2>
<p>Once the immediate fix is in place, the following practices reduce the risk of encountering this again:</p>
<p><strong>Add expiry jitter by default.</strong> Make random TTL offset a team convention rather than a case-by-case addition. A helper extension method that wraps <code>IMemoryCache.Set</code> with built-in jitter ensures it is never forgotten.</p>
<p><strong>Monitor per-key cache miss rate.</strong> Most observability platforms (Application Insights, Grafana, Seq) can be configured to alert when a specific cache key experiences a sustained elevated miss rate. A miss rate spike is an early warning of a developing stampede.</p>
<p><strong>Use</strong> <code>HybridCache</code> <strong>for all new high-traffic cache points.</strong> Retrofit is cheap — the API is similar to <code>IMemoryCache</code>. There is no reason to write new stampede-vulnerable code on .NET 9+.</p>
<p><strong>Review factory delegate latency.</strong> The faster the factory, the narrower the stampede window. Queries inside factory delegates should use <code>AsNoTracking()</code>, return only the columns needed, and have appropriate indexes. A 10ms factory has a much smaller blast radius than a 300ms one.</p>
<p><strong>Load test expiry behaviour explicitly.</strong> Add a load test scenario that fires a burst of concurrent requests at a key that has just expired. This is the exact condition that triggers a stampede and it is trivially reproducible under controlled conditions. Catch it before production does.</p>
<blockquote>
<p>☕ Found this useful? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<h2>FAQ</h2>
<p><strong>Does</strong> <code>IMemoryCache.GetOrCreate</code> <strong>(non-async) have the same stampede problem?</strong> Yes. The synchronous <code>GetOrCreate</code> method has the same race condition: multiple callers can enter the factory delegate concurrently for a missing key. The same <code>SemaphoreSlim</code> pattern applies, using <code>SemaphoreSlim.Wait</code> and <code>Release</code> instead of the async equivalents. For new code, prefer the async path to avoid blocking ThreadPool threads.</p>
<p><strong>Will switching to Redis distributed cache fix the stampede?</strong> Not automatically. <code>IDistributedCache</code> has the same absence of per-key serialization as <code>IMemoryCache</code>. Multiple instances of your API can still race to populate the same key in Redis simultaneously. The fix — <code>SemaphoreSlim</code> or <code>HybridCache</code> — is needed regardless of which backing store you use. <code>HybridCache</code> with Redis as L2 does protect against this, because stampede coalescing happens at the <code>HybridCache</code> layer.</p>
<p><strong>How many concurrent requests does it take to trigger a stampede?</strong> There is no fixed threshold. A stampede can form with as few as two concurrent callers if the factory delegate is slow enough. In practice, stampedes become operationally significant when the factory takes more than 50ms and more than 10 concurrent callers are hitting the same key simultaneously. High-traffic endpoints with expensive factories are the highest risk.</p>
<p><strong>Can output caching be used instead?</strong> Output caching caches the full HTTP response at the middleware layer rather than at the service layer. It has stampede protection built in and is a valid alternative for read-only endpoints that return the same response to all callers. It is not a substitute for <code>IMemoryCache</code> or <code>HybridCache</code> in scenarios where caching is used at the service or repository level, or where the cached value feeds into further processing before the response is formed.</p>
<p><strong>Is HybridCache's coalescing guarantee cluster-wide or per-instance?</strong> Per-instance. The stampede protection in <code>HybridCache</code> prevents multiple concurrent requests on the same instance from all executing the factory simultaneously. It does not prevent two different pods in a Kubernetes cluster from both executing the factory at the same time for the same key. For cluster-wide coalescing, a distributed lock (Redis <code>SET NX</code> or a similar primitive) is required. In practice, per-instance protection is sufficient for most workloads — the factory executes once per instance, not once per request.</p>
<p><strong>Should I use</strong> <code>AbsoluteExpiration</code> <strong>or</strong> <code>SlidingExpiration</code> <strong>to reduce stampede risk?</strong> Absolute expiration with jitter is generally safer. Sliding expiration extends the TTL on every access, which means that for popular keys, the entry may never expire cleanly — but when it eventually does expire (because traffic drops overnight, for example), all clients that start hitting it in the morning will find a cold cache simultaneously. Absolute expiration with a small random jitter gives more predictable expiry distribution and makes the stampede window easier to reason about.</p>
<p><strong>Does the</strong> <code>ConcurrentDictionary</code> <strong>used for the semaphore lock introduce its own thread safety issues?</strong> No. <code>ConcurrentDictionary&lt;TKey, SemaphoreSlim&gt;</code> is thread-safe for concurrent reads and writes. The standard pattern — <code>GetOrAdd</code> to retrieve or create the semaphore, <code>WaitAsync</code> to acquire it, <code>Release</code> to free it, and then removal of the entry after the factory completes — is safe under concurrent access. The entry-removal step requires care to avoid a race where a newly added entry is removed before another caller has a chance to acquire it; the typical fix is to check the cache again after acquiring the semaphore before executing the factory.</p>
]]></content:encoded></item><item><title><![CDATA[C# Primary Constructors vs Traditional Constructors in .NET: Which Should Your Team Use in 2026?]]></title><description><![CDATA[C# primary constructors, introduced in C# 12, have quietly become one of the most debated syntax choices in enterprise .NET teams. The pitch is compelling: fewer lines, less boilerplate, cleaner servi]]></description><link>https://codingdroplets.com/csharp-primary-constructors-vs-traditional-constructors-dotnet</link><guid isPermaLink="true">https://codingdroplets.com/csharp-primary-constructors-vs-traditional-constructors-dotnet</guid><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[dependency injection]]></category><category><![CDATA[C# 12]]></category><category><![CDATA[Clean Architecture]]></category><category><![CDATA[best practices]]></category><category><![CDATA[Primary Constructors]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Mon, 01 Jun 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/850b4695-aaba-4871-ab3d-2089b91a18c7.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>C# primary constructors, introduced in C# 12, have quietly become one of the most debated syntax choices in enterprise .NET teams. The pitch is compelling: fewer lines, less boilerplate, cleaner service classes. But the controversy is real — mutable capture semantics, readability trade-offs, and a very different mental model from what most .NET developers learned first. If your team is deciding whether to adopt primary constructors across an ASP.NET Core codebase, the answer depends on where and how you use them, not on a blanket rule.</p>
<p>The complete patterns and trade-off comparisons covered in this article are explored inside a production-ready codebase on <a href="https://www.patreon.com/CodingDroplets">Patreon</a> — with real service classes, handler implementations, and annotated examples showing exactly when each approach pays off in a working ASP.NET Core API.</p>
<p>Understanding how constructors fit into clean, layered code is also a core theme in <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 11 of the ASP.NET Core Web API: Zero to Production course</a> — which covers Clean Architecture with CQRS and MediatR, where constructor design choices directly affect how handlers, repositories, and domain services wire together.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<h2>What Are Primary Constructors in C#?</h2>
<p>Primary constructors let you declare constructor parameters directly on the class declaration, making them available throughout the type body without requiring explicit field declarations. They've existed for records since C# 9, but C# 12 extended them to classes and structs.</p>
<p>For a service class with injected dependencies, a primary constructor collapses what used to be four or five lines of boilerplate field declaration, constructor signature, and field assignment into a single line at the class declaration.</p>
<p>In ASP.NET Core, the most common use case is constructor injection. The DI container resolves the dependencies and passes them to the constructor — and with primary constructors, that process is identical at the container level. The difference is entirely in how you write and read the class definition.</p>
<h2>The Core Difference: How Each Approach Wires Dependencies</h2>
<p>With a traditional constructor, you explicitly declare <code>private readonly</code> fields, write a constructor that accepts parameters, and assign each parameter to its corresponding field. The result is verbose but explicit: every dependency has a visible, named, immutable home in the type.</p>
<p>With a primary constructor, the parameters are declared on the class line and captured implicitly. You reference them directly in the class body — no field assignment needed. The parameter name itself becomes your reference throughout the type.</p>
<p>The critical difference that trips up enterprise teams: <strong>primary constructor parameters are captured as mutable variables, not readonly fields</strong>. If you reassign a primary constructor parameter inside the class body, the compiler will not stop you. Traditional constructors with <code>private readonly</code> fields enforce immutability at compile time.</p>
<h2>Side-by-Side Comparison</h2>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>Primary Constructor</th>
<th>Traditional Constructor</th>
</tr>
</thead>
<tbody><tr>
<td>Verbosity</td>
<td>Lower — no explicit field declarations</td>
<td>Higher — requires field + assignment per dependency</td>
</tr>
<tr>
<td>Immutability enforcement</td>
<td>Not enforced — parameters are mutable</td>
<td>Enforced — <code>private readonly</code> prevents reassignment</td>
</tr>
<tr>
<td>Readability in large classes</td>
<td>Can obscure which parameters are used where</td>
<td>Each field is explicitly visible at the top</td>
</tr>
<tr>
<td>Debugger experience</td>
<td>Parameters may not appear as clearly in locals</td>
<td>Fields appear with their assigned values</td>
</tr>
<tr>
<td>Tool support (Roslyn analyzers)</td>
<td>Improving — still less mature than field-based patterns</td>
<td>Fully supported — mature tooling ecosystem</td>
</tr>
<tr>
<td>XML doc integration</td>
<td>Cannot document primary constructor parameters with <code>&lt;param&gt;</code> docs on the class</td>
<td>Can document constructor parameters on the constructor declaration</td>
</tr>
<tr>
<td>Suitability for record types</td>
<td>Natural fit — records were designed for this</td>
<td>Not applicable — records favour primary constructor syntax</td>
</tr>
<tr>
<td>Risk in refactoring</td>
<td>Higher — accidental reassignment is silent</td>
<td>Lower — readonly fields catch reassignment at compile time</td>
</tr>
<tr>
<td>DI container compatibility</td>
<td>Identical — no difference at runtime</td>
<td>Identical — no difference at runtime</td>
</tr>
</tbody></table>
<h2>When Primary Constructors Are the Right Choice</h2>
<p><strong>Record types and immutable DTOs.</strong> Primary constructors were designed for records. If you are writing a result type, a request model, or a value object, primary constructors are natural and idiomatic. There is no meaningful downside here.</p>
<p><strong>Simple, focused service classes.</strong> If a class has one or two dependencies, does one job, and is unlikely to grow significantly, primary constructors reduce noise without adding risk. Small query handlers, simple validators, and adapter classes are good candidates.</p>
<p><strong>Test classes.</strong> When using a framework like xUnit, primary constructors on test classes clean up test fixture setup considerably. The risk of accidental reassignment is low in test contexts, and the readability gain is real.</p>
<p><strong>Teams with strong Roslyn analyser coverage.</strong> If your team runs a linter or analyser that flags primary constructor parameter reassignment (such as rules from Roslynator or JetBrains Rider/ReSharper), the safety concern diminishes significantly.</p>
<h2>When Traditional Constructors Are the Right Choice</h2>
<p><strong>Service classes with three or more dependencies.</strong> When a class grows, the implicit capture model of primary constructors makes it harder to see at a glance which dependencies a type holds. A traditional constructor with declared fields gives any developer an immediate inventory of what the class depends on.</p>
<p><strong>Classes where immutability matters explicitly.</strong> Any service, repository, or domain object where you want the compiler to enforce that injected dependencies cannot be replaced mid-object-lifetime should use <code>private readonly</code> fields. This is a non-trivial safety property in long-lived singleton services.</p>
<p><strong>Public or protected classes in library or SDK code.</strong> If external consumers can inherit from your class, the implicit parameter capture model can confuse inheriting developers. Traditional constructors with clearly documented parameters are the safer public API surface.</p>
<p><strong>Classes with complex initialization logic.</strong> If your constructor does more than simple field assignment — configuring options, validating preconditions, or initialising derived state — a traditional constructor body is cleaner and more intentional.</p>
<p><strong>Teams working across a mix of C# versions.</strong> If parts of your codebase target older runtimes or contributors use older tooling that has limited primary constructor support in editors and analysers, consistency may favour staying with traditional constructors project-wide.</p>
<h2>The Mutable Capture Problem in Detail</h2>
<p>The single biggest risk with primary constructors in service classes is the mutable capture semantics. In a traditional constructor, once you write <code>_service = service</code>, the field is <code>readonly</code> and cannot be overwritten. The compiler enforces this.</p>
<p>With a primary constructor, the parameter <code>service</code> is not backed by a readonly field. You can write <code>service = null;</code> inside any method body and the compiler will accept it silently. The DI container still injects correctly. The mutable capture only becomes a problem if someone accidentally or intentionally reassigns the parameter — but in large codebases with many contributors, silent mutability is a footgun.</p>
<p>Teams that adopt primary constructors for ASP.NET Core services should pair them with a Roslyn analyser rule that flags any reassignment of primary constructor parameters in non-record class bodies.</p>
<h2>Does It Affect Performance?</h2>
<p>No. Primary constructors and traditional constructors compile to identical IL in practice for simple injection scenarios. The JIT sees the same constructor invocation, the same field storage (even if the C# source doesn't show it explicitly), and the same method calls. There is no performance argument for either approach.</p>
<h2>What Does Microsoft's Own Codebase Do?</h2>
<p>Microsoft's ASP.NET Core team has adopted primary constructors selectively in .NET 9 and .NET 10 source. The pattern appears most in simple internal service implementations, test helpers, and record-like types. The more complex services in the framework — those with several dependencies and complex lifecycles — continue to use traditional constructors with explicit field declarations.</p>
<p>This mirrors the practical recommendation for enterprise teams: use primary constructors where they simplify the code without introducing implicit state concerns, and stick with traditional constructors where explicitness has value.</p>
<h2>Is There a Middle Path?</h2>
<p>Some teams adopt a hybrid convention:</p>
<ul>
<li><p><strong>Records and simple value types:</strong> primary constructors always</p>
</li>
<li><p><strong>Small handler and adapter classes (≤2 dependencies):</strong> primary constructors allowed</p>
</li>
<li><p><strong>Service classes with ≥3 dependencies or complex lifecycle:</strong> traditional constructors required</p>
</li>
<li><p><strong>Abstract base classes or public library types:</strong> traditional constructors required</p>
</li>
</ul>
<p>This approach captures most of the readability gains from primary constructors while preserving explicitness where it matters most. The key is documenting the convention and enforcing it through code review, not leaving it to individual judgment.</p>
<h2>How to Choose: Decision Framework</h2>
<p>Ask these questions before reaching for primary constructors in a new class:</p>
<ol>
<li><p><strong>Is this a record type or immutable DTO?</strong> → Primary constructor is the natural choice.</p>
</li>
<li><p><strong>Does this class have more than two injected dependencies?</strong> → Traditional constructor preserves clarity.</p>
</li>
<li><p><strong>Will this class be inherited or used as a public API surface?</strong> → Traditional constructor is safer.</p>
</li>
<li><p><strong>Does your team have an analyser rule for primary constructor parameter reassignment?</strong> → If yes, primary constructors are safer. If no, weigh the risk.</p>
</li>
<li><p><strong>Is this a test fixture or adapter class?</strong> → Primary constructor typically wins on readability.</p>
</li>
</ol>
<p>There is no wrong answer that applies to all scenarios. The goal is intentional consistency, not a fleet-wide mandate in either direction.</p>
<h2>Refactoring Existing Codebases</h2>
<p>If you are considering migrating an existing ASP.NET Core codebase from traditional to primary constructors, go incrementally. Target the simplest, lowest-risk classes first — small handlers, validators, and adapters. Leave the large, stateful service classes on traditional constructors until your team has built confidence with the new pattern and your analyser coverage is solid.</p>
<p>Rider and Visual Studio both offer automated refactoring to convert traditional constructors to primary constructors. Running these transformations in bulk across a large codebase is risky — it is better to migrate class by class, reviewing each transformation for the mutable capture concern before merging.</p>
<p>For teams following the <a href="https://codingdroplets.com/domain-events-vs-integration-events-dotnet">Domain Events vs Integration Events in .NET</a> architectural pattern, domain event handlers are typically excellent candidates for primary constructors given their focused, single-dependency nature. Larger aggregates and orchestration services are better left on traditional constructors.</p>
<h2>Internal Links</h2>
<p>For the DI lifetime decisions that interact with constructor design, see the post on <a href="https://codingdroplets.com/aspnet-core-di-lifetimes-singleton-scoped-transient-enterprise-decision-guide">ASP.NET Core DI Lifetimes: Singleton vs Scoped vs Transient</a> — lifetime mismatches remain the most common constructor-related runtime error in ASP.NET Core, regardless of which constructor style you use.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<h2>FAQ</h2>
<p><strong>Are C# primary constructors safe to use with ASP.NET Core dependency injection?</strong> Yes. The DI container resolves and passes dependencies to primary constructors exactly as it does with traditional constructors. The difference is entirely in how the C# source is written, not how the runtime wires dependencies. There is no compatibility or registration difference.</p>
<p><strong>Why are primary constructor parameters mutable in C# class types?</strong> This was a deliberate language design decision to keep primary constructors general-purpose. Unlike record types, where primary constructor parameters map directly to init-only properties, class primary constructors were designed to support scenarios beyond immutable data — including cases where the parameter value needs to change during initialisation. The downside is that you lose the compiler-enforced immutability that <code>private readonly</code> fields provide.</p>
<p><strong>Should I convert all my existing ASP.NET Core service classes to use primary constructors?</strong> No — not as a bulk operation. Evaluate each class individually. Simple, small service classes are good candidates. Large services with multiple dependencies, complex state, or inheritance chains are better left on traditional constructors. Mass conversion without an analyser to catch mutable capture issues introduces subtle risk.</p>
<p><strong>Do primary constructors work with ASP.NET Core options like</strong> <code>IOptions&lt;T&gt;</code><strong>?</strong> Yes. <code>IOptions&lt;T&gt;</code>, <code>IOptionsSnapshot&lt;T&gt;</code>, and <code>IOptionsMonitor&lt;T&gt;</code> are injected through primary constructors in exactly the same way as through traditional constructors. The registration in <code>Program.cs</code> is unchanged.</p>
<p><strong>Can I use primary constructors in ASP.NET Core controllers?</strong> Yes. Controller classes support primary constructors in C# 12 and later. The DI container injects action-injected dependencies normally. However, controllers tend to have more dependencies than simple service classes, so the clarity trade-off often favours traditional constructors for controllers in enterprise codebases.</p>
<p><strong>What is the impact of primary constructors on code coverage and debugging?</strong> Some debuggers and coverage tools have slightly less mature support for primary constructor parameters compared to traditional fields. JetBrains Rider and Visual Studio 2026 have improved this significantly, but if you rely on field-watching in complex debugging scenarios, traditional constructors with named readonly fields give a more predictable experience.</p>
<p><strong>Do primary constructors affect XML documentation in ASP.NET Core libraries?</strong> Yes, in a minor way. You cannot use <code>&lt;param&gt;</code> tags on the class declaration to document primary constructor parameters in the standard way. This is a real limitation for library authors and public SDK surfaces. Traditional constructors with explicit <code>/// &lt;summary&gt;</code> and <code>&lt;param&gt;</code> documentation on the constructor itself are better for documentation-heavy codebases.</p>
]]></content:encoded></item><item><title><![CDATA[How to Protect ASP.NET Core APIs Against Broken Authentication]]></title><description><![CDATA[Broken authentication is the second entry on the OWASP API Security Top 10 for good reason — it is one of the most consistently exploited vulnerabilities in production APIs, and ASP.NET Core teams are]]></description><link>https://codingdroplets.com/broken-authentication-aspnet-core-api</link><guid isPermaLink="true">https://codingdroplets.com/broken-authentication-aspnet-core-api</guid><category><![CDATA[asp.net core]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[Security]]></category><category><![CDATA[owasp]]></category><category><![CDATA[api security]]></category><category><![CDATA[JWT]]></category><category><![CDATA[C#]]></category><category><![CDATA[Web API]]></category><category><![CDATA[authentication]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Sun, 31 May 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/2deda050-1f2e-4374-80d4-42171401e495.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Broken authentication is the second entry on the OWASP API Security Top 10 for good reason — it is one of the most consistently exploited vulnerabilities in production APIs, and ASP.NET Core teams are not immune. Unlike BOLA, which is about authorising access to the right object, broken authentication is about whether the identity itself can be trusted in the first place. When authentication breaks down, every other security control downstream becomes unreliable.</p>
<p>The gap between teams that implement JWT correctly and teams that implement it insecurely is rarely about intent — it is almost always about the specific defaults they accepted without questioning them. The complete authentication patterns, including token families, refresh token rotation, and secure key management, are available with working source code on <a href="https://www.patreon.com/CodingDroplets">Patreon</a> — ready to drop into a real production codebase.</p>
<p>If you want to see these authentication concepts wired together inside a full production API — including token rotation, policy-based authorization, and hardened defaults — <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 7 of the ASP.NET Core Web API: Zero to Production course</a> covers exactly that, with a complete codebase you can run immediately.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<h2>What Broken Authentication Actually Means in ASP.NET Core APIs</h2>
<p>OWASP API2:2023 (Broken Authentication) covers a broad class of weaknesses that all share one root cause: the API cannot reliably verify that the calling party is who they claim to be. In ASP.NET Core APIs, this typically surfaces in one of five patterns:</p>
<ul>
<li><p><strong>Weak or predictable signing keys</strong> — JWT secrets that are too short, hardcoded in source control, or shared across environments</p>
</li>
<li><p><strong>Disabled or incomplete token validation</strong> — accepting tokens with <code>alg: none</code>, skipping audience/issuer checks, or setting <code>ClockSkew</code> too high</p>
</li>
<li><p><strong>Insecure token transmission</strong> — tokens passed over HTTP, logged to disk, or stored in <code>localStorage</code> on the client</p>
</li>
<li><p><strong>Missing expiry enforcement</strong> — long-lived access tokens with no rotation strategy, or refresh tokens that never expire</p>
</li>
<li><p><strong>Credential stuffing exposure</strong> — login and token endpoints with no rate limiting, no account lockout, and no anomaly detection</p>
</li>
</ul>
<p>Each of these represents a distinct attack surface, and each requires a different mitigation. A team that has fixed the key management problem but left validation parameters at their defaults is still vulnerable — just to a different exploit.</p>
<h2>The Threat: How Broken Authentication Is Exploited</h2>
<p>Understanding how attackers approach broken authentication makes the mitigations more intuitive.</p>
<h3>Weak Signing Keys</h3>
<p>A JWT secret that is fewer than 256 bits (32 bytes) for HMAC-SHA256 can be brute-forced offline once an attacker has intercepted a token. This is not theoretical — tools exist specifically for cracking JWT secrets, and they are effective against anything shorter than a strong random key. If the key is also checked into source control, the window of exposure extends indefinitely to anyone with repository access, past or present.</p>
<p>The fix is not complex. Use a cryptographically random key of at least 256 bits, store it in environment variables or a secrets manager (Azure Key Vault, AWS Secrets Manager), and never commit it to source control under any circumstances. The Options pattern in ASP.NET Core — <code>IOptions&lt;JwtSettings&gt;</code> — makes it straightforward to load signing keys from the configuration hierarchy at startup without hardcoding anything.</p>
<h3>Disabled or Incomplete Token Validation</h3>
<p>The <code>TokenValidationParameters</code> class in ASP.NET Core gives developers granular control over what gets validated. The problem is that several of the most important checks are not enforced by default, and teams that copy boilerplate JWT setup code often accept those defaults without reading what they mean.</p>
<p>The <code>alg: none</code> attack is the classic example. A JWT with the algorithm field set to <code>none</code> carries no cryptographic signature — the server should reject it outright. Modern versions of <code>System.IdentityModel.Tokens.Jwt</code> handle this correctly by default, but hand-rolled or legacy validation logic may not. Always validate against a known set of accepted algorithms: <code>ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 }</code>.</p>
<p>Audience and issuer validation are equally important. Skipping <code>ValidateAudience</code> or <code>ValidateIssuer</code> means a token issued for one of your services can be replayed against any other service that trusts the same key — a lateral movement opportunity that attackers actively look for in multi-service architectures. Both should be enabled, and the values should be specific: not <code>*</code>, not empty, not a development placeholder left in place.</p>
<p><code>ClockSkew</code> defaults to five minutes in ASP.NET Core. That means a token whose expiry says 14:00:00 is still accepted until 14:05:00 on any server. For most production systems, setting <code>ClockSkew = TimeSpan.Zero</code> is the correct posture. If you need a small tolerance for clock drift, keep it under thirty seconds and document why.</p>
<h3>Token Transmission and Storage</h3>
<p>A correctly signed, well-validated JWT is worthless if it travels over HTTP in plain text or ends up in a server log. Enforce HTTPS at the infrastructure level and use <code>UseHttpsRedirection()</code> as a fallback. For APIs, also consider rejecting requests that arrive without TLS — a middleware check on <code>context.Request.IsHttps</code> is a simple safeguard.</p>
<p>Logging middleware is a common culprit for token leakage. Structured logging libraries like Serilog, when configured with request body or header logging, will happily capture the <code>Authorization</code> header. Ensure that <code>Authorization</code>, <code>Cookie</code>, and any other credential-bearing headers are added to a destructuring exclusion list in your Serilog configuration.</p>
<p>On the client side, <code>localStorage</code> is accessible to any JavaScript running on the page — including injected scripts from a successful XSS attack. For web applications that consume your API, <code>HttpOnly</code> cookies provide a safer token storage mechanism. For mobile and server-to-server integrations, the token should live in memory only — never written to persistent storage.</p>
<h3>Token Expiry and Rotation</h3>
<p>Long-lived access tokens are a significant risk because any leak gives the attacker a long operational window. The standard guidance — and what production security postures converge on — is short access tokens (10–15 minutes) combined with a refresh token rotation strategy. When a refresh token is used to obtain a new access token, the old refresh token should be invalidated immediately. If a refresh token is used twice, that is a strong signal of theft, and the entire token family for that user session should be revoked.</p>
<p>ASP.NET Core does not include refresh token rotation out of the box. It requires a stored token table, a revocation check on every refresh request, and a token family concept to detect replay. This is where many teams shortcut and pay the price later. If you are building this for the first time, the effort is substantial but well worth it — and it is covered in full in <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 7 of the Zero to Production course</a>.</p>
<p>Reference tokens — where the token is an opaque identifier that maps to session data stored server-side — are an alternative approach that makes revocation trivially simple. The trade-off is that every request requires a database or cache lookup. For internal services where that overhead is acceptable, reference tokens can be a more operationally controllable choice. The <a href="https://codingdroplets.com/jwt-authentication-vs-reference-tokens-aspnet-core-apis-enterprise-decision-guide">JWT vs Reference Tokens Enterprise Decision Guide</a> covers this trade-off in detail.</p>
<h3>Credential Stuffing and Brute-Force on Auth Endpoints</h3>
<p>The <code>/auth/login</code> and <code>/auth/refresh</code> endpoints deserve the same rate limiting treatment as any other high-value endpoint. Without it, they become targets for credential stuffing — automated attacks that try large volumes of username/password combinations sourced from data breaches.</p>
<p>ASP.NET Core's built-in rate limiting middleware makes per-endpoint policies straightforward to apply. A fixed window policy on login — limiting to a small number of attempts per IP per minute — raises the cost of credential stuffing significantly. Combine this with a <code>Retry-After</code> response header on 429s and an account lockout mechanism (even a temporary soft lock) to push the cost even higher. The <a href="https://codingdroplets.com/asp-net-core-10-rate-limiting-for-saas-in-2026-enterprise-policy-guide">ASP.NET Core Rate Limiting Enterprise Policy Guide</a> covers the implementation options in detail.</p>
<h2>Defence-in-Depth Checklist</h2>
<h3>Is Your JWT Configuration Actually Secure?</h3>
<p>No single control eliminates broken authentication risk. The effective mitigation is layering defences so that exploiting one weakness does not immediately compromise the system:</p>
<ul>
<li><p>✅ <strong>Signing key:</strong> Minimum 256-bit random value, stored in secrets manager, never in source control</p>
</li>
<li><p>✅ <strong>Algorithm validation:</strong> Explicitly set <code>ValidAlgorithms</code> — reject <code>none</code> and any algorithm you are not actively using</p>
</li>
<li><p>✅ <strong>Audience and issuer:</strong> Both validated, both specific to the environment</p>
</li>
<li><p>✅ <strong>ClockSkew:</strong> Set to <code>TimeSpan.Zero</code> or under 30 seconds</p>
</li>
<li><p>✅ <strong>Access token expiry:</strong> 10–15 minutes maximum for user-facing APIs</p>
</li>
<li><p>✅ <strong>Refresh token rotation:</strong> Old token invalidated on use; token family revoked on replay detection</p>
</li>
<li><p>✅ <strong>HTTPS enforcement:</strong> <code>UseHttpsRedirection()</code> and TLS at infrastructure level; never accept credentials over HTTP</p>
</li>
<li><p>✅ <strong>Header logging exclusions:</strong> <code>Authorization</code> and <code>Cookie</code> headers excluded from structured log output</p>
</li>
<li><p>✅ <strong>Rate limiting on auth endpoints:</strong> Fixed or sliding window with <code>Retry-After</code> on 429 responses</p>
</li>
<li><p>✅ <strong>Account lockout:</strong> Temporary lockout or exponential back-off after repeated failed attempts</p>
</li>
</ul>
<h2>What Teams Get Wrong in Production</h2>
<p>The most common failure mode is not a single wrong decision — it is a series of small shortcuts that compound. A team hardcodes the JWT secret during development and never rotates it to a secrets manager before go-live. They leave <code>ValidateAudience = false</code> because it was causing errors during local testing. They set a one-week access token expiry because the product team did not want users to see re-authentication prompts. They log full request headers for debugging and forget to clean that up before production.</p>
<p>Each of these shortcuts is understandable in isolation. Together, they create an API where a single intercepted token gives an attacker a week of access, the signing key is readable from the repo, and validation parameters are loose enough to accept forged tokens from other services. This is not a hypothetical scenario — it is the authentication posture of a significant fraction of production ASP.NET Core APIs that have not been through a formal security review.</p>
<p>The corrective path is methodical. Start with the signing key and token validation parameters — those fixes are low-effort and high-impact. Then address token lifetime and rotation. Then lock down the auth endpoints with rate limiting. Then audit your logging configuration for credential leakage. Treat it as a progression, not a single sprint.</p>
<h2>When Broken Authentication Intersects with Other Risks</h2>
<p>Broken authentication rarely operates in isolation. It interacts with several other API security risks in ways that amplify the impact:</p>
<p><strong>With BOLA:</strong> If authentication is broken and an attacker obtains a valid token for any user — not just a privileged one — they can then use object-level authorization flaws to access data belonging to other users. The token provides the initial foothold; BOLA provides the lateral movement.</p>
<p><strong>With Excessive Data Exposure:</strong> If token validation is loose and a service accepts tokens not intended for it, it may return responses that include more data than the legitimate caller would ever see — inadvertently exposing fields that were gated by audience/scope.</p>
<p><strong>With Broken Function Level Authorization:</strong> A stolen or forged token that passes weak validation may carry claims that grant access to administrative functions the original user was never meant to reach — especially in systems where role claims are embedded in the token without server-side verification.</p>
<p>This is why the defence-in-depth checklist matters. Fixing authentication in isolation is necessary but not sufficient for a secure API.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<h2>Frequently Asked Questions</h2>
<p><strong>What is broken authentication in the context of ASP.NET Core APIs?</strong> Broken authentication refers to weaknesses in how an ASP.NET Core API verifies the identity of callers. This includes weak JWT signing keys, incomplete token validation (skipping audience/issuer checks), insecure token transmission over HTTP, long-lived tokens without rotation, and auth endpoints exposed to brute-force or credential stuffing attacks. OWASP classifies this as API2:2023.</p>
<p><strong>How short should JWT access token expiry be in ASP.NET Core?</strong> For user-facing APIs, the standard recommendation is 10–15 minutes. This limits the damage window if a token is intercepted or leaked. Short access tokens should be paired with a refresh token rotation strategy so that users are not forced to re-authenticate constantly — the refresh token silently issues a new access token before the current one expires.</p>
<p><strong>What happens if I leave</strong> <code>ValidateAudience = false</code> <strong>in my JWT configuration?</strong> Disabling audience validation means a token issued for one service in your system can be used against any other service that trusts the same signing key. In a multi-service architecture, this creates a cross-service token replay risk — an attacker with a token for a low-privilege service can use it against a higher-privilege one, depending on what role claims are embedded.</p>
<p><strong>Should I set</strong> <code>ClockSkew</code> <strong>to</strong> <code>TimeSpan.Zero</code> <strong>in production?</strong> Yes, in most cases. The default five-minute clock skew means tokens are accepted for five minutes past their stated expiry. For short-lived access tokens, this represents a 33–50% extension of the valid window. Setting <code>ClockSkew = TimeSpan.Zero</code> eliminates this, but requires your server clocks to be synchronised (NTP, or cloud-provider time sync — which is standard in Azure, AWS, and GCP environments).</p>
<p><strong>What is refresh token rotation and why does it matter for ASP.NET Core APIs?</strong> Refresh token rotation means that when a refresh token is used to obtain a new access token, the old refresh token is immediately invalidated and a new one is issued. If the old refresh token is presented again (indicating it may have been stolen and used by an attacker), the server detects the replay and revokes the entire token family for that session. This significantly reduces the impact of refresh token theft compared to long-lived static refresh tokens.</p>
<p><strong>How do I prevent JWT secrets from appearing in application logs in ASP.NET Core?</strong> Configure your structured logging provider (Serilog, for example) to exclude the <code>Authorization</code> and <code>Cookie</code> headers from request log output. This is typically done through destructuring policies or explicit header exclusions in the <code>UseSerilogRequestLogging()</code> configuration. Also ensure that exception handlers never log the full request body — a login request body contains plaintext credentials.</p>
<p><strong>Is rate limiting on the login endpoint enough to prevent credential stuffing?</strong> Rate limiting raises the cost significantly but is not a complete defence on its own. Effective credential stuffing mitigation combines rate limiting per IP, per username (to detect distributed attacks), temporary account lockout after repeated failures, <code>Retry-After</code> headers on 429 responses, and ideally CAPTCHA or bot detection for web-facing login flows. Each layer makes automated attacks less economical.</p>
]]></content:encoded></item><item><title><![CDATA[The ASP.NET Core Authorization Checklist for .NET Teams]]></title><description><![CDATA[Authorization is one of those things that feels solved until it isn't. Teams ship a working JWT setup, add a few [Authorize] attributes, and assume the job is done — only to discover much later that r]]></description><link>https://codingdroplets.com/aspnet-core-authorization-checklist-dotnet-teams</link><guid isPermaLink="true">https://codingdroplets.com/aspnet-core-authorization-checklist-dotnet-teams</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[authorization]]></category><category><![CDATA[Security]]></category><category><![CDATA[WebAPI]]></category><category><![CDATA[ASP.NET Core Web API]]></category><category><![CDATA[dotnet10]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Sun, 31 May 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/2d5fac15-7b92-4328-b092-d71234c02fe5.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Authorization is one of those things that feels solved until it isn't. Teams ship a working JWT setup, add a few <code>[Authorize]</code> attributes, and assume the job is done — only to discover much later that role checks are inconsistent, resource ownership is never verified, and service-to-service calls are bypassing auth entirely. The asp.net core authorization checklist below gives every .NET team a structured way to audit what they have and close the gaps before they become incidents.</p>
<p>If you want to go beyond the checklist and see these patterns wired into a complete production API — with policy handlers, resource-based checks, and M2M auth all working together — the full implementation is on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, annotated and ready to adapt for your own system.</p>
<p>Authorization in isolation is understandable. Seeing how role policies, resource-level checks, and API key middleware interact inside a single production codebase is what actually makes the decisions click. <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 8 of the ASP.NET Core Web API: Zero to Production course</a> covers exactly that — walking through every authorization layer inside one connected API, with source code you can run immediately.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<hr />
<h2>Why Authorization Fails in Production</h2>
<p>Most authorization failures are not caused by the absence of controls — they are caused by controls applied inconsistently. A team might use <code>[Authorize(Roles = "Admin")]</code> on some endpoints, named policies on others, and nothing at all on a handful of internal endpoints that were "only for testing." Over time, these inconsistencies become exploitable.</p>
<p>The checklist format works well for authorization because the failure mode is usually omission, not misunderstanding. Teams know what to do — they just need a reliable way to verify it is actually done everywhere.</p>
<hr />
<h2>The Checklist</h2>
<h3>✅ 1. Register a Default Fallback Policy</h3>
<p>Every API should require authentication by default. Rather than placing <code>[Authorize]</code> on each controller or endpoint individually, register a fallback policy at the application level using <code>AddAuthorizationBuilder</code>. This means forgetting to add <code>[Authorize]</code> to a new endpoint does not silently open it to unauthenticated callers. Endpoints that must be public are then explicitly opted out with <code>[AllowAnonymous]</code>.</p>
<p>This one control closes entire classes of accidentally public endpoints.</p>
<h3>✅ 2. Use Named Policies — Never Inline Role Strings</h3>
<p>Hard-coded role strings scattered across attributes (<code>[Authorize(Roles = "Admin,Manager")]</code>) are a maintenance and audit nightmare. Define authorization requirements as named policies in a central location — typically an <code>AuthorizationPolicies</code> static class or similar. Each policy gets a clear name, and the role membership or claim logic lives in one place.</p>
<p>When a role is renamed or a new requirement is added, the change happens once. When you want to understand what <code>"ContentEditor"</code> can access, you look in one file — not across forty controllers.</p>
<h3>✅ 3. Write Custom <code>IAuthorizationRequirement</code> for Non-Trivial Logic</h3>
<p>Built-in helpers like <code>RequireRole</code> and <code>RequireClaim</code> cover simple cases. Any authorization logic that needs database access, time-windowed conditions, or multi-claim evaluation belongs in a dedicated <code>AuthorizationRequirement</code> and <code>AuthorizationHandler&lt;T&gt;</code> pair.</p>
<p>This keeps authorization logic testable in isolation — you can write unit tests against the handler without spinning up the full request pipeline. It also makes the intent explicit: <code>MinimumSubscriptionTierRequirement</code> is far more descriptive than a chain of claims comparisons inline in a controller action.</p>
<h3>✅ 4. Apply Resource-Based Authorization for Ownership Checks</h3>
<p>A policy check verifies what a user is allowed to do in general. Resource-based authorization verifies what a user is allowed to do with a specific record. The two serve different purposes and both are necessary in most real applications.</p>
<p>Use <code>IAuthorizationService.AuthorizeAsync(user, resource, policyName)</code> at the handler or service layer when an action depends on who owns the resource — not just what role the caller has. An <code>Orders</code> endpoint might require authentication (policy) and additionally require that the authenticated user owns the specific order being modified (resource check). Skipping the resource check is how BOLA vulnerabilities appear.</p>
<h3>✅ 5. Separate Authentication from Authorization Middleware Position</h3>
<p><code>UseAuthentication()</code> and <code>UseAuthorization()</code> must appear in the correct order in the middleware pipeline, and both must appear after <code>UseRouting()</code> but before <code>UseEndpoints()</code> (or <code>MapControllers()</code>). Swapping them, or placing them before routing, produces silent failures where the authenticated identity is never populated when authorization runs.</p>
<p>Review <code>Program.cs</code> and verify the order explicitly: exception handling → HSTS → HTTPS redirection → static files → routing → CORS → authentication → authorization → endpoint mapping.</p>
<h3>✅ 6. Lock Down Machine-to-Machine Endpoints</h3>
<p>Service-to-service calls should not share the same authorization path as user-facing endpoints. Implement a dedicated API key middleware or OAuth 2.0 client credentials flow for M2M scenarios. The middleware should validate the key against a stored hash (never plaintext), return <code>401</code> on missing keys, and return <code>403</code> on valid but unpermitted keys.</p>
<p>If API keys are used, ensure they are scoped — a key issued to a background worker should not have the same permissions as a key issued to an external partner. Store which key maps to which service identity so audit logs are meaningful.</p>
<h3>✅ 7. Return 403 — Not 404 — When Authorization Fails on a Known Resource</h3>
<p>A common mistake is returning <code>404 Not Found</code> when an authorized user tries to access a resource they are not permitted to view — the intent being to hide whether the resource exists. While this is sometimes appropriate for high-sensitivity resources, it is often applied wholesale and incorrectly. The result is that legitimate permission errors look like "not found" in logs and during debugging, making support and incident response harder.</p>
<p>Use <code>403 Forbidden</code> when the resource exists and the caller is authenticated but not permitted. Reserve the <code>404</code> approach for resources where existence itself is sensitive (e.g., protected files, confidential records).</p>
<h3>✅ 8. Test Each Policy in Isolation</h3>
<p>Every named policy and every custom <code>AuthorizationHandler</code> should have a corresponding unit test. Use <code>AuthorizationHandlerContext</code> directly in tests — you do not need a web host to test handler logic. Verify both the success and failure paths, and test boundary conditions for time-windowed or claim-value-based requirements.</p>
<p>Authorization tests are cheap to write and expensive to skip. A passing CI pipeline with no authorization tests gives false confidence.</p>
<h3>✅ 9. Log Authorization Failures at the Right Level</h3>
<p>Failed authorization attempts — both authentication failures (401) and permission denials (403) — should be logged at <code>Warning</code> level, not <code>Information</code>. Include the requesting identity (or "anonymous"), the endpoint path, and the specific policy or resource that failed. This makes security incident investigation tractable.</p>
<p>Avoid logging at <code>Error</code> for expected authorization failures. Error-level noise in auth logs drowns out genuine problems. Reserve <code>Error</code> for unexpected exceptions inside authorization handlers.</p>
<h3>✅ 10. Scope Policies to the Minimum Required Permission</h3>
<p>It is tempting to create broad policies like <code>"CanManageEverything"</code> for administrative users and apply them everywhere for convenience. This approach grants more permission than required for each action and makes the blast radius of a compromised admin account larger than necessary.</p>
<p>Define policies at the granularity of the operation — <code>"CanPublishContent"</code>, <code>"CanArchiveContent"</code>, <code>"CanDeleteContent"</code> — rather than the role. Roles then become collections of policies, not permission boundaries in themselves. This approach maps directly to RBAC best practices and makes permission audits straightforward.</p>
<h3>✅ 11. Audit the Authorization Surface Before Each Release</h3>
<p>Before shipping a new feature or releasing a new API version, audit the authorization surface: are all new endpoints covered by the fallback policy or an explicit <code>[Authorize]</code>? Are all write operations protected? Are any sensitive <code>GET</code> endpoints missing ownership checks? Have any <code>[AllowAnonymous]</code> exemptions been added without a documented reason?</p>
<p>This does not need to be a heavyweight process. A five-minute review of new endpoints against the checklist catches most regressions before they ship.</p>
<h3>✅ 12. Document Who Can Do What — And Review It Quarterly</h3>
<p>Authorization logic lives in code, but the permission model it implements is a business decision. Document the intended permission matrix for your API — which roles or policies grant access to which operations — and review it quarterly with the team. This surfaces role creep (permissions that were added temporarily and never removed) and ensures that the code-level authorization model still reflects the current business intent.</p>
<p>A permission matrix in a Confluence page or a README section takes an hour to write and prevents months of confusion.</p>
<hr />
<h2>What Is the Best Way to Implement Authorization in ASP.NET Core?</h2>
<p>The best approach combines three layers: a default fallback policy that requires authentication globally, named policies for operation-level control, and resource-based authorization for ownership checks. Each layer addresses a different failure mode. Relying on any single layer leaves gaps that the others would have caught.</p>
<hr />
<h2>Authorization Anti-Patterns to Avoid</h2>
<p><strong>Checking roles in business logic</strong> — Authorization belongs in the authorization layer, not inside services or repositories. Business logic that checks <code>HttpContext.User.IsInRole("Admin")</code> is hard to test and easy to miss during refactoring.</p>
<p><strong>Using</strong> <code>[Authorize]</code> <strong>without a policy name</strong> — A bare <code>[Authorize]</code> only checks that the user is authenticated. It does not verify any permission. Most write operations need more than this.</p>
<p><strong>Skipping resource-based checks</strong> — Policy checks alone do not prevent users from accessing other users' data. Resource-based authorization is not optional when data ownership matters.</p>
<p><strong>Sharing API keys across services</strong> — A single shared API key means a compromised key affects every service it was given to. Issue scoped keys per service and rotate them on a defined schedule.</p>
<p><strong>Allowing authorization exceptions to surface as 500s</strong> — Exceptions inside <code>AuthorizationHandler</code> implementations should be caught and logged. An unhandled exception in an authorization handler can result in a <code>500 Internal Server Error</code> that leaks stack trace information and bypasses the intended <code>403</code> response.</p>
<hr />
<p>☕ Find the checklist useful? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — it keeps the content coming!</p>
<hr />
<h2>Frequently Asked Questions</h2>
<p><strong>What is the difference between authentication and authorization in ASP.NET Core?</strong> Authentication answers "who are you?" — it validates identity through tokens, cookies, or certificates. Authorization answers "what are you allowed to do?" — it checks whether the authenticated identity has the required permissions. In ASP.NET Core, <code>UseAuthentication()</code> runs first and populates <code>HttpContext.User</code>; <code>UseAuthorization()</code> runs after and evaluates policies against that identity. Both are required, and order matters.</p>
<p><strong>What is a fallback authorization policy in ASP.NET Core?</strong> A fallback authorization policy is applied to any endpoint that does not have an explicit authorization attribute — neither <code>[Authorize]</code> nor <code>[AllowAnonymous]</code>. Setting a fallback policy that requires authentication ensures that new endpoints are protected by default, so omitting an <code>[Authorize]</code> attribute does not silently create a public endpoint. You configure this via <code>AddAuthorizationBuilder().SetFallbackPolicy(...)</code> in <code>Program.cs</code>.</p>
<p><strong>When should I use resource-based authorization instead of policy-based authorization?</strong> Use policy-based authorization when the permission depends on who the user is (their role, claims, or subscription tier). Use resource-based authorization when the permission also depends on the specific resource being accessed — for example, verifying that the authenticated user is the owner of the order they are trying to modify. Most real-world APIs need both.</p>
<p><strong>How do I test authorization handlers in ASP.NET Core?</strong> Instantiate the handler directly in a unit test and call <code>HandleAsync</code> with a constructed <code>AuthorizationHandlerContext</code>. Pass a mock or stub resource if required. Assert on <code>context.HasSucceeded</code> or <code>context.HasFailed</code>. This approach tests the handler logic without needing a full HTTP request pipeline, making tests fast and focused.</p>
<p><strong>What HTTP status code should a failed authorization return?</strong> Return <code>401 Unauthorized</code> when the caller is not authenticated (no identity established). Return <code>403 Forbidden</code> when the caller is authenticated but does not have the required permission. Using <code>404</code> to hide resource existence is sometimes appropriate for sensitive cases but should be a deliberate, documented decision — not a default behavior applied everywhere.</p>
<p><strong>How should API keys be implemented for service-to-service authorization in ASP.NET Core?</strong> Implement a custom middleware that reads an <code>X-Api-Key</code> header, hashes the value, and compares it against stored hashed keys. Never store API keys in plaintext. Associate each key with a service identity so authorization handlers can treat the service as a principal with defined permissions. Return <code>401</code> if the key is missing and <code>403</code> if the key is valid but the associated service lacks the required permission.</p>
<p><strong>What is the recommended way to handle authorization failures without leaking information?</strong> Return <code>403 Forbidden</code> with a <a href="https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-10.0">Problem Details</a> response body. The body should describe the error type (<code>AuthorizationFailure</code>) and a generic message (<code>Insufficient permissions</code>) — never include the specific policy name, resource ID, or claim values in the response. Log the full detail server-side at <code>Warning</code> level for audit purposes.</p>
]]></content:encoded></item><item><title><![CDATA[7 Common ASP.NET Core Logging Mistakes (And How to Fix Them)]]></title><description><![CDATA[Logging is one of those things that looks trivial until something breaks in production and you realise your logs are useless. The default ILogger<T> integration in ASP.NET Core is solid, but most team]]></description><link>https://codingdroplets.com/aspnet-core-logging-mistakes</link><guid isPermaLink="true">https://codingdroplets.com/aspnet-core-logging-mistakes</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[logging]]></category><category><![CDATA[serilog]]></category><category><![CDATA[ilogger]]></category><category><![CDATA[Structured logging]]></category><category><![CDATA[best practices]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Sat, 30 May 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/49a48513-9c5d-4782-bdf9-02b6f2b9b55d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Logging is one of those things that looks trivial until something breaks in production and you realise your logs are useless. The default <code>ILogger&lt;T&gt;</code> integration in ASP.NET Core is solid, but most teams accumulate a handful of quiet mistakes that compound over time — logs that are too noisy to read, too sparse to debug, or structured in a way that makes querying impossible. Each mistake is easy to make and equally easy to fix once you know what to look for. If you want to go deeper on choosing the right logging provider — Serilog, NLog, or the built-in <code>ILogger</code> abstraction — the <a href="https://codingdroplets.com/aspnet-core-structured-logging-serilog-nlog-ilogger-enterprise-decision-guide">Structured Logging: Serilog vs NLog vs ILogger Enterprise Decision Guide</a> covers the trade-offs in detail.</p>
<p>The full working examples, production-ready Serilog configuration, and log enrichment patterns are available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a> — with annotated source code you can drop straight into an existing ASP.NET Core project.</p>
<p>Understanding <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 14 of the ASP.NET Core Web API: Zero to Production course</a> is where this clicks into place — it covers structured logging with Serilog, <code>UseSerilogRequestLogging</code>, log levels, and OpenTelemetry in the same chapter, wired into a full production codebase.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<hr />
<h2>Mistake 1: Using String Interpolation Instead of Message Templates</h2>
<p>This is the most widespread logging mistake in .NET codebases, and it costs you more than you might expect.</p>
<p>When you write <code>_logger.LogInformation($"User {userId} logged in")</code>, you get a formatted string. The log provider stores it as plain text. You lose the ability to query by <code>userId</code> later, and you pay the cost of string allocation even when the log level is filtered out.</p>
<p>The correct approach is to use named placeholders in the message template:</p>
<pre><code class="language-csharp">_logger.LogInformation("User {UserId} logged in", userId);
</code></pre>
<p>With named placeholders, structured log providers like Serilog capture <code>UserId</code> as a first-class searchable property. You can then run queries like <code>WHERE UserId = '123'</code> in Seq, Loki, or Application Insights without parsing free-form text. This matters enormously at scale — filtering by message text is slow, filtering by a structured property is fast.</p>
<p>The performance gain is also real. <code>ILogger</code> checks whether the log level is enabled before formatting the message. With string interpolation, the interpolation happens before the check. With message templates, nothing is allocated if the log level is filtered.</p>
<p><strong>The fix:</strong> Replace every interpolated log string with a structured template. If you are working on a large codebase, use a Roslyn analyser (the <code>Microsoft.Extensions.Logging.Analyzers</code> NuGet package includes <code>CA2254</code>) to catch violations automatically.</p>
<hr />
<h2>Mistake 2: Logging at the Wrong Level</h2>
<p>Every team has a production system where warnings dominate and errors are drowned out, or a system where everything is <code>Information</code> and the noise-to-signal ratio is impossible. Wrong log levels are a silent problem — they don't break anything, but they make your logs unreliable as a diagnostic tool.</p>
<p>The practical rule for ASP.NET Core APIs:</p>
<ul>
<li><p><code>Trace</code> <strong>/</strong> <code>Debug</code><strong>:</strong> Development only. Entering a method, loop iterations, variable states. Should never reach production log sinks.</p>
</li>
<li><p><code>Information</code><strong>:</strong> Something meaningful happened. A request completed, a scheduled job ran, a user authenticated. Readable in production but filtered in high-traffic environments.</p>
</li>
<li><p><code>Warning</code><strong>:</strong> Something unexpected but recoverable. A retry succeeded, a fallback was triggered, a deprecated path was hit.</p>
</li>
<li><p><code>Error</code><strong>:</strong> Something failed that should not have. An operation that was expected to succeed did not. Requires investigation.</p>
</li>
<li><p><code>Critical</code><strong>:</strong> The application is about to stop or has entered an unrecoverable state.</p>
</li>
</ul>
<p>The most common violation is logging caught exceptions at <code>Information</code>. If you caught an exception and decided to continue, it belongs at <code>Warning</code> at minimum — you expected the happy path, something went wrong, but you recovered. Log the exception object, not just the message:</p>
<pre><code class="language-csharp">_logger.LogWarning(ex, "Downstream service unavailable, using cached result for {CustomerId}", customerId);
</code></pre>
<p>Passing the exception as the first argument ensures the full stack trace is captured by structured providers. Many developers mistakenly log only <code>ex.Message</code>, which throws away the stack trace entirely.</p>
<p><strong>The fix:</strong> Audit your log calls by level. Every <code>LogError</code> should represent a genuine failure requiring investigation. Every <code>LogWarning</code> should represent something abnormal but handled. <code>LogInformation</code> should be readable and meaningful, not a wall of method-entry noise.</p>
<hr />
<h2>Mistake 3: Not Filtering Log Levels Per Namespace in <code>appsettings.json</code></h2>
<p>The default <code>appsettings.json</code> logging configuration looks innocuous:</p>
<pre><code class="language-json">"Logging": {
  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning"
  }
}
</code></pre>
<p>But many teams stop here and never tune it further. The result is that EF Core logs every SQL query at <code>Information</code> in production, ASP.NET Core internal pipeline logs clutter your sinks, and Microsoft framework noise makes real application events hard to find.</p>
<p>The fix is to apply category-level filters. EF Core's SQL logging, for example, belongs at <code>Debug</code> in production:</p>
<pre><code class="language-json">"Logging": {
  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning",
    "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
    "System.Net.Http.HttpClient": "Warning"
  }
}
</code></pre>
<p>Setting <code>Microsoft.EntityFrameworkCore.Database.Command</code> to <code>Warning</code> suppresses the SQL query logs unless they fail. Setting <code>System.Net.Http.HttpClient</code> to <code>Warning</code> suppresses the lifecycle noise from <code>IHttpClientFactory</code>-managed clients.</p>
<p>Also remember that <code>appsettings.Development.json</code> should override these to <code>Debug</code> or <code>Trace</code> for local development, giving you full visibility without polluting production sinks.</p>
<p><strong>The fix:</strong> Treat <code>appsettings.json</code> log configuration as a first-class concern. Profile your production log output and suppress framework namespaces that produce noise without diagnostic value.</p>
<hr />
<h2>Mistake 4: Logging Sensitive Data</h2>
<p>Developers log user input and request data to make debugging easier — and accidentally build a PII audit trail that violates GDPR, HIPAA, or their own data handling policy. Passwords, tokens, credit card numbers, email addresses, and user identifiers in log sinks are a compliance incident waiting to happen.</p>
<p>The most common vector is logging the entire request body or a model that contains sensitive fields. This often happens during debugging and gets committed without review.</p>
<p>For Serilog users, the <code>Serilog.Expressions</code> destructuring policies let you strip sensitive properties from logged objects before they reach any sink. You can also use <code>[LogMasked]</code> from <code>Destructurama.Attributed</code> to annotate DTO properties that should be redacted:</p>
<pre><code class="language-csharp">public class LoginRequest
{
    public string Username { get; set; }

    [NotLogged]
    public string Password { get; set; }
}
</code></pre>
<p>For teams using the built-in <code>ILogger</code>, the pattern is to avoid logging model objects directly — log only the properties you specifically need, by name.</p>
<p><strong>The fix:</strong> Establish a team rule: never log objects that might contain credentials, payment data, or user-identifying information without explicit scrubbing. Add a code review checklist item for any log call that destructures (<code>@</code>) an object. For Serilog-based teams, configure destructuring policies at setup time rather than relying on per-developer discipline.</p>
<hr />
<h2>Mistake 5: Injecting <code>ILogger</code> Statically or via <code>LoggerFactory.Create</code></h2>
<p>Some codebases — often older ones migrated to ASP.NET Core from .NET Framework — use <code>LoggerFactory.Create</code> or static logger instances instead of constructor injection. This bypasses the DI-managed provider chain, which means:</p>
<ul>
<li><p>Configuration changes in <code>appsettings.json</code> have no effect</p>
</li>
<li><p>The logger does not inherit sink configuration from the host</p>
</li>
<li><p>Log enrichers (like request correlation IDs or environment names) are not applied</p>
</li>
<li><p>The logger cannot be replaced in tests</p>
</li>
</ul>
<p>The correct approach is always to inject <code>ILogger&lt;T&gt;</code> through the constructor:</p>
<pre><code class="language-csharp">public class OrderService
{
    private readonly ILogger&lt;OrderService&gt; _logger;

    public OrderService(ILogger&lt;OrderService&gt; logger)
    {
        _logger = logger;
    }
}
</code></pre>
<p>The generic type parameter <code>&lt;T&gt;</code> is the log category name. It corresponds to the class the logger belongs to, which is what you use in <code>appsettings.json</code> to configure per-namespace filtering (see Mistake 3).</p>
<p><strong>The fix:</strong> Grep for <code>LoggerFactory.Create</code>, <code>new Logger</code>, or direct <code>Serilog.Log.</code> calls outside of <code>Program.cs</code>. Any logger instantiated outside the DI container is a liability. The only legitimate place for static logger access is early in <code>Program.cs</code> before the host is built — for bootstrap logging only.</p>
<hr />
<h2>Mistake 6: Missing Correlation IDs Across Service Boundaries</h2>
<p>In a distributed system — or even a monolith with multiple background jobs — the same root request spawns multiple log entries. Without a shared correlation ID, you cannot trace a single user request through its entire lifecycle. You end up with a sea of disconnected log entries and no way to reconstruct what happened.</p>
<p>ASP.NET Core provides <code>IHttpContextAccessor</code> to read the HTTP context, and the <code>X-Correlation-ID</code> header convention is widely adopted. The right place to handle this is in middleware: read or generate a correlation ID on each incoming request, add it to the current activity, and enrich all log entries for that request automatically.</p>
<p>With Serilog, <code>UseSerilogRequestLogging()</code> captures request-level metadata (duration, status code, path) in a single structured log event per request — which is far more useful than the separate per-request entries that ASP.NET Core emits by default. Pair it with a middleware that calls <code>LogContext.PushProperty("CorrelationId", correlationId)</code> and every log entry in that request automatically carries the correlation ID.</p>
<p>For how to choose the right log aggregation platform to query correlation IDs across services, the <a href="https://codingdroplets.com/seq-vs-grafana-loki-vs-application-insights-dotnet-2026">Seq vs Grafana Loki vs Azure Application Insights</a> comparison breaks down which tool fits which team size and budget.</p>
<p><strong>The fix:</strong> Add correlation ID middleware early in your pipeline. Enrich every log entry with the correlation ID via Serilog's <code>LogContext</code> or a custom <code>ILogger</code> scope. Make it a deployment standard, not an optional enhancement.</p>
<hr />
<h2>Mistake 7: Logging Too Much or Too Little in the Application Layer</h2>
<p>Teams swing between two failure modes: logging every method entry and exit (producing gigabytes of noise), or logging only at the controller layer and missing everything that happens inside services and repositories.</p>
<p>The right model is to log at decision points, not execution points:</p>
<ul>
<li><p><strong>Log when a significant decision is made</strong> — a feature flag resolved to an alternate path, a payment was approved, a rate limit was triggered</p>
</li>
<li><p><strong>Log when something unexpected happened but was handled</strong> — a cache miss forced a database fallback, a downstream service returned a non-2xx response</p>
</li>
<li><p><strong>Do not log</strong> routine reads, validation passes, or anything that happens on every request unconditionally</p>
</li>
</ul>
<p>Background services are a particular trap. A hosted service that polls every second and logs <code>"Background job started"</code> and <code>"Background job completed"</code> on each cycle generates 172,800 log entries per day from a single host. None of them are useful unless something actually went wrong.</p>
<p>The EF Core <code>SaveChanges</code> path is another. Logging every database write at <code>Information</code> level in a write-heavy API produces noise proportional to your traffic — not to the number of things worth knowing about.</p>
<p><strong>The fix:</strong> Review your application layer logs as a product decision. For every recurring log entry, ask: "When would I actually look at this?" If the honest answer is "only when something is broken" — that's <code>Debug</code> or <code>Trace</code>, not <code>Information</code>. Reserve <code>Information</code> for log entries that tell a meaningful story about what the system is doing.</p>
<hr />
<h2>Bring It Together</h2>
<p>These seven mistakes share a common root cause: treating logging as an afterthought rather than an architectural concern. Logging that is noisy enough to ignore is just as harmful as no logging at all — in both cases, you are flying blind when production issues occur.</p>
<p>The fixes are straightforward once identified: use message templates, choose log levels deliberately, filter by namespace, protect sensitive data, use DI-managed loggers, add correlation IDs, and log decisions not executions.</p>
<blockquote>
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<hr />
<h2>FAQ</h2>
<p><strong>What is the most common ASP.NET Core logging mistake?</strong> Using string interpolation (<code>$"..."</code>) instead of structured message templates. Interpolation produces plain text that cannot be queried by property, and it allocates a string even when the log level is filtered out. Use named placeholders like <code>"User {UserId} logged in"</code> instead.</p>
<p><strong>Should I use Serilog or the built-in ILogger in ASP.NET Core?</strong> Use <code>ILogger&lt;T&gt;</code> from <code>Microsoft.Extensions.Logging</code> throughout your application code regardless of your chosen provider. Serilog, NLog, and other providers plug in as sinks behind that abstraction. Your application code should never reference <code>Serilog.Log</code> directly — that's provider configuration, not application logic.</p>
<p><strong>What log level should I use for exceptions in ASP.NET Core?</strong> Caught exceptions that were handled and from which the application recovered should be logged at <code>Warning</code>. Exceptions that represent genuine failures requiring investigation should be <code>Error</code>. Always pass the exception object as the first argument (before the message template) so structured providers capture the full stack trace.</p>
<p><strong>How do I prevent sensitive data from appearing in logs?</strong> Avoid logging objects or models that may contain sensitive fields. For Serilog, configure destructuring policies or use <code>[NotLogged]</code> from <code>Destructurama.Attributed</code> on DTO properties. For built-in <code>ILogger</code>, log only the specific properties you need by name — never log a raw request body or an authentication model.</p>
<p><strong>What is</strong> <code>UseSerilogRequestLogging</code> <strong>and why should I use it?</strong><code>UseSerilogRequestLogging()</code> is Serilog's ASP.NET Core integration method that replaces the multiple per-request log entries ASP.NET Core emits by default with a single structured log event per request, including duration, status code, and path as structured properties. It reduces noise significantly in high-traffic APIs and makes per-request analysis far easier in log aggregation tools.</p>
<p><strong>How do I add correlation IDs to all log entries in ASP.NET Core?</strong> Create a middleware that reads the <code>X-Correlation-ID</code> header (or generates a new GUID if absent), then calls <code>Serilog.Context.LogContext.PushProperty("CorrelationId", correlationId)</code> inside a <code>using</code> block for the lifetime of that request. Every log entry written during that request automatically inherits the correlation ID as a structured property.</p>
<p><strong>Why are my log level filters in</strong> <code>appsettings.json</code> <strong>not working?</strong> Log level configuration is applied hierarchically by namespace. If you set <code>"Default": "Information"</code>, all categories inherit that unless overridden. For EF Core SQL logs, explicitly set <code>"Microsoft.EntityFrameworkCore.Database.Command": "Warning"</code>. Also verify that your Serilog or NLog setup reads from the <code>Logging</code> section of configuration — some setup guides configure the provider directly in code, which bypasses <code>appsettings.json</code> filters entirely.</p>
]]></content:encoded></item><item><title><![CDATA[Domain Events vs Integration Events in .NET: Which Should Your Team Use?]]></title><description><![CDATA[Most .NET teams reach for "events" as a general-purpose solution long before they have a clear model for what kind of event they're working with. The confusion is understandable — both domain events a]]></description><link>https://codingdroplets.com/domain-events-vs-integration-events-dotnet</link><guid isPermaLink="true">https://codingdroplets.com/domain-events-vs-integration-events-dotnet</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[#Domain-Driven-Design]]></category><category><![CDATA[architecture]]></category><category><![CDATA[event-driven]]></category><category><![CDATA[C#]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Clean Architecture]]></category><category><![CDATA[#CQRS]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Sat, 30 May 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/b1429136-0075-4670-8657-6923a1bd7e92.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most .NET teams reach for "events" as a general-purpose solution long before they have a clear model for what kind of event they're working with. The confusion is understandable — both domain events and integration events involve something happening and code reacting to it. But they solve completely different problems, live in different layers, and carry different reliability guarantees. Getting them mixed up produces systems that either over-engineer simple flows or silently lose critical cross-service notifications.</p>
<p>The distinction becomes especially important in ASP.NET Core projects built around Clean Architecture or CQRS, where the boundaries between your domain layer, application layer, and external infrastructure must stay sharp. For teams building these patterns in a real production codebase, <a href="https://aspnetcoreapi.codingdroplets.com/">Chapter 11 and 12 of the ASP.NET Core Web API: Zero to Production course</a> cover exactly this — Clean Architecture, CQRS with MediatR, the Outbox pattern, and domain events all wired together in a full codebase you can run immediately.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" /></a></p>
<p>Understanding where your domain ends and your infrastructure begins is what makes the difference between a maintainable codebase and one that bleeds responsibilities across every layer. The patterns covered in this article go much deeper on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, with production-ready source code that shows how domain events dispatch through MediatR pipeline behaviors, how integration events publish via the Outbox, and how the two co-exist cleanly without coupling.</p>
<h2>What Are Domain Events?</h2>
<p>A domain event represents something that happened within your bounded context — a fact about your domain model. It is raised inside the domain layer, dispatched in-process, and consumed by handlers within the same application boundary. It does not cross a network, does not involve a message broker, and is not durable by default.</p>
<p>The typical shape: an order is placed, an <code>OrderPlaced</code> domain event fires, and handlers within the same process react — perhaps applying a discount policy, checking inventory, or publishing an audit entry. All of this happens synchronously in the same transaction window, or asynchronously via an in-memory dispatcher like MediatR.</p>
<p>Domain events are synchronous in intent even when dispatched asynchronously. Their purpose is to keep your domain model free of direct dependencies on side-effect logic. Instead of your <code>Order</code> entity calling an email service or a notification handler, it raises <code>OrderPlaced</code> and lets the application layer wire the consequences together through handlers.</p>
<p>Key characteristics:</p>
<ul>
<li><strong>Scope:</strong> In-process, same bounded context</li>
<li><strong>Dispatcher:</strong> MediatR <code>INotification</code> + <code>INotificationHandler&lt;T&gt;</code>, or a simple <code>IDomainEventDispatcher</code></li>
<li><strong>Durability:</strong> Not durable — if the process crashes after raising the event but before handling, the event is lost</li>
<li><strong>Coupling:</strong> Handlers are registered via DI — no shared infrastructure required</li>
<li><strong>Transaction:</strong> Can participate in the same database transaction as the originating aggregate change</li>
</ul>
<h2>What Are Integration Events?</h2>
<p>An integration event is a message published across a process boundary — to another service, another bounded context, or any external consumer. It represents a contract between systems, not an internal domain concern.</p>
<p>Where a domain event is a private signal within your model, an integration event is a public broadcast. It travels over a message broker (RabbitMQ, Azure Service Bus, Kafka), persists durably, and must be designed with versioning, idempotency, and consumer compatibility in mind from the start.</p>
<p>The typical shape: after an order is confirmed, the Order service publishes an <code>OrderConfirmed</code> integration event. The Notification service, the Inventory service, and the Analytics service each subscribe independently. They receive the event even if the Order service is briefly offline, because the broker persists it.</p>
<p>Key characteristics:</p>
<ul>
<li><strong>Scope:</strong> Cross-process, cross-service, across bounded contexts</li>
<li><strong>Transport:</strong> Message broker — RabbitMQ, Azure Service Bus, Kafka, MassTransit, NServiceBus</li>
<li><strong>Durability:</strong> Durable — the broker guarantees delivery even across transient failures</li>
<li><strong>Coupling:</strong> Consumers depend only on the message contract, not the publishing service's implementation</li>
<li><strong>Transaction:</strong> Must be handled with the Outbox pattern to guarantee atomic publishing alongside the database write</li>
</ul>
<h2>How Do Domain Events and Integration Events Relate?</h2>
<p>The most reliable pattern in production ASP.NET Core systems is: <strong>domain events trigger the creation of integration events via the Outbox pattern</strong>.</p>
<p>Here's the flow:</p>
<ol>
<li>An aggregate raises a domain event (<code>OrderPlaced</code>) during the command handler</li>
<li>A domain event handler subscribes to <code>OrderPlaced</code> and writes an <code>OrderConfirmedIntegrationEvent</code> record to the Outbox table — as part of the same <code>SaveChanges</code> call</li>
<li>A background processor reads the Outbox, publishes the integration event to the message broker, and marks the record as processed</li>
</ol>
<p>This pattern solves the dual-write problem: you can't atomically write to a database and publish to a broker in a single transaction. The Outbox bridges that gap. Your <a href="https://codingdroplets.com/aspnet-core-outbox-pattern-enterprise-decision-guide">ASP.NET Core Outbox Pattern guide</a> covers the full implementation pattern in detail.</p>
<p>Domain events and integration events do not compete — they compose. Domain events are the internal mechanism for reacting to changes within your model. Integration events are the external mechanism for communicating those changes to the outside world.</p>
<h2>Side-by-Side Comparison</h2>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>Domain Event</th>
<th>Integration Event</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Scope</strong></td>
<td>In-process, same bounded context</td>
<td>Cross-process, cross-service</td>
</tr>
<tr>
<td><strong>Transport</strong></td>
<td>In-memory (MediatR, custom dispatcher)</td>
<td>Message broker (RabbitMQ, Azure Service Bus, Kafka)</td>
</tr>
<tr>
<td><strong>Durability</strong></td>
<td>Not durable by default</td>
<td>Durable — broker persists until consumed</td>
</tr>
<tr>
<td><strong>Versioning</strong></td>
<td>Not required</td>
<td>Required — breaking changes affect all consumers</td>
</tr>
<tr>
<td><strong>Idempotency</strong></td>
<td>Rarely needed</td>
<td>Required — consumers must handle duplicates</td>
</tr>
<tr>
<td><strong>Transaction</strong></td>
<td>Participates in originating transaction</td>
<td>Published via Outbox, separate from domain transaction</td>
</tr>
<tr>
<td><strong>Coupling</strong></td>
<td>Handlers registered in same process</td>
<td>Consumers in separate processes, separate deployments</td>
</tr>
<tr>
<td><strong>Failure handling</strong></td>
<td>Simple retry via handler</td>
<td>Retry, dead-letter queue, poison message handling</td>
</tr>
<tr>
<td><strong>Schema</strong></td>
<td>Private to bounded context</td>
<td>Public contract — treat as a public API</td>
</tr>
</tbody></table>
<h2>When to Use Domain Events</h2>
<p>Use domain events when:</p>
<ul>
<li>You need to react to a state change within your domain model without coupling the aggregate to side-effect logic</li>
<li>The reaction happens in the same process and participates in the same unit of work</li>
<li>You want to enforce the dependency rule: domain layer raises events, application layer handles them</li>
<li>The consequence is immediate and does not need to cross a service boundary</li>
</ul>
<p><strong>Do not use domain events for:</strong> cross-service communication, events that must survive process restarts, or anything that requires a guaranteed delivery contract with external consumers.</p>
<h2>When to Use Integration Events</h2>
<p>Use integration events when:</p>
<ul>
<li>A state change in one service must notify another service</li>
<li>The consuming service is deployed independently and has its own database</li>
<li>You need guaranteed delivery — the broker must hold the event until the consumer acknowledges it</li>
<li>The event represents a public contract that other teams or services depend on</li>
</ul>
<p><strong>Do not use integration events for:</strong> internal reactions within the same bounded context. Routing everything through a message broker for in-process concerns adds latency, infrastructure complexity, and debuggability overhead for zero gain.</p>
<h2>Is It Always Domain → Integration Events?</h2>
<p>Not necessarily. Some teams use integration events without domain events — a command handler writes to the Outbox directly, skipping the domain event layer entirely. This is simpler, and for workflows without a rich domain model, it is often the right call.</p>
<p>Other teams use domain events without integration events — a single-service application where all reactions are in-process and no cross-service communication is needed.</p>
<p>The domain-event-to-integration-event pipeline makes the most sense in systems that: have a genuine domain model with aggregates, use CQRS and Clean Architecture, and communicate with multiple other services. For CRUD APIs or simple services, adding both layers is over-engineering.</p>
<h2>What Do Most .NET Teams Get Wrong?</h2>
<p><strong>Using integration events in-process.</strong> Publishing to a broker for reactions within the same service wastes infrastructure and makes debugging harder. In-process reactions belong to domain events.</p>
<p><strong>Using domain events for cross-service communication.</strong> An in-memory event that raises across a service boundary doesn't cross that boundary — it just doesn't execute. Domain events cannot substitute for a message broker.</p>
<p><strong>Skipping the Outbox.</strong> Publishing an integration event directly inside a transaction handler (before <code>SaveChanges</code>) creates a dual-write problem. If the broker publish succeeds but <code>SaveChanges</code> fails, the event was never supposed to fire. Always write to the Outbox table inside the same transaction, then publish from a background processor.</p>
<p><strong>Treating integration event schemas as internal.</strong> Integration events are contracts. Renaming a property, removing a field, or changing a type without versioning breaks all consumers silently. Apply the same discipline as a REST API: additive changes only, version for breaking changes.</p>
<p>For teams working through the <a href="https://codingdroplets.com/cqrs-and-mediatr-in-asp-net-core-enterprise-decision-guide">CQRS and MediatR implementation in ASP.NET Core</a>, the point at which domain events should convert to integration events is one of the most frequently misunderstood decisions in Clean Architecture.</p>
<h2>Recommendation</h2>
<p>For a standard ASP.NET Core API that communicates with at least one other service:</p>
<ul>
<li>Use <strong>domain events</strong> (via MediatR <code>INotification</code>) for in-process reactions to aggregate state changes</li>
<li>Use the <strong>Outbox pattern</strong> to bridge from domain events to integration events safely</li>
<li>Use <strong>integration events</strong> over a message broker for any cross-service communication</li>
<li>Keep the two layers completely separate — integration events should not re-use domain event types</li>
</ul>
<p>For a single-service application or a simple CRUD API: skip domain events unless you have a genuine domain model. A command handler that writes directly to the Outbox and publishes via a broker is simpler and easier to trace.</p>
<blockquote>
<p>☕ Building this right takes time. If this walkthrough saved you some, <a href="https://buymeacoffee.com/codingdroplets">buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<h2>FAQ</h2>
<p><strong>What is the difference between a domain event and an integration event in .NET?</strong>
A domain event is an in-process signal within a bounded context — dispatched via MediatR or a custom dispatcher, not durable, and handled within the same transaction. An integration event is a cross-service message published to a broker (RabbitMQ, Azure Service Bus) — durable, versioned, and consumed by independent services.</p>
<p><strong>Can I use MediatR for integration events in ASP.NET Core?</strong>
MediatR is in-process only and is not suitable for integration events. It has no built-in broker, no persistence, and no delivery guarantee. Use it for domain events. For integration events, use a message broker with a transport library like MassTransit, NServiceBus, or direct SDK clients for Azure Service Bus or RabbitMQ.</p>
<p><strong>Do I always need both domain events and integration events?</strong>
No. Use domain events when you have a rich domain model with aggregates and need to keep side-effect logic out of the domain layer. Use integration events when you need to communicate with other services. A simple CRUD API may need neither. A complex microservices system will need both.</p>
<p><strong>How do I prevent losing integration events if my service crashes after committing the database but before publishing to the broker?</strong>
Use the Outbox pattern: write the integration event to a database table inside the same transaction as your domain change. A background processor polls the Outbox and publishes events to the broker, marking each as processed after acknowledgement. This guarantees at-least-once delivery without dual-write risk.</p>
<p><strong>What is the best way to dispatch domain events in ASP.NET Core?</strong>
Register domain events as <code>INotification</code> in MediatR and dispatch them from within your command handlers after <code>SaveChangesAsync</code>. You can also dispatch them inside EF Core's <code>SaveChanges</code> override by reading pending events from aggregates before committing. The second approach is cleaner for rich domain models — it ensures events are always dispatched atomically with the persistence operation.</p>
<p><strong>Should integration events use the same types as domain events?</strong>
No. They serve different purposes and evolve at different rates. Domain event types are internal — they can change freely without affecting consumers. Integration event types are public contracts — changing them requires versioning and backward-compatibility planning. Keep them in separate namespaces and assemblies, with integration events in a shared contracts project if multiple services consume them.</p>
<p><strong>How do I handle failed integration event consumers in ASP.NET Core?</strong>
Design consumers to be idempotent — processing the same event twice should produce the same outcome. Brokers deliver at-least-once by default. For failures, configure dead-letter queues (DLQ) on the broker so failed messages are captured for inspection rather than dropped. Retry policies with exponential backoff, combined with a DLQ, cover most production failure scenarios.</p>
]]></content:encoded></item><item><title><![CDATA[IHttpContextAccessor in ASP.NET Core: Enterprise Decision Guide]]></title><description><![CDATA[IHttpContextAccessor is one of those ASP.NET Core abstractions that developers reach for early and often — and almost always in ways that create problems later. It works perfectly in controllers and m]]></description><link>https://codingdroplets.com/ihttpcontextaccessor-aspnet-core-enterprise-decision-guide</link><guid isPermaLink="true">https://codingdroplets.com/ihttpcontextaccessor-aspnet-core-enterprise-decision-guide</guid><category><![CDATA[asp.net core]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[Enterprise Development]]></category><category><![CDATA[dependency injection]]></category><category><![CDATA[Clean Architecture]]></category><category><![CDATA[Web API]]></category><category><![CDATA[IHttpContextAccessor]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Fri, 29 May 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/3e151fb6-ce54-4a97-bb46-794ae2433e92.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><code>IHttpContextAccessor</code> is one of those ASP.NET Core abstractions that developers reach for early and often — and almost always in ways that create problems later. It works perfectly in controllers and middleware, where it was designed to live. The trouble starts when it travels deeper: into services, domain logic, background jobs, and repositories. Every team that has shipped a production ASP.NET Core API has wrestled with this question at some point, and most have made the mistake at least once. The full walkthrough — including a production-ready <code>ICurrentUser</code> abstraction with complete DI wiring — is on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, where the source code is structured the way enterprise teams actually build it.</p>
<p>Understanding the right scope for <code>IHttpContextAccessor</code> is also central to building APIs that hold up under Clean Architecture constraints. Chapter 11 of the <a href="https://aspnetcoreapi.codingdroplets.com/">Zero to Production course</a> covers this directly — showing how to keep the domain layer free of infrastructure concerns while still threading user context through the full request pipeline correctly.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" /></a></p>
<p>This guide lays out the decision clearly: when <code>IHttpContextAccessor</code> is the right tool, when it becomes a liability, what the enterprise-approved alternative looks like, and which anti-patterns to avoid before they end up in production.</p>
<h2>What Is IHttpContextAccessor and Why Does It Exist?</h2>
<p>ASP.NET Core's request pipeline is built around <code>HttpContext</code> — the object that carries everything about the current HTTP request: headers, route values, query parameters, the authenticated user's claims, response state, and more. Inside a controller action or a middleware component, <code>HttpContext</code> is always available directly. The problem is everywhere else.</p>
<p>Services that are injected into controllers don't get <code>HttpContext</code> passed to them automatically. If a service registered in the DI container needs to read the current user's ID, check a request header, or inspect a claim, it has no direct path to that data. <code>IHttpContextAccessor</code> was introduced specifically to bridge this gap — it provides a way to reach the ambient <code>HttpContext</code> from any class that can participate in DI.</p>
<p>It works by using <code>AsyncLocal&lt;T&gt;</code> internally to store the current <code>HttpContext</code> and make it retrievable anywhere in the callstack during an active HTTP request. Register <code>AddHttpContextAccessor()</code> in your service registration, inject <code>IHttpContextAccessor</code> where you need it, and call <code>.HttpContext</code> to get the current request's context.</p>
<p>Simple enough. The problem is not what it does — it is where teams use it.</p>
<h2>When IHttpContextAccessor Is the Right Choice</h2>
<p>There are legitimate cases where injecting <code>IHttpContextAccessor</code> is completely appropriate.</p>
<p><strong>Middleware components</strong> that need to inspect or modify the request or response are the canonical use case. Middleware sits at the HTTP layer by design, and accessing <code>HttpContext</code> there is expected. The accessor adds nothing over the directly available context in this case — but it is not harmful either.</p>
<p><strong>Infrastructure services</strong> that are explicitly scoped to the HTTP request and live in the Infrastructure layer — such as an audit logging service that records the requester's IP address, or a request correlation service that reads the <code>X-Correlation-ID</code> header — are reasonable candidates for <code>IHttpContextAccessor</code>. These services have an intentional dependency on HTTP infrastructure, and that dependency is honest.</p>
<p><strong>Scaffolded or framework-generated code</strong> often uses <code>IHttpContextAccessor</code> directly for simple read operations. If you are working in a small application where layering is not a concern, using it directly in a service class is pragmatic and not worth fighting.</p>
<p>The key indicator that <code>IHttpContextAccessor</code> belongs where you are putting it: the class you are injecting it into is explicitly part of the HTTP infrastructure layer and has no expectation of running outside a web request context.</p>
<h2>When IHttpContextAccessor Becomes a Problem</h2>
<p>The anti-pattern is injecting <code>IHttpContextAccessor</code> into application services, domain logic, and repositories — layers that should have no dependency on HTTP infrastructure. This creates several compounding problems.</p>
<p><strong><code>HttpContext</code> can be null.</strong> <code>IHttpContextAccessor.HttpContext</code> returns null when accessed outside an active HTTP request. This means any service that injects it is fundamentally unsafe in background jobs, hosted services, unit tests, and integration tests that do not spin up a full HTTP pipeline. Developers routinely encounter <code>NullReferenceException</code> in these scenarios and then add null-checks that mask the underlying architectural mistake.</p>
<p><strong>It violates Clean Architecture dependency rules.</strong> In a properly layered system, the Application layer and Domain layer have no knowledge of HTTP. They receive data through method parameters, not through ambient context. Injecting <code>IHttpContextAccessor</code> into an application service couples the entire application layer to ASP.NET Core's HTTP infrastructure — making it impossible to use that layer in a console application, a background worker, or a test without a full web host.</p>
<p><strong>It makes unit testing painful.</strong> To test a service that injects <code>IHttpContextAccessor</code>, you either need to mock the accessor and fabricate an <code>HttpContext</code> — which requires instantiating a <code>DefaultHttpContext</code>, populating its <code>User</code> with a <code>ClaimsPrincipal</code>, and wiring it into the mock — or you need to skip those tests entirely. Neither outcome is acceptable for services that contain real business logic.</p>
<p><strong>It couples services to the HTTP request lifecycle.</strong> A service that depends on <code>IHttpContextAccessor</code> implicitly assumes it is only ever called during an active HTTP request. If a future requirement introduces a background process that calls the same service, the accessor returns null and the service breaks. The caller cannot know this from the service's interface.</p>
<p><strong><code>IHttpContextAccessor</code> was explicitly marked as unsafe by the ASP.NET Core team in specific scenarios.</strong> The GitHub issue <a href="https://github.com/dotnet/aspnetcore/issues/14975">dotnet/aspnetcore#14975</a> documents cases where <code>HttpContext</code> can return the wrong context or an already-disposed one under certain async execution patterns. The accessor is not a reliable ambient source of truth.</p>
<h2>The Enterprise Alternative: ICurrentUser</h2>
<p>The pattern that resolves all of these issues is a thin, domain-owned interface that abstracts the concept of "the current caller" away from its HTTP origins. Teams implement this in slightly different ways, but the shape is consistent across enterprise codebases.</p>
<p>The interface belongs in the Application or Domain layer and contains only what that layer actually needs:</p>
<pre><code class="language-csharp">public interface ICurrentUser
{
    string UserId { get; }
    string? Email { get; }
    IReadOnlyList&lt;string&gt; Roles { get; }
    bool IsAuthenticated { get; }
}
</code></pre>
<p>The implementation lives in the Infrastructure layer and is the only place <code>IHttpContextAccessor</code> appears:</p>
<pre><code class="language-csharp">public class HttpCurrentUser : ICurrentUser
{
    private readonly IHttpContextAccessor _accessor;

    public HttpCurrentUser(IHttpContextAccessor accessor)
        =&gt; _accessor = accessor;

    public string UserId =&gt;
        _accessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
        ?? throw new UnauthorizedAccessException("No active user context.");

    public string? Email =&gt;
        _accessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email);

    public IReadOnlyList&lt;string&gt; Roles =&gt;
        _accessor.HttpContext?.User.Claims
            .Where(c =&gt; c.Type == ClaimTypes.Role)
            .Select(c =&gt; c.Value)
            .ToList() ?? [];

    public bool IsAuthenticated =&gt;
        _accessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
}
</code></pre>
<p>Registration is straightforward:</p>
<pre><code class="language-csharp">builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped&lt;ICurrentUser, HttpCurrentUser&gt;();
</code></pre>
<p>With this pattern in place, application services depend only on <code>ICurrentUser</code> — an interface they own, with no knowledge of HTTP. Unit tests replace it with a simple in-memory stub. Background jobs can implement a different <code>ICurrentUser</code> that reads from a job context or returns a system identity. The HTTP infrastructure never leaks past the Infrastructure layer.</p>
<h2>Trade-Offs and When to Keep It Direct</h2>
<p>The <code>ICurrentUser</code> pattern adds indirection. For small applications without layering concerns, that indirection is pure overhead — an extra interface, an extra class, and an extra registration for something that <code>IHttpContextAccessor</code> could handle in a single line.</p>
<p>The decision matrix is straightforward:</p>
<table>
<thead>
<tr>
<th>Context</th>
<th>Recommended Approach</th>
</tr>
</thead>
<tbody><tr>
<td>Middleware or pipeline components</td>
<td>Use <code>HttpContext</code> directly</td>
</tr>
<tr>
<td>Infrastructure services (audit logs, correlation ID)</td>
<td><code>IHttpContextAccessor</code> is acceptable</td>
</tr>
<tr>
<td>Application services with user-dependent logic</td>
<td>Use <code>ICurrentUser</code> abstraction</td>
</tr>
<tr>
<td>Domain logic or entities</td>
<td>No user context at all — pass as method parameter</td>
</tr>
<tr>
<td>Background jobs or hosted services</td>
<td>Implement <code>ICurrentUser</code> backed by job context</td>
</tr>
<tr>
<td>Clean Architecture or layered DDD project</td>
<td>Always use <code>ICurrentUser</code> — never <code>IHttpContextAccessor</code> below Infrastructure</td>
</tr>
</tbody></table>
<p>The rule of thumb: if the class you are writing could in principle run without an HTTP request, it should not depend on <code>IHttpContextAccessor</code>.</p>
<h2>Anti-Patterns to Avoid</h2>
<p><strong>Injecting <code>IHttpContextAccessor</code> into a repository.</strong> Repositories belong to the Infrastructure layer and deal with data persistence. A repository that reads the current user from the accessor is hiding an implicit dependency that should be explicit — either passed as a method parameter or resolved through a dedicated abstraction.</p>
<p><strong>Using <code>IHttpContextAccessor</code> in a singleton service.</strong> Singletons live for the lifetime of the application. <code>IHttpContextAccessor.HttpContext</code> holds the current request's context, which changes on every request. Accessing it from a singleton means the singleton sees the context of whatever request happens to be executing at that moment — or null, if no request is active. This is a data race waiting to happen.</p>
<p><strong>Null-checking <code>HttpContext</code> and silently falling back.</strong> If a service checks whether <code>HttpContext</code> is null and returns a default value when it is, the service is lying about its contract. Callers that rely on the user's identity for authorization decisions will silently receive incorrect data. Fail loudly — throw an exception or use a typed null object that makes the absence of a user context explicit.</p>
<p><strong>Accessing <code>HttpContext.Items</code> for cross-layer communication.</strong> <code>HttpContext.Items</code> is a request-scoped dictionary that developers sometimes use to pass data between layers. This is ambient coupling — invisible to the compiler, invisible to tests, and invisible to anyone who reads the service's interface. Use DI, method parameters, or typed context objects instead.</p>
<h2>Clean Architecture Placement Guide</h2>
<p>For teams building Clean Architecture systems — especially those using CQRS with MediatR (see the <a href="https://codingdroplets.com/cqrs-and-mediatr-in-asp-net-core-enterprise-decision-guide">CQRS and MediatR in ASP.NET Core: Enterprise Decision Guide</a>) — the placement is clear:</p>
<ul>
<li><strong>Domain layer:</strong> No user context injected at all. If a domain operation needs the current user's ID, it receives it as a method parameter or embedded in the command/query object.</li>
<li><strong>Application layer:</strong> Depends on <code>ICurrentUser</code> (owned by the Application layer). Handlers read <code>ICurrentUser</code> to build audit trails, enforce ownership rules, or attach author IDs to entities.</li>
<li><strong>Infrastructure layer:</strong> Contains <code>HttpCurrentUser : ICurrentUser</code>, which is the only class allowed to depend on <code>IHttpContextAccessor</code>.</li>
<li><strong>API layer:</strong> Does not inject <code>ICurrentUser</code> directly — it populates commands and queries with the values it needs, rather than passing the accessor downstream.</li>
</ul>
<p>This separation means every layer is independently testable and independently deployable. If you also want to understand how authorization policies interact with this pattern, the <a href="https://codingdroplets.com/background-services-dotnet-10-ihostedservice-vs-backgroundservice-enterprise-guide">Background Services in .NET 10 guide</a> shows how to thread user context into background processing safely.</p>
<h2>Microsoft's Official Position</h2>
<p>The <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-context">official ASP.NET Core documentation</a> explicitly recommends against using <code>IHttpContextAccessor</code> in middleware and instead recommends passing <code>HttpContext</code> as a method parameter. For services outside middleware, Microsoft's guidance is to inject <code>IHttpContextAccessor</code> but acknowledges the risks. The <a href="https://github.com/dotnet/aspnetcore/issues/14975">GitHub issue tracking the accessor's unreliability</a> in async scenarios is linked from the docs and has never been fully resolved — the accessor is marked as unreliable in certain async patterns by the framework team itself.</p>
<p>The practical enterprise interpretation: use it in Infrastructure, hide it behind an abstraction everywhere else, and never let it cross into Application or Domain.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<h2>FAQ</h2>
<h3>What does IHttpContextAccessor do in ASP.NET Core?</h3>
<p><code>IHttpContextAccessor</code> provides access to the current <code>HttpContext</code> from any class that participates in ASP.NET Core's dependency injection system. It uses <code>AsyncLocal&lt;T&gt;</code> internally to track the active request context. Register it with <code>AddHttpContextAccessor()</code> and inject it where needed to read request data, user claims, headers, or response state from outside a controller or middleware.</p>
<h3>Is it safe to inject IHttpContextAccessor into a service layer?</h3>
<p>Not without understanding the risks. <code>IHttpContextAccessor.HttpContext</code> returns null when accessed outside an active HTTP request — such as in background jobs, hosted services, or unit tests. Injecting it into the service layer also couples your application logic to ASP.NET Core's HTTP infrastructure, which violates Clean Architecture principles and makes testing harder. The enterprise-recommended approach is to wrap it in an <code>ICurrentUser</code> abstraction and inject that instead.</p>
<h3>Can IHttpContextAccessor return null in production?</h3>
<p>Yes. <code>IHttpContextAccessor.HttpContext</code> returns null if called outside the scope of an HTTP request. This can happen in background services, <code>IHostedService</code> implementations, message consumers, and any code path that is not driven by an incoming HTTP request. Accessing it from a singleton service is especially risky — the singleton may hold a reference to a disposed <code>HttpContext</code> from a completed request.</p>
<h3>What is the ICurrentUser pattern in ASP.NET Core?</h3>
<p><code>ICurrentUser</code> is an application-owned interface that abstracts the concept of the caller's identity away from its HTTP origins. It is defined in the Application or Domain layer and exposes only what that layer needs — typically <code>UserId</code>, <code>Email</code>, <code>Roles</code>, and <code>IsAuthenticated</code>. The implementation lives in the Infrastructure layer and reads from <code>IHttpContextAccessor</code> internally. Application services depend on <code>ICurrentUser</code>, not on <code>IHttpContextAccessor</code>, keeping the dependency direction clean and the code testable.</p>
<h3>How do I test a service that needs the current user in ASP.NET Core?</h3>
<p>If the service depends on <code>ICurrentUser</code> rather than <code>IHttpContextAccessor</code>, testing is straightforward. Create a stub implementation of <code>ICurrentUser</code> that returns hardcoded test values, and register it in the test DI container. No mock HTTP context required. If the service injects <code>IHttpContextAccessor</code> directly, you must construct a <code>DefaultHttpContext</code>, populate its <code>User</code> property with a <code>ClaimsPrincipal</code>, and set it on a mocked accessor — significantly more friction for the same outcome.</p>
<h3>Should IHttpContextAccessor be registered as singleton or scoped?</h3>
<p><code>IHttpContextAccessor</code> is registered as a singleton by <code>AddHttpContextAccessor()</code>. The singleton registration is safe because the accessor does not store the <code>HttpContext</code> itself — it reads it from <code>AsyncLocal&lt;T&gt;</code>, which is request-scoped by nature. The singleton accessor reads the current request's context on every call. The risk of registering a consuming service as singleton is different: if your service is a singleton and stores a reference to a value from <code>HttpContext</code>, that reference may outlive the request it came from.</p>
<h3>When should I NOT use ICurrentUser and pass the user ID as a method parameter instead?</h3>
<p>In domain logic and entities, neither <code>ICurrentUser</code> nor <code>IHttpContextAccessor</code> should be injected. Domain objects should receive everything they need through constructor arguments or method parameters — making dependencies visible and the domain logic portable. If a domain method needs the current user's ID to enforce an ownership rule, pass it as a parameter from the application service layer. The application service reads from <code>ICurrentUser</code> and passes the value down; the domain object has no knowledge of where it came from.</p>
]]></content:encoded></item><item><title><![CDATA[EF Core Interceptors in ASP.NET Core: Enterprise Decision Guide]]></title><description><![CDATA[EF Core interceptors sit quietly in most codebases — underused, occasionally misunderstood, and almost always underestimated. Once your team grasps what they actually do and where they pay off, they b]]></description><link>https://codingdroplets.com/ef-core-interceptors-aspnet-core-enterprise-decision-guide</link><guid isPermaLink="true">https://codingdroplets.com/ef-core-interceptors-aspnet-core-enterprise-decision-guide</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[efcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[entity framework]]></category><category><![CDATA[enterprise]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Fri, 29 May 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/881ae79a-ad82-4ac7-8a2e-edd6fa7a8be0.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>EF Core interceptors sit quietly in most codebases — underused, occasionally misunderstood, and almost always underestimated. Once your team grasps what they actually do and where they pay off, they become one of the cleanest tools in the EF Core toolbox for cross-cutting concerns at the database layer.</p>
<p>For teams building enterprise ASP.NET Core APIs, EF Core interceptors deserve a clear-eyed answer to a simple question: <em>when should you reach for them, and when should you not?</em> If you want to work through these patterns inside a real production codebase — with audit fields, soft deletes, and everything wired together — the complete implementation is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, where full source code maps directly to what production teams actually ship.</p>
<p>Understanding interceptors in isolation is useful — seeing them work inside a complete production API, alongside repository patterns, global query filters, and change tracking, is what makes them click. That's exactly what Chapter 3 of the <a href="https://aspnetcoreapi.codingdroplets.com/">ASP.NET Core Web API: Zero to Production course</a> covers: EF Core beyond the basics, inside a full production codebase you can run immediately.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" /></a></p>
<h2>What EF Core Interceptors Actually Are</h2>
<p>Interceptors are hooks that let you observe — and optionally modify or suppress — EF Core operations before, during, or after they execute. They operate inside EF Core's internal pipeline, which means they fire reliably on every operation that goes through your <code>DbContext</code>, regardless of where in your application the operation was initiated.</p>
<p>The <a href="https://learn.microsoft.com/en-us/ef/core/logging-events-diagnostics/interceptors">official EF Core interceptors documentation</a> covers all available interfaces in detail. In practice, the core interfaces break down by concern:</p>
<ul>
<li><strong><code>ISaveChangesInterceptor</code></strong> — fires before and after <code>SaveChanges</code> / <code>SaveChangesAsync</code>; gives you access to the full <code>ChangeTracker</code> state</li>
<li><strong><code>IDbCommandInterceptor</code></strong> — fires at the ADO.NET command level; lets you inspect, modify, or replace the SQL being sent to the database</li>
<li><strong><code>IDbConnectionInterceptor</code></strong> — fires on connection open/close events</li>
<li><strong><code>IDbTransactionInterceptor</code></strong> — fires on transaction begin/commit/rollback</li>
<li><strong><code>IMaterializationInterceptor</code></strong> — fires when EF Core materializes query results into entity instances</li>
</ul>
<p>For most enterprise use cases, <code>ISaveChangesInterceptor</code> and <code>IDbCommandInterceptor</code> are the two you'll actually use. The others are more specialised and rarely needed outside infrastructure-heavy scenarios.</p>
<h2>When to Use EF Core Interceptors</h2>
<h3>Audit Logging Without Polluting Your Domain</h3>
<p>The most compelling use case for enterprise teams is automated audit trails. When your <code>DbContext</code> calls <code>SaveChanges</code>, the <code>ChangeTracker</code> holds a snapshot of every entity that's being added, modified, or deleted — including what changed and what the previous values were.</p>
<p>An <code>ISaveChangesInterceptor</code> implementation can capture this state reliably and write audit records as part of the same database round-trip. The important distinction is that this happens <em>inside</em> EF Core's pipeline, not as a separate application-layer concern that developers have to remember to call. You register it once and it runs on every save — regardless of which service, handler, or background job triggered the change.</p>
<p>This is the pattern that keeps audit logic out of your domain services, your CQRS handlers, and your repository implementations. It also means new developers on the team cannot accidentally bypass auditing by forgetting to call an audit method — because there is no audit method to call. See <a href="https://codingdroplets.com/ef-core-global-query-filters-aspnet-core-enterprise-decision-guide">EF Core Global Query Filters in ASP.NET Core</a> for a complementary pattern that works well alongside interceptor-based audit trails.</p>
<h3>Soft Delete Enforcement</h3>
<p>Teams using soft deletes (a <code>DeletedAt</code> timestamp or <code>IsDeleted</code> flag instead of a physical <code>DELETE</code>) often struggle with consistency. If you enforce soft deletes in service methods or repository implementations, the enforcement is brittle — it depends on every call site doing the right thing.</p>
<p>An <code>ISaveChangesInterceptor</code> can intercept <code>EntityState.Deleted</code> transitions and convert them to <code>EntityState.Modified</code> with the <code>DeletedAt</code> field set, before EF Core generates SQL. The entity never reaches the database as a delete statement. This approach pairs naturally with <a href="https://codingdroplets.com/ef-core-global-query-filters-aspnet-core-enterprise-decision-guide">EF Core Global Query Filters</a>, which filter soft-deleted records out of all queries automatically.</p>
<h3>Query Hints and Read Replica Routing</h3>
<p><code>IDbCommandInterceptor</code> fires at the ADO.NET command level, which means you can inspect the SQL text before it executes and append or modify it. This is where teams implement things like:</p>
<ul>
<li>Appending <code>NOLOCK</code> or <code>WITH (NOLOCK)</code> hints to read queries in SQL Server environments where dirty reads are acceptable</li>
<li>Routing read-only queries to a read replica connection string by replacing the connection on specific command types</li>
</ul>
<p>This is admittedly an advanced use case and comes with sharp edges — particularly for <code>NOLOCK</code> which trades consistency for speed. But when the team has made a deliberate decision to use these patterns, interceptors are the cleanest place to centralise that logic rather than spreading it across every raw SQL call.</p>
<h3>Stamping <code>CreatedAt</code> / <code>UpdatedAt</code> Fields</h3>
<p>Many teams handle timestamp fields in <code>SaveChangesAsync</code> overrides on <code>DbContext</code>. Interceptors offer an equivalent but decoupled alternative — useful when you have multiple <code>DbContext</code> types or when you want to keep the <code>DbContext</code> class itself minimal. The interceptor pattern makes this reusable across multiple contexts without inheritance.</p>
<h2>When NOT to Use EF Core Interceptors</h2>
<h3>Simple Validation</h3>
<p>Interceptors are not the right place for business rule validation. If an entity needs to be in a valid state before being saved, that belongs in your domain model or your application layer — not buried in a database pipeline interceptor where the feedback loop is slow and the error reporting is indirect.</p>
<p><code>FluentValidation</code> behaviours in a MediatR pipeline (see the Zero to Production course, Chapter 11) are a far better home for this concern.</p>
<h3>Logging SQL Queries</h3>
<p>EF Core has a first-class SQL logging mechanism built into its options: <code>optionsBuilder.LogTo(...)</code> or Serilog integration via <code>AddDbContext</code>. Using <code>IDbCommandInterceptor</code> to log SQL is technically possible but is documented by Microsoft as the wrong tool for this job — the built-in logging mechanisms are more efficient and better integrated with structured logging pipelines.</p>
<h3>Complex Business Logic</h3>
<p>If your interceptor implementation is making decisions based on business state, calling other services, or branching on domain concepts — stop. Interceptors have no natural way to surface domain exceptions cleanly, they run in a low-level pipeline where debugging is harder, and they create implicit dependencies that are invisible to the reader of your domain code. Move the logic up the stack.</p>
<h3>Performance-Critical Paths</h3>
<p>Every <code>SaveChanges</code> call runs through registered interceptors synchronously as part of the pipeline. For high-frequency writes where you're already squeezing out every millisecond — bulk insert scenarios, event store appends, high-throughput metrics writes — interceptors add overhead that may not be acceptable. Use <code>ExecuteUpdate</code> and <code>ExecuteDelete</code> (which bypass <code>ChangeTracker</code> and interceptors entirely) for bulk operations where change tracking and auditing are not required.</p>
<h2>The Decision Matrix</h2>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>Interceptors?</th>
<th>Better Alternative If Not</th>
</tr>
</thead>
<tbody><tr>
<td>Automated audit trail</td>
<td>✅ Yes</td>
<td>Service-layer audit calls (fragile)</td>
</tr>
<tr>
<td>Soft delete enforcement</td>
<td>✅ Yes</td>
<td>Repository overrides (duplication risk)</td>
</tr>
<tr>
<td>Timestamp stamping</td>
<td>✅ Yes</td>
<td><code>DbContext.SaveChangesAsync</code> override</td>
</tr>
<tr>
<td>SQL query hints (deliberate)</td>
<td>✅ Yes</td>
<td>Raw SQL / <code>FromSqlRaw</code> per call site</td>
</tr>
<tr>
<td>Read replica routing</td>
<td>✅ Yes (with care)</td>
<td>Multiple <code>DbContext</code> registrations</td>
</tr>
<tr>
<td>Business rule validation</td>
<td>❌ No</td>
<td>MediatR pipeline behaviour</td>
</tr>
<tr>
<td>SQL logging</td>
<td>❌ No</td>
<td><code>LogTo</code> / Serilog EF Core sink</td>
</tr>
<tr>
<td>Complex domain logic</td>
<td>❌ No</td>
<td>Application service layer</td>
</tr>
<tr>
<td>Bulk write operations</td>
<td>❌ No</td>
<td><code>ExecuteUpdate</code> / <code>ExecuteDelete</code></td>
</tr>
</tbody></table>
<h2>Registration and Lifecycle Gotcha</h2>
<p>Interceptors are registered as services via <code>AddInterceptors(...)</code> in your <code>AddDbContext</code> call. The key mistake teams make: registering an interceptor as a singleton when it has scoped dependencies.</p>
<p>If your audit interceptor needs to resolve the current user's identity from <code>IHttpContextAccessor</code> (a scoped dependency), the interceptor itself must be registered as scoped and resolved from the DI container at <code>DbContext</code> registration time — not instantiated as a singleton. Singleton interceptors with scoped dependencies will throw <code>InvalidOperationException</code> at runtime.</p>
<p>The correct pattern is to retrieve your interceptor from the service provider inside <code>AddDbContext</code>:</p>
<pre><code class="language-csharp">builder.Services.AddScoped&lt;AuditInterceptor&gt;();

builder.Services.AddDbContext&lt;AppDbContext&gt;((sp, options) =&gt;
{
    options.UseSqlServer(connectionString)
           .AddInterceptors(sp.GetRequiredService&lt;AuditInterceptor&gt;());
});
</code></pre>
<p>This is one of the non-obvious production issues teams hit when first adopting interceptors — and it's covered in detail in the full implementation on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>.</p>
<h2>Anti-Patterns to Avoid</h2>
<p><strong>Interceptor chains with order dependencies.</strong> When multiple interceptors are registered, they execute in registration order. If interceptor B depends on state set by interceptor A, you have a hidden ordering dependency that breaks silently when registration order changes. Design each interceptor to be independent.</p>
<p><strong>Throwing exceptions in <code>IDbCommandInterceptor</code>.</strong> If your command interceptor throws an unhandled exception, it surfaces as a database error, not a domain error. This makes error handling confusing and breaks the clean exception hierarchy your global error handler relies on.</p>
<p><strong>Using interceptors as a caching layer.</strong> Interceptors run on every operation — not selectively. Building cache-aside logic inside an <code>IDbCommandInterceptor</code> that conditionally replaces database calls with cache reads is possible but creates a hidden, hard-to-test caching layer that is disconnected from your application's caching strategy. Use <code>IMemoryCache</code> or <code>HybridCache</code> explicitly at the service layer instead.</p>
<p><strong>Reading <code>ChangeTracker</code> in <code>IDbCommandInterceptor</code>.</strong> <code>IDbCommandInterceptor</code> fires at the ADO.NET level, after EF Core has already generated SQL. At that point, <code>ChangeTracker</code> state may not reflect what you expect for all scenarios. Use <code>ISaveChangesInterceptor</code> when you need entity state — it fires earlier in the pipeline and gives you reliable access to the full change set.</p>
<h2>Composability With Global Query Filters</h2>
<p>EF Core interceptors and Global Query Filters are complementary, not competing. A common production pattern is:</p>
<ul>
<li><strong>Global Query Filter</strong> on <code>IsDeleted</code> — filters soft-deleted records out of all queries automatically</li>
<li><strong><code>ISaveChangesInterceptor</code></strong> — converts <code>EntityState.Deleted</code> to a soft delete before SQL is generated</li>
</ul>
<p>Together, they make soft delete completely transparent to the rest of the application. Application code reads and writes entities as if they always exist — the infrastructure layer handles the filtering and the conversion. Developers who join the team do not need to know about soft delete logic in their application service code; it is enforced at the infrastructure layer.</p>
<h2>Should You Use Interceptors or Override <code>SaveChangesAsync</code>?</h2>
<p>Both approaches work for <code>SaveChanges</code>-level concerns. The practical difference:</p>
<p><strong><code>SaveChangesAsync</code> override</strong> is simple, discoverable, and co-located with the <code>DbContext</code>. It works well for single-context applications where a single team owns the <code>DbContext</code> and audit/timestamp logic is stable.</p>
<p><strong>Interceptors</strong> are better when:</p>
<ul>
<li>You have multiple <code>DbContext</code> types that share the same cross-cutting concern</li>
<li>You want the concern to be unit-testable independently of the <code>DbContext</code></li>
<li>You want the concern to be composable (register different interceptors per environment or feature flag)</li>
<li>The interceptor has its own dependencies managed by DI</li>
</ul>
<p>For greenfield enterprise applications, interceptors are the cleaner long-term choice. For existing codebases with established <code>SaveChangesAsync</code> overrides, the migration cost rarely justifies the switch unless you hit a specific pain point.</p>
<blockquote>
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
</blockquote>
<h2>FAQ</h2>
<p><strong>What is the difference between <code>ISaveChangesInterceptor</code> and <code>IDbCommandInterceptor</code>?</strong>
<code>ISaveChangesInterceptor</code> fires at the EF Core level — before and after <code>SaveChanges</code> executes, with full access to the <code>ChangeTracker</code> and entity state. <code>IDbCommandInterceptor</code> fires at the ADO.NET level — when the actual SQL command is sent to the database. Use <code>ISaveChangesInterceptor</code> for entity-level concerns (audit trails, soft deletes, timestamps) and <code>IDbCommandInterceptor</code> for SQL-level concerns (query hints, command logging, connection routing).</p>
<p><strong>Do EF Core interceptors work with <code>ExecuteUpdate</code> and <code>ExecuteDelete</code>?</strong>
No. <code>ExecuteUpdate</code> and <code>ExecuteDelete</code> are bulk operations that bypass <code>ChangeTracker</code> and the <code>SaveChanges</code> pipeline entirely. They generate <code>UPDATE</code> and <code>DELETE</code> SQL directly. Interceptors registered via <code>ISaveChangesInterceptor</code> will not fire for these operations. If your audit requirements cover bulk updates, you need a separate mechanism — either a database trigger or explicit audit records in the calling code.</p>
<p><strong>Can I use interceptors to implement row-level security in EF Core?</strong>
With caution. You can use <code>IDbCommandInterceptor</code> to append WHERE clauses or <code>IDbConnectionInterceptor</code> to set session context for database-level row security. However, EF Core's Global Query Filters are a cleaner and more maintainable approach for most row-level filtering scenarios. Use interceptors for row-level security only when you need to push enforcement down to the database session level (e.g., for SQL Server Row-Level Security with session context variables).</p>
<p><strong>How do I unit test an EF Core interceptor?</strong>
Interceptors are standard C# classes that implement an interface — you can instantiate them directly in tests without needing a full <code>DbContext</code>. For <code>ISaveChangesInterceptor</code> tests, you'll want an in-memory SQLite <code>DbContext</code> to exercise the full pipeline. For <code>IDbCommandInterceptor</code>, you can use <code>DbCommandInterceptorTestBase</code> patterns or mock <code>DbCommand</code>. Keeping interceptors small and single-purpose makes them significantly easier to test in isolation.</p>
<p><strong>Will registering multiple interceptors affect performance?</strong>
Each registered interceptor adds a small overhead to every covered operation. In practice, the overhead of one or two well-written interceptors is negligible compared to network round-trips and query execution time. The performance concern only becomes relevant for very high-frequency bulk write operations — and in those cases, you typically want to use <code>ExecuteUpdate</code>/<code>ExecuteDelete</code> anyway, which bypass interceptors entirely. Profile before optimising; premature interceptor removal is rarely the right call.</p>
<p><strong>Should every ASP.NET Core API use interceptors?</strong>
Not necessarily. If your application has no cross-cutting database concerns (no audit logging, no soft deletes, no query hints), adding interceptors for their own sake adds complexity without benefit. Start without them. Add them when you have a specific, well-understood requirement that benefits from centralisation at the EF Core pipeline level. The decision guide above is the right checklist: if the use case fits, interceptors are excellent. If it doesn't, pick a more appropriate tool.</p>
]]></content:encoded></item><item><title><![CDATA[Repository Pattern vs Direct DbContext in ASP.NET Core: Which Should Your .NET Team Use in 2026?]]></title><description><![CDATA[Few architectural debates are as persistent in the .NET community as this one: should your ASP.NET Core application wrap data access in a Repository Pattern, or should it use DbContext directly? Both ]]></description><link>https://codingdroplets.com/repository-pattern-vs-direct-dbcontext-aspnet-core</link><guid isPermaLink="true">https://codingdroplets.com/repository-pattern-vs-direct-dbcontext-aspnet-core</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[efcore]]></category><category><![CDATA[C#]]></category><category><![CDATA[repository-pattern]]></category><category><![CDATA[Clean Architecture]]></category><category><![CDATA[entity framework]]></category><category><![CDATA[Data access]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[asp .net]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Thu, 28 May 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/308d4825-2b84-4947-bd51-c45b90e6ef14.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Few architectural debates are as persistent in the .NET community as this one: should your ASP.NET Core application wrap data access in a Repository Pattern, or should it use <code>DbContext</code> directly? Both camps have vocal proponents, and both are defending something real. The repository pattern vs direct DbContext question is not about dogma — it is a practical decision with genuine trade-offs that depend on your team size, codebase complexity, and long-term maintenance goals.</p>
<p>The full production implementation of both approaches — complete with service layer integration, unit-of-work variants, and a working test suite — is available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>. The code maps directly to what enterprise .NET teams ship.</p>
<p>Understanding this decision in isolation is useful, but seeing how data access patterns fit into a complete production API — alongside authentication, validation, error handling, and testing — is where it really clicks. That end-to-end context is exactly what the <a href="https://aspnetcoreapi.codingdroplets.com/">ASP.NET Core Web API: Zero to Production course</a> provides, starting from Chapter 3 where the repository interface in Domain and its EF Core implementation in Infrastructure are covered step by step.</p>
<p><a href="https://aspnetcoreapi.codingdroplets.com/"><img src="https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg" alt="ASP.NET Core Web API: Zero to Production" style="display:block;margin:0 auto" /></a></p>
<h2>What the Repository Pattern Actually Is (and What It Is Not)</h2>
<p>The Repository Pattern introduces an abstraction layer between your domain or application logic and your data access technology. At its core, it is an interface — <code>IProductRepository</code>, for example — that defines data operations (find, add, update, remove) without exposing how those operations are implemented. Your application code depends on the interface; your infrastructure layer provides the EF Core implementation.</p>
<p>What it is <em>not</em> is a generic wrapper over <code>DbSet&lt;T&gt;</code> that mirrors every EF Core operation. That variant — the generic repository — adds indirection without adding abstraction. It does not hide EF Core; it just adds boilerplate between your code and it.</p>
<p>The distinction matters because the strongest arguments against repositories are almost always arguments against generic repositories specifically, not against domain-focused repositories that actually model your bounded context's operations.</p>
<h2>What Direct DbContext Means in Practice</h2>
<p>Direct DbContext usage means injecting <code>YourDbContext</code> or <code>IDbContextFactory&lt;YourDbContext&gt;</code> into your service, handler, or endpoint — and using LINQ, <code>AsNoTracking()</code>, <code>FindAsync()</code>, and EF Core's full API surface directly, without any wrapping interface.</p>
<p>In small-to-medium codebases, this is honest and practical. EF Core's <code>DbContext</code> is already a Unit of Work; <code>DbSet&lt;T&gt;</code> is already a repository of sorts. Adding another layer on top of it risks hiding the very features — compiled queries, split queries, <code>ExecuteUpdate</code>, <code>ExecuteDelete</code> — that make EF Core 10 worth using in the first place. You can read more about those query performance features in <a href="https://codingdroplets.com/efcore-10-query-performance-asnotracking-compiled-split-queries">EF Core 10 Query Performance: AsNoTracking, Compiled Queries and Split Queries Explained</a>.</p>
<h2>When to Use the Repository Pattern</h2>
<p>The case for the repository pattern is strongest in these specific conditions:</p>
<p><strong>Your domain has meaningful aggregate boundaries.</strong> If you are practicing Domain-Driven Design and your application has aggregates — <code>Order</code>, <code>Customer</code>, <code>Shipment</code> — then a repository per aggregate root (<code>IOrderRepository</code>, <code>ICustomerRepository</code>) is architecturally correct. The repository is not a data access convenience; it is a domain contract. The implementation happens to use EF Core.</p>
<p><strong>Your application will be tested extensively with unit tests.</strong> Mocking <code>DbContext</code> directly is technically possible but ergonomically painful. A repository interface with a clearly defined set of operations (<code>GetByIdAsync</code>, <code>GetPagedAsync</code>, <code>AddAsync</code>) is trivial to mock in Moq or NSubstitute. If your team writes unit tests for application layer handlers or services — as they should — repository interfaces make those tests clean and fast.</p>
<p><strong>You are working in a Clean Architecture or layered structure where Domain and Infrastructure are separate projects.</strong> In this structure, Domain cannot reference Infrastructure. The only way to depend on data access from Domain or Application is through an interface. The repository interface lives in Application; the EF Core implementation lives in Infrastructure. This is the pattern used in <a href="https://codingdroplets.com/clean-architecture-cqrs-mediatr-aspnet-core-2026">Clean Architecture with CQRS + MediatR in ASP.NET Core: The Complete Guide (2026)</a> and it is sound.</p>
<p><strong>Your team plans to test data access behaviour in isolation.</strong> If you want to write handler unit tests that do not spin up a database, the repository interface is your seam.</p>
<h2>When to Use Direct DbContext</h2>
<p>Direct DbContext is the right default in these situations:</p>
<p><strong>You are building a small-to-medium API without DDD constraints.</strong> If your application is primarily CRUD, your team is small, and your architecture is a single project or a simple layered app without bounded contexts — direct DbContext is cleaner. You get full access to EF Core's API, no abstraction tax, and faster onboarding for new developers.</p>
<p><strong>You are building read-heavy endpoints where query composition matters.</strong> Complex filtered, sorted, and paginated queries are harder to model behind a repository interface. With direct DbContext you compose <code>IQueryable&lt;T&gt;</code> chains freely. The moment you put those queries behind an interface, you either leak <code>IQueryable</code> (defeating the abstraction) or you duplicate every filter combination as a new method. Neither is good.</p>
<p><strong>You want to leverage EF Core 10's newest primitives freely.</strong> <code>ExecuteUpdate</code>, <code>ExecuteDelete</code>, JSON columns, complex type projections, and new bulk operations do not slot neatly into a <code>Add/Update/Delete</code> repository interface. You can include them, but you either expose them directly (leaking EF Core into the interface) or you miss them entirely.</p>
<p><strong>You are prototyping or iterating quickly.</strong> Repository interfaces slow down early development. You should not pay an abstraction tax until you know the shape of your domain.</p>
<h2>Side-By-Side Trade-Off Comparison</h2>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Repository Pattern</th>
<th>Direct DbContext</th>
</tr>
</thead>
<tbody><tr>
<td>Testability (unit tests)</td>
<td>✅ Easy — mock the interface</td>
<td>⚠️ Harder — DbContext mocking is verbose</td>
</tr>
<tr>
<td>EF Core feature access</td>
<td>⚠️ Constrained by interface contract</td>
<td>✅ Full access to all EF Core APIs</td>
</tr>
<tr>
<td>Abstraction clarity</td>
<td>✅ Domain operations, not DB operations</td>
<td>❌ EF Core leaks into application code</td>
</tr>
<tr>
<td>Onboarding cost</td>
<td>⚠️ Higher — extra layer to understand</td>
<td>✅ Lower — just inject and use</td>
</tr>
<tr>
<td>Query composition</td>
<td>⚠️ Limited by interface design</td>
<td>✅ Flexible IQueryable composition</td>
</tr>
<tr>
<td>Clean Architecture compatibility</td>
<td>✅ Required for domain/infrastructure separation</td>
<td>❌ Cross-project references break layering</td>
</tr>
<tr>
<td>Risk of over-engineering</td>
<td>⚠️ Generic repositories are common misuse</td>
<td>✅ None — WYSIWYG</td>
</tr>
<tr>
<td>Maintenance burden</td>
<td>✅ Stable interfaces, swappable implementations</td>
<td>✅ Simpler codebase, fewer layers</td>
</tr>
</tbody></table>
<h2>The Generic Repository Anti-Pattern</h2>
<p>The most common mistake teams make is building a <code>GenericRepository&lt;T&gt;</code> that wraps <code>DbSet&lt;T&gt;</code> with methods like <code>GetAll()</code>, <code>GetById()</code>, <code>Add()</code>, <code>Update()</code>, <code>Delete()</code> — and calling it "the repository pattern."</p>
<p>This pattern provides no real abstraction. It does not hide EF Core (it wraps it transparently). It does not model domain operations (CRUD is not a business language). It blocks access to EF Core features (you cannot call <code>ExecuteUpdate</code> through a generic interface without leaking it). And it creates maintenance overhead for no benefit.</p>
<p>If you find yourself writing a generic repository, stop. Either go direct DbContext or commit to domain-specific repository interfaces that model actual aggregate operations.</p>
<h2>The Specification Pattern: A Middle Ground</h2>
<p>When teams want the testability benefit of an interface but resist full repository abstraction, the Specification Pattern is worth considering. A <code>Specification&lt;T&gt;</code> encapsulates a query predicate, include rules, and ordering. You pass specifications into a thin generic query executor that applies them against <code>IQueryable&lt;T&gt;</code>.</p>
<p>This approach gives you testable, reusable query objects without leaking EF Core into your application layer, while still giving the EF Core implementation the freedom to optimise how the specification is translated into SQL. It is particularly useful for read models in CQRS architectures where the write side uses domain repositories and the read side uses direct queries with specifications.</p>
<h2>Decision Matrix: Which Should Your Team Use?</h2>
<p>Ask these questions in order:</p>
<ol>
<li><p><strong>Are you using Clean Architecture or DDD with separate Domain and Infrastructure projects?</strong> → Use repository interfaces. They are the only architectural fit.</p>
</li>
<li><p><strong>Do you need extensive unit testing of application logic without a database?</strong> → Use repository interfaces. The mock-ability benefit is real.</p>
</li>
<li><p><strong>Is your application primarily CRUD with few complex domain rules?</strong> → Use direct DbContext. There is no abstraction to justify.</p>
</li>
<li><p><strong>Do you need full access to EF Core 10 bulk operations and advanced query features?</strong> → Favour direct DbContext, or design repository interfaces that expose these operations explicitly.</p>
</li>
<li><p><strong>Is your team small and iteration speed matters more than long-term separation?</strong> → Start with direct DbContext. You can introduce repository interfaces when complexity demands it.</p>
</li>
</ol>
<p>The answer for most enterprise .NET teams in 2026: use domain-specific repository interfaces when your architecture calls for them, and direct DbContext everywhere else. The mistake is applying one pattern everywhere regardless of context.</p>
<h2>What About the Unit of Work Pattern?</h2>
<p>EF Core's <code>DbContext</code> is already a Unit of Work. Calling <code>SaveChangesAsync()</code> commits all tracked changes in one transaction. There is rarely a need to wrap this in a separate <code>IUnitOfWork</code> interface unless:</p>
<ul>
<li><p>You are abstracting multiple <code>DbContext</code> instances across bounded contexts in the same transaction</p>
</li>
<li><p>You need to control SaveChanges behaviour explicitly from the application layer</p>
</li>
<li><p>Your testing strategy requires mocking the commit boundary separately from repository operations</p>
</li>
</ul>
<p>In most applications, injecting <code>DbContext</code> directly and calling <code>SaveChangesAsync()</code> in your service or handler is perfectly correct. The Unit of Work abstraction becomes valuable when you are orchestrating across multiple aggregates or bounded contexts in a single operation. For deeper context on EF Core performance considerations in high-traffic APIs, see <a href="https://codingdroplets.com/ef-core-performance-tuning-checklist-high-traffic-apis">EF Core Performance Tuning Checklist for High-Traffic APIs</a>.</p>
<h2>Integrating the Decision Into Your Architecture</h2>
<p>If you are starting a new ASP.NET Core API today, consider this layering rule of thumb:</p>
<ul>
<li><p><strong>Single-project or simple layered app:</strong> Use <code>DbContext</code> directly in your service classes. Add <code>AsNoTracking()</code> on all read queries. Call <code>SaveChangesAsync()</code> explicitly. Keep it simple.</p>
</li>
<li><p><strong>Clean Architecture (Domain / Application / Infrastructure / API):</strong> Repository interfaces belong in Domain or Application. Implementations belong in Infrastructure. Only the infrastructure project references EF Core.</p>
</li>
<li><p><strong>CQRS with MediatR:</strong> Command handlers typically need repository interfaces (they mutate domain aggregates through repository contracts). Query handlers often use direct DbContext or Dapper — they are read-only and performance-sensitive, so the full query API matters.</p>
</li>
</ul>
<p>This separation is practical and scales well. It also avoids the classic mistake of putting EF Core references in the wrong layer.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<hr />
<h2>FAQ</h2>
<p><strong>Should I use the repository pattern for every entity in my application?</strong> No. Repository interfaces should map to aggregate roots in a DDD context, or to meaningful data access contracts in a layered architecture. Creating a repository for every entity — including simple lookup tables and configuration data — adds overhead without value. Apply repository interfaces where the abstraction boundary genuinely matters.</p>
<p><strong>Does EF Core's DbContext already implement the Repository and Unit of Work patterns?</strong> Conceptually yes. <code>DbSet&lt;T&gt;</code> behaves like a repository (it tracks and queries entities of a specific type), and <code>DbContext</code> behaves like a Unit of Work (it tracks all changes and commits them as a single transaction via <code>SaveChangesAsync()</code>). This is why direct DbContext usage is architecturally defensible — you are not bypassing a pattern, you are using the framework's built-in implementation of it.</p>
<p><strong>Can I mix repository pattern and direct DbContext in the same application?</strong> Yes, and this is often the right approach. Use repository interfaces for your write model (command side, domain aggregates) where testability and domain isolation matter. Use direct DbContext or Dapper for your read model (query side) where query composition flexibility and performance are the priority. This hybrid is commonly used in CQRS architectures.</p>
<p><strong>What is the performance difference between repository pattern and direct DbContext?</strong> The repository interface itself introduces no measurable performance overhead — it is just an interface dispatch. The performance difference, if any, comes from how the implementation is written. A direct DbContext call with <code>AsNoTracking()</code> and <code>ExecuteUpdate</code> is faster than one routed through a repository method that uses full entity tracking unnecessarily — but that is a usage issue, not a pattern issue.</p>
<p><strong>How do I unit test services that use DbContext directly without a repository interface?</strong> You have two options. The first is to use EF Core's in-memory provider (<code>UseInMemoryDatabase</code>) or SQLite in-memory mode in your unit tests — these are fast and require no mocking infrastructure. The second is to use <code>Microsoft.EntityFrameworkCore.InMemory</code> as a lightweight test double. Both are valid. The trade-off is that these tests become integration tests (they use the real EF Core stack) rather than pure unit tests. If your team values the distinction, repository interfaces give you a cleaner seam.</p>
<p><strong>Is the repository pattern dead in 2026?</strong> No. The generic repository pattern is largely obsolete — it was always a misunderstanding of what the pattern is for. But domain-focused repository interfaces in Clean Architecture and DDD applications remain the correct approach. The pattern is not dead; it is often misapplied. Understanding the difference is what makes the difference.</p>
<p><strong>Does the repository pattern make it easier to swap out EF Core for another ORM?</strong> In theory yes — your application code depends only on the interface, not EF Core. In practice, ORM swaps are rare enough that this benefit rarely justifies the abstraction cost on its own. The more practical benefit is test isolation: you can swap a real EF Core repository for a mock in unit tests, which you do constantly. Design for testability first; ORM portability is a secondary benefit.</p>
]]></content:encoded></item><item><title><![CDATA[ASP.NET Core Intermittent Latency Spikes in Production: GC Pressure, ThreadPool Starvation, and Connection Pool Root Causes and Fixes]]></title><description><![CDATA[Intermittent latency spikes are one of the most deceptive production problems in ASP.NET Core. The API runs fine under low load, all your unit tests pass, and local benchmarks look healthy — then unde]]></description><link>https://codingdroplets.com/aspnet-core-intermittent-latency-spikes-production-fix</link><guid isPermaLink="true">https://codingdroplets.com/aspnet-core-intermittent-latency-spikes-production-fix</guid><category><![CDATA[dotnet]]></category><category><![CDATA[asp.net core]]></category><category><![CDATA[performance]]></category><category><![CDATA[C#]]></category><category><![CDATA[production]]></category><category><![CDATA[diagnostics]]></category><category><![CDATA[Web API]]></category><category><![CDATA[threadpool]]></category><category><![CDATA[Garbage Collection]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Thu, 28 May 2026 03:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/9fd6e8d4-30a1-4d0d-8bc7-8b315bed679a.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Intermittent latency spikes are one of the most deceptive production problems in ASP.NET Core. The API runs fine under low load, all your unit tests pass, and local benchmarks look healthy — then under real production traffic, requests that normally complete in under 50ms suddenly start hitting 2–5 seconds, seemingly at random, before recovering on their own. Logs show nothing obvious. No exceptions. No errors. Just elevated p99 response times that nobody can explain. If you have encountered this pattern, the root cause is almost always one of three things: garbage collector pressure, ThreadPool starvation, or connection pool exhaustion — and often a combination of all three. The full annotated diagnostic scripts and configuration patterns that go with this article are available on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, with worked examples against a real production-scale load test.</p>
<p>Understanding ASP.NET Core latency spikes in production means understanding how the runtime manages memory, threads, and I/O concurrency at the same time. These three systems interact in ways that are not always obvious from application code. A GC pause that blocks threads for 80ms can cascade into ThreadPool starvation. An overloaded connection pool creates a queue of waiting requests that all time out together, then recover together — which looks like a spike when it is actually a backlog. Getting to the root cause requires knowing how to observe each layer independently before concluding which one is responsible.</p>
<h2>Why Intermittent Spikes Are So Hard to Diagnose</h2>
<p>The word "intermittent" is the key signal. Deterministic bugs produce deterministic symptoms. Intermittent spikes mean the problem is load-dependent, resource-dependent, or timing-dependent — it only appears when specific conditions align. The three main culprits behave this way by design:</p>
<ul>
<li><strong>GC pressure</strong> appears when allocation rates exceed what the background GC can keep up with, triggering blocking Gen 2 or LOH compaction events</li>
<li><strong>ThreadPool starvation</strong> appears when all available threads are blocked waiting on I/O or synchronous operations, forcing new requests to queue until a thread becomes free</li>
<li><strong>Connection pool exhaustion</strong> appears when all database connections in the pool are held by in-flight queries, causing new requests to wait for a connection lease</li>
</ul>
<p>Each of these creates a distinctive spike profile, and each has a different diagnosis path.</p>
<h2>Root Cause 1: Garbage Collector Pressure</h2>
<p>The .NET GC is designed to run in the background without stopping application threads. For most workloads it does exactly that. But when allocation rates are high — particularly allocations of objects that survive into Gen 2, or allocations of large objects (greater than 85,000 bytes by default) that go directly to the Large Object Heap — the GC must perform a compacting collection that briefly stops all threads. These stop-the-world pauses typically last anywhere from 10ms to 300ms depending on heap size and fragmentation.</p>
<p>The practical causes of GC pressure in ASP.NET Core APIs include:</p>
<p><strong>String allocations in hot paths.</strong> Serialisation, log interpolation, and query string building that runs on every request creates short-lived allocations that age into Gen 1 and Gen 2 faster than expected under load.</p>
<p><strong>Large response buffers.</strong> Returning large JSON payloads or loading bulk data into memory in a single call puts objects directly onto the LOH. An 86KB+ array created per request at 500 req/s is a significant LOH pressure source.</p>
<p><strong>LINQ materialisation on every request.</strong> Calling <code>.ToList()</code> on large result sets, re-projecting collections, or not using streaming enumerables forces collections to be fully allocated in memory on every request.</p>
<p><strong>Diagnostic approach:</strong> Use <code>dotnet-counters</code> to observe GC pause frequency and Gen 2 collection rate in real time:</p>
<pre><code class="language-bash">dotnet-counters monitor --process-id &lt;pid&gt; System.Runtime
</code></pre>
<p>Watch for <code>gc-heap-size</code>, <code>gen-2-gc-count</code>, <code>loh-size</code>, and <code>time-in-gc</code>. A <code>time-in-gc</code> above 10% under load is a clear signal of GC pressure causing latency impact.</p>
<p><strong>Fix strategy:</strong> Reduce allocation on hot paths. Use <code>ArrayPool&lt;T&gt;</code> and <code>MemoryPool&lt;T&gt;</code> for buffer reuse. Replace string concatenation in loops with <code>StringBuilder</code> or interpolated strings with <code>ReadOnlySpan&lt;char&gt;</code>. Use <code>IAsyncEnumerable&lt;T&gt;</code> for large result sets instead of materialising everything with <code>.ToList()</code>. For JSON serialisation in high-throughput scenarios, <code>System.Text.Json</code> with source generation eliminates much of the per-request allocator pressure.</p>
<h2>Root Cause 2: ThreadPool Starvation</h2>
<p>The ASP.NET Core Kestrel server processes each request on a ThreadPool thread. The ThreadPool starts with a small number of threads and grows dynamically — but growth is gated by a hill-climbing algorithm that adds one thread per second when it detects contention. Under a sudden traffic spike, this growth rate is far too slow. If existing threads are blocked waiting on synchronous I/O or <code>.Result</code>/<code>.Wait()</code> calls on <code>Task</code>s, incoming requests queue behind them and start breaching SLA thresholds before the ThreadPool can compensate.</p>
<p>We covered <a href="https://codingdroplets.com/aspnet-core-threadpool-starvation-production-fix">ThreadPool starvation in detail in a dedicated article</a> — but the short version is that two patterns cause almost all starvation cases:</p>
<ol>
<li><strong>Calling <code>.Result</code> or <code>.Wait()</code> on async code</strong> — common in legacy middleware, startup code that was "quickly made synchronous," or third-party libraries</li>
<li><strong>Sync-over-async in database access</strong> — using synchronous EF Core or ADO.NET methods (<code>Find(id)</code> instead of <code>FindAsync(id)</code>, <code>SaveChanges()</code> instead of <code>SaveChangesAsync()</code>)</li>
</ol>
<p><strong>Diagnostic approach:</strong> <code>dotnet-counters</code> again:</p>
<pre><code class="language-bash">dotnet-counters monitor --process-id &lt;pid&gt; System.Runtime --counters threadpool-queue-length,threadpool-thread-count
</code></pre>
<p>If <code>threadpool-queue-length</code> spikes to dozens or hundreds during a latency event while <code>threadpool-thread-count</code> grows slowly, you have starvation. You can also use <code>dotnet-trace</code> to capture a trace during a spike and analyse it with PerfView or SpeedScope to identify exactly which call stacks are blocking threads.</p>
<p><strong>Fix strategy:</strong> Audit every synchronous blocking call in the request pipeline. Replace <code>.Result</code> with <code>await</code>. Replace <code>.Wait()</code> with <code>await</code>. Replace synchronous EF Core methods with their async counterparts. For startup code that must run synchronous operations, ensure it runs before <code>app.Run()</code> and not inside middleware handlers. Where a third-party library forces synchronous execution, consider offloading to a dedicated <code>TaskCreationOptions.LongRunning</code> thread rather than using a ThreadPool thread.</p>
<h2>Root Cause 3: Database Connection Pool Exhaustion</h2>
<p>EF Core and ADO.NET maintain a connection pool — by default a maximum of 100 connections for SQL Server. When all 100 connections are in use, new requests that need a database connection must wait in a queue. If the wait exceeds the connection timeout (default: 15 seconds for SQL Server), the request throws a <code>SqlException</code> with the message "Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool." If it does not exceed the timeout, the request simply spends its entire latency budget waiting for a connection to become free — which is what creates the spike.</p>
<p>We covered this root cause in detail in <a href="https://codingdroplets.com/ef-core-connection-pool-exhaustion-aspnet-core-production-fix">EF Core Connection Pool Exhaustion in ASP.NET Core</a>. The common triggers are:</p>
<ul>
<li><strong>Long-running queries holding connections.</strong> Connections are only returned to the pool once the query is complete and the <code>DbContext</code> is disposed. A slow query that takes 3 seconds holds a connection for 3 seconds — at 50 concurrent slow requests, the pool is saturated.</li>
<li><strong><code>DbContext</code> not disposed promptly.</strong> In non-DI scenarios or manual <code>DbContext</code> instantiation, connections can be held far beyond their useful lifetime.</li>
<li><strong>N+1 query patterns.</strong> Loading a parent entity then querying children one at a time in a loop multiplies the connection hold time per request, saturating the pool faster than expected.</li>
<li><strong>Missing <code>AsNoTracking()</code> on read queries.</strong> EF Core tracking overhead keeps contexts alive longer than necessary in scenarios where writes never happen.</li>
<li><strong>Too many concurrent operations per request.</strong> Parallelising <code>DbContext</code> operations (e.g., <code>Task.WhenAll</code> with multiple queries) on the same context instance causes errors; spreading them across multiple contexts simultaneously exhausts the pool.</li>
</ul>
<p><strong>Diagnostic approach:</strong> Monitor the <code>Microsoft.EntityFrameworkCore.Database.Connection</code> category with structured logging enabled at <code>Information</code> level, or instrument your application with OpenTelemetry to track active connection counts. In SQL Server, the DMV <code>sys.dm_exec_requests</code> shows active connections and their wait states. Azure SQL provides this through Query Performance Insight.</p>
<p><strong>Fix strategy:</strong> Increase awareness of query duration in hot paths. Add <code>AsNoTracking()</code> on all read-only queries. Resolve N+1 patterns with <code>.Include()</code> or split queries. Review your <code>Max Pool Size</code> connection string setting — increasing from 100 to 200–300 is often appropriate for high-traffic APIs, but treat this as a palliative measure, not a fix. The real fix is shortening query duration and reducing unnecessary holds.</p>
<h2>How the Three Root Causes Interact</h2>
<p>What makes production latency spikes particularly difficult to diagnose is that these three causes interact. A GC pause that blocks all threads for 50ms causes incoming requests to queue. If those queued requests all proceed simultaneously once threads are released, they saturate the connection pool together. The connection pool exhaustion then holds the connections long enough that threads block waiting for results, which contributes to a secondary ThreadPool pressure event. The result is a cascade — a short GC pause triggers a spike that looks far worse than the GC event itself would suggest.</p>
<p>This is why diagnosing with a single metric is unreliable. Observing all three — GC pause frequency, ThreadPool queue depth, and active database connection count — simultaneously during a latency event gives you a causal chain to work from. <code>dotnet-counters</code> and <code>dotnet-trace</code> provide exactly this visibility without requiring application restarts or code changes.</p>
<h2>A Diagnostic Playbook for Production Latency Spikes</h2>
<p>When a latency spike event is in progress or has just occurred:</p>
<p><strong>Step 1 — Confirm the scope.</strong> Check your APM dashboard (Application Insights, Datadog, Grafana + OpenTelemetry) for which endpoints are affected. A spike isolated to one endpoint strongly suggests a specific slow query or blocking call. A spike across all endpoints suggests a runtime-level cause (GC or ThreadPool).</p>
<p><strong>Step 2 — Check GC metrics.</strong> If you have live metrics available, look at <code>time-in-gc</code> and <code>gen-2-gc-count</code>. A spike in Gen 2 collections coinciding with the latency event confirms GC pressure. Run <code>dotnet-counters</code> against the live process if metrics are not already instrumented.</p>
<p><strong>Step 3 — Check ThreadPool metrics.</strong> Look at <code>threadpool-queue-length</code>. A queue length that spikes significantly during the latency window — with slow thread count growth — confirms starvation. Look for synchronous blocking call stacks in a <code>dotnet-trace</code> capture.</p>
<p><strong>Step 4 — Check database connection metrics.</strong> Query <code>sys.dm_exec_requests</code> or your equivalent. Look for a large number of requests in <code>WAITFOR</code> or <code>SLEEP</code> states, or requests that have been active for many seconds. Enable EF Core connection logging at <code>Debug</code> level temporarily if you need per-query visibility.</p>
<p><strong>Step 5 — Cross-correlate timing.</strong> Map the timestamps of each signal against the p99 response time timeline. Whichever signal appears first is the triggering cause. The others may be downstream effects.</p>
<h2>Prevention: Reducing Spike Frequency at the Source</h2>
<p>Beyond diagnostics, three architectural decisions significantly reduce the frequency and severity of latency spikes:</p>
<p><strong>Server GC over Workstation GC.</strong> Container deployments that do not explicitly configure GC mode can default to Workstation GC, which uses fewer threads and pauses more frequently. Set <code>System.GC.Server=true</code> in <code>runtimeconfig.json</code> for production ASP.NET Core workloads. Alternatively, set the environment variable <code>DOTNET_GCConserveMemory</code> to tune memory vs latency trade-offs.</p>
<p><strong>Minimum ThreadPool threads.</strong> The ThreadPool default minimum for many environments is set to the number of logical processors. Under burst traffic, this is too low. Use <code>ThreadPool.SetMinThreads(workerThreads, completionPortThreads)</code> at startup to pre-warm enough threads to absorb an initial burst without triggering the slow hill-climbing growth delay. Be conservative — excessively high minimums waste memory.</p>
<p><strong>Connection pool sizing matched to workload.</strong> Profile your average query duration under load. Multiply expected concurrency by average query duration in seconds to estimate your target pool size. Add a safety margin. Set <code>Max Pool Size</code> in your connection string accordingly — but pair it with query performance work rather than relying solely on a larger pool.</p>
<h2>What Should Not Be Your Diagnostic Tool</h2>
<p>A few approaches that are commonly tried but are poor diagnostic choices:</p>
<p><strong>Restarting the process.</strong> A restart clears the symptom temporarily but tells you nothing about the cause, and risks data integrity if connections are mid-transaction.</p>
<p><strong>Adding more replicas without diagnosis.</strong> Horizontal scaling can reduce per-instance load but does not fix a structural issue. GC pressure from large allocations will affect every replica. ThreadPool starvation from synchronous code will affect every replica. More instances of a broken design is still a broken design.</p>
<p><strong>Increasing timeouts.</strong> Raising connection timeout, command timeout, or Kestrel request timeout delays failure but does not prevent queuing. It often makes spikes worse by holding resources longer before releasing them.</p>
<h2>FAQ</h2>
<h3>What is the most common cause of intermittent latency spikes in ASP.NET Core production APIs?</h3>
<p>The most common single cause is ThreadPool starvation from synchronous blocking calls — typically <code>.Result</code>, <code>.Wait()</code>, or synchronous EF Core operations used in code paths that run under real concurrent load. This is common in APIs that were progressively async-ified from a synchronous codebase and still contain legacy synchronous sections in middleware or service layers. GC pressure from large or frequent allocations is the second most common cause.</p>
<h3>How do I know if my ASP.NET Core latency spikes are caused by GC or something else?</h3>
<p>The clearest signal is correlation between <code>gen-2-gc-count</code> and <code>time-in-gc</code> metrics and the timestamps of your latency events. If the GC pause duration and frequency spike at exactly the same time as your p99 latency rises, GC is the cause. Use <code>dotnet-counters</code> to observe these metrics live. If the GC metrics look healthy during a latency event, shift your investigation to ThreadPool queue depth and connection pool wait times.</p>
<h3>Can GC pauses really cause noticeable latency spikes in production?</h3>
<p>Yes. In APIs with high allocation rates or significant LOH usage, stop-the-world Gen 2 collections can pause all threads for 50–300ms depending on heap size. At p99, this is highly visible. The effect is amplified when concurrent requests are queued during the pause and then all proceed simultaneously when threads resume — creating a burst that itself strains the ThreadPool and connection pool.</p>
<h3>What is the recommended way to diagnose a live ASP.NET Core production latency spike?</h3>
<p>Use <code>dotnet-counters</code> to monitor key runtime counters live against the process — specifically <code>threadpool-queue-length</code>, <code>gen-2-gc-count</code>, <code>time-in-gc</code>, and <code>loh-size</code>. If the spike is reproducible, capture a <code>dotnet-trace</code> during the event and analyse it in PerfView or SpeedScope to identify the blocking call stacks. For database-related spikes, query your database engine's active request DMVs or query the activity log from your APM tool. Microsoft's <a href="https://learn.microsoft.com/en-us/dotnet/core/diagnostics/">.NET diagnostics documentation</a> provides the full reference for these tools.</p>
<h3>How does the <code>dotnet-trace</code> tool help identify latency root causes?</h3>
<p><code>dotnet-trace</code> captures a continuous trace of .NET runtime events — including GC events, ThreadPool events, and method-level timing — with very low overhead. After a spike, you can load the trace file in PerfView and look at the Flame Graph view to identify which methods are spending the most time executing or waiting. Call stacks that show <code>.Result</code> or <code>.Wait()</code> at the top of blocked thread stacks are the classic ThreadPool starvation signature. Concentrated Gen 2 GC events clustered around the spike timestamp confirm GC pressure.</p>
<h3>How many database connections should my ASP.NET Core API pool have configured?</h3>
<p>The right pool size depends on your query duration and concurrency profile. A rough formula: <code>Max Pool Size = (average concurrent requests) × (average query duration in seconds) × safety_factor(1.5)</code>. For most production APIs serving a few hundred concurrent users with sub-100ms queries, the default of 100 is adequate if queries are written efficiently. If you are regularly saturating the pool, start by optimising slow queries and adding <code>AsNoTracking()</code> to read-only paths before increasing <code>Max Pool Size</code>.</p>
<h3>Is it worth increasing the minimum ThreadPool threads in production?</h3>
<p>Yes, for APIs that experience burst traffic. Pre-warming threads with <code>ThreadPool.SetMinThreads()</code> at startup avoids the slow hill-climbing growth delay when traffic spikes. A reasonable starting point is setting the minimum to the number of logical processors multiplied by 4–8, then measuring the impact on burst-traffic p99 latency. This does not fix starvation caused by synchronous blocking code — it only reduces the ramp-up delay when load increases suddenly.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
]]></content:encoded></item><item><title><![CDATA[The Strangler Fig Pattern in ASP.NET Core: When to Use It and How]]></title><description><![CDATA[Legacy .NET systems do not fail all at once — they accumulate risk gradually, one missed refactor at a time. Teams that try to modernize them in a single big-bang rewrite often find themselves six mon]]></description><link>https://codingdroplets.com/strangler-fig-pattern-aspnet-core</link><guid isPermaLink="true">https://codingdroplets.com/strangler-fig-pattern-aspnet-core</guid><category><![CDATA[asp.net core]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[architecture-pattern]]></category><category><![CDATA[migration]]></category><category><![CDATA[YARP]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[legacy modernization]]></category><category><![CDATA[.NET Core]]></category><dc:creator><![CDATA[Coding Droplets]]></dc:creator><pubDate>Wed, 27 May 2026 14:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68004fd8a92d3bb6c84e6384/24edcab5-277c-4604-8e5a-3cb8baf09c7b.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Legacy .NET systems do not fail all at once — they accumulate risk gradually, one missed refactor at a time. Teams that try to modernize them in a single big-bang rewrite often find themselves six months in with a half-finished system that cannot be shipped and a production system that cannot be changed. The Strangler Fig pattern exists precisely for this situation: it lets you replace a legacy system one endpoint, one service, or one feature at a time, without ever stopping the clock.</p>
<p>If you want to see this pattern applied inside a full production-grade ASP.NET Core codebase — with every layer wired together and tested — the complete source code and worked examples are on <a href="https://www.patreon.com/CodingDroplets">Patreon</a>, annotated and ready to run.</p>
<p>The pattern takes its name from the strangler fig tree, which grows around an existing host tree, gradually taking over its structure until the original tree can be removed entirely. Applied to software, the new ASP.NET Core system is built alongside the legacy application — first intercepting specific routes, then more, until the legacy system handles nothing and can be decommissioned.</p>
<h2>What Problem Does the Strangler Fig Pattern Solve?</h2>
<p>Enterprise .NET teams face a common dilemma: the legacy system is too risky to touch, but also too expensive to leave alone. It might be a classic ASP.NET Framework MVC application running on IIS, a .NET Framework Web API that predates dependency injection, or a monolith with tightly coupled business logic and no automated tests.</p>
<p>A full rewrite forces you to pause feature delivery, maintain two codebases in parallel under pressure, and go live with an untested replacement. Most big-bang rewrites fail — not because of technical incompetence, but because the risk accumulates and the deadline pressure shortcuts the quality.</p>
<p>The Strangler Fig pattern avoids this by making modernization a series of small, independently deployable steps. Each step ships. Each step reduces legacy surface area. The system is always in production and always functional.</p>
<h2>How the Strangler Fig Pattern Works</h2>
<p>The mechanics are straightforward: a facade layer sits in front of both the legacy and new systems. Every incoming request is intercepted at this facade. If the new system has a working implementation for that endpoint, the request is routed to the new system. If not, the request passes through to the legacy system unchanged.</p>
<p>The facade grows over time. You migrate one endpoint or one domain. You verify it works. You update the routing rules to stop forwarding that endpoint to legacy. You repeat. Over months — or years, for large systems — the legacy system handles progressively fewer requests until the routing table no longer contains any legacy routes and the legacy system is taken offline.</p>
<p>In the ASP.NET Core ecosystem, <strong>YARP (Yet Another Reverse Proxy)</strong> is the standard tool for implementing the facade. YARP is a Microsoft-maintained, ASP.NET Core–native reverse proxy that runs as standard middleware. It can route, transform, and load-balance requests based on route patterns, headers, or custom predicates — and it integrates with the rest of the ASP.NET Core pipeline cleanly.</p>
<h2>When to Use It</h2>
<p>The Strangler Fig pattern is the right choice when:</p>
<p><strong>The legacy system is live and cannot afford downtime.</strong> If you cannot take the system offline, you cannot do a big-bang replacement. The Strangler Fig lets you migrate with zero planned downtime.</p>
<p><strong>The legacy system has no adequate test coverage.</strong> Migrating piece by piece allows you to write tests for each piece as you move it. You cannot safely test a full rewrite of an untested system.</p>
<p><strong>The business still needs features during migration.</strong> The new system can deliver new functionality while the migration continues in parallel. Feature delivery does not pause.</p>
<p><strong>Teams are small and migration must be incremental.</strong> A two-person team cannot rewrite a 100K-line system in six months. They can migrate ten endpoints a sprint while shipping new features.</p>
<p><strong>The technology stack needs to change, not just the architecture.</strong> Moving from ASP.NET Framework 4.x to ASP.NET Core, or from synchronous request handling to async, is far less risky when done one domain at a time.</p>
<h2>When Not to Use It</h2>
<p><strong>When the legacy system is unmaintainable and tightly coupled.</strong> If a single endpoint touches forty stored procedures and three legacy services, migrating it in isolation is not straightforward. The Strangler Fig works best when domain boundaries can be drawn.</p>
<p><strong>When the legacy system's data model is the actual problem.</strong> Migrating endpoints does not fix a fundamentally broken data schema. If the schema needs major restructuring, that work must run in parallel — and may require a different strategy (event-driven migration, dual-write patterns, or schema versioning).</p>
<p><strong>When the team lacks operational maturity to run two systems simultaneously.</strong> Running legacy and new in parallel means two sets of deployments, two sets of monitoring, two sets of support. If your team cannot manage that operational overhead, the pattern adds more risk than it removes.</p>
<p><strong>When the window for migration is very short.</strong> The Strangler Fig pattern is a long-game strategy. If you have three months and need the full system migrated, you may need a more aggressive approach — with higher risk.</p>
<h2>Core Concepts</h2>
<h3>The Facade (YARP as the Router)</h3>
<p>YARP acts as the strangler facade. It receives every inbound request and decides where to send it based on route configuration. Early in a migration, most routes point to the legacy system. As endpoints are migrated, their routes are updated to point to the new ASP.NET Core application.</p>
<p>YARP configuration can be code-based or file-based (<code>appsettings.json</code>). The key capability is <strong>route matching</strong> — you can match by path prefix, exact path, HTTP method, headers, or custom predicates. This granularity is what makes endpoint-by-endpoint migration practical.</p>
<p>A useful pattern is to use a YARP cluster named <code>legacy</code> that points to your on-premise IIS-hosted legacy application and a cluster named <code>new-api</code> that points to your new ASP.NET Core service. Each route entry specifies which cluster handles that request. Migrating an endpoint is as simple as changing one route's <code>ClusterId</code> from <code>legacy</code> to <code>new-api</code> — no downtime, just a config push.</p>
<h3>Migrating Domain by Domain, Not Layer by Layer</h3>
<p>The most common Strangler Fig mistake is trying to migrate infrastructure layers horizontally — "we'll migrate all controllers first, then the services, then the database access." This approach produces nothing deployable for months.</p>
<p>The correct strategy is vertical slicing: pick one complete domain — for example, product catalogue read operations — and migrate the full stack for that domain: the endpoint, the application logic, the data access, and the tests. Once that slice is working and routed through the new system, start the next slice. Each slice is independently valuable and independently deployable.</p>
<h3>Dual-Write and Data Synchronisation</h3>
<p>The most technically demanding aspect of a Strangler Fig migration is usually data. If the legacy and new systems share a database, you may be able to migrate endpoints without data changes initially — both systems read and write the same tables. If you want to move to a new schema or a new database in the new system, you need a <strong>dual-write period</strong> where changes are written to both stores and a reconciliation process keeps them in sync.</p>
<p>This is where the complexity genuinely lives. The Strangler Fig pattern does not prescribe how to solve data migration — that is a separate concern. But it is worth naming: you will need a data migration strategy, and the pattern works best when that strategy is planned before migration begins.</p>
<h3>Feature Flags as a Traffic Controller</h3>
<p>Feature flags can complement YARP routing in nuanced ways. Rather than hardcoding which cluster a route points to in YARP configuration, you can implement a custom YARP middleware that checks a feature flag per request. This allows you to:</p>
<ul>
<li>Route a percentage of traffic to the new system (canary release)</li>
<li>Route traffic by tenant, user role, or region</li>
<li>Roll back a specific endpoint to legacy without a deployment</li>
</ul>
<p>ASP.NET Core's built-in feature management (<code>Microsoft.FeatureManagement</code>) integrates naturally with this approach.</p>
<h2>Implementation Sketch</h2>
<p>Setting up YARP as a strangler facade involves three key steps.</p>
<p><strong>Step 1:</strong> Add YARP to your new ASP.NET Core host. YARP is available via the <code>Yarp.ReverseProxy</code> NuGet package and is registered as middleware in <code>Program.cs</code> alongside your new API routes. Both the new controllers and the YARP forwarder run in the same process.</p>
<p><strong>Step 2:</strong> Configure routes and clusters. Your <code>appsettings.json</code> (or code-based configuration) maps route patterns to clusters. Migrated endpoints point to the <code>new-api</code> cluster. Everything else points to <code>legacy</code>. As you migrate, you move routes from one cluster to the other.</p>
<p><strong>Step 3:</strong> Establish an observability boundary. Add structured logging and distributed tracing at the YARP middleware level. You need to know, for every request, which system handled it. This is non-negotiable — it is how you validate that legacy traffic is declining and new-system traffic is increasing as expected.</p>
<h2>Trade-offs</h2>
<table>
<thead>
<tr>
<th>Trade-off</th>
<th>Detail</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Operational complexity</strong></td>
<td>Two systems in production simultaneously doubles deployment, monitoring, and support surface area</td>
</tr>
<tr>
<td><strong>Route management discipline</strong></td>
<td>The routing config is a critical artefact — it must be version-controlled, reviewed, and treated with the same rigour as application code</td>
</tr>
<tr>
<td><strong>Data migration complexity</strong></td>
<td>The pattern does not solve database migration — that needs a separate strategy</td>
</tr>
<tr>
<td><strong>Long migration cycles</strong></td>
<td>For large systems, migration may span years. Organisational commitment is required</td>
</tr>
<tr>
<td><strong>Test coverage gap</strong></td>
<td>Legacy code being forwarded through YARP has no new test coverage. The test gap only closes as endpoints are migrated</td>
</tr>
</tbody></table>
<p>The Circuit Breaker pattern integrates naturally with the facade layer — if the legacy system becomes unavailable, YARP can fail fast and the new system can return a graceful fallback response rather than timing out. For teams also working on ACL layers between domains, the <a href="https://codingdroplets.com/anti-corruption-layer-pattern-aspnet-core">Anti-Corruption Layer pattern</a> is a direct complement to Strangler Fig when legacy domain models need translation before entering the new system. The <a href="https://codingdroplets.com/circuit-breaker-pattern-aspnet-core">Circuit Breaker pattern in ASP.NET Core</a> covers the resilience side in detail.</p>
<h2>Anti-Patterns to Avoid</h2>
<p><strong>Keeping the facade forever.</strong> The facade is temporary scaffolding. Teams that stop the migration partway through end up with a permanent proxy that adds latency and complexity without completing the goal. Decide upfront what "done" looks like and treat it as a hard milestone.</p>
<p><strong>Migrating without feature parity validation.</strong> Before updating a route in YARP to point to the new system, run both systems in parallel on the same request shape and compare responses (shadow testing or traffic mirroring). Skipping this step leads to silent regressions.</p>
<p><strong>Migrating only the happy path.</strong> Legacy systems often have years of edge-case handling baked in — error responses, content negotiation quirks, unusual header behaviour. Your new implementation needs to match that behaviour, not just the success path.</p>
<p><strong>Treating data migration as an afterthought.</strong> Many migrations stall because the endpoint logic is migrated but the database is not. Plan data migration upfront, even if execution is deferred.</p>
<p><strong>Letting the legacy codebase continue to grow during migration.</strong> Every new feature added to the legacy system is another thing to migrate. If migration is underway, new feature work should go into the new system — even if the facade is not yet routing that domain.</p>
<hr />
<p>☕ Prefer a one-time tip? <a href="https://buymeacoffee.com/codingdroplets">Buy us a coffee</a> — every bit helps keep the content coming!</p>
<hr />
<h2>FAQ</h2>
<p><strong>What is the Strangler Fig pattern in ASP.NET Core?</strong>
The Strangler Fig pattern is an incremental migration strategy where a new ASP.NET Core system is built alongside a legacy application. A reverse proxy facade (typically YARP in .NET) intercepts all requests and routes each one to either the new or legacy system based on which endpoints have been migrated. The legacy system is decommissioned once all routes have been moved to the new system.</p>
<p><strong>When should I use the Strangler Fig pattern instead of a full rewrite?</strong>
Use it when your legacy system is live and cannot afford downtime, when the team is small and migration must happen alongside feature delivery, when the legacy codebase lacks test coverage, or when risk tolerance is low. A full rewrite is appropriate only when the legacy system is truly unmaintainable and all stakeholders accept the risk of a big-bang cutover.</p>
<p><strong>How does YARP fit into the Strangler Fig pattern in .NET?</strong>
YARP (Yet Another Reverse Proxy) is a Microsoft-maintained ASP.NET Core library that acts as the strangler facade. It runs as middleware in an ASP.NET Core host and routes incoming requests to either the legacy cluster or the new API cluster based on configurable route rules. Migrating an endpoint from legacy to new is as simple as updating a route's cluster target in YARP configuration — no code change required.</p>
<p><strong>How do I handle database migration with the Strangler Fig pattern?</strong>
The pattern does not prescribe a data migration strategy. Common approaches include sharing a single database between legacy and new systems during migration (simplest, works when the schema is not changing), dual-write with synchronisation (required when the new system uses a different schema), and event-driven migration using the outbox pattern to propagate changes between stores. Plan data migration before starting endpoint migration — it is almost always the most complex part.</p>
<p><strong>Can I use feature flags with the Strangler Fig pattern in ASP.NET Core?</strong>
Yes. Custom YARP middleware can evaluate feature flags (via <code>Microsoft.FeatureManagement</code>) to decide which cluster a request is forwarded to. This enables canary releases (route 5% of traffic to the new system), tenant-based routing, and instant rollback without a deployment — just flip the flag.</p>
<p><strong>How do I validate that the new system behaves identically to the legacy system?</strong>
Shadow testing (traffic mirroring) is the most reliable approach. Route production traffic to both systems simultaneously, compare responses, and log discrepancies — without affecting the user. YARP supports request duplication via custom middleware. This should be done before updating any route to actively serve users from the new system.</p>
<p><strong>What is the typical timeline for a Strangler Fig migration?</strong>
There is no universal answer — it depends on the size of the legacy system, team capacity, and how cleanly the domain boundaries can be drawn. Small APIs (20–50 endpoints) can be migrated in a few months. Large monoliths may take 12–24 months. The key is to keep migration slices small, ship each one independently, and maintain organisational commitment for the full duration.</p>
]]></content:encoded></item></channel></rss>