<?xml version='1.0' encoding='UTF-8'?><?xml-stylesheet href="http://www.blogger.com/styles/atom.css" type="text/css"?><feed xmlns='http://www.w3.org/2005/Atom' xmlns:openSearch='http://a9.com/-/spec/opensearchrss/1.0/' xmlns:blogger='http://schemas.google.com/blogger/2008' xmlns:georss='http://www.georss.org/georss' xmlns:gd="http://schemas.google.com/g/2005" xmlns:thr='http://purl.org/syndication/thread/1.0'><id>tag:blogger.com,1999:blog-9014153206090669340</id><updated>2026-05-31T15:51:26.397+05:45</updated><category term="C#"/><category term=".NET 8"/><category term="ASP.NET Core"/><category term="SQL"/><category term="WinForms"/><category term="Entity Framework Core"/><category term="PostgreSQL"/><category term="VB.NET"/><category term="Web API"/><category term="EF Core"/><category term=".NET"/><category term="Npgsql"/><category term="Performance"/><category term="Windows Forms"/><category term="REST API"/><category term="SQL Server"/><category term="UI Tips"/><category term="database"/><category term="Architecture"/><category term="DBA Tips"/><category term="DbContext"/><category term="ASP.NET"/><category term="Backend Development"/><category term="Best Practices"/><category term="Claims"/><category term="Controllers"/><category term="Database Administration"/><category term="Database Design"/><category term="Fluent API"/><category term="GIN Index"/><category term="JSONB"/><category term="LINQ"/><category term="Minimal APIs"/><category term="Query Optimization"/><category term="T-SQL"/><category term="Web API Security"/><category term="mysql"/><category term=".NET 9"/><category term="ADO.NET"/><category term="ALTER DATABASE"/><category term="API Authentication"/><category term="API Design"/><category term="API Versioning"/><category term="AsNoTracking"/><category term="Asp.Versioning"/><category term="Authorization Policies"/><category term="B-Tree"/><category term="Background Jobs"/><category term="BackgroundService"/><category term="Backward Compatibility"/><category term="Bearer Token"/><category term="Boxing"/><category term="CHECKDB"/><category term="CI/CD"/><category term="CLR"/><category term="Caching"/><category term="Change Tracker"/><category term="Clean Architecture"/><category term="Collections"/><category term="ComboBox"/><category term="Concurrency"/><category term="Connection Pooling"/><category term="Connection Strings"/><category term="ControlPaint"/><category term="Controller"/><category term="Controls"/><category term="Correlation ID"/><category term="Corruption"/><category term="Crystal Reports"/><category term="CrystalReportViewer"/><category term="Custom Controls"/><category term="DBCC"/><category term="DPI"/><category term="Dapper"/><category term="Data Access"/><category term="Data Annotations"/><category term="Data Binding"/><category term="Database Deployment"/><category term="Database Files"/><category term="Database Migration"/><category term="Database Recovery"/><category term="DbUpdateConcurrencyException"/><category term="Dependency Injection"/><category term="Design Patterns"/><category term="DevOps"/><category term="Diagnostics"/><category term="Disaster Recovery"/><category term="Distributed Cache"/><category term="Drawing"/><category term="EF Core Migrations"/><category term="EF Core Modeling"/><category term="EF Core Setup"/><category term="Error Handling"/><category term="ErrorProvider"/><category term="Exception Handling"/><category term="FluentValidation"/><category term="Foreign Keys"/><category term="FromSql"/><category term="FromSqlRaw"/><category term="Full-Text Search"/><category term="Getting Started"/><category term="Global Query Filters"/><category term="Graphics"/><category term="HTTP"/><category term="Hangfire"/><category term="HttpWebRequest"/><category term="IEntityTypeConfiguration"/><category term="IHostedService"/><category term="IMemoryCache"/><category term="Indexes"/><category term="Indexing"/><category term="Integration"/><category term="JSON"/><category term="JWT Authentication"/><category term="Keyboard Shortcuts"/><category term="Keyset Pagination"/><category term="Logging"/><category term="Many-to-Many"/><category term="MeasureString"/><category term="Memory Management"/><category term="Micro-ORM"/><category term="Middleware"/><category term="Model Validation"/><category term="Multi-Tenancy"/><category term="Multiplexing"/><category term="Naming Conventions"/><category term="Networking"/><category term="Observability"/><category term="Offset Pagination"/><category term="One-to-Many"/><category term="Optimistic Concurrency"/><category term="Owned Entities"/><category term="Pagination"/><category term="ParameterFields"/><category term="Parameters"/><category term="Partial Index"/><category term="Performance Tuning"/><category term="PgBouncer"/><category term="Problem Details"/><category term="ProcessCmdKey"/><category term="Project Structure"/><category term="Query Performance"/><category term="RBAC"/><category term="RFC 7807"/><category term="Raw SQL"/><category term="Recurring Jobs"/><category term="Redis"/><category term="Reference Types"/><category term="Refresh Tokens"/><category term="Relationships"/><category term="Reporting"/><category term="Repository Pattern"/><category term="Response Caching"/><category term="Role-Based Authorization"/><category term="SMS"/><category term="Schema Review"/><category term="Security"/><category term="Serilog"/><category term="Server Maintenance"/><category term="Soft Delete"/><category term="Solution Design"/><category term="SqlConnectionStringBuilder"/><category term="Storage"/><category term="Structured Logging"/><category term="TextBox"/><category term="Token Validation"/><category term="ToolStrip"/><category term="ToolStripButton"/><category term="UX"/><category term="Unit Testing"/><category term="UserControl"/><category term="Validation"/><category term="Value Types"/><category term="Web Services"/><category term="async await"/><category term="foreign key"/><category term="foreign key constraints"/><category term="information_schema"/><category term="mysql database"/><category term="mysql db"/><category term="sp_resetstatus"/><category term="sys.master_files"/><category term="tsvector"/><category term="xmin"/><title type='text'>.NET Programming With Me</title><subtitle type='html'>Sharing my programming and SQLing experiences</subtitle><link rel='http://schemas.google.com/g/2005#feed' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/posts/default'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default?redirect=false'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/'/><link rel='hub' href='http://pubsubhubbub.appspot.com/'/><link rel='next' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default?start-index=26&amp;max-results=25&amp;redirect=false'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><generator version='7.00' uri='http://www.blogger.com'>Blogger</generator><openSearch:totalResults>71</openSearch:totalResults><openSearch:startIndex>1</openSearch:startIndex><openSearch:itemsPerPage>25</openSearch:itemsPerPage><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-782753386286247587</id><published>2026-05-31T15:51:26.396+05:45</published><updated>2026-05-31T15:51:26.397+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Correlation ID"/><category scheme="http://www.blogger.com/atom/ns#" term="Diagnostics"/><category scheme="http://www.blogger.com/atom/ns#" term="Logging"/><category scheme="http://www.blogger.com/atom/ns#" term="Observability"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><category scheme="http://www.blogger.com/atom/ns#" term="Serilog"/><category scheme="http://www.blogger.com/atom/ns#" term="Structured Logging"/><title type='text'>Logging and Structured Diagnostics in .NET 8 with Serilog and PostgreSQL Sink</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;When something breaks in production at 2 AM, plain-text log lines like &quot;Error processing order&quot; are nearly useless. You cannot filter them, you cannot correlate them across a request, and you cannot query them. Structured logging fixes this by treating every log entry as data with named properties, not a sentence. Serilog is the de facto standard for it in .NET.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have a .NET 8 API that logs structured events through Serilog, enriches every entry with request context, writes them to PostgreSQL where you can query them with SQL, and correlates all logs from a single request together.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK and an existing ASP.NET Core Web API project&lt;/li&gt;
  &lt;li&gt;A running PostgreSQL instance to act as the log sink&lt;/li&gt;
  &lt;li&gt;The &lt;code&gt;Serilog.AspNetCore&lt;/code&gt; and &lt;code&gt;Serilog.Sinks.PostgreSQL&lt;/code&gt; NuGet packages&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; A database is a convenient and queryable sink for moderate volumes, but high-throughput systems usually ship logs to a dedicated platform such as Seq, Elasticsearch, or a cloud log service. The structured-logging principles here apply identically; only the sink changes.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Wire up Serilog&lt;/div&gt;

&lt;p&gt;The goal is to replace the default logging provider with Serilog as early as possible, so that even startup errors are captured. .NET 8&#39;s minimal hosting model makes this clean with a two-stage bootstrap.&lt;/p&gt;

&lt;h3&gt;Bootstrap logger plus full configuration&lt;/h3&gt;

&lt;p&gt;The first logger catches failures during host build; the second, configured from &lt;code&gt;appsettings.json&lt;/code&gt;, takes over once the app is running.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.UseSerilog((context, services, config) =&amp;gt; config
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext());

    var app = builder.Build();
    app.UseSerilogRequestLogging(); // one tidy log line per HTTP request
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, &quot;Application terminated unexpectedly.&quot;);
}
finally
{
    Log.CloseAndFlush();
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;UseSerilogRequestLogging()&lt;/code&gt; is a quiet hero: it collapses the framework&#39;s noisy per-request log spam into a single structured line with method, path, status code, and elapsed time. &lt;code&gt;CloseAndFlush()&lt;/code&gt; in the finally block guarantees buffered logs reach the sink before the process exits, which matters most when the app is crashing, exactly when you need those logs.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Write structured events&lt;/div&gt;

&lt;p&gt;This is the habit shift that makes everything else pay off. Use message templates with named placeholders rather than string interpolation. Serilog captures each value as a queryable property instead of baking it into an opaque string.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Do this: the values become structured properties
_logger.LogInformation(
    &quot;Order {OrderId} placed by user {UserId} for {Amount:C}&quot;,
    order.Id, userId, order.Total);

// Not this: the values are lost inside one flat string
_logger.LogInformation(
    $&quot;Order {order.Id} placed by user {userId} for {order.Total:C}&quot;);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;With the first form, you can later query &quot;all orders over $500&quot; or &quot;every event for OrderId 4827&quot; directly, because &lt;code&gt;OrderId&lt;/code&gt;, &lt;code&gt;UserId&lt;/code&gt;, and &lt;code&gt;Amount&lt;/code&gt; are real fields. The second form throws that structure away the moment it is written. The difference looks cosmetic; operationally it is enormous.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Sink to PostgreSQL&lt;/div&gt;

&lt;p&gt;Now we route those structured events into a PostgreSQL table where each property can map to its own column. Defining the column map explicitly keeps the schema predictable and queryable.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — configure the PostgreSQL sink
var columns = new Dictionary&amp;lt;string, ColumnWriterBase&amp;gt;
{
    [&quot;message&quot;]      = new RenderedMessageColumnWriter(),
    [&quot;level&quot;]        = new LevelColumnWriter(),
    [&quot;timestamp&quot;]    = new TimestampColumnWriter(),
    [&quot;exception&quot;]    = new ExceptionColumnWriter(),
    [&quot;properties&quot;]   = new LogEventSerializedColumnWriter(),
    [&quot;request_id&quot;]   = new SinglePropertyColumnWriter(&quot;RequestId&quot;)
};

builder.Host.UseSerilog((context, services, config) =&amp;gt; config
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.PostgreSQL(
        connectionString: context.Configuration.GetConnectionString(&quot;Logs&quot;)!,
        tableName: &quot;app_logs&quot;,
        columnOptions: columns,
        needAutoCreateTable: true));&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;With &lt;code&gt;needAutoCreateTable&lt;/code&gt; set, the sink creates &lt;code&gt;app_logs&lt;/code&gt; on first run. The &lt;code&gt;properties&lt;/code&gt; column stores the full structured payload as JSON, while common fields get dedicated columns. That hybrid gives you fast filtering on the columns plus the full detail in JSON when you need to dig deeper.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Querying logs becomes ordinary SQL
SELECT timestamp, level, message, request_id
FROM app_logs
WHERE level = &#39;Error&#39;
  AND timestamp &amp;gt; now() - interval &#39;1 hour&#39;
ORDER BY timestamp DESC;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Performance note:&lt;/strong&gt; writing each log line synchronously to a database will throttle a busy API. Use the sink&#39;s batching options (period and batch size) so writes are buffered and flushed in groups, and never log at &lt;code&gt;Debug&lt;/code&gt; level in production unless you are actively investigating.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Correlate logs per request&lt;/div&gt;

&lt;p&gt;The final piece is correlation. When a single request produces ten log lines across services, you need to tie them together. Enriching every log within a request with a shared identifier lets you reconstruct the full story of any one call.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Middleware that pushes a correlation id into the log context
public sealed class CorrelationMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers[&quot;X-Correlation-ID&quot;]
            .FirstOrDefault() ?? Guid.NewGuid().ToString();

        context.Response.Headers[&quot;X-Correlation-ID&quot;] = correlationId;

        using (LogContext.PushProperty(&quot;CorrelationId&quot;, correlationId))
        {
            await next(context);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — register before the endpoints run
app.UseMiddleware&amp;lt;CorrelationMiddleware&amp;gt;();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Because &lt;code&gt;Enrich.FromLogContext()&lt;/code&gt; is already configured, every log written inside that &lt;code&gt;using&lt;/code&gt; block automatically carries the &lt;code&gt;CorrelationId&lt;/code&gt; property, with no extra code at the call sites. Returning the id in the response header also lets clients quote it in support tickets, so you can jump straight to their exact request: &lt;code&gt;WHERE properties -&amp;gt;&amp;gt; &#39;CorrelationId&#39; = &#39;...&#39;&lt;/code&gt;.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;You now have an API that logs structured, queryable events through Serilog, enriches them with per-request correlation ids, and persists them to PostgreSQL where ordinary SQL answers your operational questions. That is the difference between guessing what happened in production and knowing.&lt;/p&gt;

&lt;p&gt;This closes out Series 5 on real-world .NET 8 API features. Across the five posts you have built authentication, authorization, background processing, caching, and now diagnostics, the cross-cutting concerns that turn a working API into a production-ready one.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/782753386286247587/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/logging-and-structured-diagnostics-in.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/782753386286247587'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/782753386286247587'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/logging-and-structured-diagnostics-in.html' title='Logging and Structured Diagnostics in .NET 8 with Serilog and PostgreSQL Sink'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-6707070197524079956</id><published>2026-05-31T14:48:56.829+05:45</published><updated>2026-05-31T14:48:56.829+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Caching"/><category scheme="http://www.blogger.com/atom/ns#" term="Distributed Cache"/><category scheme="http://www.blogger.com/atom/ns#" term="IMemoryCache"/><category scheme="http://www.blogger.com/atom/ns#" term="Performance"/><category scheme="http://www.blogger.com/atom/ns#" term="Redis"/><category scheme="http://www.blogger.com/atom/ns#" term="Response Caching"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Caching Strategies in a .NET 8 API: In-Memory, Distributed, and Response Cache</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Caching is the cheapest performance win in most APIs and one of the easiest to get subtly wrong. The hard part is rarely storing a value; it is choosing the right layer, picking sensible expirations, and invalidating stale data without serving someone else&#39;s. .NET 8 gives you three distinct caching tools, each suited to a different job.&lt;/p&gt;
&lt;p&gt;By the end of this post you will understand when to use in-memory caching, distributed caching with Redis, and HTTP response caching, and you will have correct, idiomatic code for all three, including the cache-stampede protection most tutorials leave out.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK and an existing ASP.NET Core Web API project&lt;/li&gt;
  &lt;li&gt;A Redis instance for the distributed caching section (a local Docker container works)&lt;/li&gt;
  &lt;li&gt;The &lt;code&gt;Microsoft.Extensions.Caching.StackExchangeRedis&lt;/code&gt; package for that same section&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; The golden rule of caching is to cache data that is read far more often than it changes and that you can tolerate being slightly stale. If correctness on every read is non-negotiable, caching may not be your answer; pick the layer to match how stale you can afford to be.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; In-memory caching&lt;/div&gt;

&lt;p&gt;&lt;code&gt;IMemoryCache&lt;/code&gt; stores objects in the process&#39;s own memory. It is the fastest option because there is no network hop and no serialization, which makes it ideal for small, frequently read, expensive-to-compute data on a single instance.&lt;/p&gt;

&lt;h3&gt;The naive version and its flaw&lt;/h3&gt;

&lt;p&gt;A first attempt usually looks like a get-or-create. It works, but under load it has a hidden problem.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Cache-aside: check cache, fall back to source, store result
public async Task&amp;lt;Product?&amp;gt; GetProductAsync(int id)
{
    return await _cache.GetOrCreateAsync($&quot;product:{id}&quot;, async entry =&amp;gt;
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        entry.SlidingExpiration = TimeSpan.FromMinutes(2);
        return await _db.Products.FindAsync(id);
    });
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The flaw is the cache stampede: when a popular key expires, many concurrent requests all miss at once and all hit the database simultaneously. On a hot key under traffic, that thundering herd can knock over the very database the cache was meant to protect.&lt;/p&gt;

&lt;h3&gt;Protecting against the stampede&lt;/h3&gt;

&lt;p&gt;A per-key lock ensures only one request rebuilds the value while the rest wait for it.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;private readonly SemaphoreSlim _gate = new(1, 1);

public async Task&amp;lt;Product?&amp;gt; GetProductSafeAsync(int id)
{
    var key = $&quot;product:{id}&quot;;
    if (_cache.TryGetValue(key, out Product? cached))
        return cached;

    await _gate.WaitAsync();
    try
    {
        // Double-check: another thread may have populated it while we waited.
        if (_cache.TryGetValue(key, out cached))
            return cached;

        var product = await _db.Products.FindAsync(id);
        _cache.Set(key, product, TimeSpan.FromMinutes(10));
        return product;
    }
    finally
    {
        _gate.Release();
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The double-checked pattern inside the lock is what makes this correct: by the time a waiting thread acquires the gate, the value is usually already cached, so it returns immediately without a redundant query.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Distributed caching with Redis&lt;/div&gt;

&lt;p&gt;In-memory caching breaks down the moment you scale to more than one instance: each server has its own cache, so hit rates drop and invalidation becomes inconsistent. A distributed cache like Redis gives every instance a single shared store.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
builder.Services.AddStackExchangeRedisCache(options =&amp;gt;
{
    options.Configuration = builder.Configuration.GetConnectionString(&quot;Redis&quot;);
    options.InstanceName = &quot;yourapp:&quot;;
});&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Because Redis stores bytes, you serialize on the way in and deserialize on the way out. Wrapping that in a small helper keeps call sites clean.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public async Task&amp;lt;T?&amp;gt; GetOrSetAsync&amp;lt;T&amp;gt;(
    string key, Func&amp;lt;Task&amp;lt;T&amp;gt;&amp;gt; factory, TimeSpan ttl)
{
    var cached = await _distributedCache.GetStringAsync(key);
    if (cached is not null)
        return JsonSerializer.Deserialize&amp;lt;T&amp;gt;(cached);

    var value = await factory();

    await _distributedCache.SetStringAsync(
        key,
        JsonSerializer.Serialize(value),
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = ttl
        });

    return value;
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The tradeoff is real: Redis adds a network round trip and serialization cost, so it is slower per call than in-memory. You accept that latency in exchange for a cache that is consistent across every instance and survives an individual app restart.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Edge case:&lt;/strong&gt; always handle the cache being unavailable. A Redis outage should degrade you to &quot;slower, hitting the database&quot; rather than taking the whole API down. Wrap cache calls so a connection failure falls through to the source instead of throwing.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; HTTP response caching&lt;/div&gt;

&lt;p&gt;The two layers above cache data inside your code. Response caching works at the HTTP level, instructing clients, proxies, and the framework to reuse a whole response. It is the most efficient option when an entire endpoint&#39;s output is cacheable, because a cache hit can skip your action method entirely.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
builder.Services.AddResponseCaching();

var app = builder.Build();
app.UseResponseCaching();&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpGet(&quot;categories&quot;)]
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any, VaryByQueryKeys = [&quot;page&quot;])]
public IActionResult GetCategories(int page = 1)
{
    return Ok(_catalog.GetCategories(page));
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;VaryByQueryKeys&lt;/code&gt; is essential here: without it, the cache could serve page 1&#39;s data for a page 2 request. Response caching only applies to GET and HEAD requests that return a success status and carry no &lt;code&gt;Authorization&lt;/code&gt; header by default, which is exactly right for public, anonymous, read-heavy endpoints.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Worth knowing:&lt;/strong&gt; .NET 8 also ships output caching (&lt;code&gt;AddOutputCache&lt;/code&gt;), a more powerful server-side cache with tag-based invalidation. Response caching is the standards-based HTTP option; output caching gives you finer control. For new projects, evaluate output caching for server-controlled scenarios.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Choosing and testing the right layer&lt;/div&gt;

&lt;p&gt;The decision comes down to scope and freshness. Use in-memory for single-instance, ultra-hot, small data. Use Redis when you run multiple instances and need a shared, consistent cache. Use response or output caching when an entire endpoint response is reusable and you want to skip the work altogether.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// A quick way to prove caching works: log and time the source call
public async Task&amp;lt;Product?&amp;gt; GetProductAsync(int id)
{
    return await GetOrSetAsync($&quot;product:{id}&quot;, async () =&amp;gt;
    {
        _logger.LogInformation(&quot;CACHE MISS for product {Id}&quot;, id);
        return await _db.Products.FindAsync(id);
    }, TimeSpan.FromMinutes(10));
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Call the endpoint twice and you should see exactly one &quot;CACHE MISS&quot; log line, with the second response served from cache and noticeably faster. If you see the miss on every call, your key is varying when it should not (a common cause is including a timestamp or request-specific value in the key).&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;You now have three caching layers and a clear rule for each: in-memory for raw speed on one box, Redis for consistency across many, and response or output caching to skip work entirely. Just as importantly, you have stampede protection and graceful degradation, the details that separate a cache that helps from one that becomes a liability.&lt;/p&gt;

&lt;p&gt;The final post in this series turns to observability, so you can actually see what your cached, authenticated, job-running API is doing in production: structured logging with Serilog and a PostgreSQL sink.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/6707070197524079956/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/caching-strategies-in-net-8-api-in.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/6707070197524079956'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/6707070197524079956'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/caching-strategies-in-net-8-api-in.html' title='Caching Strategies in a .NET 8 API: In-Memory, Distributed, and Response Cache'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-8238595866706347586</id><published>2026-05-31T14:46:50.321+05:45</published><updated>2026-05-31T14:46:50.321+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Background Jobs"/><category scheme="http://www.blogger.com/atom/ns#" term="BackgroundService"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Hangfire"/><category scheme="http://www.blogger.com/atom/ns#" term="IHostedService"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><category scheme="http://www.blogger.com/atom/ns#" term="Recurring Jobs"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Background Jobs in .NET 8 Web API with IHostedService and Hangfire</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Some work has no business running inside an HTTP request. Sending a welcome email, resizing an upload, generating a report, or cleaning up stale records all make the caller wait for something they do not need to wait for. Worse, if the process restarts mid-request, that work is lost. Background jobs solve both problems.&lt;/p&gt;
&lt;p&gt;By the end of this post you will know when to reach for the built-in &lt;code&gt;IHostedService&lt;/code&gt; versus Hangfire, and you will have working examples of both: a recurring cleanup task with a hosted service, and a durable, retryable fire-and-forget job with Hangfire backed by PostgreSQL.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK and an existing ASP.NET Core Web API project&lt;/li&gt;
  &lt;li&gt;A running PostgreSQL instance for the Hangfire storage example&lt;/li&gt;
  &lt;li&gt;The &lt;code&gt;Hangfire.AspNetCore&lt;/code&gt; and &lt;code&gt;Hangfire.PostgreSql&lt;/code&gt; NuGet packages for the second half&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; The two tools solve different problems. &lt;code&gt;IHostedService&lt;/code&gt; is in-process and ephemeral, ideal for periodic in-memory tasks. Hangfire persists jobs to storage so they survive restarts and retry on failure, which is what you want for work that must not be lost. Choosing the wrong one is the most common mistake here.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; A recurring task with IHostedService&lt;/div&gt;

&lt;p&gt;For periodic in-process work, .NET 8 gives you &lt;code&gt;BackgroundService&lt;/code&gt;, a base class over &lt;code&gt;IHostedService&lt;/code&gt; that runs alongside your app for its entire lifetime. We will build a worker that purges expired refresh tokens every hour.&lt;/p&gt;

&lt;h3&gt;Implement the worker&lt;/h3&gt;

&lt;p&gt;The critical detail is scope. A hosted service is a singleton, but most useful work needs scoped dependencies like a &lt;code&gt;DbContext&lt;/code&gt;. You must create a scope per iteration rather than injecting scoped services directly.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// TokenCleanupService.cs
public sealed class TokenCleanupService(
    IServiceScopeFactory scopeFactory,
    ILogger&amp;lt;TokenCleanupService&amp;gt; logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService&amp;lt;AppDbContext&amp;gt;();

                var removed = await db.RefreshTokens
                    .Where(t =&amp;gt; t.ExpiresUtc &amp;lt; DateTime.UtcNow)
                    .ExecuteDeleteAsync(stoppingToken);

                logger.LogInformation(&quot;Purged {Count} expired tokens.&quot;, removed);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, &quot;Token cleanup iteration failed.&quot;);
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
builder.Services.AddHostedService&amp;lt;TokenCleanupService&amp;gt;();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Three things make this production-safe: &lt;code&gt;PeriodicTimer&lt;/code&gt; gives clean, allocation-light scheduling that respects the cancellation token; the per-iteration scope prevents the captured-dependency bug; and the try/catch ensures one failed run does not tear down the whole loop. Without that catch, a single transient database hiccup would silently kill the service for the rest of the process lifetime.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Durable jobs with Hangfire&lt;/div&gt;

&lt;p&gt;&lt;code&gt;IHostedService&lt;/code&gt; is great until the requirement becomes &quot;this email must be sent even if the server restarts.&quot; That is Hangfire&#39;s domain. It persists every enqueued job to storage (PostgreSQL here), executes it on a background worker, and retries automatically on failure.&lt;/p&gt;

&lt;h3&gt;Register Hangfire&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
builder.Services.AddHangfire(config =&amp;gt; config
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UsePostgreSqlStorage(options =&amp;gt;
        options.UseNpgsqlConnection(
            builder.Configuration.GetConnectionString(&quot;Hangfire&quot;))));

builder.Services.AddHangfireServer();

var app = builder.Build();
app.UseHangfireDashboard(&quot;/jobs&quot;); // protect this in production&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Hangfire creates its own schema in PostgreSQL on first run. The dashboard at &lt;code&gt;/jobs&lt;/code&gt; is a genuinely useful operational window, but it exposes job data, so wrap it in an authorization filter before shipping; an unprotected dashboard is a real security risk.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Enqueue and schedule work&lt;/div&gt;

&lt;p&gt;With Hangfire registered, queueing work from a controller is a single call that returns immediately. The job runs out-of-band on a worker, and the HTTP response is fast.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// In a controller, after registering a user
[HttpPost(&quot;register&quot;)]
public async Task&amp;lt;IActionResult&amp;gt; Register([FromBody] RegisterDto dto)
{
    var user = await _userService.CreateAsync(dto);

    // Fire-and-forget: returns instantly, runs in the background.
    _backgroundJobs.Enqueue&amp;lt;IEmailSender&amp;gt;(
        sender =&amp;gt; sender.SendWelcomeEmailAsync(user.Email));

    return Ok(new { user.Id });
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Hangfire also handles delayed and recurring jobs. A nightly report or a &quot;remind me in 24 hours&quot; email needs no custom timer code:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Delayed: run once, later
_backgroundJobs.Schedule&amp;lt;IEmailSender&amp;gt;(
    sender =&amp;gt; sender.SendReminderAsync(userId),
    TimeSpan.FromHours(24));

// Recurring: cron-scheduled, survives restarts
_recurringJobs.AddOrUpdate&amp;lt;IReportService&amp;gt;(
    &quot;nightly-report&quot;,
    service =&amp;gt; service.GenerateDailyReportAsync(),
    Cron.Daily(2)); // 2 AM every day&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Because Hangfire passes a type and an expression rather than an instance, it resolves your service from DI at execution time and serializes only the arguments. Keep those arguments small and serializable; passing entire entities is a common pitfall that bloats storage and breaks when the type changes.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Running and verifying&lt;/div&gt;

&lt;p&gt;Start the app and register a user. The HTTP response returns immediately, then the welcome-email job appears in the Hangfire dashboard, moving from Enqueued to Processing to Succeeded. The hosted cleanup service logs its purge count once an hour.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Make a job fail deliberately to watch retries in the dashboard
public Task SendWelcomeEmailAsync(string email)
{
    if (string.IsNullOrWhiteSpace(email))
        throw new InvalidOperationException(&quot;No email address.&quot;);
    // ... send logic
    return Task.CompletedTask;
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Throw an exception on purpose and you will see Hangfire move the job to the Failed state and then retry it on a back-off schedule, all visible in the dashboard. That automatic retry, with full visibility, is precisely what a hand-rolled timer cannot give you and the main reason to reach for Hangfire when work must not be lost.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Idempotency matters:&lt;/strong&gt; because Hangfire retries, a job can run more than once. Design jobs so a second execution is harmless (check whether the email was already sent, use upserts, guard with a dedupe key). Assuming exactly-once execution is the single biggest source of background-job bugs.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;You now have both tools in your kit: &lt;code&gt;IHostedService&lt;/code&gt; for lightweight, in-process recurring work, and Hangfire for durable, retryable, observable jobs backed by PostgreSQL. The decision rule is simple: if losing the work is acceptable, a hosted service is enough; if it is not, persist it with Hangfire.&lt;/p&gt;

&lt;p&gt;Next we tackle another lever for responsiveness and cost: caching, looking at in-memory, distributed, and response caching strategies in a .NET 8 API.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/8238595866706347586/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/background-jobs-in-net-8-web-api-with.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8238595866706347586'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8238595866706347586'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/background-jobs-in-net-8-web-api-with.html' title='Background Jobs in .NET 8 Web API with IHostedService and Hangfire'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-8048624214275496262</id><published>2026-05-31T14:40:12.384+05:45</published><updated>2026-05-31T14:40:12.385+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Authorization Policies"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Claims"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><category scheme="http://www.blogger.com/atom/ns#" term="RBAC"/><category scheme="http://www.blogger.com/atom/ns#" term="Role-Based Authorization"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API Security"/><title type='text'>Role-Based Authorization in ASP.NET Core with EF Core and PostgreSQL</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Authentication tells you who is calling. Authorization decides what they are allowed to do, and getting it wrong is one of the most common, and most damaging, API security failures. Role-based authorization (RBAC) is the workhorse approach: assign users to roles, then gate endpoints by role.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have an ASP.NET Core API that stores users and roles in PostgreSQL via EF Core, projects those roles into the user&#39;s claims, and enforces them on endpoints with both attribute-based and policy-based authorization.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK and a running PostgreSQL instance (local Docker container is fine)&lt;/li&gt;
  &lt;li&gt;The &lt;code&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/code&gt; package installed&lt;/li&gt;
  &lt;li&gt;A working JWT authentication setup, since roles are carried as claims inside the token (see the previous post in this series)&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; Roles can be modeled in two places: inside the token as claims, or looked up from the database on each request. This post puts them in the token for performance, which means role changes only take effect when a new token is issued. That tradeoff matters; we revisit it at the end.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Model users and roles in EF Core&lt;/div&gt;

&lt;p&gt;RBAC is fundamentally a many-to-many relationship: a user can hold several roles, and a role belongs to many users. We model that explicitly with a join entity so the schema stays clean and queryable in PostgreSQL.&lt;/p&gt;

&lt;h3&gt;The entities&lt;/h3&gt;

&lt;p&gt;Keeping roles as their own table (rather than a comma-separated string column) means you can query, audit, and extend them later without a painful migration.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Entities
public sealed class AppUser
{
    public Guid Id { get; set; }
    public string Email { get; set; } = string.Empty;
    public string PasswordHash { get; set; } = string.Empty;
    public ICollection&amp;lt;UserRole&amp;gt; UserRoles { get; set; } = new List&amp;lt;UserRole&amp;gt;();
}

public sealed class AppRole
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty; // &quot;Admin&quot;, &quot;Manager&quot;, &quot;User&quot;
    public ICollection&amp;lt;UserRole&amp;gt; UserRoles { get; set; } = new List&amp;lt;UserRole&amp;gt;();
}

public sealed class UserRole
{
    public Guid UserId { get; set; }
    public AppUser User { get; set; } = null!;
    public int RoleId { get; set; }
    public AppRole Role { get; set; } = null!;
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Configure the join in the DbContext&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&amp;lt;UserRole&amp;gt;()
        .HasKey(ur =&amp;gt; new { ur.UserId, ur.RoleId });

    modelBuilder.Entity&amp;lt;UserRole&amp;gt;()
        .HasOne(ur =&amp;gt; ur.User)
        .WithMany(u =&amp;gt; u.UserRoles)
        .HasForeignKey(ur =&amp;gt; ur.UserId);

    modelBuilder.Entity&amp;lt;UserRole&amp;gt;()
        .HasOne(ur =&amp;gt; ur.Role)
        .WithMany(r =&amp;gt; r.UserRoles)
        .HasForeignKey(ur =&amp;gt; ur.RoleId);

    modelBuilder.Entity&amp;lt;AppRole&amp;gt;()
        .HasData(
            new AppRole { Id = 1, Name = &quot;Admin&quot; },
            new AppRole { Id = 2, Name = &quot;Manager&quot; },
            new AppRole { Id = 3, Name = &quot;User&quot; });
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The composite primary key on &lt;code&gt;UserRole&lt;/code&gt; prevents duplicate assignments at the database level, and seeding the roles with &lt;code&gt;HasData&lt;/code&gt; guarantees they exist after the first migration. Run &lt;code&gt;dotnet ef migrations add InitRoles&lt;/code&gt; followed by &lt;code&gt;dotnet ef database update&lt;/code&gt; to apply this to PostgreSQL.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Project roles into claims at login&lt;/div&gt;

&lt;p&gt;For attribute-based role checks to work, the role names must be present in the authenticated user&#39;s claims. When a user logs in, we load their roles from PostgreSQL and add each one as a &lt;code&gt;ClaimTypes.Role&lt;/code&gt; claim on the token.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// During login, after verifying the password
var user = await _db.Users
    .Include(u =&amp;gt; u.UserRoles)
        .ThenInclude(ur =&amp;gt; ur.Role)
    .FirstOrDefaultAsync(u =&amp;gt; u.Email == request.Email);

var roleNames = user!.UserRoles.Select(ur =&amp;gt; ur.Role.Name).ToList();
var accessToken = _tokenService.CreateAccessToken(user.Id, user.Email, roleNames);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The eager &lt;code&gt;Include&lt;/code&gt; and &lt;code&gt;ThenInclude&lt;/code&gt; matter: without them the role collection is empty and every role check silently fails. ASP.NET Core maps &lt;code&gt;ClaimTypes.Role&lt;/code&gt; claims into the role system automatically, so &lt;code&gt;User.IsInRole(&quot;Admin&quot;)&lt;/code&gt; just works once the token carries them.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Enforce roles on endpoints&lt;/div&gt;

&lt;p&gt;There are two ways to gate access, and mature APIs use both. Attribute-based checks are simple and declarative; policy-based checks centralize complex rules so they are not duplicated across controllers.&lt;/p&gt;

&lt;h3&gt;Attribute-based, for simple gates&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[Authorize(Roles = &quot;Admin&quot;)]
[HttpDelete(&quot;users/{id:guid}&quot;)]
public async Task&amp;lt;IActionResult&amp;gt; DeleteUser(Guid id)
{
    // Only Admins reach this line.
    await _userService.DeleteAsync(id);
    return NoContent();
}

[Authorize(Roles = &quot;Admin,Manager&quot;)]
[HttpGet(&quot;reports&quot;)]
public IActionResult GetReports() =&amp;gt; Ok(_reportService.GetAll());&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Policy-based, for reusable rules&lt;/h3&gt;

&lt;p&gt;When the same combination of roles guards many endpoints, define it once as a named policy. Changing the rule then happens in a single place rather than across dozens of attributes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
builder.Services.AddAuthorization(options =&amp;gt;
{
    options.AddPolicy(&quot;CanManageContent&quot;, policy =&amp;gt;
        policy.RequireRole(&quot;Admin&quot;, &quot;Manager&quot;));

    options.AddPolicy(&quot;AdminOnly&quot;, policy =&amp;gt;
        policy.RequireRole(&quot;Admin&quot;));
});&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[Authorize(Policy = &quot;CanManageContent&quot;)]
[HttpPost(&quot;articles&quot;)]
public async Task&amp;lt;IActionResult&amp;gt; CreateArticle([FromBody] ArticleDto dto)
{
    var id = await _articleService.CreateAsync(dto);
    return CreatedAtAction(nameof(CreateArticle), new { id }, null);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A request from a user without the required role returns 403 Forbidden (they are authenticated but not permitted), while a request with no valid token returns 401 Unauthorized. Distinguishing those two responses correctly is a sign your pipeline is configured properly.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Test the authorization matrix&lt;/div&gt;

&lt;p&gt;The fastest way to gain confidence is to test the full matrix: each role against each protected endpoint, plus the anonymous case. A small integration test makes regressions impossible to miss.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[Theory]
[InlineData(&quot;Admin&quot;,   &quot;/api/users/{id}&quot;, &quot;DELETE&quot;, 204)]
[InlineData(&quot;Manager&quot;, &quot;/api/users/{id}&quot;, &quot;DELETE&quot;, 403)]
[InlineData(&quot;User&quot;,    &quot;/api/reports&quot;,    &quot;GET&quot;,    403)]
[InlineData(null,      &quot;/api/reports&quot;,    &quot;GET&quot;,    401)]
public async Task Endpoints_enforce_roles(
    string? role, string path, string method, int expectedStatus)
{
    var client = _factory.CreateAuthenticatedClient(role);
    var response = await client.SendAsync(new HttpRequestMessage(
        new HttpMethod(method), path));

    Assert.Equal(expectedStatus, (int)response.StatusCode);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;When this theory passes, you have proof that Admins can delete, Managers cannot, Users are blocked from reports, and anonymous callers get a clean 401. That table is also excellent living documentation of your access rules.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;The tradeoff to remember:&lt;/strong&gt; because roles live in the token, revoking a role does not take effect until the token expires or is refreshed. If you need instant revocation, validate critical roles against the database inside an authorization handler instead, accepting the extra query cost for security-sensitive operations.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;You now have a PostgreSQL-backed RBAC model in EF Core, roles flowing into JWT claims at login, and endpoints protected by both attribute and policy-based authorization, all verified by a test matrix. That is a production-grade foundation for controlling access in any .NET 8 API.&lt;/p&gt;

&lt;p&gt;Next in the series we shift from security to throughput: running work outside the request thread with background jobs using &lt;code&gt;IHostedService&lt;/code&gt; and Hangfire.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/8048624214275496262/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/role-based-authorization-in-aspnet-core.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8048624214275496262'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8048624214275496262'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/role-based-authorization-in-aspnet-core.html' title='Role-Based Authorization in ASP.NET Core with EF Core and PostgreSQL'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-8848990427248003814</id><published>2026-05-31T14:35:24.052+05:45</published><updated>2026-05-31T14:35:24.052+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="API Authentication"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Bearer Token"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Claims"/><category scheme="http://www.blogger.com/atom/ns#" term="JWT Authentication"/><category scheme="http://www.blogger.com/atom/ns#" term="Refresh Tokens"/><category scheme="http://www.blogger.com/atom/ns#" term="Token Validation"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API Security"/><title type='text'>JWT Authentication in a .NET 8 Web API: Setup, Claims, and Refresh Tokens</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Almost every real-world API eventually needs to answer one question on every request: who is calling, and are they allowed to? JWT (JSON Web Token) authentication is the most common answer in the .NET world, but most tutorials stop at &quot;here is how to validate a token&quot; and skip the parts that actually bite you in production: claims design, token expiry, and refresh tokens.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have a .NET 8 Web API that issues signed access tokens, embeds meaningful claims, validates them on protected endpoints, and hands out refresh tokens so users are not forced to log in every fifteen minutes.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK installed (verify with &lt;code&gt;dotnet --version&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;Visual Studio 2022 (17.8 or later) or VS Code with the C# Dev Kit&lt;/li&gt;
  &lt;li&gt;A working ASP.NET Core Web API project and basic familiarity with dependency injection and middleware ordering&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; JWT signing keys are secrets. The hard-coded key in this post is for local development only. In production, load it from a secrets manager, Azure Key Vault, or environment variables, never from &lt;code&gt;appsettings.json&lt;/code&gt; committed to source control.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Configure JWT authentication&lt;/div&gt;

&lt;p&gt;The first step is wiring the JWT bearer handler into the request pipeline so that the framework knows how to read, validate, and reject tokens. We start by defining the token settings, then registering the authentication scheme.&lt;/p&gt;

&lt;h3&gt;Define your token settings&lt;/h3&gt;

&lt;p&gt;Keeping the issuer, audience, key, and lifetimes in a strongly typed options object avoids magic strings scattered across the codebase.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// JwtSettings.cs — bound from configuration
public sealed class JwtSettings
{
    public string Issuer { get; init; } = string.Empty;
    public string Audience { get; init; } = string.Empty;
    public string SigningKey { get; init; } = string.Empty;
    public int AccessTokenMinutes { get; init; } = 15;
    public int RefreshTokenDays { get; init; } = 7;
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-aspnet&quot;&gt;// appsettings.json
{
  &quot;Jwt&quot;: {
    &quot;Issuer&quot;: &quot;https://api.yourapp.com&quot;,
    &quot;Audience&quot;: &quot;https://yourapp.com&quot;,
    &quot;SigningKey&quot;: &quot;DEV-ONLY-replace-with-a-32+-char-secret-from-vault&quot;,
    &quot;AccessTokenMinutes&quot;: 15,
    &quot;RefreshTokenDays&quot;: 7
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Register the bearer handler&lt;/h3&gt;

&lt;p&gt;This is where the validation rules live. Notice that every &lt;code&gt;Validate*&lt;/code&gt; flag is explicitly set to &lt;code&gt;true&lt;/code&gt;; relying on defaults is how subtle security holes slip in.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
var jwt = builder.Configuration.GetSection(&quot;Jwt&quot;).Get&amp;lt;JwtSettings&amp;gt;()!;
builder.Services.AddSingleton(jwt);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =&gt;
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwt.Issuer,
            ValidAudience = jwt.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwt.SigningKey)),
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

builder.Services.AddAuthorization();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The key detail most people miss is &lt;code&gt;ClockSkew&lt;/code&gt;. It defaults to five minutes, which means a &quot;15 minute&quot; token actually lives for up to 20. Tightening it to 30 seconds keeps short-lived tokens genuinely short-lived. Also remember that &lt;code&gt;UseAuthentication()&lt;/code&gt; must come before &lt;code&gt;UseAuthorization()&lt;/code&gt; in the middleware pipeline, or every request will be treated as anonymous.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Issue access tokens with claims&lt;/div&gt;

&lt;p&gt;A token is only as useful as the claims it carries. Claims are the key-value statements the API trusts because the token is signed. Good claim design means you rarely have to hit the database again just to know who the caller is or what they can do.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// TokenService.cs
public sealed class TokenService(JwtSettings settings)
{
    public string CreateAccessToken(Guid userId, string email, IEnumerable&amp;lt;string&amp;gt; roles)
    {
        var claims = new List&amp;lt;Claim&amp;gt;
        {
            new(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new(JwtRegisteredClaimNames.Email, email),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        claims.AddRange(roles.Select(role =&amp;gt; new Claim(ClaimTypes.Role, role)));

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: settings.Issuer,
            audience: settings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(settings.AccessTokenMinutes),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;sub&lt;/code&gt; claim holds the stable user identifier, &lt;code&gt;jti&lt;/code&gt; gives each token a unique ID (useful if you ever need to blocklist a specific token), and the role claims drive authorization. Keep tokens lean; do not stuff a user&#39;s entire profile in here, because the token travels on every request and large tokens hurt both bandwidth and header size limits.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Add refresh tokens&lt;/div&gt;

&lt;p&gt;Access tokens are deliberately short-lived, so without a refresh mechanism your users would be logged out constantly. A refresh token is a long-lived, single-use, server-tracked secret that can be exchanged for a fresh access token. Because it is stored and revocable, it gives you a way to invalidate sessions that a stateless JWT alone cannot.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// RefreshToken.cs — persisted entity
public sealed class RefreshToken
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string TokenHash { get; set; } = string.Empty;
    public DateTime ExpiresUtc { get; set; }
    public DateTime? RevokedUtc { get; set; }
    public bool IsActive =&amp;gt; RevokedUtc is null &amp;amp;&amp;amp; DateTime.UtcNow &amp;lt; ExpiresUtc;
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// AuthController.cs — the refresh endpoint
[HttpPost(&quot;refresh&quot;)]
[AllowAnonymous]
public async Task&amp;lt;IActionResult&amp;gt; Refresh([FromBody] RefreshRequest request)
{
    var hash = TokenHasher.Hash(request.RefreshToken);
    var stored = await _db.RefreshTokens
        .FirstOrDefaultAsync(t =&amp;gt; t.TokenHash == hash);

    if (stored is null || !stored.IsActive)
        return Unauthorized(&quot;Invalid or expired refresh token.&quot;);

    // Rotate: revoke the old token and issue a new pair.
    stored.RevokedUtc = DateTime.UtcNow;

    var user = await _db.Users.FindAsync(stored.UserId);
    var (newRefresh, newHash) = TokenHasher.Generate();

    _db.RefreshTokens.Add(new RefreshToken
    {
        UserId = user!.Id,
        TokenHash = newHash,
        ExpiresUtc = DateTime.UtcNow.AddDays(_settings.RefreshTokenDays)
    });
    await _db.SaveChangesAsync();

    var accessToken = _tokenService.CreateAccessToken(
        user.Id, user.Email, user.Roles);

    return Ok(new { accessToken, refreshToken = newRefresh });
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Two non-negotiable practices appear here. First, store only a hash of the refresh token, never the raw value, so a database leak does not hand attackers live sessions. Second, rotate on every use: each refresh revokes the old token and issues a new one, which lets you detect token theft (a revoked token being replayed is a strong signal something is wrong).&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Edge case:&lt;/strong&gt; If a revoked refresh token is presented, treat it as a possible breach. A robust implementation revokes the entire token family for that user, forcing a fresh login. This is the difference between a tidy demo and a system you can defend.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Protect endpoints and test the flow&lt;/div&gt;

&lt;p&gt;With issuance and refresh in place, protecting an endpoint is a single attribute. The framework reads the bearer token, validates the signature and claims, and populates &lt;code&gt;User&lt;/code&gt; for you.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[Authorize]
[HttpGet(&quot;me&quot;)]
public IActionResult Me()
{
    var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub);
    var email  = User.FindFirstValue(JwtRegisteredClaimNames.Email);
    return Ok(new { userId, email });
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;To verify the full loop, call your login endpoint to receive an access and refresh token, then call a protected endpoint with the access token in the header:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-aspnet&quot;&gt;GET /api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A valid token returns 200 with your claims. Wait for the access token to expire and the same call returns 401; hit the refresh endpoint with your refresh token and you get a fresh pair, confirming the rotation works end to end.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;You now have a .NET 8 API that issues signed, claim-bearing access tokens, validates them strictly, and supports rotating refresh tokens backed by hashed, revocable storage. That covers the authentication half of the security story: proving who the caller is.&lt;/p&gt;

&lt;p&gt;The natural next step is authorization, deciding what an authenticated caller is allowed to do. That is exactly what the next post in this series tackles with role-based authorization using EF Core and PostgreSQL.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/8848990427248003814/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/jwt-authentication-in-net-8-web-api.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8848990427248003814'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8848990427248003814'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/jwt-authentication-in-net-8-web-api.html' title='JWT Authentication in a .NET 8 Web API: Setup, Claims, and Refresh Tokens'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-1509423954683147506</id><published>2026-05-24T16:19:06.479+05:45</published><updated>2026-05-24T16:19:06.480+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Connection Pooling"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Multiplexing"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="Performance"/><category scheme="http://www.blogger.com/atom/ns#" term="PgBouncer"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><title type='text'>Connection Pooling with Npgsql and .NET 8: PgBouncer, Multiplexing, and Best Practices</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Connection pooling is more critical with PostgreSQL than it is with SQL Server, and the reason is architectural. Each PostgreSQL client connection spawns a dedicated OS process on the server, making connections expensive to establish and memory-intensive to hold. Under load, exhausting that process limit is one of the most common scaling failures for .NET APIs backed by PostgreSQL.&lt;/p&gt;
&lt;p&gt;This post walks through three layers of the solution: Npgsql&#39;s built-in client-side pool, the multiplexing mode introduced in Npgsql 7, and PgBouncer as a server-side proxy for high-concurrency deployments. By the end you will have a clear picture of which layer solves which problem and what configuration to use in a .NET 8 API.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK or later&lt;/li&gt;
  &lt;li&gt;PostgreSQL 14 or later&lt;/li&gt;
  &lt;li&gt;Npgsql 8.x (the standalone driver) or Npgsql.EntityFrameworkCore.PostgreSQL 8.x&lt;/li&gt;
  &lt;li&gt;PgBouncer installed locally or via Docker if you want to follow the PgBouncer section&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; PostgreSQL&#39;s default &lt;code&gt;max_connections&lt;/code&gt; is 100. Each connection consumes roughly 5–10 MB of RAM on the server. A single .NET API instance with a default Npgsql pool size of 100 can exhaust &lt;code&gt;max_connections&lt;/code&gt; on its own, before any other clients connect.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Npgsql&#39;s built-in client-side pool&lt;/div&gt;

&lt;p&gt;Npgsql maintains a pool of physical connections per unique connection string. When your code opens a logical connection (&lt;code&gt;DbContext&lt;/code&gt; method call, &lt;code&gt;NpgsqlConnection.OpenAsync()&lt;/code&gt;), Npgsql hands out a connection from the pool rather than opening a new one. When the logical connection is closed or the &lt;code&gt;DbContext&lt;/code&gt; is disposed, the physical connection returns to the pool for reuse.&lt;/p&gt;

&lt;h3&gt;Configuring the pool via NpgsqlDataSourceBuilder&lt;/h3&gt;

&lt;p&gt;In .NET 8, the recommended approach is to build an &lt;code&gt;NpgsqlDataSource&lt;/code&gt; once at startup and register it with the DI container. This is more efficient than passing a raw connection string because Npgsql can reuse prepared statement caches and configuration across the application lifetime.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
var connectionString = builder.Configuration.GetConnectionString(&quot;Default&quot;)!;

var dataSource = new NpgsqlDataSourceBuilder(connectionString)
    .Build();

builder.Services.AddSingleton(dataSource);

builder.Services.AddDbContext&amp;lt;AppDbContext&amp;gt;(options =&amp;gt;
    options.UseNpgsql(dataSource));
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Key connection string pool parameters&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// appsettings.json connection string with explicit pool settings
// &quot;Server=localhost;Port=5432;Database=mydb;Username=api;Password=secret;
//  Minimum Pool Size=2;Maximum Pool Size=20;
//  Connection Idle Lifetime=300;Connection Pruning Interval=10&quot;

var builder = new NpgsqlConnectionStringBuilder(connectionString)
{
    MinPoolSize = 2,            // Keep at least 2 connections open at all times
    MaxPoolSize = 20,           // Never exceed 20 physical connections per instance
    ConnectionIdleLifetime = 300,  // Close idle connections after 5 minutes
    ConnectionPruningInterval = 10 // Check for idle connections every 10 seconds
};
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Size the pool relative to your workload and the number of application instances. If you run 5 replicas each with &lt;code&gt;MaxPoolSize=20&lt;/code&gt;, you need PostgreSQL&#39;s &lt;code&gt;max_connections&lt;/code&gt; set above 100 (5 × 20), with headroom for admin connections. A common production formula is &lt;code&gt;MaxPoolSize = max_connections / number_of_instances - buffer&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Do not set &lt;code&gt;MaxPoolSize&lt;/code&gt; too high. More connections means more RAM consumed on the PostgreSQL server, more lock contention, and more context switching. A smaller pool with a short wait timeout is often faster than a large pool where the server is thrashing. Start at 20 per instance and tune from there.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Multiplexing mode in Npgsql 7+&lt;/div&gt;

&lt;p&gt;Npgsql&#39;s multiplexing mode allows multiple concurrent commands to share a single physical connection through pipelining. Instead of each async operation waiting for an exclusive connection from the pool, multiplexing queues commands and sends them over shared connections, reducing the total number of physical connections needed under high concurrency.&lt;/p&gt;

&lt;h3&gt;Enabling multiplexing&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var dataSource = new NpgsqlDataSourceBuilder(connectionString)
    .EnableDynamicJson()  // optional; keeps JSON handling flexible
    .Build();

// Multiplexing is enabled via the connection string parameter
// Add &quot;Multiplexing=true;Max Auto Prepare=20&quot; to your connection string:
// &quot;...;Multiplexing=true;Max Auto Prepare=20&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Or configure it on the builder directly:
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString)
{
    Multiplexing = true,
    MaxAutoPrepare = 20   // Npgsql auto-prepares the 20 most-used statements
};

var dataSource = new NpgsqlDataSourceBuilder(csBuilder.ConnectionString).Build();
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;When multiplexing helps and when it does not&lt;/h3&gt;

&lt;p&gt;Multiplexing reduces the connection count under high-concurrency, short-duration query workloads. It is most effective when many requests are running simple queries simultaneously, such as a read-heavy API endpoint with hundreds of concurrent users.&lt;/p&gt;

&lt;p&gt;Multiplexing is not compatible with all Npgsql features. Do not enable it if your code uses any of the following, as they require exclusive connection ownership:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Explicit transactions (&lt;code&gt;BeginTransactionAsync&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;COPY operations&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;LISTEN&lt;/code&gt; / &lt;code&gt;NOTIFY&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Cursors or portal-based streaming&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; EF Core wraps most operations in implicit transactions, which are incompatible with multiplexing. If you use EF Core as your primary data access layer with &lt;code&gt;SaveChangesAsync()&lt;/code&gt;, multiplexing provides limited benefit and may cause errors. It is better suited to scenarios using raw &lt;code&gt;NpgsqlCommand&lt;/code&gt; or Dapper without explicit transactions.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; PgBouncer as a server-side pool&lt;/div&gt;

&lt;p&gt;When you have many application instances (microservices, containerised replicas, serverless functions) each maintaining their own Npgsql pool, the total connection count against PostgreSQL can still grow beyond what the server handles efficiently. PgBouncer is a lightweight connection pooler that sits between your .NET applications and PostgreSQL, multiplexing many client connections into a smaller number of server connections.&lt;/p&gt;

&lt;h3&gt;PgBouncer pooling modes&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Session mode&lt;/strong&gt; assigns one server connection per client connection for the full session lifetime. This is the most compatible mode but provides the least reduction in server-side connections. Use it as a safe default when first introducing PgBouncer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transaction mode&lt;/strong&gt; assigns a server connection only for the duration of a single transaction, then releases it immediately. This is the most efficient mode and provides the largest reduction in server-side connections, but it is incompatible with session-level features: prepared statements, advisory locks, &lt;code&gt;SET&lt;/code&gt; settings, and temporary tables all break in transaction mode unless handled carefully.&lt;/p&gt;

&lt;h3&gt;Configuring Npgsql for PgBouncer transaction mode&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// When using PgBouncer in transaction mode, disable Npgsql&#39;s prepared statement
// cache and set No Reset On Close to skip the session-cleanup command
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString)
{
    // Point at PgBouncer&#39;s port (default 6432), not PostgreSQL directly
    Host = &quot;pgbouncer-host&quot;,
    Port = 6432,

    // Disable server-side prepared statements (not supported in transaction mode)
    MaxAutoPrepare = 0,
    NoResetOnClose = true,   // Skip &quot;DISCARD ALL&quot; on connection return

    // Keep Npgsql&#39;s client pool small; PgBouncer owns the server-side pool
    MaxPoolSize = 10
};

var dataSource = new NpgsqlDataSourceBuilder(csBuilder.ConnectionString).Build();
builder.Services.AddDbContext&amp;lt;AppDbContext&amp;gt;(o =&amp;gt; o.UseNpgsql(dataSource));
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Minimal PgBouncer configuration for a .NET API&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;; pgbouncer.ini
[databases]
mydb = host=postgres-host port=5432 dbname=mydb

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000    ; total clients PgBouncer accepts
default_pool_size = 25    ; server connections per database/user pair
server_reset_query =      ; empty: skip DISCARD ALL in transaction mode
&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Run PgBouncer as a sidecar container alongside each application pod in Kubernetes rather than as a single shared instance. A sidecar keeps the network hop local and removes a single point of failure, while each pod&#39;s PgBouncer contributes its &lt;code&gt;default_pool_size&lt;/code&gt; to the total server-side connection count you need to budget for.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Running and verifying your pool&lt;/div&gt;

&lt;p&gt;After configuring pooling, verify the actual connection count on the PostgreSQL server to confirm your settings are having the intended effect.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Count active connections by application and state
SELECT
    application_name,
    state,
    count(*) AS connection_count
FROM pg_stat_activity
WHERE datname = &#39;mydb&#39;
GROUP BY application_name, state
ORDER BY connection_count DESC;

-- Check if any connections are waiting for a pool slot (wait_event_type = &#39;Client&#39;)
SELECT pid, wait_event_type, wait_event, query_start, state
FROM pg_stat_activity
WHERE wait_event_type = &#39;Client&#39;
  AND datname = &#39;mydb&#39;;
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// In .NET 8, log Npgsql pool events to watch for pool exhaustion at startup
builder.Logging.AddFilter(&quot;Npgsql&quot;, LogLevel.Information);

// Pool exhaustion looks like this in logs:
// The connection pool has been exhausted, either raise MaxPoolSize
// or set ConnectionTimeout to a higher value.
&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;Connection pooling with PostgreSQL and .NET requires thinking at three levels. Npgsql&#39;s client-side pool handles the common case for a single application instance: size it conservatively and use &lt;code&gt;NpgsqlDataSourceBuilder&lt;/code&gt; for lifetime management. Multiplexing is a useful additional tool for raw command workloads with very high concurrency and no transactions. PgBouncer steps in when you have many application instances and need to cap the total server-side connection count below what PostgreSQL can efficiently handle.&lt;/p&gt;
&lt;p&gt;The most important thing you can do before tuning is measure: check &lt;code&gt;pg_stat_activity&lt;/code&gt; under realistic load, watch for pool exhaustion in logs, and size your pool relative to &lt;code&gt;max_connections&lt;/code&gt; and the number of application replicas actually running.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/1509423954683147506/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/connection-pooling-with-npgsql-and-net.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/1509423954683147506'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/1509423954683147506'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/connection-pooling-with-npgsql-and-net.html' title='Connection Pooling with Npgsql and .NET 8: PgBouncer, Multiplexing, and Best Practices'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-7476942331097420703</id><published>2026-05-24T16:17:42.639+05:45</published><updated>2026-05-24T16:17:42.640+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="B-Tree"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="GIN Index"/><category scheme="http://www.blogger.com/atom/ns#" term="Indexing"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="Partial Index"/><category scheme="http://www.blogger.com/atom/ns#" term="Performance"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><title type='text'>Indexing Strategies in PostgreSQL That Every .NET Developer Should Know</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Most .NET developers let EF Core generate their PostgreSQL indexes and then wonder why certain queries are slow in production. The default behaviour is reasonable, but PostgreSQL offers several index types that SQL Server does not, and knowing when to reach for each one is the difference between a fast API and a scaling problem.&lt;/p&gt;
&lt;p&gt;This post covers the four index strategies that matter most for .NET API workloads: B-Tree, GIN, partial, and expression indexes. Each section explains what the index is for, shows how to create it (both via EF Core and raw SQL), and includes a &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; pattern to verify it is being used.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK and PostgreSQL 14 or later&lt;/li&gt;
  &lt;li&gt;Npgsql.EntityFrameworkCore.PostgreSQL 8.x&lt;/li&gt;
  &lt;li&gt;Access to psql, pgAdmin, or DataGrip to run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;A working EF Core project with at least one entity mapped to a table&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; Index decisions should always be validated with realistic data volumes. An index that helps at 100,000 rows may be ignored at 500 rows because the query planner considers a sequential scan cheaper on small tables.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; B-Tree indexes — the default&lt;/div&gt;

&lt;p&gt;Every &lt;code&gt;HasIndex()&lt;/code&gt; call in EF Core creates a B-Tree index. B-Tree is the right choice for equality and range filters on scalar columns: integers, GUIDs, dates, strings, and decimals. If you are filtering by &lt;code&gt;UserId&lt;/code&gt;, sorting by &lt;code&gt;CreatedAt&lt;/code&gt;, or looking up by &lt;code&gt;Email&lt;/code&gt;, B-Tree is what you want.&lt;/p&gt;

&lt;h3&gt;Creating B-Tree indexes with EF Core&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;modelBuilder.Entity&amp;lt;Order&amp;gt;(entity =&amp;gt;
{
    // Single-column index for equality lookups
    entity.HasIndex(o =&amp;gt; o.CustomerId);

    // Composite index — column order matters
    // Queries that filter on Status AND sort by CreatedAt benefit from this
    entity.HasIndex(o =&amp;gt; new { o.Status, o.CreatedAt });

    // Unique index — enforced at the database level
    entity.HasIndex(o =&amp;gt; o.ReferenceNumber).IsUnique();
});
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Composite index column ordering&lt;/h3&gt;

&lt;p&gt;For composite B-Tree indexes, put the highest-cardinality equality filter column first, followed by range or sort columns. A query that filters on &lt;code&gt;Status = &#39;Pending&#39;&lt;/code&gt; and orders by &lt;code&gt;CreatedAt DESC&lt;/code&gt; uses the index above efficiently. A query that only filters on &lt;code&gt;CreatedAt&lt;/code&gt; cannot use the leftmost prefix and will do a sequential scan.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Confirm index usage for the composite index
EXPLAIN ANALYZE
SELECT * FROM &quot;Orders&quot;
WHERE &quot;Status&quot; = &#39;Pending&#39;
ORDER BY &quot;CreatedAt&quot; DESC
LIMIT 50;
-- Look for: Index Scan Backward using &quot;IX_Orders_Status_CreatedAt&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; A covering index (PostgreSQL 11+) stores additional columns in the index leaf nodes, allowing the query to be satisfied without touching the heap. Use &lt;code&gt;IncludeProperties()&lt;/code&gt; in EF Core to add include columns to a B-Tree index.
&lt;/div&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Covering index: the query planner can serve the SELECT entirely from the index
entity.HasIndex(o =&amp;gt; new { o.Status, o.CreatedAt })
    .IncludeProperties(o =&amp;gt; new { o.Id, o.TotalAmount });
&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; GIN indexes — for JSONB, arrays, and full-text search&lt;/div&gt;

&lt;p&gt;GIN (Generalised Inverted Index) indexes the internal structure of a composite value rather than the value itself. Use GIN for &lt;code&gt;jsonb&lt;/code&gt; columns, PostgreSQL array columns, and &lt;code&gt;tsvector&lt;/code&gt; full-text search columns. A B-Tree index on a &lt;code&gt;jsonb&lt;/code&gt; column is useless for containment queries; GIN is what makes them fast.&lt;/p&gt;

&lt;h3&gt;GIN index on a JSONB column&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// EF Core does not expose GIN natively; use a raw SQL migration
public partial class AddMetadataGinIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Full-document GIN index — covers all keys and values in the document
        migrationBuilder.Sql(
            &quot;&quot;&quot;
            CREATE INDEX ix_products_metadata_gin
            ON &quot;Products&quot;
            USING GIN (&quot;Metadata&quot;);
            &quot;&quot;&quot;
        );
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            &quot;&quot;&quot;DROP INDEX IF EXISTS ix_products_metadata_gin;&quot;&quot;&quot;
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;GIN index on a tsvector column&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// When using HasGeneratedTsVectorColumn, chain HasMethod(&quot;GIN&quot;)
modelBuilder.Entity&amp;lt;Article&amp;gt;()
    .HasGeneratedTsVectorColumn(a =&amp;gt; a.SearchVector, &quot;english&quot;,
        a =&amp;gt; new { a.Title, a.Body })
    .HasIndex(a =&amp;gt; a.SearchVector)
    .HasMethod(&quot;GIN&quot;);  // Npgsql honours this for tsvector columns
&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; GIN indexes are larger and slower to build than B-Tree indexes, but they are dramatically faster for containment and match queries on complex types. Use &lt;code&gt;gin_pending_list_limit&lt;/code&gt; (default 4 MB) to control how much data is buffered in a pending list before being merged into the main index structure.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Partial indexes — index a subset of rows&lt;/div&gt;

&lt;p&gt;A partial index includes only the rows that match a &lt;code&gt;WHERE&lt;/code&gt; clause. If 95% of your &lt;code&gt;Orders&lt;/code&gt; table has &lt;code&gt;Status = &#39;Completed&#39;&lt;/code&gt;, an index that covers all statuses is bloated with rows you rarely query. A partial index on &lt;code&gt;Status = &#39;Pending&#39;&lt;/code&gt; is smaller, faster to scan, and faster to update because only new pending orders touch it.&lt;/p&gt;

&lt;h3&gt;Creating a partial index with EF Core&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;modelBuilder.Entity&amp;lt;Order&amp;gt;(entity =&amp;gt;
{
    // Only index rows where the order is not yet completed
    entity.HasIndex(o =&amp;gt; o.CreatedAt)
        .HasFilter(&quot;\&quot;Status\&quot; != &#39;Completed&#39;&quot;);
});
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Equivalent SQL (generated by the migration):
CREATE INDEX &quot;IX_Orders_CreatedAt&quot;
ON &quot;Orders&quot; (&quot;CreatedAt&quot;)
WHERE &quot;Status&quot; != &#39;Completed&#39;;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Soft-delete pattern with a partial index&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// A very common pattern: index only non-deleted rows
modelBuilder.Entity&amp;lt;Customer&amp;gt;(entity =&amp;gt;
{
    entity.HasIndex(c =&amp;gt; c.Email)
        .IsUnique()
        .HasFilter(&quot;\&quot;IsDeleted\&quot; = false&quot;);
    // This also enforces unique emails only among active customers
});
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This pattern is particularly useful for soft-delete implementations: the unique constraint fires only for active records, and the index is small because deleted rows are excluded.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Expression indexes — index a computed value&lt;/div&gt;

&lt;p&gt;An expression index (also called a functional index) indexes the result of an expression rather than a raw column value. Use this when your queries consistently apply a function to a column before filtering, such as lower-casing an email for case-insensitive lookups or extracting a date from a timestamp.&lt;/p&gt;

&lt;h3&gt;Case-insensitive email lookup&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Create via a raw SQL migration (EF Core HasIndex does not support expressions)
CREATE INDEX ix_customers_email_lower
ON &quot;Customers&quot; (lower(&quot;Email&quot;));
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Your LINQ query must use the same expression for the index to apply
var customer = await context.Customers
    .Where(c =&amp;gt; c.Email.ToLower() == email.ToLower())
    .FirstOrDefaultAsync();

// EF Core + Npgsql translates ToLower() to lower(), which matches the index
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Indexing a date extracted from a timestamp&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Queries that filter on the date part of a timestamptz column
CREATE INDEX ix_orders_created_date
ON &quot;Orders&quot; (date_trunc(&#39;day&#39;, &quot;CreatedAt&quot; AT TIME ZONE &#39;UTC&#39;));
&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; The expression in a LINQ &lt;code&gt;Where&lt;/code&gt; clause must match the index expression exactly for the planner to use it. If the index is on &lt;code&gt;lower(&quot;Email&quot;)&lt;/code&gt; but your query sends &lt;code&gt;&quot;Email&quot; ILIKE $1&lt;/code&gt;, the index will not be used. Always verify with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;6&lt;/span&gt; Finding missing and unused indexes&lt;/div&gt;

&lt;p&gt;PostgreSQL tracks index usage statistics in &lt;code&gt;pg_stat_user_indexes&lt;/code&gt;. After running your application under realistic load, query this view to identify indexes that are never used (candidates for removal) and tables with high sequential scan counts but no supporting index (candidates for a new index).&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Indexes that have never been used since the last statistics reset
SELECT
    schemaname,
    relname    AS table_name,
    indexrelname AS index_name,
    idx_scan   AS times_used
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND schemaname = &#39;public&#39;
ORDER BY relname;

-- Tables with high sequential scans (potential missing indexes)
SELECT
    relname    AS table_name,
    seq_scan,
    idx_scan,
    n_live_tup AS row_estimate
FROM pg_stat_user_tables
WHERE seq_scan &amp;gt; 100
  AND schemaname = &#39;public&#39;
ORDER BY seq_scan DESC;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;An unused index still incurs write overhead on every &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt;. Remove indexes that have never been used after several weeks of production traffic. Statistics are reset by &lt;code&gt;pg_stat_reset()&lt;/code&gt; or when PostgreSQL restarts, so collect data over a meaningful time window before drawing conclusions.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;PostgreSQL&#39;s index toolkit is broader than most SQL Server developers realise. B-Tree covers the common cases that EF Core&#39;s &lt;code&gt;HasIndex()&lt;/code&gt; already generates. GIN unlocks efficient querying of &lt;code&gt;jsonb&lt;/code&gt; documents, arrays, and full-text search vectors. Partial indexes keep write-heavy tables lean by excluding rows you rarely read. Expression indexes push function application into the index so query-time function calls do not bypass it.&lt;/p&gt;
&lt;p&gt;The consistent discipline across all four types is the same: define the index in a migration, load representative data, run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on your actual queries, and check &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; after the application has been under load. Never assume an index is being used without verifying it.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/7476942331097420703/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/indexing-strategies-in-postgresql-that.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/7476942331097420703'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/7476942331097420703'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/indexing-strategies-in-postgresql-that.html' title='Indexing Strategies in PostgreSQL That Every .NET Developer Should Know'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-8117706227053215907</id><published>2026-05-24T16:16:36.059+05:45</published><updated>2026-05-24T16:16:36.060+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Full-Text Search"/><category scheme="http://www.blogger.com/atom/ns#" term="GIN Index"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><category scheme="http://www.blogger.com/atom/ns#" term="tsvector"/><title type='text'>Full-Text Search in PostgreSQL from a .NET 8 EF Core Controller API</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Adding keyword search to an API often leads .NET developers toward Elasticsearch or Azure Cognitive Search before they have even looked at what their database already supports. PostgreSQL&#39;s built-in full-text search, backed by a GIN index, handles a large class of search requirements without any external service, and Npgsql exposes it cleanly in LINQ.&lt;/p&gt;
&lt;p&gt;This post walks through configuring a generated &lt;code&gt;tsvector&lt;/code&gt; column on a PostgreSQL table, indexing it correctly, and wiring up a working search endpoint in an ASP.NET Core Controller API using EF Core 8.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK or later&lt;/li&gt;
  &lt;li&gt;PostgreSQL 14 or later&lt;/li&gt;
  &lt;li&gt;Npgsql.EntityFrameworkCore.PostgreSQL 8.x NuGet package&lt;/li&gt;
  &lt;li&gt;An ASP.NET Core Web API project with a configured &lt;code&gt;DbContext&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; All FTS examples use the &lt;code&gt;english&lt;/code&gt; language configuration. PostgreSQL ships with configurations for many languages. Run &lt;code&gt;SELECT cfgname FROM pg_ts_config;&lt;/code&gt; in psql to see what is available on your instance.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; How PostgreSQL full-text search works&lt;/div&gt;

&lt;p&gt;PostgreSQL FTS uses two building blocks. A &lt;code&gt;tsvector&lt;/code&gt; is a pre-processed, sorted list of lexemes (normalised word roots) derived from a text document. A &lt;code&gt;tsquery&lt;/code&gt; is a search expression with AND (&lt;code&gt;&amp;amp;&lt;/code&gt;), OR (&lt;code&gt;|&lt;/code&gt;), NOT (&lt;code&gt;!&lt;/code&gt;), and phrase (&lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt;) operators. The &lt;code&gt;@@&lt;/code&gt; match operator checks whether a &lt;code&gt;tsquery&lt;/code&gt; matches a &lt;code&gt;tsvector&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- What tsvector looks like for a sentence:
SELECT to_tsvector(&#39;english&#39;, &#39;Building scalable APIs with .NET 8 and PostgreSQL&#39;);
-- Result: &#39;.net&#39;:5 &#39;8&#39;:6 &#39;api&#39;:4 &#39;build&#39;:1 &#39;postgresql&#39;:8 &#39;scalabl&#39;:3

-- What tsquery looks like for a user search term:
SELECT plainto_tsquery(&#39;english&#39;, &#39;scalable apis&#39;);
-- Result: &#39;scalabl&#39; &amp;amp; &#39;api&#39;

-- The match operator:
SELECT to_tsvector(&#39;english&#39;, &#39;Building scalable APIs with .NET 8&#39;)
    @@ plainto_tsquery(&#39;english&#39;, &#39;scalable apis&#39;);
-- Result: true
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The key insight is that both sides are reduced to lexemes: &quot;Building&quot; becomes &quot;build&quot;, &quot;APIs&quot; becomes &quot;api&quot;, &quot;scalable&quot; becomes &quot;scalabl&quot;. This is why FTS finds &quot;APIs&quot; when you search for &quot;api&quot; and why it ignores stop words like &quot;with&quot; and &quot;and&quot;.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Adding a generated tsvector column&lt;/div&gt;

&lt;p&gt;Rather than recomputing the &lt;code&gt;tsvector&lt;/code&gt; on every search query, store it as a generated (computed) column that PostgreSQL keeps up to date automatically whenever the source columns change. Npgsql exposes a dedicated fluent API for this pattern.&lt;/p&gt;

&lt;h3&gt;The entity&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using NpgsqlTypes;

public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public DateTime PublishedAt { get; set; }

    // Populated automatically by PostgreSQL; never set this from C#
    public NpgsqlTsVector SearchVector { get; set; } = null!;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Configuring the generated column and GIN index&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&amp;lt;Article&amp;gt;(entity =&amp;gt;
    {
        entity.HasKey(a =&amp;gt; a.Id);

        // Tell Npgsql to create a GENERATED ALWAYS AS tsvector column
        // weighting Title more heavily than Body (A = highest, D = lowest)
        entity.HasGeneratedTsVectorColumn(
                a =&amp;gt; a.SearchVector,
                &quot;english&quot;,
                a =&amp;gt; new { a.Title, a.Body })
            .HasIndex(a =&amp;gt; a.SearchVector)
            .HasMethod(&quot;GIN&quot;);
    });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;What the migration generates&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Npgsql emits a STORED generated column backed by to_tsvector:
ALTER TABLE &quot;Articles&quot;
    ADD COLUMN &quot;SearchVector&quot; tsvector
    GENERATED ALWAYS AS (
        to_tsvector(&#39;english&#39;,
            coalesce(&quot;Title&quot;, &#39;&#39;) || &#39; &#39; || coalesce(&quot;Body&quot;, &#39;&#39;))
    ) STORED;

CREATE INDEX &quot;IX_Articles_SearchVector&quot;
    ON &quot;Articles&quot; USING GIN (&quot;SearchVector&quot;);
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Run &lt;code&gt;dotnet ef migrations add AddArticleSearchVector&lt;/code&gt; and &lt;code&gt;dotnet ef database update&lt;/code&gt;. Inspect the migration to confirm both the generated column and the GIN index are present before applying.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; If you need to weight the title higher than the body (so that a title match ranks above a body match), use a raw SQL migration to create the generated column manually with &lt;code&gt;setweight(to_tsvector(&#39;english&#39;, &quot;Title&quot;), &#39;A&#39;) || setweight(to_tsvector(&#39;english&#39;, &quot;Body&quot;), &#39;B&#39;)&lt;/code&gt;. Npgsql&#39;s &lt;code&gt;HasGeneratedTsVectorColumn&lt;/code&gt; concatenates columns without weights.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Writing the search endpoint&lt;/div&gt;

&lt;p&gt;With the column and index in place, the Controller endpoint is straightforward. Use &lt;code&gt;PlainToTsQuery&lt;/code&gt; rather than &lt;code&gt;ToTsQuery&lt;/code&gt; for user-supplied input: &lt;code&gt;PlainToTsQuery&lt;/code&gt; treats the input as plain text without requiring valid tsquery syntax, so it will not throw on arbitrary search strings.&lt;/p&gt;

&lt;h3&gt;The Controller&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NpgsqlTypes;

[ApiController]
[Route(&quot;api/[controller]&quot;)]
public class ArticlesController(AppDbContext context) : ControllerBase
{
    [HttpGet(&quot;search&quot;)]
    public async Task&amp;lt;IActionResult&amp;gt; Search(
        [FromQuery] string q,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20)
    {
        if (string.IsNullOrWhiteSpace(q))
            return BadRequest(&quot;A search term is required.&quot;);

        // PlainToTsQuery is safe for arbitrary user input
        var tsQuery = EF.Functions.PlainToTsQuery(&quot;english&quot;, q.Trim());

        var results = await context.Articles
            .Where(a =&amp;gt; a.SearchVector.Matches(tsQuery))
            .OrderByDescending(a =&amp;gt; a.SearchVector.Rank(tsQuery))
            .Select(a =&amp;gt; new ArticleSearchResult(
                a.Id,
                a.Title,
                a.Author,
                a.PublishedAt,
                a.Body.Length &amp;gt; 300 ? a.Body.Substring(0, 300) + &quot;…&quot; : a.Body))
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();

        return Ok(results);
    }
}

public record ArticleSearchResult(
    int Id,
    string Title,
    string Author,
    DateTime PublishedAt,
    string Excerpt);
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;What EF Core sends to PostgreSQL&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT a.&quot;Id&quot;, a.&quot;Title&quot;, a.&quot;Author&quot;, a.&quot;PublishedAt&quot;,
    CASE WHEN length(a.&quot;Body&quot;) &amp;gt; 300
         THEN substring(a.&quot;Body&quot;, 1, 300) || &#39;…&#39;
         ELSE a.&quot;Body&quot; END
FROM &quot;Articles&quot; AS a
WHERE a.&quot;SearchVector&quot; @@ plainto_tsquery(&#39;english&#39;, $1)
ORDER BY ts_rank(a.&quot;SearchVector&quot;, plainto_tsquery(&#39;english&#39;, $1)) DESC
LIMIT $2 OFFSET $3
&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; &lt;code&gt;WebSearchToTsQuery&lt;/code&gt; is an alternative to &lt;code&gt;PlainToTsQuery&lt;/code&gt; available in PostgreSQL 11+. It understands Google-style syntax: quoted phrases, the &lt;code&gt;-word&lt;/code&gt; exclusion operator, and &lt;code&gt;OR&lt;/code&gt;. Use it when you want to expose advanced search syntax to users without building a custom parser.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Running and testing it&lt;/div&gt;

&lt;p&gt;Seed the table with a few articles, then test the endpoint with representative queries to confirm the GIN index is being used.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Quick seed in Program.cs (development only)
if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService&amp;lt;AppDbContext&amp;gt;();
    await db.Database.MigrateAsync();

    if (!await db.Articles.AnyAsync())
    {
        db.Articles.AddRange(
            new Article { Title = &quot;Getting started with EF Core 8&quot;, Body = &quot;Entity Framework Core 8 introduces JSON columns, bulk updates, and complex type mappings.&quot;, Author = &quot;Anaya&quot;, PublishedAt = DateTime.UtcNow.AddDays(-10) },
            new Article { Title = &quot;PostgreSQL indexing strategies&quot;, Body = &quot;B-Tree, GIN, and partial indexes each serve a different purpose in PostgreSQL.&quot;, Author = &quot;Anaya&quot;, PublishedAt = DateTime.UtcNow.AddDays(-5) }
        );
        await db.SaveChangesAsync();
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Confirm the GIN index is used (run in psql or pgAdmin)
EXPLAIN ANALYZE
SELECT * FROM &quot;Articles&quot;
WHERE &quot;SearchVector&quot; @@ plainto_tsquery(&#39;english&#39;, &#39;entity framework&#39;);

-- Look for: Bitmap Index Scan on &quot;IX_Articles_SearchVector&quot;
-- or: Index Scan using &quot;IX_Articles_SearchVector&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A successful result shows a Bitmap Index Scan or Index Scan against &lt;code&gt;IX_Articles_SearchVector&lt;/code&gt;, not a sequential scan. If you see a sequential scan on a freshly populated table, run &lt;code&gt;ANALYZE &quot;Articles&quot;;&lt;/code&gt; to update the planner statistics and re-check.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;PostgreSQL&#39;s built-in full-text search, combined with Npgsql&#39;s &lt;code&gt;HasGeneratedTsVectorColumn&lt;/code&gt; and the &lt;code&gt;Matches&lt;/code&gt; LINQ extension, gives you a capable search endpoint without any external service. The generated column keeps the &lt;code&gt;tsvector&lt;/code&gt; current automatically, the GIN index keeps searches fast at scale, and &lt;code&gt;PlainToTsQuery&lt;/code&gt; handles arbitrary user input safely.&lt;/p&gt;
&lt;p&gt;For the next step, consider adding highlighted snippets using PostgreSQL&#39;s &lt;code&gt;ts_headline()&lt;/code&gt; function via &lt;code&gt;EF.Functions&lt;/code&gt; or a raw SQL projection, and look at &lt;code&gt;pg_trgm&lt;/code&gt; if you need fuzzy matching for misspellings alongside FTS relevance ranking.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/8117706227053215907/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/full-text-search-in-postgresql-from-net.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8117706227053215907'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8117706227053215907'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/full-text-search-in-postgresql-from-net.html' title='Full-Text Search in PostgreSQL from a .NET 8 EF Core Controller API'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-748584162558725826</id><published>2026-05-24T16:01:47.133+05:45</published><updated>2026-05-24T16:01:47.133+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="database"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="JSON"/><category scheme="http://www.blogger.com/atom/ns#" term="JSONB"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><title type='text'>JSONB Columns in PostgreSQL with EF Core: When and How to Use Them</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;PostgreSQL&#39;s &lt;code&gt;jsonb&lt;/code&gt; column type is not a glorified text field for stashing serialised strings. It is a binary-indexed, queryable, first-class storage format that gives you document-style flexibility inside a relational schema, without reaching for a separate document database. If you have ever pushed a JSON string into an &lt;code&gt;nvarchar(max)&lt;/code&gt; column and then written ugly string parsing to query it, &lt;code&gt;jsonb&lt;/code&gt; is the answer you were looking for.&lt;/p&gt;
&lt;p&gt;EF Core 8 introduced proper JSON column support that Npgsql maps directly to &lt;code&gt;jsonb&lt;/code&gt;. This post walks through defining the mapping, writing LINQ queries that PostgreSQL executes efficiently, and adding the right index so that power is not wasted on full scans.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK or later&lt;/li&gt;
  &lt;li&gt;PostgreSQL 14 or later (16 is recommended)&lt;/li&gt;
  &lt;li&gt;Npgsql.EntityFrameworkCore.PostgreSQL 8.x NuGet package&lt;/li&gt;
  &lt;li&gt;Familiarity with EF Core migrations and basic LINQ queries&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; EF Core&#39;s owned entity &lt;code&gt;ToJson()&lt;/code&gt; mapping requires EF Core 7 or later. All examples here use EF Core 8. Npgsql maps &lt;code&gt;ToJson()&lt;/code&gt; to a &lt;code&gt;jsonb&lt;/code&gt; column by default; it does not emit &lt;code&gt;json&lt;/code&gt; (text) or &lt;code&gt;nvarchar&lt;/code&gt;.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Defining a JSONB column&lt;/div&gt;

&lt;p&gt;EF Core 8 uses owned entity types configured with &lt;code&gt;ToJson()&lt;/code&gt; to project a C# object into a single JSON column. You define the C# shape normally, then tell EF Core to store it as JSON rather than as a separate table.&lt;/p&gt;

&lt;h3&gt;The entity model&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }

    // This navigation property will become a single jsonb column
    public ProductDetails? Details { get; set; }
}

public class ProductDetails
{
    public string? Sku { get; set; }
    public int StockQuantity { get; set; }
    public List&amp;lt;string&amp;gt; Tags { get; set; } = new();
    public Dictionary&amp;lt;string, string&amp;gt; Attributes { get; set; } = new();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Configuring the mapping&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class AppDbContext(DbContextOptions&amp;lt;AppDbContext&amp;gt; options) : DbContext(options)
{
    public DbSet&amp;lt;Product&amp;gt; Products =&amp;gt; Set&amp;lt;Product&amp;gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&amp;lt;Product&amp;gt;(entity =&amp;gt;
        {
            entity.HasKey(p =&amp;gt; p.Id);

            entity.OwnsOne(p =&amp;gt; p.Details, details =&amp;gt;
            {
                details.ToJson(); // Npgsql emits: &quot;Details&quot; jsonb
            });
        });
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;What the migration generates&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// The generated migration contains:
migrationBuilder.AddColumn&amp;lt;string&amp;gt;(
    name: &quot;Details&quot;,
    table: &quot;Products&quot;,
    type: &quot;jsonb&quot;,      // Npgsql sets this; SQL Server would emit nvarchar(max)
    nullable: true);
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Run &lt;code&gt;dotnet ef migrations add AddProductDetails&lt;/code&gt; followed by &lt;code&gt;dotnet ef database update&lt;/code&gt;. Inspect the generated migration file to confirm the column type is &lt;code&gt;jsonb&lt;/code&gt; before applying it.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; &lt;code&gt;jsonb&lt;/code&gt; stores JSON in a decomposed binary format. PostgreSQL parses and validates the document on write, making writes slightly heavier than the &lt;code&gt;json&lt;/code&gt; (plain text) type, but reads are faster because the document does not need re-parsing on every access.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Querying JSONB columns with LINQ&lt;/div&gt;

&lt;p&gt;This is where &lt;code&gt;jsonb&lt;/code&gt; earns its place. EF Core translates LINQ expressions against the owned type into PostgreSQL JSON path operators, which execute server-side without pulling every row into memory.&lt;/p&gt;

&lt;h3&gt;Filtering on a scalar JSON property&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Find all products currently in stock
var inStock = await context.Products
    .Where(p =&amp;gt; p.Details != null &amp;amp;&amp;amp; p.Details.StockQuantity &amp;gt; 0)
    .OrderBy(p =&amp;gt; p.Name)
    .ToListAsync();

// PostgreSQL executes something equivalent to:
// WHERE (details -&amp;gt;&amp;gt; &#39;StockQuantity&#39;)::int &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Filtering with a JSON array containment check&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Find products tagged as &quot;clearance&quot;
var clearance = await context.Products
    .Where(p =&amp;gt; p.Details != null &amp;amp;&amp;amp; p.Details.Tags.Contains(&quot;clearance&quot;))
    .ToListAsync();
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Combining relational and JSON filters&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Under $50, in stock, and tagged as &quot;sale&quot;
var saleItems = await context.Products
    .Where(p =&amp;gt;
        p.Price &amp;lt; 50m &amp;amp;&amp;amp;
        p.Details != null &amp;amp;&amp;amp;
        p.Details.StockQuantity &amp;gt; 0 &amp;amp;&amp;amp;
        p.Details.Tags.Contains(&quot;sale&quot;))
    .Select(p =&amp;gt; new
    {
        p.Id,
        p.Name,
        p.Price,
        p.Details!.Sku
    })
    .ToListAsync();
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Updating a JSON document&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Load, modify in memory, and save — EF Core tracks the change
var product = await context.Products
    .FirstOrDefaultAsync(p =&amp;gt; p.Id == productId);

if (product?.Details is not null)
{
    product.Details.StockQuantity -= quantitySold;
    product.Details.Tags.Add(&quot;low-stock&quot;);
    await context.SaveChangesAsync();
}
// EF Core sends the full updated jsonb document back to PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; EF Core replaces the entire &lt;code&gt;jsonb&lt;/code&gt; document on update, not individual fields. For high-frequency partial updates to large documents, consider a raw SQL &lt;code&gt;jsonb_set()&lt;/code&gt; call via &lt;code&gt;ExecuteSqlRawAsync&lt;/code&gt; to minimise the payload.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Adding a GIN index for performance&lt;/div&gt;

&lt;p&gt;Without an index every &lt;code&gt;jsonb&lt;/code&gt; query performs a sequential scan. A GIN (Generalised Inverted Index) lets PostgreSQL push filters into the index structure rather than reading every row.&lt;/p&gt;

&lt;h3&gt;Full-document GIN index via a custom migration&lt;/h3&gt;

&lt;p&gt;EF Core&#39;s fluent API does not expose GIN index creation natively, so you add it in a raw SQL migration.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public partial class AddProductDetailsGinIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            &quot;&quot;&quot;
            CREATE INDEX ix_products_details_gin
            ON &quot;Products&quot;
            USING GIN (&quot;Details&quot;);
            &quot;&quot;&quot;
        );
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            &quot;&quot;&quot;DROP INDEX IF EXISTS ix_products_details_gin;&quot;&quot;&quot;
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Targeted expression indexes for specific paths&lt;/h3&gt;

&lt;p&gt;A full-document GIN index covers every key in the document but can grow large. If you only filter on one or two paths, a targeted expression index is leaner and faster for those specific queries.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Index just the StockQuantity path, cast to integer
CREATE INDEX ix_products_stock_qty
ON &quot;Products&quot; (( (&quot;Details&quot; -&amp;gt;&amp;gt; &#39;StockQuantity&#39;)::int ));

-- GIN index on only the Tags array within the document
CREATE INDEX ix_products_tags_gin
ON &quot;Products&quot;
USING GIN ((&quot;Details&quot; -&amp;gt; &#39;Tags&#39;));
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Verifying the index is used&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Run in psql or pgAdmin after loading representative data
EXPLAIN ANALYZE
SELECT * FROM &quot;Products&quot;
WHERE (&quot;Details&quot; -&amp;gt;&amp;gt; &#39;StockQuantity&#39;)::int &amp;gt; 0;

-- Look for &quot;Index Scan using ix_products_stock_qty&quot; in the output.
-- If you see &quot;Seq Scan&quot;, run ANALYZE &quot;Products&quot;; to refresh statistics.
&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; PostgreSQL&#39;s query planner may choose a sequential scan on small tables even with an index present, because the cost model favours sequential I/O at low row counts. Test index usage with a realistic data volume before concluding the index is not working.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;EF Core 8 and Npgsql together make &lt;code&gt;jsonb&lt;/code&gt; columns a first-class part of your .NET data model. You get strongly typed C# objects, LINQ translation to efficient PostgreSQL JSON path operators, and index support that keeps reads fast as rows accumulate.&lt;/p&gt;
&lt;p&gt;Use &lt;code&gt;jsonb&lt;/code&gt; when your schema genuinely varies across rows or when you need semi-structured metadata alongside relational data. Do not use it as an excuse to avoid proper schema design; a relational model still outperforms &lt;code&gt;jsonb&lt;/code&gt; for structured data with a known, stable shape.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/748584162558725826/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/jsonb-columns-in-postgresql-with-ef.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/748584162558725826'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/748584162558725826'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/jsonb-columns-in-postgresql-with-ef.html' title='JSONB Columns in PostgreSQL with EF Core: When and How to Use Them'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-5928479765197533815</id><published>2026-05-24T16:01:05.211+05:45</published><updated>2026-05-24T16:01:05.212+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Database Migration"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="JSONB"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><category scheme="http://www.blogger.com/atom/ns#" term="SQL Server"/><title type='text'>PostgreSQL vs. SQL Server: What .NET Developers Need to Know Before Switching</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-decision {
  border-left: 4px solid #2563eb;
  background: #f0f6ff;
  padding: 14px 18px; margin: 1em 0;
  border-radius: 0 6px 6px 0;
}
.pbi-decision.b {
  border-color: #16a34a;
  background: #f0fdf4;
}
.pbi-decision h4 {
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
  font-size: 14px; font-weight: 700; margin: 0 0 8px;
  color: #1e3a5f;
}
.pbi-decision.b h4 { color: #14532d; }
.pbi-decision ul { margin: 0; padding-left: 1.4em; }
.pbi-decision li { font-size: 14px; color: var(--ink-mid); margin-bottom: 4px; }
.pbi-table { width: 100%; border-collapse: collapse; margin: 1em 0 1.5em; font-size: 14px; }
.pbi-table th {
  background: var(--ink); color: var(--bg);
  padding: 10px 14px; text-align: left; font-weight: 600;
}
.pbi-table td { padding: 9px 14px; border-bottom: 1px solid var(--rule); color: var(--ink-mid); }
.pbi-table tr:nth-child(even) td { background: var(--code-bg); }
.pbi-table td:first-child { font-weight: 600; color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;You have shipped .NET APIs backed by SQL Server for years, and the next project lands with a PostgreSQL requirement. Or maybe your team is evaluating whether to drop SQL Server licence costs entirely. Either way, you are about to discover that &quot;it is just another relational database&quot; is only partially true.&lt;/p&gt;
&lt;p&gt;Both SQL Server and PostgreSQL are mature, ACID-compliant databases with excellent EF Core support, but they make very different decisions around licensing, data types, and SQL dialect. This post unpacks those differences with real code so you can make the switch with confidence instead of surprises.&lt;/p&gt;
&lt;p&gt;By the end you will have a side-by-side code comparison, a clear breakdown of the tradeoffs, and a decision framework you can use in your next architecture conversation.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; A quick history first&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;SQL Server&lt;/strong&gt; grew out of Microsoft&#39;s partnership with Sybase in the late 1980s and became the default relational database for Windows-based enterprise .NET applications. It is proprietary, commercially licensed, and deeply integrated with the Microsoft tooling ecosystem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt; descended from the POSTGRES project at UC Berkeley, went fully open-source in 1996, and has since grown into one of the most feature-rich databases in existence, with a reputation for standards compliance and extensibility.&lt;/p&gt;
&lt;p&gt;Both use a cost-based query planner, support full ACID transactions, foreign keys, triggers, and stored procedures, and both have first-class EF Core providers that track releases closely.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; The same task, two ways&lt;/div&gt;

&lt;p&gt;To make the differences concrete, here is the same EF Core setup performed for each database: registering the context, applying a migration, and executing a simple parameterised query.&lt;/p&gt;

&lt;h3&gt;SQL Server approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
builder.Services.AddDbContext&amp;lt;AppDbContext&amp;gt;(options =&amp;gt;
    options.UseSqlServer(
        builder.Configuration.GetConnectionString(&quot;Default&quot;),
        sql =&amp;gt; sql.MigrationsHistoryTable(&quot;__EFMigrationsHistory&quot;, &quot;dbo&quot;)
    ));
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// AppDbContext.cs
public class AppDbContext(DbContextOptions&amp;lt;AppDbContext&amp;gt; options) : DbContext(options)
{
    public DbSet&amp;lt;Product&amp;gt; Products =&amp;gt; Set&amp;lt;Product&amp;gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&amp;lt;Product&amp;gt;()
            .Property(p =&amp;gt; p.Name)
            .HasMaxLength(256)
            .IsRequired();
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;PostgreSQL approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — only the provider registration changes
builder.Services.AddDbContext&amp;lt;AppDbContext&amp;gt;(options =&amp;gt;
    options.UseNpgsql(
        builder.Configuration.GetConnectionString(&quot;Default&quot;),
        npgsql =&amp;gt; npgsql.MigrationsHistoryTable(&quot;__ef_migrations_history&quot;, &quot;public&quot;)
    ));
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// AppDbContext.cs — identical; EF Core abstracts the provider completely
public class AppDbContext(DbContextOptions&amp;lt;AppDbContext&amp;gt; options) : DbContext(options)
{
    public DbSet&amp;lt;Product&amp;gt; Products =&amp;gt; Set&amp;lt;Product&amp;gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&amp;lt;Product&amp;gt;()
            .Property(p =&amp;gt; p.Name)
            .HasMaxLength(256)
            .IsRequired();
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The DbContext and entity configuration are identical. The provider swap is contained entirely in the &lt;code&gt;Program.cs&lt;/code&gt; registration. The first gotcha surfaces in migrations: PostgreSQL folds unquoted identifiers to lowercase, so a column named &lt;code&gt;Name&lt;/code&gt; becomes &lt;code&gt;name&lt;/code&gt; on disk. The Npgsql provider handles this transparently in generated migrations, but any raw SQL you have written against SQL Server conventions will need a review pass before it works on PostgreSQL.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The real tradeoffs&lt;/div&gt;

&lt;h3&gt;1. Licensing and cost&lt;/h3&gt;
&lt;p&gt;SQL Server Standard edition starts at around $900 per core; Enterprise runs into the tens of thousands. Developer edition is free but cannot be used in production. PostgreSQL is completely free under the PostgreSQL Licence with no core count or server caps. For teams running many environments (dev, staging, pre-prod, multiple production regions), the licence difference is significant. Azure Database for PostgreSQL Flexible Server also prices lower than Azure SQL on equivalent compute tiers, though your specific configuration will vary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this matter to you?&lt;/strong&gt; If you are building SaaS and want to avoid per-core fee growth as you scale, PostgreSQL wins on economics. If your organisation already covers SQL Server under a Microsoft Enterprise Agreement, the marginal cost may already be absorbed.&lt;/p&gt;

&lt;h3&gt;2. Data types&lt;/h3&gt;
&lt;p&gt;PostgreSQL ships with native types that SQL Server lacks or emulates awkwardly: &lt;code&gt;jsonb&lt;/code&gt; (binary JSON with index support), &lt;code&gt;uuid&lt;/code&gt; (first-class UUID without the byte-order quirks of &lt;code&gt;uniqueidentifier&lt;/code&gt;), typed arrays, range types, and the &lt;code&gt;citext&lt;/code&gt; extension for case-insensitive text. SQL Server stores JSON as plain &lt;code&gt;nvarchar(max)&lt;/code&gt; and queries it through scalar functions like &lt;code&gt;JSON_VALUE&lt;/code&gt; and &lt;code&gt;OPENJSON&lt;/code&gt;, without binary storage or index support on that data.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// EF Core 8: JSONB column via owned entity (PostgreSQL maps ToJson() to jsonb)
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ProductMetadata? Metadata { get; set; }
}

public class ProductMetadata
{
    public string? Sku { get; set; }
    public List&amp;lt;string&amp;gt; Tags { get; set; } = new();
}

// In OnModelCreating:
modelBuilder.Entity&amp;lt;Product&amp;gt;()
    .OwnsOne(p =&amp;gt; p.Metadata, b =&amp;gt; b.ToJson());
// Npgsql emits: &quot;Metadata&quot; jsonb
// SQL Server emits: &quot;Metadata&quot; nvarchar(max)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Same EF Core API; different storage reality. On PostgreSQL you can index into that &lt;code&gt;jsonb&lt;/code&gt; column with a GIN index and run efficient containment queries. On SQL Server you are doing a full table scan through a function call.&lt;/p&gt;

&lt;h3&gt;3. SQL dialect differences&lt;/h3&gt;
&lt;p&gt;PostgreSQL is stricter about ANSI SQL compliance. Several T-SQL patterns break immediately: &lt;code&gt;TOP&lt;/code&gt; becomes &lt;code&gt;LIMIT&lt;/code&gt;, string concatenation with &lt;code&gt;+&lt;/code&gt; must become &lt;code&gt;||&lt;/code&gt; or &lt;code&gt;CONCAT()&lt;/code&gt;, and &lt;code&gt;GETDATE()&lt;/code&gt; becomes &lt;code&gt;NOW()&lt;/code&gt; or &lt;code&gt;CURRENT_TIMESTAMP&lt;/code&gt;. LINQ queries translated by EF Core are provider-aware and generally safe, but anything written with &lt;code&gt;FromSqlRaw&lt;/code&gt;, Dapper, or stored procedures needs a dialect review. The case-sensitivity difference also catches teams off-guard: SQL Server string comparisons are case-insensitive by default (depending on collation); PostgreSQL is case-sensitive by default.&lt;/p&gt;

&lt;h3&gt;4. Tooling&lt;/h3&gt;
&lt;p&gt;SQL Server Management Studio is a mature, battle-tested GUI with profiler integration, visual execution plan analysis, and a large ecosystem of DBA tooling. PostgreSQL&#39;s first-party tool, pgAdmin 4, has improved significantly but still lags SSMS for complex query plan visualisation. Third-party tools such as DBeaver Community and JetBrains DataGrip fill that gap well. For .NET developers, the EF Core CLI (&lt;code&gt;dotnet ef migrations add&lt;/code&gt;, &lt;code&gt;dotnet ef database update&lt;/code&gt;) works identically across providers.&lt;/p&gt;

&lt;h3&gt;5. Extensions and ecosystem&lt;/h3&gt;
&lt;p&gt;PostgreSQL&#39;s extension system is one of its biggest differentiators. PostGIS adds production-grade geospatial support. TimescaleDB turns it into a time-series database. &lt;code&gt;pg_trgm&lt;/code&gt; enables trigram similarity search. &lt;code&gt;pgvector&lt;/code&gt; adds vector embeddings for AI workloads. These capabilities require separate licences or entirely different database products under SQL Server.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Which one should you choose?&lt;/div&gt;

&lt;p&gt;The decision comes down to your licensing economics, the PostgreSQL-specific features your domain genuinely needs, and your team&#39;s operational familiarity.&lt;/p&gt;

&lt;div class=&quot;pbi-decision&quot;&gt;
  &lt;h4&gt;Choose SQL Server when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;Your organisation covers SQL Server licences under a Microsoft EA and switching has no financial upside&lt;/li&gt;
    &lt;li&gt;Your team depends on SSMS, SQL Profiler, SQL Server Agent, or SSIS for operational workflows&lt;/li&gt;
    &lt;li&gt;You are integrating with SSRS, SSIS, or other SQL Server ecosystem components&lt;/li&gt;
    &lt;li&gt;Your team has deep T-SQL expertise and limited bandwidth to ramp on PostgreSQL dialect differences&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision b&quot;&gt;
  &lt;h4&gt;Choose PostgreSQL when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;You want to eliminate per-core licence costs, particularly across many environments&lt;/li&gt;
    &lt;li&gt;Your domain benefits from native &lt;code&gt;jsonb&lt;/code&gt;, arrays, range types, or extensions like PostGIS or pgvector&lt;/li&gt;
    &lt;li&gt;You are deploying to Linux containers, AWS RDS, or Azure Database for PostgreSQL Flexible Server&lt;/li&gt;
    &lt;li&gt;SQL portability and ANSI standards compliance matter to your architecture&lt;/li&gt;
    &lt;li&gt;You need full-text search, geospatial queries, or vector similarity without additional licences&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;p&gt;You can also run both in the same solution. EF Core&#39;s per-context provider model makes it straightforward to keep an existing SQL Server context alongside a new PostgreSQL context for a service that needs JSONB or cost efficiency.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Running two providers side by side in the same .NET 8 application
builder.Services.AddDbContext&amp;lt;LegacyDbContext&amp;gt;(options =&amp;gt;
    options.UseSqlServer(builder.Configuration.GetConnectionString(&quot;Legacy&quot;)));

builder.Services.AddDbContext&amp;lt;CatalogDbContext&amp;gt;(options =&amp;gt;
    options.UseNpgsql(builder.Configuration.GetConnectionString(&quot;Catalog&quot;)));
&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Quick reference summary&lt;/div&gt;

&lt;table class=&quot;pbi-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concern&lt;/th&gt;
      &lt;th&gt;SQL Server&lt;/th&gt;
      &lt;th&gt;PostgreSQL&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;Licence&lt;/td&gt;&lt;td&gt;Commercial (free Developer edition, not for prod)&lt;/td&gt;&lt;td&gt;Open-source, free in all environments&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;EF Core NuGet&lt;/td&gt;&lt;td&gt;Microsoft.EntityFrameworkCore.SqlServer&lt;/td&gt;&lt;td&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Native JSON storage&lt;/td&gt;&lt;td&gt;nvarchar(max) with scalar functions&lt;/td&gt;&lt;td&gt;jsonb with binary indexing&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;UUID type&lt;/td&gt;&lt;td&gt;uniqueidentifier (non-standard byte order)&lt;/td&gt;&lt;td&gt;uuid (RFC 4122, native)&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Case sensitivity&lt;/td&gt;&lt;td&gt;Case-insensitive by default (collation-dependent)&lt;/td&gt;&lt;td&gt;Case-sensitive by default&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Full-text search&lt;/td&gt;&lt;td&gt;Separate FT Catalog and Index, CONTAINS/FREETEXT&lt;/td&gt;&lt;td&gt;Built-in via tsvector and tsquery&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Arrays&lt;/td&gt;&lt;td&gt;Not supported natively&lt;/td&gt;&lt;td&gt;First-class typed array columns&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Extensions&lt;/td&gt;&lt;td&gt;Limited; additional features require licences&lt;/td&gt;&lt;td&gt;PostGIS, pgvector, TimescaleDB, pg_trgm, and more&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Primary GUI&lt;/td&gt;&lt;td&gt;SQL Server Management Studio&lt;/td&gt;&lt;td&gt;pgAdmin 4, DBeaver, DataGrip&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Docker image&lt;/td&gt;&lt;td&gt;mcr.microsoft.com/mssql/server&lt;/td&gt;&lt;td&gt;postgres (official, minimal, fast to pull)&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Final take&lt;/div&gt;

&lt;p&gt;If you are starting a new project today and do not have SQL Server licences already in place, PostgreSQL is the stronger default for .NET work in 2024. The Npgsql EF Core provider is excellent, the type system is richer, cloud-managed costs are lower, and the extension ecosystem opens doors that would require separate products under SQL Server.&lt;/p&gt;
&lt;p&gt;If your team lives in SSMS, your SQL Server licences are paid for, and your domain has no need for JSONB or PostgreSQL-specific extensions, there is no compelling reason to switch for its own sake. The migration effort is real, and &quot;it&#39;s free&quot; is not a good enough reason on its own. Use PostgreSQL when the specific features or the economics give you a clear return on the investment.&lt;/p&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;What&#39;s next?&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Spin up PostgreSQL locally:&lt;/strong&gt; Run &lt;code&gt;docker run --name pg-dev -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16&lt;/code&gt; and point your first EF Core migration at it&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Explore JSONB:&lt;/strong&gt; The next post in this series covers JSONB columns in EF Core 8 with query examples, GIN indexing, and performance considerations&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Audit your raw SQL:&lt;/strong&gt; Before migrating an existing project, grep your codebase for &lt;code&gt;FromSqlRaw&lt;/code&gt; and Dapper queries and flag any T-SQL-specific syntax for rewriting&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or a scenario I did not cover? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/5928479765197533815/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/postgresql-vs-sql-server-what-net.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5928479765197533815'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5928479765197533815'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/05/postgresql-vs-sql-server-what-net.html' title='PostgreSQL vs. SQL Server: What .NET Developers Need to Know Before Switching'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-5282068016060537211</id><published>2026-04-29T11:24:47.964+05:45</published><updated>2026-04-29T11:24:47.965+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET"/><category scheme="http://www.blogger.com/atom/ns#" term="Best Practices"/><category scheme="http://www.blogger.com/atom/ns#" term="Boxing"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="CLR"/><category scheme="http://www.blogger.com/atom/ns#" term="Collections"/><category scheme="http://www.blogger.com/atom/ns#" term="Memory Management"/><category scheme="http://www.blogger.com/atom/ns#" term="Performance"/><category scheme="http://www.blogger.com/atom/ns#" term="Reference Types"/><category scheme="http://www.blogger.com/atom/ns#" term="Value Types"/><title type='text'>Boxing and Unboxing in C#: The Silent Performance Tax You Might Be Paying</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Boxing and unboxing are two of the most common sources of silent, hard-to-diagnose performance regressions in .NET applications. They do not throw exceptions, they do not show up in compiler warnings, and they will not break your unit tests. They simply make your application slower, one heap allocation at a time.&lt;/p&gt;
&lt;p&gt;By the end of this post you will understand exactly what boxing and unboxing are, why they carry a real cost, which everyday code patterns trigger them without you realising it, and how modern C# gives you the tools to avoid them entirely on hot paths.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; The foundation: value types vs. reference types&lt;/div&gt;

&lt;p&gt;Boxing only makes sense once you have a clear mental model of where the runtime stores data. The CLR uses two memory regions with very different characteristics.&lt;/p&gt;

&lt;h3&gt;The stack&lt;/h3&gt;

&lt;p&gt;Think of the stack like your desk: fast, tidy, and everything is right in front of you. Value types live here. The variable &lt;em&gt;is&lt;/em&gt; the data, stored at a fixed, compile-time-known size. When a method returns, the stack frame is unwound and those values disappear instantly. No garbage collector involved.&lt;/p&gt;

&lt;p&gt;Common value types: &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;long&lt;/code&gt;, &lt;code&gt;double&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt;, &lt;code&gt;char&lt;/code&gt;, &lt;code&gt;decimal&lt;/code&gt;, &lt;code&gt;DateTime&lt;/code&gt;, &lt;code&gt;Guid&lt;/code&gt;, &lt;code&gt;TimeSpan&lt;/code&gt;, and any &lt;code&gt;struct&lt;/code&gt; you define yourself.&lt;/p&gt;

&lt;h3&gt;The heap&lt;/h3&gt;

&lt;p&gt;The heap is more like a filing cabinet: more room, but you need a label (a reference, or pointer) to find your file. Reference types live here. The variable holds a managed reference to data allocated on the heap, and the garbage collector is responsible for reclaiming that memory when nothing holds a reference to it anymore.&lt;/p&gt;

&lt;p&gt;Common reference types: &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;object&lt;/code&gt;, every &lt;code&gt;class&lt;/code&gt; you define, arrays (even &lt;code&gt;int[]&lt;/code&gt;), &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;Task&lt;/code&gt;, and &lt;code&gt;Delegate&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Value types: stored directly on the stack
int    count   = 42;
double price   = 19.99;
bool   isReady = true;

// Copying a value type produces independent copies
int a = 10;
int b = a;
b = 99;
Console.WriteLine(a); // 10 — a is unchanged

// Reference types: stack holds a pointer; data lives on the heap
string    name    = &quot;Alice&quot;;
List&amp;lt;int&amp;gt; numbers = new() { 1, 2, 3 };

// Copying a reference type copies the pointer, not the data
var list1 = new List&amp;lt;int&amp;gt; { 1, 2, 3 };
var list2 = list1;   // same heap object!
list2.Add(4);
Console.WriteLine(list1.Count); // 4 — both variables see the same list&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This distinction is the prerequisite for everything that follows. Boxing is what happens when the runtime is forced to treat a value type as a reference type.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Boxing: wrapping a value in an object&lt;/div&gt;

&lt;p&gt;Boxing is the implicit conversion of a value type to &lt;code&gt;object&lt;/code&gt; (or to an interface the value type implements). When it happens, the runtime allocates a new object on the heap, copies the value into it, and returns a reference to that object. The original stack value and the new heap object are completely independent from that point on.&lt;/p&gt;

&lt;p&gt;The key word is &lt;em&gt;implicit&lt;/em&gt;. The compiler inserts the boxing instruction for you with no visible cast syntax, which is exactly what makes accidental boxing so easy to miss.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Implicit boxing: no cast keyword, but a heap allocation happens here
int    num = 42;
object o   = num;  // 1. allocates object on heap
                   // 2. copies 42 into it
                   // 3. o holds a reference to the new object

// Boxing to an interface also allocates
IComparable comp = num; // boxes — IComparable is a reference type

// The two copies are now independent
int x     = 10;
object bx = x;
x = 99;
Console.WriteLine(bx); // still 10 — bx points to a separate heap object

// Legacy collections box every element they store
ArrayList old = new ArrayList();
old.Add(1);   // boxes 1   → new heap object
old.Add(2);   // boxes 2   → new heap object
old.Add(3);   // boxes 3   → new heap object (3 allocations for 3 ints)&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Performance note:&lt;/strong&gt; Every boxing operation is a heap allocation. In a tight loop that runs a million times, that is a million objects queued for garbage collection. The GC will eventually collect them, but the collection pauses and the memory pressure accumulate silently and degrade throughput in ways that are very difficult to attribute to a root cause after the fact.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Unboxing: extracting the value back out&lt;/div&gt;

&lt;p&gt;Unboxing is the reverse operation: extracting a value type from a boxed &lt;code&gt;object&lt;/code&gt;. Unlike boxing, unboxing is never implicit. You must write an explicit cast. The runtime validates the cast at execution time, not at compile time, which means a wrong cast produces an &lt;code&gt;InvalidCastException&lt;/code&gt; at runtime rather than a build error.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Step 1: box an int
int    original = 42;
object boxed    = original;  // boxing

// Step 2: unbox with the exact original type
int extracted = (int)boxed; // correct
Console.WriteLine(extracted); // 42

// Step 3: wrong cast type throws at runtime, not at compile time
object o = 42; // was boxed as int
try
{
    double wrong = (double)o; // InvalidCastException!
}
catch (InvalidCastException ex)
{
    Console.WriteLine(ex.Message);
}

// Step 4: safe unboxing with pattern matching (modern C#, preferred)
object result = GetFromLegacyApi(); // returns object, type unknown

if (result is int value)
{
    Console.WriteLine($&quot;Got int: {value}&quot;); // safe and concise
}

// &#39;as&#39; with Nullable&amp;lt;T&amp;gt; — also safe, returns null on mismatch
int? safe = result as int?;
if (safe.HasValue)
    Console.WriteLine(safe.Value);&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Critical trap:&lt;/strong&gt; The unboxing cast must match the &lt;em&gt;exact type used during boxing&lt;/em&gt;, not a compatible one. A value boxed as &lt;code&gt;int&lt;/code&gt; cannot be unboxed as &lt;code&gt;long&lt;/code&gt;, even though an &lt;code&gt;int&lt;/code&gt; normally widens to &lt;code&gt;long&lt;/code&gt; without issue. The runtime checks the type metadata stamped on the heap object, not numeric compatibility. Prefer &lt;code&gt;is int value&lt;/code&gt; pattern matching over a bare cast whenever the type is not guaranteed.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Pitfalls: where boxing hides in everyday code&lt;/div&gt;

&lt;p&gt;The four patterns below are the most common sources of unintentional boxing in production .NET code. Each one compiles cleanly and runs correctly; the cost is purely in allocation count and GC pressure.&lt;/p&gt;

&lt;h3&gt;1. Legacy non-generic collections&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Every Add call boxes the integer — avoid
var bad = new ArrayList();
bad.Add(1);
bad.Add(2);
bad.Add(3);

// No boxing; values stored inline in the backing array
var good = new List&amp;lt;int&amp;gt; { 1, 2, 3 };&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;2. String.Format with value-type arguments&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;int age = 30;

// string.Format accepts object[] — every value-type argument is boxed
var s1 = string.Format(&quot;Age: {0}&quot;, age); // boxes age

// String interpolation compiles to FormattableString/string.Create paths
// that avoid boxing for known types
var s2 = $&quot;Age: {age}&quot;; // no boxing&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;3. Boxing inside a hot loop&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// 1,000,000 heap allocations — do not do this on a hot path
for (int i = 0; i &amp;lt; 1_000_000; i++)
{
    object o = i;    // boxes every iteration
    Process(o);
}

// Use a generic method instead — zero allocation
for (int i = 0; i &amp;lt; 1_000_000; i++)
{
    Process(i);      // T is inferred as int; no boxing
}

static void Process&amp;lt;T&amp;gt;(T value) =&amp;gt; Console.WriteLine(value);&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;4. Casting a struct to an interface it implements&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;struct Temperature : IComparable&amp;lt;Temperature&amp;gt;
{
    public double Celsius { get; init; }
    public int CompareTo(Temperature other) =&amp;gt; Celsius.CompareTo(other.Celsius);
}

// Storing as the interface boxes the struct
IComparable&amp;lt;Temperature&amp;gt; t1 = new Temperature { Celsius = 22.5 }; // boxed!

// Keep the concrete type; use the interface only in generic constraints
Temperature t2 = new Temperature { Celsius = 22.5 };               // no boxing&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Writing boxing-aware code every day&lt;/div&gt;

&lt;p&gt;The good news is that modern C# makes the boxing-free path the natural one. These five habits cover the vast majority of cases.&lt;/p&gt;

&lt;h3&gt;Always use generic collections&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Reach for these in all new code
List&amp;lt;int&amp;gt;                ids;
Dictionary&amp;lt;string, int&amp;gt;  scores;
HashSet&amp;lt;Guid&amp;gt;            seen;
Queue&amp;lt;DateTime&amp;gt;          events;
Stack&amp;lt;decimal&amp;gt;           prices;

// ArrayList and Hashtable exist only for pre-2.0 interop; do not use them in new code&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Write generic methods instead of object parameters&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Forces boxing for any value-type argument
void Log(object value) =&amp;gt; Console.WriteLine(value);

// No boxing; the JIT specialises the method per type
void Log&amp;lt;T&amp;gt;(T value) =&amp;gt; Console.WriteLine(value?.ToString());

// With a numeric constraint — .NET 7+
T Square&amp;lt;T&amp;gt;(T x) where T : INumber&amp;lt;T&amp;gt; =&amp;gt; x * x;&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Use Span&amp;lt;T&amp;gt; for high-performance slicing&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;int[] data = { 1, 2, 3, 4, 5 };

// AsSpan returns a stack-only view — no allocation, no boxing
Span&amp;lt;int&amp;gt; slice = data.AsSpan(1, 3);
foreach (var item in slice)
    Console.WriteLine(item); // 2, 3, 4&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Prefer pattern matching for safe unboxing of unknown types&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Common in plugin systems, legacy APIs, or deserialization scenarios
object result = plugin.Execute();

switch (result)
{
    case int n:
        ProcessNumber(n);
        break;
    case string s:
        ProcessText(s);
        break;
    case null:
        HandleEmpty();
        break;
    default:
        throw new NotSupportedException($&quot;Unexpected type: {result.GetType().Name}&quot;);
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Know when boxing is legitimate&lt;/h3&gt;

&lt;p&gt;Not every boxing site is a problem. These scenarios are genuinely fine:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// COM interop: the API requires object
xlRange.Value2 = (object)42;

// Reflection: GetValue always returns object
var val = fieldInfo.GetValue(instance);

// Heterogeneous data (rare, but sometimes the right model)
object[] mixed = { 42, &quot;hello&quot;, true, 3.14 };

// Low-frequency startup configuration: the GC cost is negligible
settings[&quot;timeout&quot;] = (object)30;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tooling tip:&lt;/strong&gt; JetBrains Rider and the Heap Allocation Viewer extension for Visual Studio surface boxing sites as inline hints. BenchmarkDotNet with &lt;code&gt;[MemoryDiagnoser]&lt;/code&gt; will give you exact allocation counts per benchmark iteration, which makes it easy to confirm a fix actually eliminated the allocation.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;Boxing converts a value type to a heap-allocated &lt;code&gt;object&lt;/code&gt; implicitly and silently; unboxing reverses that with an explicit cast that can fail at runtime if the type does not match exactly. Neither operation announces itself in compiler output, which is what makes them worth understanding at a conceptual level rather than just memorising a list of rules.&lt;/p&gt;
&lt;p&gt;The practical takeaway is straightforward: use generic collections, write generic methods, lean on pattern matching for unknown types, and reach for &lt;code&gt;Span&amp;lt;T&amp;gt;&lt;/code&gt; on hot paths. With those habits in place, boxing becomes something you deliberately opt into for COM interop or reflection, rather than something that accumulates quietly across your codebase. A follow-up post on &lt;code&gt;readonly struct&lt;/code&gt; and &lt;code&gt;ref struct&lt;/code&gt; is a natural next step for anyone who wants to push further into allocation-free patterns.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/5282068016060537211/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/boxing-and-unboxing-in-c-silent.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5282068016060537211'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5282068016060537211'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/boxing-and-unboxing-in-c-silent.html' title='Boxing and Unboxing in C#: The Silent Performance Tax You Might Be Paying'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-6393523246494563443</id><published>2026-04-24T13:01:04.933+05:45</published><updated>2026-04-24T13:01:04.934+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="API Versioning"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Asp.Versioning"/><category scheme="http://www.blogger.com/atom/ns#" term="Backward Compatibility"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Controller"/><category scheme="http://www.blogger.com/atom/ns#" term="REST API"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Versioning Your ASP.NET Core API Without Breaking Existing Clients</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Sooner or later every API that has real consumers needs to change in ways that are not backward-compatible. You rename a field, remove a property, change a response shape, or restructure a route. Without versioning, any of those changes breaks existing clients immediately. With versioning, you introduce the new contract alongside the old one, communicate a deprecation timeline, and let clients migrate at a pace that works for them.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have a .NET 8 Web API that supports URL segment versioning, query string versioning, and custom header versioning simultaneously using the official &lt;code&gt;Asp.Versioning.Http&lt;/code&gt; library. You will also see how to mark versions as deprecated so API consumers get a clear signal that they need to migrate.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK (8.0.100 or later)&lt;/li&gt;
  &lt;li&gt;An existing ASP.NET Core Web API project targeting .NET 8&lt;/li&gt;
  &lt;li&gt;NuGet package: &lt;code&gt;Asp.Versioning.Mvc&lt;/code&gt; (for controller-based APIs) or &lt;code&gt;Asp.Versioning.Http&lt;/code&gt; (for Minimal APIs)&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;Asp.Versioning.*&lt;/code&gt; packages are the official successor to the archived &lt;code&gt;Microsoft.AspNetCore.Mvc.Versioning&lt;/code&gt; packages. If your project still references the old packages, plan a migration; they are no longer maintained and do not support .NET 7 or .NET 8 correctly.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Install and configure versioning&lt;/div&gt;

&lt;p&gt;Add the package to your API project and register the versioning services in &lt;code&gt;Program.cs&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// From the terminal — add the NuGet package
// dotnet add package Asp.Versioning.Mvc
// dotnet add package Asp.Versioning.Mvc.ApiExplorer   // for Swagger integration&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — versioning service registration
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddApiVersioning(options =&amp;gt;
{
    options.DefaultApiVersion          = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions          = true;             // adds api-supported-versions header
    options.ApiVersionReader           = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader(&quot;X-Api-Version&quot;),
        new QueryStringApiVersionReader(&quot;api-version&quot;)
    );
})
.AddApiExplorer(options =&amp;gt;
{
    options.GroupNameFormat           = &quot;&#39;v&#39;VVV&quot;;
    options.SubstituteApiVersionInUrl = true;
});

var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;ApiVersionReader.Combine&lt;/code&gt; call means ASP.NET Core will accept the version from a URL segment, a custom header, or a query string. If more than one is present, the URL segment takes precedence. &lt;code&gt;ReportApiVersions&lt;/code&gt; adds &lt;code&gt;api-supported-versions&lt;/code&gt; and &lt;code&gt;api-deprecated-versions&lt;/code&gt; response headers so clients can discover what is available without reading documentation.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Version your controllers with URL segments&lt;/div&gt;

&lt;p&gt;URL segment versioning is the most explicit strategy and the one most API consumers expect to see: &lt;code&gt;/api/v1/products&lt;/code&gt;, &lt;code&gt;/api/v2/products&lt;/code&gt;. Each major version lives in its own controller class inside a versioned namespace.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Controllers/V1/ProductsController.cs
namespace Catalog.Api.Controllers.V1;

[ApiController]
[ApiVersion(&quot;1.0&quot;)]
[Route(&quot;api/v{version:apiVersion}/[controller]&quot;)]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service)
        =&gt; _service = service;

    [HttpGet(&quot;{id:int}&quot;)]
    [ProducesResponseType(typeof(ProductResponseV1), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task&amp;lt;ActionResult&amp;lt;ProductResponseV1&amp;gt;&amp;gt; GetById(
        int id, CancellationToken ct)
    {
        var product = await _service.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(ProductResponseV1.From(product));
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Controllers/V2/ProductsController.cs
namespace Catalog.Api.Controllers.V2;

[ApiController]
[ApiVersion(&quot;2.0&quot;)]
[Route(&quot;api/v{version:apiVersion}/[controller]&quot;)]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service)
        =&gt; _service = service;

    [HttpGet(&quot;{id:int}&quot;)]
    [ProducesResponseType(typeof(ProductResponseV2), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task&amp;lt;ActionResult&amp;lt;ProductResponseV2&amp;gt;&amp;gt; GetById(
        int id, CancellationToken ct)
    {
        var product = await _service.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(ProductResponseV2.From(product));
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Separating each version into its own namespace keeps the folder structure self-documenting. A developer navigating to &lt;code&gt;Controllers/V2/&lt;/code&gt; immediately knows they are looking at the current contract.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Create separate response DTO types per version rather than adding optional fields to a single type. Separate DTOs make it obvious what changed, keep serialization clean, and avoid nullable sprawl in your shared models.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Support header and query string versioning on the same endpoints&lt;/div&gt;

&lt;p&gt;Because &lt;code&gt;ApiVersionReader.Combine&lt;/code&gt; is already registered, no additional controller changes are required. A client can reach the same v2 endpoint three ways.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Three equivalent requests for the v2 endpoint:

// 1. URL segment (most explicit — preferred)
// GET /api/v2/products/42

// 2. Query string (useful for quick testing and exploration)
// GET /api/products/42?api-version=2.0

// 3. Custom header (common in B2B integrations where the URL must not change)
// GET /api/products/42
// X-Api-Version: 2.0&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;When none of the three is supplied and &lt;code&gt;AssumeDefaultVersionWhenUnspecified&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, ASP.NET Core uses the default version defined in &lt;code&gt;DefaultApiVersion&lt;/code&gt;, which is 1.0 in this configuration. Clients that have never heard of versioning continue to work against the v1 contract without modification.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Deprecate old versions gracefully&lt;/div&gt;

&lt;p&gt;Marking a version as deprecated does not remove it. It signals to consumers through response headers that the version has an end-of-life date and they should plan to migrate. The endpoint continues to function normally.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Controllers/V1/ProductsController.cs — mark version 1.0 as deprecated
[ApiController]
[ApiVersion(&quot;1.0&quot;, Deprecated = true)]
[Route(&quot;api/v{version:apiVersion}/[controller]&quot;)]
public class ProductsController : ControllerBase
{
    // ... existing v1 actions unchanged
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;With &lt;code&gt;Deprecated = true&lt;/code&gt; and &lt;code&gt;ReportApiVersions = true&lt;/code&gt;, every response to a v1 endpoint now includes both headers, giving clients a clear machine-readable signal.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Response headers returned on any v1 endpoint call:
// api-supported-versions: 2.0
// api-deprecated-versions: 1.0&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Complement the headers with a &lt;code&gt;Sunset&lt;/code&gt; header indicating the retirement date. You can inject this via middleware or by overriding the response in a filter.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Api/Filters/SunsetHeaderFilter.cs
public class SunsetHeaderFilter : IActionFilter
{
    private static readonly DateTimeOffset V1Sunset =
        new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero);

    public void OnActionExecuting(ActionExecutingContext context) { }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        var requestedVersion = context.HttpContext
            .GetRequestedApiVersion()?.ToString();

        if (requestedVersion == &quot;1.0&quot;)
        {
            context.HttpContext.Response.Headers[&quot;Sunset&quot;] =
                V1Sunset.ToString(&quot;R&quot;);   // RFC 1123 date format
        }
    }
}

// Program.cs — register as a global filter
builder.Services.AddControllers(options =&amp;gt;
    options.Filters.Add&amp;lt;SunsetHeaderFilter&amp;gt;());&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;6&lt;/span&gt; Running and testing it&lt;/div&gt;

&lt;p&gt;Run the project and confirm that the correct controller action is invoked based on the version provided.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// dotnet run --project Catalog.Api

// URL segment — should return v2 response shape
// GET https://localhost:7001/api/v2/products/1

// Query string — should return v1 response shape with deprecation headers
// GET https://localhost:7001/api/products/1?api-version=1.0

// No version — should return v1 response (default) with deprecation headers
// GET https://localhost:7001/api/products/1

// Unsupported version — should return 400 with Problem Details
// GET https://localhost:7001/api/v99/products/1&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;When a client requests a version that no controller supports, ASP.NET Core returns a 400 Bad Request with a Problem Details body automatically. No additional error handling code is needed.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Integration test verifying version routing
[Theory]
[InlineData(&quot;/api/v1/products/1&quot;, &quot;1.0&quot;)]
[InlineData(&quot;/api/v2/products/1&quot;, &quot;2.0&quot;)]
public async Task GetById_RoutesToCorrectVersion(string url, string expectedVersion)
{
    await using var factory = new WebApplicationFactory&amp;lt;Program&amp;gt;();
    var client = factory.CreateClient();

    var response = await client.GetAsync(url);

    Assert.True(response.IsSuccessStatusCode);

    // Confirm the supported version header is present and correct
    Assert.True(response.Headers.Contains(&quot;api-supported-versions&quot;));
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;A versioned .NET 8 Web API needs four things: the &lt;code&gt;Asp.Versioning.Mvc&lt;/code&gt; package, a versioning configuration that accepts the version from URL segments, headers, and query strings, controller classes organized by version namespace, and the &lt;code&gt;Deprecated&lt;/code&gt; flag on any version that has an end-of-life date. Together these give you a versioning strategy that is transparent to consumers, easy to extend, and capable of signaling retirement without breaking anything.&lt;/p&gt;
&lt;p&gt;This post completes the ASP.NET Core Controller API Patterns series. The patterns covered across these posts, from routing model selection through project structure, validation, error handling, and versioning, form a baseline that scales from a small internal API to a multi-team public contract.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/6393523246494563443/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/versioning-your-aspnet-core-api-without.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/6393523246494563443'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/6393523246494563443'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/versioning-your-aspnet-core-api-without.html' title='Versioning Your ASP.NET Core API Without Breaking Existing Clients'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-895559509630802337</id><published>2026-04-24T12:53:28.939+05:45</published><updated>2026-04-24T12:53:28.940+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Error Handling"/><category scheme="http://www.blogger.com/atom/ns#" term="Exception Handling"/><category scheme="http://www.blogger.com/atom/ns#" term="Middleware"/><category scheme="http://www.blogger.com/atom/ns#" term="Problem Details"/><category scheme="http://www.blogger.com/atom/ns#" term="RFC 7807"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Error Handling and Problem Details in ASP.NET Core Controller APIs</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Returning a raw 500 with an exception stack trace is not an error handling strategy; it is an absence of one. API consumers need consistent, machine-readable error responses so they can distinguish a &quot;not found&quot; from a &quot;validation failure&quot; from a genuine server fault and react accordingly. ASP.NET Core has supported the Problem Details standard since .NET 5, and in .NET 7 and .NET 8 the built-in support became comprehensive enough that you no longer need a third-party library to get it right.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have a working error handling pipeline that maps domain exceptions to Problem Details responses, produces consistent JSON for validation errors and unhandled exceptions, and never leaks internal stack traces to API clients.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK (8.0.100 or later)&lt;/li&gt;
  &lt;li&gt;An existing ASP.NET Core Web API project targeting .NET 8&lt;/li&gt;
  &lt;li&gt;Basic familiarity with middleware and the ASP.NET Core request pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;AddProblemDetails&lt;/code&gt; and &lt;code&gt;UseExceptionHandler&lt;/code&gt; APIs described here are available from .NET 7 onward. On .NET 6 you will need a slightly different setup; the middleware exists but the unified &lt;code&gt;IProblemDetailsService&lt;/code&gt; abstraction does not.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; What Problem Details actually is&lt;/div&gt;

&lt;p&gt;Problem Details is a machine-readable format for HTTP error responses defined in RFC 9457 (which supersedes the earlier RFC 7807). An ASP.NET Core controller decorated with &lt;code&gt;[ApiController]&lt;/code&gt; already returns validation errors in this format automatically. The goal of this post is to extend that same format to all other error conditions: domain exceptions, authorization failures, and unhandled exceptions.&lt;/p&gt;

&lt;p&gt;A minimal Problem Details response looks like this:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Typical 404 Problem Details JSON response
// {
//   &quot;type&quot;:   &quot;https://tools.ietf.org/html/rfc9110#section-15.5.5&quot;,
//   &quot;title&quot;:  &quot;Not Found&quot;,
//   &quot;status&quot;: 404,
//   &quot;detail&quot;: &quot;Product with id 42 was not found.&quot;,
//   &quot;traceId&quot;: &quot;00-a1b2c3d4e5f6a7b8-c9d0e1f2a3b4c5d6-00&quot;
// }&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; field is a URI that identifies the problem type. The &lt;code&gt;title&lt;/code&gt; is a short, human-readable label. The &lt;code&gt;detail&lt;/code&gt; is specific to this occurrence. The &lt;code&gt;traceId&lt;/code&gt; field, added automatically by ASP.NET Core, lets you correlate the response to a specific request in your logging system. You can also add custom extension members alongside these standard fields.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Register the built-in Problem Details service&lt;/div&gt;

&lt;p&gt;Add &lt;code&gt;AddProblemDetails&lt;/code&gt; and configure &lt;code&gt;UseExceptionHandler&lt;/code&gt; to point at the built-in Problem Details pipeline. This is the minimum setup that gives you consistent JSON errors for unhandled exceptions.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddProblemDetails();                    // register IProblemDetailsService

var app = builder.Build();

app.UseExceptionHandler();                               // use built-in exception handler
app.UseStatusCodePages();                                // convert 4xx with no body to Problem Details

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;With just these three lines, any unhandled exception that escapes a controller action is caught by &lt;code&gt;UseExceptionHandler&lt;/code&gt; and converted to a Problem Details response with a 500 status code. In development the response includes the exception detail and stack trace; in production it is suppressed automatically.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Important:&lt;/strong&gt; Call &lt;code&gt;app.UseExceptionHandler()&lt;/code&gt; before any other middleware that produces responses. If you call it after &lt;code&gt;app.MapControllers()&lt;/code&gt;, it will never intercept exceptions from controller actions.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Define domain exceptions and map them to status codes&lt;/div&gt;

&lt;p&gt;The built-in handler covers unhandled exceptions with a 500. What it does not do by default is map your domain-specific exceptions, such as a &lt;code&gt;NotFoundException&lt;/code&gt;, to the correct HTTP status code. The cleanest way to handle this is a custom &lt;code&gt;IExceptionHandler&lt;/code&gt; registered before the built-in fallback.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Exceptions/NotFoundException.cs
public class NotFoundException : Exception
{
    public NotFoundException(string resourceName, object key)
        : base($&quot;{resourceName} with id {key} was not found.&quot;)
    {
        ResourceName = resourceName;
        Key = key;
    }

    public string ResourceName { get; }
    public object Key { get; }
}

// Exceptions/ConflictException.cs
public class ConflictException : Exception
{
    public ConflictException(string message) : base(message) { }
}

// Exceptions/ValidationException.cs — distinct from FluentValidation&#39;s own type
public class DomainValidationException : Exception
{
    public DomainValidationException(string message) : base(message) { }
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Infrastructure/ExceptionHandlers/DomainExceptionHandler.cs
public class DomainExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetails;

    public DomainExceptionHandler(IProblemDetailsService problemDetails)
        =&gt; _problemDetails = problemDetails;

    public async ValueTask&amp;lt;bool&amp;gt; TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        (int statusCode, string title) = exception switch
        {
            NotFoundException nfe     =&amp;gt; (StatusCodes.Status404NotFound,     &quot;Resource not found&quot;),
            ConflictException         =&amp;gt; (StatusCodes.Status409Conflict,     &quot;Conflict&quot;),
            DomainValidationException =&amp;gt; (StatusCodes.Status422UnprocessableEntity, &quot;Validation error&quot;),
            _                         =&amp;gt; (0, string.Empty)   // not handled here
        };

        if (statusCode == 0)
            return false;  // let the next handler take it

        httpContext.Response.StatusCode = statusCode;

        return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception   = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = statusCode,
                Title  = title,
                Detail = exception.Message
            }
        });
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — register before AddProblemDetails fallback
builder.Services.AddExceptionHandler&amp;lt;DomainExceptionHandler&amp;gt;();
builder.Services.AddProblemDetails();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;ASP.NET Core calls &lt;code&gt;IExceptionHandler&lt;/code&gt; implementations in registration order. If &lt;code&gt;DomainExceptionHandler&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, the next handler in the chain gets the exception. The built-in fallback added by &lt;code&gt;UseExceptionHandler()&lt;/code&gt; is always last, so unrecognized exceptions always get a 500 response.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Customize the Problem Details output&lt;/div&gt;

&lt;p&gt;If you need to add extension fields to every Problem Details response, such as a correlation ID, an error code, or an environment name, configure the &lt;code&gt;AddProblemDetails&lt;/code&gt; options delegate.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — custom Problem Details fields
builder.Services.AddProblemDetails(options =&amp;gt;
{
    options.CustomizeProblemDetails = ctx =&amp;gt;
    {
        ctx.ProblemDetails.Extensions[&quot;traceId&quot;] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;

        ctx.ProblemDetails.Extensions[&quot;requestId&quot;] =
            ctx.HttpContext.TraceIdentifier;

        if (ctx.Exception is NotFoundException nfe)
        {
            ctx.ProblemDetails.Extensions[&quot;resource&quot;] = nfe.ResourceName;
            ctx.ProblemDetails.Extensions[&quot;key&quot;]      = nfe.Key;
        }
    };
});&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;CustomizeProblemDetails&lt;/code&gt; delegate runs for every Problem Details response, including automatic validation error responses from &lt;code&gt;[ApiController]&lt;/code&gt;. This is the single place to add cross-cutting fields without modifying any handler.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;6&lt;/span&gt; Running and testing the error responses&lt;/div&gt;

&lt;p&gt;Call an endpoint that throws a &lt;code&gt;NotFoundException&lt;/code&gt; and inspect the response body to confirm the Problem Details format is correct.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Controllers/ProductsController.cs — throw domain exception
[HttpGet(&quot;{id:int}&quot;)]
public async Task&amp;lt;ActionResult&amp;lt;ProductResponse&amp;gt;&amp;gt; GetById(int id, CancellationToken ct)
{
    var product = await _productService.GetByIdAsync(id, ct);

    if (product is null)
        throw new NotFoundException(nameof(Product), id);

    return Ok(product);
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Expected JSON response for GET /api/products/999 (non-existent)
// HTTP 404
// {
//   &quot;type&quot;:      &quot;https://tools.ietf.org/html/rfc9110#section-15.5.5&quot;,
//   &quot;title&quot;:     &quot;Resource not found&quot;,
//   &quot;status&quot;:    404,
//   &quot;detail&quot;:    &quot;Product with id 999 was not found.&quot;,
//   &quot;traceId&quot;:   &quot;00-a1b2c3d4e5f6a7b8-c9d0e1f2a3b4c5d6-00&quot;,
//   &quot;resource&quot;:  &quot;Product&quot;,
//   &quot;key&quot;:       999
// }&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Write an integration test against &lt;code&gt;WebApplicationFactory&amp;lt;T&amp;gt;&lt;/code&gt; to ensure the error shape does not regress as the codebase evolves.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Tests/ErrorHandlingIntegrationTests.cs
[Fact]
public async Task GetById_WithNonExistentId_ReturnsProblemDetails404()
{
    await using var factory = new WebApplicationFactory&amp;lt;Program&amp;gt;();
    var client = factory.CreateClient();

    var response = await client.GetAsync(&quot;/api/products/999999&quot;);

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

    var body = await response.Content.ReadFromJsonAsync&amp;lt;ProblemDetails&amp;gt;();
    Assert.NotNull(body);
    Assert.Equal(404, body.Status);
    Assert.Equal(&quot;Resource not found&quot;, body.Title);
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;A complete error handling pipeline in .NET 8 needs three things: &lt;code&gt;AddProblemDetails&lt;/code&gt; to register the service, &lt;code&gt;UseExceptionHandler&lt;/code&gt; to catch unhandled exceptions, and one or more &lt;code&gt;IExceptionHandler&lt;/code&gt; implementations to map domain exceptions to the right HTTP status codes. The &lt;code&gt;CustomizeProblemDetails&lt;/code&gt; delegate handles any cross-cutting fields you need in every response.&lt;/p&gt;
&lt;p&gt;The next post in this series covers API versioning: how to support multiple versions of your API simultaneously without breaking existing clients and without duplicating controller code.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/895559509630802337/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/error-handling-and-problem-details-in.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/895559509630802337'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/895559509630802337'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/error-handling-and-problem-details-in.html' title='Error Handling and Problem Details in ASP.NET Core Controller APIs'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-5158654167169941881</id><published>2026-04-24T12:47:23.856+05:45</published><updated>2026-04-24T12:47:23.856+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Data Annotations"/><category scheme="http://www.blogger.com/atom/ns#" term="FluentValidation"/><category scheme="http://www.blogger.com/atom/ns#" term="Model Validation"/><category scheme="http://www.blogger.com/atom/ns#" term="REST API"/><category scheme="http://www.blogger.com/atom/ns#" term="Validation"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Validation in ASP.NET Core: FluentValidation vs. Data Annotations</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-decision {
  border-left: 4px solid #2563eb;
  background: #f0f6ff;
  padding: 14px 18px; margin: 1em 0;
  border-radius: 0 6px 6px 0;
}
.pbi-decision.b {
  border-color: #16a34a;
  background: #f0fdf4;
}
.pbi-decision h4 {
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
  font-size: 14px; font-weight: 700; margin: 0 0 8px;
  color: #1e3a5f;
}
.pbi-decision.b h4 { color: #14532d; }
.pbi-decision ul { margin: 0; padding-left: 1.4em; }
.pbi-decision li { font-size: 14px; color: var(--ink-mid); margin-bottom: 4px; }
.pbi-table { width: 100%; border-collapse: collapse; margin: 1em 0 1.5em; font-size: 14px; }
.pbi-table th {
  background: var(--ink); color: var(--bg);
  padding: 10px 14px; text-align: left; font-weight: 600;
}
.pbi-table td { padding: 9px 14px; border-bottom: 1px solid var(--rule); color: var(--ink-mid); }
.pbi-table tr:nth-child(even) td { background: var(--code-bg); }
.pbi-table td:first-child { font-weight: 600; color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Validation is one of the first real decisions you make when building an ASP.NET Core API, and the answer is almost always one of two options: Data Annotations or FluentValidation. Both plug into the ASP.NET Core model binding pipeline, both return 400 responses with structured error details when validation fails, and both have years of production use behind them. What differs is where the rules live, how complex they can get, and how easily you can test them in isolation.&lt;/p&gt;
&lt;p&gt;Data Annotations are attributes placed directly on your request DTO properties. FluentValidation is a separate library where validation rules live in dedicated validator classes. This post compares both approaches using the same request model, walks through five tradeoffs, and gives you a clear framework for choosing the right tool for your project.&lt;/p&gt;
&lt;p&gt;By the end you will have working code for both approaches, an understanding of where each breaks down, and a decision you can commit to before you write your first endpoint.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; A quick history first&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Data Annotations&lt;/strong&gt; ship with the .NET base class library under &lt;code&gt;System.ComponentModel.DataAnnotations&lt;/code&gt;. They predate ASP.NET Core by over a decade, originating in WPF and WCF validation scenarios. In ASP.NET Core, the &lt;code&gt;[ApiController]&lt;/code&gt; attribute triggers automatic model state validation before the action method is called, so invalid requests are rejected without any filter or explicit check in the action body.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FluentValidation&lt;/strong&gt; is a popular open-source library by Jeremy Skinner, available on NuGet as &lt;code&gt;FluentValidation.AspNetCore&lt;/code&gt;. It integrates with ASP.NET Core&#39;s model validation pipeline through an adapter, so it participates in the same automatic validation behavior that Data Annotations use. Rules are expressed in C# via a fluent builder API inside a dedicated validator class, keeping the DTO free of attributes.&lt;/p&gt;
&lt;p&gt;Both libraries ultimately populate &lt;code&gt;ModelState&lt;/code&gt; and produce the same 400 response shape when integrated correctly. The difference is entirely in developer ergonomics and long-term maintainability.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; The same task, two ways&lt;/div&gt;

&lt;p&gt;Consider a &lt;code&gt;CreateProductRequest&lt;/code&gt; that requires a non-empty name, a positive price, a stock count between zero and ten thousand, and an optional description capped at 500 characters.&lt;/p&gt;

&lt;h3&gt;Data Annotations approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Models/Requests/CreateProductRequest.cs
public class CreateProductRequest
{
    [Required(ErrorMessage = &quot;Name is required.&quot;)]
    [MaxLength(200, ErrorMessage = &quot;Name cannot exceed 200 characters.&quot;)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 999999.99, ErrorMessage = &quot;Price must be between 0.01 and 999,999.99.&quot;)]
    public decimal Price { get; set; }

    [Range(0, 10000, ErrorMessage = &quot;Stock must be between 0 and 10,000.&quot;)]
    public int Stock { get; set; }

    [MaxLength(500, ErrorMessage = &quot;Description cannot exceed 500 characters.&quot;)]
    public string? Description { get; set; }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;FluentValidation approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Models/Requests/CreateProductRequest.cs — no attributes
public class CreateProductRequest
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public string? Description { get; set; }
}

// Validators/CreateProductRequestValidator.cs
public class CreateProductRequestValidator
    : AbstractValidator&amp;lt;CreateProductRequest&amp;gt;
{
    public CreateProductRequestValidator()
    {
        RuleFor(x =&amp;gt; x.Name)
            .NotEmpty().WithMessage(&quot;Name is required.&quot;)
            .MaximumLength(200).WithMessage(&quot;Name cannot exceed 200 characters.&quot;);

        RuleFor(x =&amp;gt; x.Price)
            .InclusiveBetween(0.01m, 999_999.99m)
            .WithMessage(&quot;Price must be between 0.01 and 999,999.99.&quot;);

        RuleFor(x =&amp;gt; x.Stock)
            .InclusiveBetween(0, 10_000)
            .WithMessage(&quot;Stock must be between 0 and 10,000.&quot;);

        RuleFor(x =&amp;gt; x.Description)
            .MaximumLength(500)
            .WithMessage(&quot;Description cannot exceed 500 characters.&quot;)
            .When(x =&amp;gt; x.Description is not null);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Register FluentValidation in &lt;code&gt;Program.cs&lt;/code&gt; so it participates in automatic model validation alongside Data Annotations.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — FluentValidation registration
builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining&amp;lt;CreateProductRequestValidator&amp;gt;();

// If you want FluentValidation to replace Data Annotations in ModelState:
builder.Services.AddFluentValidationAutoValidation()
                .AddFluentValidationClientsideAdapters();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Both approaches cause ASP.NET Core to return a 400 with a structured error body when the incoming request fails validation. The controller action is never reached for invalid input.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The real tradeoffs&lt;/div&gt;

&lt;h3&gt;1. Separation of concerns&lt;/h3&gt;
&lt;p&gt;Data Annotations embed validation rules directly on the model class as attributes. This is convenient for simple cases but couples the model&#39;s shape to its validation policy. If the same DTO is used in different contexts where different rules should apply (create vs. update, admin vs. regular user), attributes cannot express that distinction without branching into custom attribute implementations.&lt;/p&gt;
&lt;p&gt;FluentValidation keeps each set of rules in its own class. You can have &lt;code&gt;CreateProductRequestValidator&lt;/code&gt; and &lt;code&gt;AdminCreateProductRequestValidator&lt;/code&gt; with different rule sets for the same DTO, registered conditionally. The model stays a plain data carrier.&lt;/p&gt;

&lt;h3&gt;2. Complex and conditional validation&lt;/h3&gt;
&lt;p&gt;This is where Data Annotations reach their practical ceiling. Rules that depend on the values of multiple properties, on data retrieved from a database, or on conditional logic require custom attribute classes that become non-trivial to write and test.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// FluentValidation handles multi-property and async rules cleanly
public class CreateOrderRequestValidator : AbstractValidator&amp;lt;CreateOrderRequest&amp;gt;
{
    private readonly IProductRepository _products;

    public CreateOrderRequestValidator(IProductRepository products)
        =&gt; _products = products;

    public CreateOrderRequestValidator()
    {
        RuleFor(x =&amp;gt; x.DeliveryDate)
            .GreaterThan(x =&amp;gt; x.OrderDate)
            .WithMessage(&quot;Delivery date must be after the order date.&quot;);

        RuleFor(x =&amp;gt; x.ProductId)
            .MustAsync(async (id, ct) =&amp;gt; await _products.ExistsAsync(id, ct))
            .WithMessage(&quot;The specified product does not exist.&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Data Annotations cannot express either of those rules without a custom attribute that takes constructor parameters, which quickly becomes awkward to maintain and impossible to inject services into cleanly.&lt;/p&gt;

&lt;h3&gt;3. Reusability and composition&lt;/h3&gt;
&lt;p&gt;FluentValidation supports reusing rule sets via &lt;code&gt;SetValidator&lt;/code&gt; and the &lt;code&gt;ChildRules&lt;/code&gt; pattern. If a dozen request models all share an address sub-object, one &lt;code&gt;AddressValidator&lt;/code&gt; class can be composed into each parent validator. Data Annotations on nested objects require the &lt;code&gt;[ValidateNever]&lt;/code&gt; and &lt;code&gt;[ValidateAlways]&lt;/code&gt; markers plus explicit recursion to achieve the same effect.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// FluentValidation composition example
public class AddressValidator : AbstractValidator&amp;lt;AddressDto&amp;gt;
{
    public AddressValidator()
    {
        RuleFor(x =&amp;gt; x.Street).NotEmpty().MaximumLength(200);
        RuleFor(x =&amp;gt; x.PostCode).NotEmpty().Matches(@&quot;^\d{4,10}$&quot;);
        RuleFor(x =&amp;gt; x.CountryCode).NotEmpty().Length(2);
    }
}

public class CreateCustomerRequestValidator
    : AbstractValidator&amp;lt;CreateCustomerRequest&amp;gt;
{
    public CreateCustomerRequestValidator()
    {
        RuleFor(x =&amp;gt; x.Email).NotEmpty().EmailAddress();
        RuleFor(x =&amp;gt; x.ShippingAddress).SetValidator(new AddressValidator());
        RuleFor(x =&amp;gt; x.BillingAddress).SetValidator(new AddressValidator());
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;4. Error messages and localization&lt;/h3&gt;
&lt;p&gt;Data Annotations support &lt;code&gt;ErrorMessage&lt;/code&gt; string properties and resource-file-based localization through &lt;code&gt;ErrorMessageResourceType&lt;/code&gt; and &lt;code&gt;ErrorMessageResourceName&lt;/code&gt;. It works, but the setup is verbose and the pattern is unfamiliar to developers who have not worked with .NET resource files before. FluentValidation has a built-in localization pipeline with language packs available as separate NuGet packages, and adding a custom message store is straightforward.&lt;/p&gt;

&lt;h3&gt;5. Testability&lt;/h3&gt;
&lt;p&gt;FluentValidation validators are plain classes with no framework dependency. You can instantiate a validator and call &lt;code&gt;ValidateAsync&lt;/code&gt; on a test object in a regular xUnit test without any mocking, middleware, or HTTP context.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Direct unit test of a FluentValidation validator
[Fact]
public async Task Price_BelowMinimum_FailsValidation()
{
    var validator = new CreateProductRequestValidator();
    var request = new CreateProductRequest { Name = &quot;Widget&quot;, Price = -1m, Stock = 10 };

    var result = await validator.ValidateAsync(request);

    Assert.False(result.IsValid);
    Assert.Contains(result.Errors,
        e =&amp;gt; e.PropertyName == nameof(CreateProductRequest.Price));
}

[Fact]
public async Task ValidRequest_PassesValidation()
{
    var validator = new CreateProductRequestValidator();
    var request = new CreateProductRequest
    {
        Name = &quot;Widget&quot;,
        Price = 9.99m,
        Stock = 100
    };

    var result = await validator.ValidateAsync(request);
    Assert.True(result.IsValid);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Testing Data Annotations rules in unit tests requires calling &lt;code&gt;Validator.TryValidateObject&lt;/code&gt; from &lt;code&gt;System.ComponentModel.DataAnnotations&lt;/code&gt;, which is workable but less clean. More commonly, Data Annotations are tested indirectly through integration tests that exercise the full HTTP pipeline.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Which one should you choose?&lt;/div&gt;

&lt;p&gt;The right choice depends on the complexity of your validation logic and your team&#39;s appetite for an additional dependency.&lt;/p&gt;

&lt;div class=&quot;pbi-decision&quot;&gt;
  &lt;h4&gt;Choose Data Annotations when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;Your validation rules are simple: required fields, length limits, range checks, and regex patterns&lt;/li&gt;
    &lt;li&gt;You want zero additional NuGet dependencies and are comfortable keeping rules on the model&lt;/li&gt;
    &lt;li&gt;The project is small or a proof of concept that does not need complex conditional rules&lt;/li&gt;
    &lt;li&gt;Your team is new to .NET and you want to minimize the number of patterns they need to learn at once&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision b&quot;&gt;
  &lt;h4&gt;Choose FluentValidation when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;Any validation rule depends on values from multiple properties or on external data (database lookups, HTTP calls)&lt;/li&gt;
    &lt;li&gt;The same DTO needs different rule sets in different contexts (create vs. update, admin vs. standard user)&lt;/li&gt;
    &lt;li&gt;You want to unit-test validators in isolation without spinning up an HTTP pipeline&lt;/li&gt;
    &lt;li&gt;You are building a multi-team or enterprise API where consistency and composability of validation logic matter&lt;/li&gt;
    &lt;li&gt;You have nested or shared sub-object validation that needs composable validators&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;p&gt;A common middle-ground approach is to use Data Annotations for simple structural constraints (non-null, length limits) and FluentValidation for business rule validation (cross-field checks, database existence checks). Both validators run in sequence, and &lt;code&gt;ModelState&lt;/code&gt; accumulates all errors from both.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Quick reference summary&lt;/div&gt;

&lt;table class=&quot;pbi-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concern&lt;/th&gt;
      &lt;th&gt;Data Annotations&lt;/th&gt;
      &lt;th&gt;FluentValidation&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;Location of rules&lt;/td&gt;&lt;td&gt;On the model class as attributes&lt;/td&gt;&lt;td&gt;Separate validator class&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Simple rules&lt;/td&gt;&lt;td&gt;Excellent; terse and familiar&lt;/td&gt;&lt;td&gt;Good; more verbose for trivial rules&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Multi-property rules&lt;/td&gt;&lt;td&gt;Requires custom attributes&lt;/td&gt;&lt;td&gt;First-class; built-in cross-property support&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Async validation&lt;/td&gt;&lt;td&gt;Not supported&lt;/td&gt;&lt;td&gt;First-class via &lt;code&gt;MustAsync&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Composability&lt;/td&gt;&lt;td&gt;Limited; nested object recursion only&lt;/td&gt;&lt;td&gt;Strong; &lt;code&gt;SetValidator&lt;/code&gt; and child rules&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Unit testability&lt;/td&gt;&lt;td&gt;Requires &lt;code&gt;Validator.TryValidateObject&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Instantiate and call directly&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;External dependencies&lt;/td&gt;&lt;td&gt;None; ships with .NET&lt;/td&gt;&lt;td&gt;FluentValidation NuGet package required&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Localization&lt;/td&gt;&lt;td&gt;Resource file-based; verbose setup&lt;/td&gt;&lt;td&gt;Built-in pipeline; language packs available&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Final take&lt;/div&gt;

&lt;p&gt;For any API that will run in production with real business logic, FluentValidation is the stronger default. The separation of validation rules from model definitions pays for itself the first time you encounter a conditional rule or a cross-field dependency. Data Annotations remain the right choice for small projects and prototypes where adding a NuGet package and learning a new library is unnecessary overhead.&lt;/p&gt;
&lt;p&gt;If your current project uses Data Annotations and you are hitting their limits, you can introduce FluentValidation incrementally: register it alongside existing annotations and migrate validators one DTO at a time.&lt;/p&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;What&#39;s next?&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Error handling:&lt;/strong&gt; The next post in this series covers how to use ASP.NET Core&#39;s Problem Details standard to return consistent, spec-compliant error responses across your entire API.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;FluentValidation docs:&lt;/strong&gt; The official FluentValidation documentation at docs.fluentvalidation.net covers every built-in rule, extension points, and async scenarios with clear examples.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Custom validators:&lt;/strong&gt; Once you are comfortable with the basics, look into &lt;code&gt;AbstractValidator&lt;/code&gt; constructor injection to build validators that query your database or call external services during validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or a scenario I did not cover? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/5158654167169941881/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/validation-in-aspnet-core.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5158654167169941881'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5158654167169941881'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/validation-in-aspnet-core.html' title='Validation in ASP.NET Core: FluentValidation vs. Data Annotations'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-2688046145859096416</id><published>2026-04-24T12:46:19.015+05:45</published><updated>2026-04-24T12:46:19.016+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Clean Architecture"/><category scheme="http://www.blogger.com/atom/ns#" term="Dependency Injection"/><category scheme="http://www.blogger.com/atom/ns#" term="Naming Conventions"/><category scheme="http://www.blogger.com/atom/ns#" term="Project Structure"/><category scheme="http://www.blogger.com/atom/ns#" term="Solution Design"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Structuring a .NET 8 Web API</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;A .NET 8 Web API that starts as a tidy three-file project has a way of becoming a navigational puzzle by the time it reaches twenty endpoints and three developers. The folder structure you choose in week one silently shapes every refactor, every onboarding session, and every pull request review that follows. Getting it right early is far cheaper than untangling it later.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have a concrete, battle-tested folder layout, a layer model with clear responsibilities, and a set of naming conventions you can copy directly into your next project or use to evaluate your current one.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK (8.0.100 or later)&lt;/li&gt;
  &lt;li&gt;Visual Studio 2022 17.8+, JetBrains Rider, or VS Code with the C# Dev Kit extension&lt;/li&gt;
  &lt;li&gt;Familiarity with dependency injection and the ASP.NET Core request pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; The folder structure described here targets controller-based APIs. If your team has chosen Minimal APIs, the layer model and naming conventions still apply; only the &lt;code&gt;Controllers&lt;/code&gt; folder becomes an &lt;code&gt;Endpoints&lt;/code&gt; folder of static extension classes.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Start with a solution, not just a project&lt;/div&gt;

&lt;p&gt;Even for a single API, wrapping it in a solution from day one pays dividends when you add a test project or an SDK library later. Create the solution structure with the CLI so the layout is deliberate rather than IDE-generated defaults.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Run these in your terminal — not C# code, shown here for clarity
// dotnet new sln -n Catalog
// dotnet new webapi -n Catalog.Api --no-openapi
// dotnet new classlib -n Catalog.Core
// dotnet new classlib -n Catalog.Infrastructure
// dotnet new xunit -n Catalog.Tests
// dotnet sln add **/*.csproj&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This gives you a four-project solution from the start. &lt;code&gt;Catalog.Api&lt;/code&gt; owns HTTP concerns. &lt;code&gt;Catalog.Core&lt;/code&gt; owns domain logic and interfaces. &lt;code&gt;Catalog.Infrastructure&lt;/code&gt; owns data access and external integrations. &lt;code&gt;Catalog.Tests&lt;/code&gt; tests all three. Project references flow inward: &lt;code&gt;Api&lt;/code&gt; references &lt;code&gt;Core&lt;/code&gt; and &lt;code&gt;Infrastructure&lt;/code&gt;; &lt;code&gt;Infrastructure&lt;/code&gt; references &lt;code&gt;Core&lt;/code&gt;; &lt;code&gt;Core&lt;/code&gt; references nothing in the solution.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Resist the urge to collapse everything into a single project to keep things simple. A single project forces you to enforce layer boundaries by convention alone; separate projects make accidental cross-layer dependencies a compile error rather than a code-review catch.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The folder layout inside each project&lt;/div&gt;

&lt;p&gt;The structure below is organized by layer first and by feature second inside each layer. This is the layout that scales most predictably for teams of two to ten developers on a single API.&lt;/p&gt;

&lt;h3&gt;Catalog.Api&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Catalog.Api/
// ├── Controllers/
// │   ├── ProductsController.cs
// │   └── CategoriesController.cs
// ├── Filters/
// │   └── ValidationExceptionFilter.cs
// ├── Models/                        // Request and response DTOs for the HTTP layer
// │   ├── Requests/
// │   │   ├── CreateProductRequest.cs
// │   │   └── UpdateProductRequest.cs
// │   └── Responses/
// │       └── ProductResponse.cs
// ├── Extensions/
// │   └── ServiceCollectionExtensions.cs
// └── Program.cs&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Catalog.Core&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Catalog.Core/
// ├── Domain/
// │   ├── Entities/
// │   │   └── Product.cs
// │   └── ValueObjects/
// │       └── Money.cs
// ├── Interfaces/
// │   ├── IProductRepository.cs
// │   └── IProductService.cs
// └── Services/
//     └── ProductService.cs&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Catalog.Infrastructure&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Catalog.Infrastructure/
// ├── Persistence/
// │   ├── CatalogDbContext.cs
// │   ├── Configurations/
// │   │   └── ProductConfiguration.cs
// │   └── Repositories/
// │       └── ProductRepository.cs
// └── Extensions/
//     └── ServiceCollectionExtensions.cs&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Keep the &lt;code&gt;Extensions&lt;/code&gt; folder in each project; it is where &lt;code&gt;IServiceCollection&lt;/code&gt; extension methods live. This prevents &lt;code&gt;Program.cs&lt;/code&gt; from becoming a registration dump that grows to 200 lines.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Naming conventions that communicate intent&lt;/div&gt;

&lt;p&gt;Consistent naming reduces the cognitive load of reading unfamiliar code. Apply these rules across the entire solution and enforce them in pull request reviews.&lt;/p&gt;

&lt;h3&gt;Controllers&lt;/h3&gt;
&lt;p&gt;Use the plural resource name followed by &lt;code&gt;Controller&lt;/code&gt;: &lt;code&gt;ProductsController&lt;/code&gt;, &lt;code&gt;OrdersController&lt;/code&gt;, &lt;code&gt;CategoriesController&lt;/code&gt;. Route templates use the conventional &lt;code&gt;[controller]&lt;/code&gt; token so the route mirrors the class name automatically. Every controller action should return &lt;code&gt;Task&amp;lt;IActionResult&amp;gt;&lt;/code&gt; or a typed &lt;code&gt;ActionResult&amp;lt;T&amp;gt;&lt;/code&gt; rather than a bare type, so the framework can generate accurate OpenAPI response descriptions.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Controllers/ProductsController.cs
[ApiController]
[Route(&quot;api/v{version:apiVersion}/[controller]&quot;)]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
        =&gt; _productService = productService;

    [HttpGet(&quot;{id:int}&quot;)]
    [ProducesResponseType(typeof(ProductResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task&amp;lt;ActionResult&amp;lt;ProductResponse&amp;gt;&amp;gt; GetById(
        int id, CancellationToken ct)
    {
        var product = await _productService.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(product);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;DTOs and request/response models&lt;/h3&gt;
&lt;p&gt;Name request bodies with the verb and the resource: &lt;code&gt;CreateProductRequest&lt;/code&gt;, &lt;code&gt;UpdateProductRequest&lt;/code&gt;. Name response models with the resource and the suffix &lt;code&gt;Response&lt;/code&gt;: &lt;code&gt;ProductResponse&lt;/code&gt;, &lt;code&gt;ProductSummaryResponse&lt;/code&gt;. Never expose your domain entity directly as an API response type; the shape of your persistence model and the shape of your public API contract will diverge, and the day they need to diverge is not the day you want to write a refactor.&lt;/p&gt;

&lt;h3&gt;Interfaces and services&lt;/h3&gt;
&lt;p&gt;Prefix interfaces with &lt;code&gt;I&lt;/code&gt; and name them after the capability: &lt;code&gt;IProductService&lt;/code&gt;, &lt;code&gt;IProductRepository&lt;/code&gt;. Implementations drop the prefix: &lt;code&gt;ProductService&lt;/code&gt;, &lt;code&gt;ProductRepository&lt;/code&gt;. Avoid descriptive suffixes like &lt;code&gt;ProductServiceImpl&lt;/code&gt; or &lt;code&gt;ConcreteProductRepository&lt;/code&gt;; they carry no information a developer cannot infer from the file location.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Core/Interfaces/IProductService.cs
public interface IProductService
{
    Task&amp;lt;ProductResponse?&amp;gt; GetByIdAsync(int id, CancellationToken ct = default);
    Task&amp;lt;IReadOnlyList&amp;lt;ProductSummaryResponse&amp;gt;&amp;gt; GetAllAsync(CancellationToken ct = default);
    Task&amp;lt;int&amp;gt; CreateAsync(CreateProductRequest request, CancellationToken ct = default);
    Task UpdateAsync(int id, UpdateProductRequest request, CancellationToken ct = default);
    Task DeleteAsync(int id, CancellationToken ct = default);
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Dependency registration as extension methods&lt;/div&gt;

&lt;p&gt;Each project that registers services should expose a single &lt;code&gt;AddX&lt;/code&gt; extension method on &lt;code&gt;IServiceCollection&lt;/code&gt;. &lt;code&gt;Program.cs&lt;/code&gt; then reads as a declaration of intent rather than an implementation listing.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Infrastructure/Extensions/ServiceCollectionExtensions.cs
public static class InfrastructureServiceCollectionExtensions
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext&amp;lt;CatalogDbContext&amp;gt;(opts =&gt;
            opts.UseSqlServer(configuration.GetConnectionString(&quot;Catalog&quot;)));

        services.AddScoped&amp;lt;IProductRepository, ProductRepository&amp;gt;();

        return services;
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Api/Extensions/ServiceCollectionExtensions.cs
public static class ApiServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        services.AddScoped&amp;lt;IProductService, ProductService&amp;gt;();
        return services;
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — lean and declarative
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Notice that &lt;code&gt;Program.cs&lt;/code&gt; does not know which concrete types are registered. Adding a new service or repository is a change inside one extension method, not a change to the entry point. This matters when merge conflicts and code ownership come into play across a team.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;6&lt;/span&gt; Running and verifying the structure&lt;/div&gt;

&lt;p&gt;With the structure in place, run the project and open the Swagger UI to verify that your controllers, their routes, and their response types are all reflected correctly in the generated document.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// From the solution root
// dotnet build
// dotnet run --project Catalog.Api

// Expected output includes:
// info: Microsoft.Hosting.Lifetime[14]
//       Now listening on: https://localhost:7001
// Navigate to https://localhost:7001/swagger to see the generated API document&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;At this point your Swagger UI should list every controller action with its route, HTTP method, and declared response types. If a controller action is missing, check that &lt;code&gt;MapControllers()&lt;/code&gt; is called in &lt;code&gt;Program.cs&lt;/code&gt; and that the controller class is in the same assembly as the API project or is explicitly included via &lt;code&gt;AddApplicationPart&lt;/code&gt;.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;A well-structured .NET 8 Web API separates concerns across projects, organizes code by layer inside each project, applies consistent naming conventions, and keeps &lt;code&gt;Program.cs&lt;/code&gt; declarative through extension methods. None of these choices are exotic; their value is in being applied uniformly so that every developer on the team can navigate, extend, and test the codebase without a guide.&lt;/p&gt;
&lt;p&gt;The next post in this series covers validation: specifically whether FluentValidation or Data Annotations is the right choice for your API, with working code for both.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/2688046145859096416/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/structuring-net-8-web-api.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/2688046145859096416'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/2688046145859096416'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/structuring-net-8-web-api.html' title='Structuring a .NET 8 Web API'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-3923739143035674126</id><published>2026-04-24T12:40:54.804+05:45</published><updated>2026-04-24T12:40:54.804+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="API Design"/><category scheme="http://www.blogger.com/atom/ns#" term="Architecture"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Controllers"/><category scheme="http://www.blogger.com/atom/ns#" term="Minimal APIs"/><category scheme="http://www.blogger.com/atom/ns#" term="REST API"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Controller vs. Minimal API in .NET 8</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-decision {
  border-left: 4px solid #2563eb;
  background: #f0f6ff;
  padding: 14px 18px; margin: 1em 0;
  border-radius: 0 6px 6px 0;
}
.pbi-decision.b {
  border-color: #16a34a;
  background: #f0fdf4;
}
.pbi-decision h4 {
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
  font-size: 14px; font-weight: 700; margin: 0 0 8px;
  color: #1e3a5f;
}
.pbi-decision.b h4 { color: #14532d; }
.pbi-decision ul { margin: 0; padding-left: 1.4em; }
.pbi-decision li { font-size: 14px; color: var(--ink-mid); margin-bottom: 4px; }
.pbi-table { width: 100%; border-collapse: collapse; margin: 1em 0 1.5em; font-size: 14px; }
.pbi-table th {
  background: var(--ink); color: var(--bg);
  padding: 10px 14px; text-align: left; font-weight: 600;
}
.pbi-table td { padding: 9px 14px; border-bottom: 1px solid var(--rule); color: var(--ink-mid); }
.pbi-table tr:nth-child(even) td { background: var(--code-bg); }
.pbi-table td:first-child { font-weight: 600; color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;If you have started a new ASP.NET Core project in the last year, you have almost certainly paused at the same fork: controllers or Minimal APIs? In .NET 8 both approaches are mature, well-documented, and production-ready. They share the same middleware pipeline, the same dependency injection container, and they produce identical HTTP responses on the wire. What differs is the programming model, the amount of ceremony, and how well each scales as your team and codebase grow.&lt;/p&gt;
&lt;p&gt;Controller-based APIs and Minimal APIs are the two official routing models in ASP.NET Core today. Both get you to a working endpoint; the question this post answers is which model fits your project size, your team&#39;s background, and your long-term maintenance plan.&lt;/p&gt;
&lt;p&gt;You will walk away with runnable side-by-side examples, an honest breakdown of five real tradeoffs, and a summary table suitable for your team&#39;s architecture decision record.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; A quick history first&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Controller-based APIs&lt;/strong&gt; trace their lineage to ASP.NET MVC, which shipped in 2009 and was rebuilt as ASP.NET Core from the ground up in 2016. The model is object-oriented: a class annotated with &lt;code&gt;[ApiController]&lt;/code&gt; groups related endpoints, and the framework uses attribute routing, convention-based model binding, and action filters to wire everything together.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Minimal APIs&lt;/strong&gt; arrived in .NET 6 as a deliberate reaction to the ceremony that controllers introduced for small or service-oriented scenarios. Instead of classes and attributes, you call &lt;code&gt;app.MapGet&lt;/code&gt;, &lt;code&gt;app.MapPost&lt;/code&gt;, and their siblings directly on the &lt;code&gt;WebApplication&lt;/code&gt; instance or in extension methods. The model is functional: a route maps to a delegate.&lt;/p&gt;
&lt;p&gt;Critically, both models compile to the same underlying endpoint routing infrastructure introduced in ASP.NET Core 3. There is no meaningful difference in the HTTP contract they produce; every observable difference lives in how you write and organize your code.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; The same task, two ways&lt;/div&gt;

&lt;p&gt;To ground the comparison, here is a &lt;code&gt;GET /api/products/{id}&lt;/code&gt; endpoint backed by an injected service, written both ways. Both examples handle cancellation, declare response types for OpenAPI, and return a 404 when the product is not found.&lt;/p&gt;

&lt;h3&gt;Controller-based approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Controllers/ProductsController.cs
[ApiController]
[Route(&quot;api/[controller]&quot;)]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service)
        =&gt; _service = service;

    [HttpGet(&quot;{id:int}&quot;)]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task&amp;lt;IActionResult&amp;gt; GetById(int id, CancellationToken ct)
    {
        var product = await _service.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(product);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Minimal API approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static IEndpointRouteBuilder MapProductEndpoints(
        this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup(&quot;/api/products&quot;)
                          .WithTags(&quot;Products&quot;);

        group.MapGet(&quot;/{id:int}&quot;, async (
            int id,
            IProductService service,
            CancellationToken ct) =&gt;
        {
            var product = await service.GetByIdAsync(id, ct);
            return product is null ? Results.NotFound() : Results.Ok(product);
        })
        .Produces&amp;lt;ProductDto&amp;gt;()
        .ProducesProblem(StatusCodes.Status404NotFound);

        return routes;
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Notice that the Minimal API version uses &lt;code&gt;MapGroup&lt;/code&gt; to avoid repeating the base path and is placed in a dedicated static class rather than &lt;code&gt;Program.cs&lt;/code&gt;. That is the idiomatic .NET 8 pattern for organizing Minimal API endpoints at any meaningful scale. Dumping all &lt;code&gt;MapGet&lt;/code&gt; calls into &lt;code&gt;Program.cs&lt;/code&gt; works for demos but does not survive a real API.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The real tradeoffs&lt;/div&gt;

&lt;h3&gt;1. Boilerplate and ceremony&lt;/h3&gt;
&lt;p&gt;Controllers require inheriting from &lt;code&gt;ControllerBase&lt;/code&gt;, marking the class with &lt;code&gt;[ApiController]&lt;/code&gt;, and defining a class per resource group. For a 30-endpoint API this structure is a feature; it is predictable and easy to navigate. For a three-endpoint microservice it feels like architecture for architecture&#39;s sake. Minimal APIs strip the class and attributes away, replacing them with terse lambda handlers and fluent builder calls.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this matter to you?&lt;/strong&gt; If your API has more than ten distinct route templates or your team rotates developers frequently, the controller convention repays its upfront cost. If you are building a self-contained service that does one thing well, the Minimal API ceremony saving is real.&lt;/p&gt;

&lt;h3&gt;2. Testability&lt;/h3&gt;
&lt;p&gt;Controllers are plain classes. You can instantiate them in a unit test, inject a mock, and call action methods directly without spinning up an HTTP server.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Direct unit test of a controller action
[Fact]
public async Task GetById_ReturnsNotFound_WhenProductMissing()
{
    var mockService = new Mock&amp;lt;IProductService&amp;gt;();
    mockService.Setup(s =&amp;gt; s.GetByIdAsync(99, default))
               .ReturnsAsync((ProductDto?)null);

    var controller = new ProductsController(mockService.Object);
    var result = await controller.GetById(99, default);

    Assert.IsType&amp;lt;NotFoundResult&amp;gt;(result);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Minimal API handlers are delegates rather than public methods on an instantiable class, so the same pattern is not available. The canonical testing approach is &lt;code&gt;WebApplicationFactory&amp;lt;T&amp;gt;&lt;/code&gt; integration tests, which are valuable but carry more setup weight per test. Teams that want fast, isolated unit tests close to the HTTP boundary will find controllers more accommodating.&lt;/p&gt;

&lt;h3&gt;3. Cross-cutting concerns&lt;/h3&gt;
&lt;p&gt;Controllers support action filters, result filters, and exception filters applied via attributes or registered globally through &lt;code&gt;MvcOptions&lt;/code&gt;. This is a mature, well-understood model for concerns like audit logging, caching headers, and conditional response transformation.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Attribute-applied action filter on a controller action
[ServiceFilter(typeof(AuditLogFilter))]
[HttpPost]
public async Task&amp;lt;IActionResult&amp;gt; Create([FromBody] CreateProductRequest request,
                                         CancellationToken ct) { ... }&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Minimal APIs support endpoint filters via &lt;code&gt;.AddEndpointFilter&amp;lt;T&amp;gt;()&lt;/code&gt; and &lt;code&gt;.AddEndpointFilterFactory()&lt;/code&gt;, which covers most of the same ground. The ecosystem of pre-built filters is smaller, however, and the model is less familiar to teams coming from an MVC background. For application-wide concerns such as exception handling and authentication, middleware applies equally to both models.&lt;/p&gt;

&lt;h3&gt;4. Code organization at scale&lt;/h3&gt;
&lt;p&gt;As an API grows past a handful of routes, the structural question becomes more important than the syntactic one. Controllers encode the convention: one class per resource, one action per HTTP verb. New developers know exactly where to look. With Minimal APIs the structure is self-imposed; &lt;code&gt;MapGroup&lt;/code&gt; combined with one static extension method per feature area is the current community standard, but enforcing it consistently across a team requires deliberate decision-making that controllers handle for you automatically.&lt;/p&gt;

&lt;h3&gt;5. OpenAPI support&lt;/h3&gt;
&lt;p&gt;In .NET 8 both models have first-class OpenAPI support. The built-in &lt;code&gt;Microsoft.AspNetCore.OpenApi&lt;/code&gt; package handles document generation for either model; controllers use &lt;code&gt;[ProducesResponseType]&lt;/code&gt; attributes while Minimal APIs use the fluent &lt;code&gt;.Produces&amp;lt;T&amp;gt;()&lt;/code&gt; builder methods. Neither approach produces materially better documentation. Teams already invested in Swashbuckle will find the controller attribute style familiar, but the Minimal API fluent style is equally expressive.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Which one should your team use?&lt;/div&gt;

&lt;p&gt;The right choice depends on project scope and team dynamics more than on any benchmark. Here is a practical decision guide.&lt;/p&gt;

&lt;div class=&quot;pbi-decision&quot;&gt;
  &lt;h4&gt;Choose Controllers when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;Your team has existing ASP.NET Core MVC experience and the controller pattern is already second nature&lt;/li&gt;
    &lt;li&gt;The API has many endpoints across multiple resource types and you need the framework to enforce organizational conventions&lt;/li&gt;
    &lt;li&gt;You rely on action filters for cross-cutting concerns and want attribute-based application&lt;/li&gt;
    &lt;li&gt;You want to unit-test action methods directly without a full &lt;code&gt;WebApplicationFactory&lt;/code&gt; setup&lt;/li&gt;
    &lt;li&gt;You are extending or maintaining an existing controller-based codebase&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision b&quot;&gt;
  &lt;h4&gt;Choose Minimal APIs when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;You are building a microservice or a focused API with a small, stable route surface&lt;/li&gt;
    &lt;li&gt;Your team is comfortable defining its own organizational structure and does not need the framework to enforce it&lt;/li&gt;
    &lt;li&gt;You prefer integration tests over action-level unit tests and already have a &lt;code&gt;WebApplicationFactory&lt;/code&gt; test project in place&lt;/li&gt;
    &lt;li&gt;Startup latency matters, such as in Azure Functions or container workloads with fast cold-start requirements&lt;/li&gt;
    &lt;li&gt;You want to minimize the number of abstractions new team members need to learn&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;p&gt;Mixing both in a single application is also a legitimate pattern. A common approach is using controllers for the primary resource API while using Minimal APIs for lightweight utility endpoints.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs — valid mixed configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped&amp;lt;IProductService, ProductService&amp;gt;();

var app = builder.Build();

app.MapControllers();                                    // controller-based routes
app.MapHealthChecks(&quot;/health&quot;);                          // lightweight utility endpoint
app.MapGet(&quot;/version&quot;, () =&gt; Results.Ok(new            // simple info endpoint
{
    Version = &quot;2.1.0&quot;,
    Runtime = Environment.Version.ToString()
}));

app.Run();&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Quick reference summary&lt;/div&gt;

&lt;table class=&quot;pbi-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concern&lt;/th&gt;
      &lt;th&gt;Controllers&lt;/th&gt;
      &lt;th&gt;Minimal APIs&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;Ceremony / boilerplate&lt;/td&gt;&lt;td&gt;Higher; class, attributes, inheritance required&lt;/td&gt;&lt;td&gt;Lower; delegates and fluent builders&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Unit testability&lt;/td&gt;&lt;td&gt;Direct; instantiate the class in any test&lt;/td&gt;&lt;td&gt;Indirect; integration tests preferred&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Filters and cross-cutting&lt;/td&gt;&lt;td&gt;Mature attribute-based ecosystem&lt;/td&gt;&lt;td&gt;Supported via endpoint filters; smaller ecosystem&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Organization at scale&lt;/td&gt;&lt;td&gt;Convention enforced by the framework&lt;/td&gt;&lt;td&gt;Self-imposed; requires team discipline&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;OpenAPI / Swagger&lt;/td&gt;&lt;td&gt;First-class; attribute-driven&lt;/td&gt;&lt;td&gt;First-class; fluent builder-driven&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Startup performance&lt;/td&gt;&lt;td&gt;Slightly higher route registration cost&lt;/td&gt;&lt;td&gt;Marginally leaner startup&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Learning curve (MVC background)&lt;/td&gt;&lt;td&gt;Low; same model as ASP.NET MVC&lt;/td&gt;&lt;td&gt;Medium; new patterns to internalize&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Final take&lt;/div&gt;

&lt;p&gt;For most teams building a multi-resource API with five or more controllers&#39; worth of endpoints, controllers remain the pragmatic default. The conventions are well understood, the tooling is battle-tested, and onboarding a new developer into a controller-based project requires almost no orientation. Minimal APIs earn their place in focused microservices and internal tools where the reduction in ceremony translates directly into a codebase that is faster to read and easier to keep lean.&lt;/p&gt;
&lt;p&gt;Lean toward controllers when you are building a product API that will grow. Lean toward Minimal APIs when you are building a service that does one thing and should stay small.&lt;/p&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;What&#39;s next?&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Project structure:&lt;/strong&gt; The next post in this series covers how to organize folders, layers, and naming conventions in a .NET 8 Web API so the codebase stays navigable as it grows.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Validation strategy:&lt;/strong&gt; Once your routing model is settled, the next meaningful decision is FluentValidation versus Data Annotations, covered later in this series.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Official reference:&lt;/strong&gt; The ASP.NET Core Minimal APIs comparison docs at learn.microsoft.com are kept up to date with each .NET release and are worth bookmarking.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or a scenario I did not cover? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/3923739143035674126/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/controller-vs-minimal-api-in-net-8.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/3923739143035674126'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/3923739143035674126'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/controller-vs-minimal-api-in-net-8.html' title='Controller vs. Minimal API in .NET 8'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-2486304739719554884</id><published>2026-04-22T14:58:45.014+05:45</published><updated>2026-04-22T14:58:45.014+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Dapper"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="FromSql"/><category scheme="http://www.blogger.com/atom/ns#" term="FromSqlRaw"/><category scheme="http://www.blogger.com/atom/ns#" term="Micro-ORM"/><category scheme="http://www.blogger.com/atom/ns#" term="Query Performance"/><category scheme="http://www.blogger.com/atom/ns#" term="Raw SQL"/><title type='text'>Raw SQL, FromSqlRaw, and Dapper Side-by-Side in a .NET 8 API</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-decision {
  border-left: 4px solid #2563eb;
  background: #f0f6ff;
  padding: 14px 18px; margin: 1em 0;
  border-radius: 0 6px 6px 0;
}
.pbi-decision.b {
  border-color: #16a34a;
  background: #f0fdf4;
}
.pbi-decision.c {
  border-color: #9333ea;
  background: #faf5ff;
}
.pbi-decision h4 {
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
  font-size: 14px; font-weight: 700; margin: 0 0 8px;
  color: #1e3a5f;
}
.pbi-decision.b h4 { color: #14532d; }
.pbi-decision.c h4 { color: #581c87; }
.pbi-decision ul { margin: 0; padding-left: 1.4em; }
.pbi-decision li { font-size: 14px; color: var(--ink-mid); margin-bottom: 4px; }
.pbi-table { width: 100%; border-collapse: collapse; margin: 1em 0 1.5em; font-size: 14px; }
.pbi-table th {
  background: var(--ink); color: var(--bg);
  padding: 10px 14px; text-align: left; font-weight: 600;
}
.pbi-table td { padding: 9px 14px; border-bottom: 1px solid var(--rule); color: var(--ink-mid); }
.pbi-table tr:nth-child(even) td { background: var(--code-bg); }
.pbi-table td:first-child { font-weight: 600; color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;EF Core&#39;s LINQ query builder handles the vast majority of data access scenarios cleanly, but every production application eventually hits a query complex enough that generating it through LINQ is more effort than writing the SQL directly. At that point you have three realistic options: EF Core&#39;s own raw SQL APIs (&lt;code&gt;FromSql&lt;/code&gt; and &lt;code&gt;ExecuteSqlRaw&lt;/code&gt;), Dapper running over the same database connection, or a mix of both.&lt;/p&gt;
&lt;p&gt;All three approaches talk to the same database, can share the same connection and transaction, and produce parameterized queries. The differences are in what they give back, how much ceremony they require, and where they break down.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have working code for all three approaches solving the same query, a clear decision framework for choosing between them, and a pattern for combining EF Core and Dapper in the same API without fighting over the connection.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; A quick history first&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;EF Core raw SQL&lt;/strong&gt; (&lt;code&gt;FromSqlRaw&lt;/code&gt;, &lt;code&gt;FromSql&lt;/code&gt;, &lt;code&gt;ExecuteSqlRaw&lt;/code&gt;) was added to give EF developers an escape hatch when the LINQ translator cannot express a query, while still returning tracked entities and allowing LINQ composition on top of the raw SQL result.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dapper&lt;/strong&gt; is a micro-ORM by the Stack Overflow team built on top of &lt;code&gt;IDbConnection&lt;/code&gt;. It maps query results to objects via property name matching and supports multi-mapping for joins. It has no change tracking, no relationship model, and no query builder; you write SQL, Dapper materializes the result.&lt;/p&gt;
&lt;p&gt;Both evolved alongside LINQ-to-SQL and NHibernate as answers to the same question: what do you do when a generated query is not good enough?&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; The same task, three ways&lt;/div&gt;

&lt;p&gt;The scenario: fetch orders for a given customer that include their line items and the product name for each line. This query involves a join, a filter, and a result shape that does not map cleanly to a single entity.&lt;/p&gt;

&lt;h3&gt;EF Core LINQ (baseline)&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var orders = await _context.Orders
    .AsNoTracking()
    .Include(o =&amp;gt; o.Lines)
        .ThenInclude(l =&amp;gt; l.Product)
    .Where(o =&amp;gt; o.CustomerId == customerId
             &amp;amp;&amp;amp; o.Status == OrderStatus.Pending)
    .OrderByDescending(o =&amp;gt; o.PlacedAt)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;EF Core FromSql approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// FromSql (EF Core 8+) uses interpolated strings safely — parameters are never inlined
var orders = await _context.Orders
    .FromSql($@&quot;
        SELECT o.*
        FROM   orders o
        WHERE  o.customer_id = {customerId}
          AND  o.status      = {(int)OrderStatus.Pending}
        ORDER BY o.placed_at DESC&quot;)
    .AsNoTracking()
    .Include(o =&amp;gt; o.Lines)
        .ThenInclude(l =&amp;gt; l.Product)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Dapper approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var connection = _context.Database.GetDbConnection();
await connection.OpenAsync(ct);

var orderDict = new Dictionary&amp;lt;int, Order&amp;gt;();

await connection.QueryAsync&amp;lt;Order, OrderLine, Product, Order&amp;gt;(
    sql: @&quot;
        SELECT o.id, o.customer_id, o.status, o.placed_at,
               l.id, l.order_id, l.quantity, l.unit_price,
               p.id, p.name, p.sku
        FROM   orders       o
        JOIN   order_lines  l ON l.order_id  = o.id
        JOIN   products     p ON p.id        = l.product_id
        WHERE  o.customer_id = @CustomerId
          AND  o.status      = @Status
        ORDER BY o.placed_at DESC&quot;,
    map: (order, line, product) =&amp;gt;
    {
        if (!orderDict.TryGetValue(order.Id, out var existing))
        {
            existing = order;
            existing.Lines = [];
            orderDict[order.Id] = existing;
        }
        line.Product = product;
        existing.Lines.Add(line);
        return existing;
    },
    param: new { CustomerId = customerId, Status = (int)OrderStatus.Pending },
    splitOn: &quot;id,id&quot;);

var orders = orderDict.Values.ToList();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The SQL in the EF and Dapper versions is nearly identical. The biggest visible difference is that EF Core can compose &lt;code&gt;Include&lt;/code&gt; on top of the raw SQL, while Dapper requires you to write the full join and wire up the object graph yourself.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The real tradeoffs&lt;/div&gt;

&lt;h3&gt;1. What you get back&lt;/h3&gt;
&lt;p&gt;EF Core raw SQL returns full entity instances that participate in the change tracker and can have navigation properties populated via &lt;code&gt;Include&lt;/code&gt;. Dapper returns plain objects: whatever class you tell it to map to, with no change tracking and no navigation resolution built in. If you want the graph assembled from a join, you write the multi-mapping lambda yourself as shown above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this matter?&lt;/strong&gt; If you need to modify returned records or rely on navigation lazy loading, EF Core raw SQL is the right tool. If you are building a read model or projecting to a DTO, Dapper&#39;s lack of overhead is an advantage.&lt;/p&gt;

&lt;h3&gt;2. SQL injection safety&lt;/h3&gt;
&lt;p&gt;All three options support parameterized queries and are safe when used correctly, but the APIs make it easier or harder to do the wrong thing.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Safe: FromSql with interpolated string — EF Core parameterizes {customerId} automatically
var orders = await _context.Orders
    .FromSql($&quot;SELECT * FROM orders WHERE customer_id = {customerId}&quot;)
    .ToListAsync(ct);

// UNSAFE: FromSqlRaw with string interpolation — inlines the value directly into SQL
var orders = await _context.Orders
    .FromSqlRaw($&quot;SELECT * FROM orders WHERE customer_id = {customerId}&quot;) // never do this
    .ToListAsync(ct);

// Safe: FromSqlRaw requires explicit parameter objects
var orders = await _context.Orders
    .FromSqlRaw(&quot;SELECT * FROM orders WHERE customer_id = {0}&quot;, customerId)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The rule for &lt;code&gt;FromSqlRaw&lt;/code&gt;: treat the first argument exactly like a &lt;code&gt;string.Format&lt;/code&gt; call and never pass a user-derived value as anything other than a positional placeholder argument. For new code in EF Core 8, prefer &lt;code&gt;FromSql&lt;/code&gt; (the interpolated string overload) because the compiler enforces correct usage.&lt;/p&gt;

&lt;h3&gt;3. LINQ composability&lt;/h3&gt;
&lt;p&gt;EF Core raw SQL results can have additional LINQ operators chained after them as long as the raw SQL forms the entire &lt;code&gt;FROM&lt;/code&gt; clause:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Legal: filter and sort on top of raw SQL
var page = await _context.Orders
    .FromSql($&quot;SELECT * FROM orders WHERE customer_id = {customerId}&quot;)
    .Where(o =&amp;gt; o.Status == OrderStatus.Pending)
    .OrderByDescending(o =&amp;gt; o.PlacedAt)
    .Skip(offset).Take(pageSize)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Dapper has no composition layer: once the SQL is written, the result is fixed. Any further filtering or pagination must happen either in SQL or in memory after the query returns, which can be a significant limitation for dynamic filter scenarios.&lt;/p&gt;

&lt;h3&gt;4. Non-query commands: ExecuteSqlRaw vs Dapper Execute&lt;/h3&gt;
&lt;p&gt;For &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, or stored procedure calls that do not return rows, both approaches are equivalent in ceremony:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// EF Core: bulk update without loading entities
int rows = await _context.Database.ExecuteSqlRawAsync(
    &quot;UPDATE orders SET status = {0} WHERE customer_id = {1} AND placed_at &amp;lt; {2}&quot;,
    (int)OrderStatus.Cancelled, customerId, cutoff, ct);

// Dapper: same operation
using var connection = _context.Database.GetDbConnection();
int rows = await connection.ExecuteAsync(
    &quot;UPDATE orders SET status = @Status WHERE customer_id = @CustomerId AND placed_at &amp;lt; @Cutoff&quot;,
    new { Status = (int)OrderStatus.Cancelled, CustomerId = customerId, Cutoff = cutoff });&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For bulk operations in EF Core 8, prefer &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; and &lt;code&gt;ExecuteDeleteAsync&lt;/code&gt; over raw SQL commands because they compose with Global Query Filters and keep the tenant and soft-delete predicates active.&lt;/p&gt;

&lt;h3&gt;5. Sharing a transaction across EF Core and Dapper&lt;/h3&gt;
&lt;p&gt;Because Dapper works directly on an &lt;code&gt;IDbConnection&lt;/code&gt;, it can participate in the same transaction that EF Core opened:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var tx = await _context.Database.BeginTransactionAsync(ct);

// EF Core write
_context.Orders.Add(newOrder);
await _context.SaveChangesAsync(ct);

// Dapper write in the same transaction
var connection   = _context.Database.GetDbConnection();
var dbTransaction = _context.Database.CurrentTransaction!.GetDbTransaction();

await connection.ExecuteAsync(
    &quot;INSERT INTO audit_log (order_id, action, ts) VALUES (@OrderId, @Action, @Ts)&quot;,
    new { OrderId = newOrder.Id, Action = &quot;created&quot;, Ts = DateTimeOffset.UtcNow },
    transaction: dbTransaction);

await tx.CommitAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This pattern lets you use EF Core&#39;s change tracking for the entities it manages well and fall back to Dapper for queries or commands that do not fit the EF Core model, all within the same transactional boundary.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Which one should you choose?&lt;/div&gt;

&lt;p&gt;Choose based on what you need from the result, not on habit or familiarity with one library.&lt;/p&gt;

&lt;div class=&quot;pbi-decision&quot;&gt;
  &lt;h4&gt;Use EF Core LINQ when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;The query can be expressed in LINQ without gymnastics and the generated SQL is acceptable.&lt;/li&gt;
    &lt;li&gt;You need to modify returned entities in the same unit of work.&lt;/li&gt;
    &lt;li&gt;You want Global Query Filters (soft delete, multi-tenancy) applied automatically.&lt;/li&gt;
    &lt;li&gt;The query benefits from &lt;code&gt;Include&lt;/code&gt; navigation loading without writing a join manually.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision b&quot;&gt;
  &lt;h4&gt;Use EF Core FromSql when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;The LINQ translation produces a suboptimal query (extra joins, missing index hints, missing CTEs) and you want to write the SQL yourself.&lt;/li&gt;
    &lt;li&gt;You still need to return tracked entities and compose LINQ on top of the result.&lt;/li&gt;
    &lt;li&gt;You are calling a view or a stored procedure that returns a shape matching an existing entity.&lt;/li&gt;
    &lt;li&gt;You want the safety of parameterized queries with minimal friction over raw ADO.NET.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision c&quot;&gt;
  &lt;h4&gt;Use Dapper when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;The result does not map to an entity: reporting queries, aggregations, complex multi-table projections, or custom DTO shapes.&lt;/li&gt;
    &lt;li&gt;You need multi-mapping (assembling a graph from a single join query) with fine-grained control over the mapping logic.&lt;/li&gt;
    &lt;li&gt;Performance is critical and you want the thinnest possible layer between SQL and the materialized object.&lt;/li&gt;
    &lt;li&gt;You are integrating a legacy stored-procedure-heavy database where no EF entity model exists.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Quick reference summary&lt;/div&gt;

&lt;table class=&quot;pbi-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concern&lt;/th&gt;
      &lt;th&gt;EF Core LINQ&lt;/th&gt;
      &lt;th&gt;EF Core FromSql&lt;/th&gt;
      &lt;th&gt;Dapper&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;Change tracking&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;Yes (unless AsNoTracking)&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;LINQ composability&lt;/td&gt;&lt;td&gt;Full&lt;/td&gt;&lt;td&gt;Partial (on top of FROM)&lt;/td&gt;&lt;td&gt;None&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Global Query Filters&lt;/td&gt;&lt;td&gt;Automatic&lt;/td&gt;&lt;td&gt;Automatic&lt;/td&gt;&lt;td&gt;Must write manually&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Custom SQL control&lt;/td&gt;&lt;td&gt;None&lt;/td&gt;&lt;td&gt;Full for the FROM clause&lt;/td&gt;&lt;td&gt;Full&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Non-entity result shapes&lt;/td&gt;&lt;td&gt;Via Select projection&lt;/td&gt;&lt;td&gt;Requires keyless entity type&lt;/td&gt;&lt;td&gt;Any class or primitive&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Multi-mapping joins&lt;/td&gt;&lt;td&gt;Via Include&lt;/td&gt;&lt;td&gt;Via Include&lt;/td&gt;&lt;td&gt;Manual but flexible&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Transaction sharing with EF&lt;/td&gt;&lt;td&gt;Native&lt;/td&gt;&lt;td&gt;Native&lt;/td&gt;&lt;td&gt;Via GetDbTransaction()&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;SQL injection safety&lt;/td&gt;&lt;td&gt;Automatic&lt;/td&gt;&lt;td&gt;Safe with FromSql/positional params&lt;/td&gt;&lt;td&gt;Safe with @param syntax&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Final take&lt;/div&gt;

&lt;p&gt;These three tools are not competitors; they are layers. Start with EF Core LINQ. Drop to &lt;code&gt;FromSql&lt;/code&gt; when the generated SQL is the problem but you still want entity tracking and LINQ composition. Drop to Dapper when the result shape does not fit the entity model at all or when you need the absolute minimum between your SQL and your object.&lt;/p&gt;
&lt;p&gt;If you find yourself using &lt;code&gt;FromSqlRaw&lt;/code&gt; everywhere because LINQ frustrates you, that is a signal your entity model may be fighting your query patterns; revisit the schema or consider a dedicated read model with Dapper for query-side endpoints.&lt;/p&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;What&#39;s next?&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Add a keyless entity type:&lt;/strong&gt; If you have reporting queries that return custom shapes, register them with &lt;code&gt;modelBuilder.Entity&amp;lt;ReportRow&amp;gt;().HasNoKey().ToView(null)&lt;/code&gt; and use &lt;code&gt;FromSqlRaw&lt;/code&gt; to return them through EF Core with full LINQ composability.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Benchmark your heavy queries:&lt;/strong&gt; Use BenchmarkDotNet with both approaches on your most-called endpoints and measure allocations, not just execution time.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Review your stored procedures:&lt;/strong&gt; If your team owns legacy stored procedures, Dapper&#39;s &lt;code&gt;commandType: CommandType.StoredProcedure&lt;/code&gt; support makes it the cleanest integration path without touching EF&#39;s entity model.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or a scenario I did not cover? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/2486304739719554884/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/raw-sql-fromsqlraw-and-dapper-side-by.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/2486304739719554884'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/2486304739719554884'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/raw-sql-fromsqlraw-and-dapper-side-by.html' title='Raw SQL, FromSqlRaw, and Dapper Side-by-Side in a .NET 8 API'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-4769169026077348634</id><published>2026-04-22T14:55:11.558+05:45</published><updated>2026-04-22T14:55:11.559+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Concurrency"/><category scheme="http://www.blogger.com/atom/ns#" term="DbUpdateConcurrencyException"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="Optimistic Concurrency"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><category scheme="http://www.blogger.com/atom/ns#" term="xmin"/><title type='text'>Handling Concurrency Conflicts in EF Core with PostgreSQL</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Optimistic concurrency assumes conflicts are rare but makes them visible when they happen. Without it, the last write silently wins: two users edit the same record, the first save succeeds, the second save overwrites it with stale data, and no one knows. EF Core&#39;s concurrency support turns that silent overwrite into an explicit exception you can handle on your terms.&lt;/p&gt;
&lt;p&gt;PostgreSQL makes this particularly clean because every row carries a built-in system column called &lt;code&gt;xmin&lt;/code&gt; that changes on every update. By the end of this post you will have EF Core using &lt;code&gt;xmin&lt;/code&gt; as a concurrency token, a typed exception handler that covers all three resolution strategies (client wins, database wins, and custom merge), and a tested endpoint that demonstrates the full conflict cycle.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK&lt;/li&gt;
  &lt;li&gt;EF Core 8 with the Npgsql provider: &lt;code&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/code&gt; version 8.x&lt;/li&gt;
  &lt;li&gt;A running PostgreSQL instance (local or Docker)&lt;/li&gt;
  &lt;li&gt;A working &lt;code&gt;DbContext&lt;/code&gt; with at least one entity you intend to protect&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; If you are on SQL Server instead of PostgreSQL, replace &lt;code&gt;UseXminAsConcurrencyToken()&lt;/code&gt; with a &lt;code&gt;[Timestamp]&lt;/code&gt; attribute or &lt;code&gt;.IsRowVersion()&lt;/code&gt; in fluent config. Everything from section 3 onward applies identically.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; Understanding the concurrency problem&lt;/div&gt;

&lt;p&gt;Optimistic concurrency works by recording the version of a row when it is loaded and then asserting that version has not changed when the update is applied. The SQL EF Core generates looks like this conceptually:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE Products
SET    Price = @newPrice,
       UpdatedAt = @now
WHERE  Id = @id
AND    xmin = @originalXmin;  -- the version we loaded&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If another process updated the row between our read and our write, &lt;code&gt;xmin&lt;/code&gt; will have advanced and the &lt;code&gt;WHERE&lt;/code&gt; clause will match zero rows. EF Core checks the affected row count: zero rows updated means a conflict, and it throws &lt;code&gt;DbUpdateConcurrencyException&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;What is xmin?&lt;/strong&gt; Every PostgreSQL row stores the transaction ID of the last transaction that inserted or updated it in a hidden system column called &lt;code&gt;xmin&lt;/code&gt;. It is monotonically increasing per-row and requires no application-level migration to use.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Configuring the concurrency token&lt;/div&gt;

&lt;p&gt;The Npgsql EF Core provider exposes a single fluent call that maps &lt;code&gt;xmin&lt;/code&gt; as the concurrency token. Add it to your entity configuration and add a &lt;code&gt;uint&lt;/code&gt; property to the entity class to hold the shadow value.&lt;/p&gt;

&lt;h3&gt;Entity class&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }

    // Maps to PostgreSQL&#39;s xmin system column
    public uint Version { get; set; }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Fluent configuration in OnModelCreating&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&amp;lt;Product&amp;gt;(product =&amp;gt;
    {
        product.HasKey(p =&amp;gt; p.Id);

        product.Property(p =&amp;gt; p.Price)
            .HasColumnType(&quot;numeric(18,4)&quot;)
            .IsRequired();

        product.Property(p =&amp;gt; p.Version)
            .IsRowVersion()
            .HasColumnName(&quot;xmin&quot;)
            .HasColumnType(&quot;xid&quot;);

        product.UseXminAsConcurrencyToken();
    });
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The combination of &lt;code&gt;UseXminAsConcurrencyToken()&lt;/code&gt; and mapping the &lt;code&gt;Version&lt;/code&gt; property ensures EF Core reads &lt;code&gt;xmin&lt;/code&gt; after every query and includes it in the &lt;code&gt;WHERE&lt;/code&gt; clause of every &lt;code&gt;UPDATE&lt;/code&gt;. You do not need to set or manage &lt;code&gt;Version&lt;/code&gt; yourself; PostgreSQL owns it.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Migrations note:&lt;/strong&gt; &lt;code&gt;xmin&lt;/code&gt; is a system column; it does not appear in your migration&#39;s &lt;code&gt;CreateTable&lt;/code&gt; call. If you generate a migration after adding &lt;code&gt;UseXminAsConcurrencyToken()&lt;/code&gt;, confirm that the migration contains no column additions for &lt;code&gt;xmin&lt;/code&gt; or &lt;code&gt;Version&lt;/code&gt;. If it does, remove them manually before applying.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Catching and handling DbUpdateConcurrencyException&lt;/div&gt;

&lt;p&gt;When a concurrency conflict occurs, EF Core throws &lt;code&gt;DbUpdateConcurrencyException&lt;/code&gt;. The exception carries an &lt;code&gt;Entries&lt;/code&gt; collection: one &lt;code&gt;EntityEntry&lt;/code&gt; per entity involved in the failed save. Use the entry to inspect proposed values, original values, and the current database state, then apply one of three resolution strategies.&lt;/p&gt;

&lt;h3&gt;The resolution helper&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public enum ConcurrencyResolution
{
    ClientWins,
    DatabaseWins,
    Merge
}

public async Task&amp;lt;bool&amp;gt; TrySaveWithRetryAsync(
    ConcurrencyResolution resolution,
    Func&amp;lt;EntityEntry, Task&amp;gt;? mergeStrategy = null,
    CancellationToken ct = default)
{
    const int maxRetries = 3;
    for (int attempt = 1; attempt &amp;lt;= maxRetries; attempt++)
    {
        try
        {
            await _context.SaveChangesAsync(ct);
            return true;
        }
        catch (DbUpdateConcurrencyException ex)
        {
            if (attempt == maxRetries) throw;

            foreach (var entry in ex.Entries)
            {
                var dbValues = await entry.GetDatabaseValuesAsync(ct);

                if (dbValues is null)
                    throw new InvalidOperationException(
                        $&quot;Row for {entry.Metadata.Name} was deleted concurrently.&quot;);

                switch (resolution)
                {
                    case ConcurrencyResolution.ClientWins:
                        // Tell EF the &quot;original&quot; version is now the DB version,
                        // so the next save sees no conflict.
                        entry.OriginalValues.SetValues(dbValues);
                        break;

                    case ConcurrencyResolution.DatabaseWins:
                        // Overwrite proposed values with what is in the database.
                        entry.CurrentValues.SetValues(dbValues);
                        entry.OriginalValues.SetValues(dbValues);
                        break;

                    case ConcurrencyResolution.Merge:
                        if (mergeStrategy is not null)
                            await mergeStrategy(entry);
                        entry.OriginalValues.SetValues(dbValues);
                        break;
                }
            }
        }
    }
    return false;
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;GetDatabaseValuesAsync&lt;/code&gt; issues a fresh &lt;code&gt;SELECT&lt;/code&gt; to fetch the current row state from the database. It returns &lt;code&gt;null&lt;/code&gt; when the row was deleted between your read and your write, which you should treat as a non-retriable error.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Applying the strategies in a real endpoint&lt;/div&gt;

&lt;p&gt;Here is a complete update endpoint that demonstrates all three strategies. In a real application you would choose one strategy per entity type based on business rules, not expose all three to the caller.&lt;/p&gt;

&lt;h3&gt;Client wins: the user&#39;s changes always prevail&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public async Task&amp;lt;IResult&amp;gt; UpdateProductPriceAsync(
    int id, decimal newPrice, uint clientVersion, CancellationToken ct)
{
    var product = await _context.Products.FindAsync([id], ct);
    if (product is null) return Results.NotFound();

    product.Price     = newPrice;
    product.UpdatedAt = DateTimeOffset.UtcNow;

    try
    {
        await _context.SaveChangesAsync(ct);
        return Results.NoContent();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var entry     = ex.Entries.Single();
        var dbValues  = await entry.GetDatabaseValuesAsync(ct)
            ?? throw new InvalidOperationException(&quot;Product was deleted.&quot;);

        // Update the original token so the next attempt succeeds
        entry.OriginalValues.SetValues(dbValues);
        await _context.SaveChangesAsync(ct);
        return Results.NoContent();
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Database wins: preserve what is already committed&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;catch (DbUpdateConcurrencyException ex)
{
    var entry    = ex.Entries.Single();
    var dbValues = await entry.GetDatabaseValuesAsync(ct)
        ?? throw new InvalidOperationException(&quot;Product was deleted.&quot;);

    // Discard the user&#39;s proposed values entirely
    entry.CurrentValues.SetValues(dbValues);
    entry.OriginalValues.SetValues(dbValues);

    // No SaveChanges call needed; nothing to write
    return Results.Conflict(new { message = &quot;Your changes were overridden by a concurrent update.&quot; });
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Custom merge: combine fields from both versions&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;catch (DbUpdateConcurrencyException ex)
{
    var entry    = ex.Entries.Single();
    var dbValues = await entry.GetDatabaseValuesAsync(ct)
        ?? throw new InvalidOperationException(&quot;Product was deleted.&quot;);

    var dbProduct       = (Product)dbValues.ToObject();
    var proposedProduct = (Product)entry.Entity;

    // Business rule: user owns the price; the database owns the stock count
    proposedProduct.StockQuantity = dbProduct.StockQuantity;
    proposedProduct.UpdatedAt     = DateTimeOffset.UtcNow;

    // Refresh the concurrency token so the retry succeeds
    entry.OriginalValues.SetValues(dbValues);
    await _context.SaveChangesAsync(ct);
    return Results.NoContent();
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;Optimistic concurrency with &lt;code&gt;xmin&lt;/code&gt; in PostgreSQL is one of the lowest-friction concurrency control options available: no application-managed columns, no migration schema changes, and no locks. EF Core&#39;s &lt;code&gt;DbUpdateConcurrencyException&lt;/code&gt; gives you everything you need to implement client wins, database wins, or a custom field-level merge in a small, testable helper method.&lt;/p&gt;

&lt;p&gt;Next in this series: Raw SQL, &lt;code&gt;FromSqlRaw&lt;/code&gt;, and Dapper side-by-side in a .NET 8 API, covering when to step outside the LINQ query builder and which tool to reach for when you do.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/4769169026077348634/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/handling-concurrency-conflicts-in-ef.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/4769169026077348634'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/4769169026077348634'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/handling-concurrency-conflicts-in-ef.html' title='Handling Concurrency Conflicts in EF Core with PostgreSQL'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-6450735206250143006</id><published>2026-04-22T14:52:25.455+05:45</published><updated>2026-04-22T14:52:25.455+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="AsNoTracking"/><category scheme="http://www.blogger.com/atom/ns#" term="Backend Development"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Change Tracker"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="LINQ"/><category scheme="http://www.blogger.com/atom/ns#" term="Performance"/><category scheme="http://www.blogger.com/atom/ns#" term="Query Optimization"/><title type='text'>Tracking vs. No-Tracking Queries in EF Core: When Each One Hurts You</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-decision {
  border-left: 4px solid #2563eb;
  background: #f0f6ff;
  padding: 14px 18px; margin: 1em 0;
  border-radius: 0 6px 6px 0;
}
.pbi-decision.b {
  border-color: #16a34a;
  background: #f0fdf4;
}
.pbi-decision h4 {
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
  font-size: 14px; font-weight: 700; margin: 0 0 8px;
  color: #1e3a5f;
}
.pbi-decision.b h4 { color: #14532d; }
.pbi-decision ul { margin: 0; padding-left: 1.4em; }
.pbi-decision li { font-size: 14px; color: var(--ink-mid); margin-bottom: 4px; }
.pbi-table { width: 100%; border-collapse: collapse; margin: 1em 0 1.5em; font-size: 14px; }
.pbi-table th {
  background: var(--ink); color: var(--bg);
  padding: 10px 14px; text-align: left; font-weight: 600;
}
.pbi-table td { padding: 9px 14px; border-bottom: 1px solid var(--rule); color: var(--ink-mid); }
.pbi-table tr:nth-child(even) td { background: var(--code-bg); }
.pbi-table td:first-child { font-weight: 600; color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;If you have ever profiled an EF Core application under load and wondered why memory climbs steadily through a read-heavy endpoint, the change tracker is likely the culprit. Tracking queries are the EF Core default for good reasons, but applying them to every query in every scenario is one of the most common sources of unnecessary overhead in .NET API projects.&lt;/p&gt;
&lt;p&gt;Tracking queries attach every loaded entity to the &lt;code&gt;DbContext&lt;/code&gt; change tracker so EF Core can detect modifications and translate them to SQL on &lt;code&gt;SaveChanges&lt;/code&gt;. No-tracking queries skip that bookkeeping entirely. Both exist because the right choice genuinely depends on what you are doing with the data.&lt;/p&gt;
&lt;p&gt;By the end of this post you will understand what each mode costs, when the cost is worth paying, and how to update entities correctly in a no-tracking world.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; A quick history first&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Tracking queries&lt;/strong&gt; have been the EF default since the original Entity Framework, designed for connected scenarios where you load an entity, modify it in the same unit of work, and call &lt;code&gt;SaveChanges&lt;/code&gt; before the &lt;code&gt;DbContext&lt;/code&gt; is disposed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No-tracking queries&lt;/strong&gt; (via &lt;code&gt;AsNoTracking()&lt;/code&gt;) were added to address a growing class of read-only use cases, such as API list endpoints, reporting queries, and projections, where the caller never modifies the returned objects and paying the tracking cost is pure waste.&lt;/p&gt;
&lt;p&gt;Both operate against the same SQL-generating LINQ pipeline; the difference is entirely in what happens after rows arrive from the database.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; The same task, two ways&lt;/div&gt;

&lt;p&gt;Suppose you are building a GET endpoint that returns a paginated list of products in a given category. Here is what each approach looks like for that exact query.&lt;/p&gt;

&lt;h3&gt;Tracking approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Default behavior: every returned Product is attached to the change tracker
var products = await _context.Products
    .Where(p =&amp;gt; p.CategoryId == categoryId &amp;amp;&amp;amp; p.IsActive)
    .OrderBy(p =&amp;gt; p.Name)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;No-tracking approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// AsNoTracking: entities are materialized but never registered with the change tracker
var products = await _context.Products
    .AsNoTracking()
    .Where(p =&amp;gt; p.CategoryId == categoryId &amp;amp;&amp;amp; p.IsActive)
    .OrderBy(p =&amp;gt; p.Name)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The SQL generated is identical. The difference is invisible in the query itself but measurable in memory and CPU once you scale to hundreds of rows or high request concurrency.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The real tradeoffs&lt;/div&gt;

&lt;h3&gt;1. Memory and change tracker overhead&lt;/h3&gt;
&lt;p&gt;For every tracked entity, EF Core allocates an &lt;code&gt;EntityEntry&lt;/code&gt;, stores a snapshot of the original property values, and adds the entity to an internal identity map keyed by primary key. On a list endpoint returning 200 rows with 15 columns each, that overhead is measurable, not catastrophic, but it is completely avoidable if you have no intention of modifying those objects.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this matter in practice?&lt;/strong&gt; Yes, under high concurrency. Because &lt;code&gt;DbContext&lt;/code&gt; is scoped per request, tracking overhead does not accumulate across requests, but it does accumulate within a single request that loads large result sets. No-tracking cuts the per-entity overhead roughly in half in benchmarks from the EF Core team.&lt;/p&gt;

&lt;h3&gt;2. Identity resolution behavior&lt;/h3&gt;
&lt;p&gt;The tracking change tracker deduplicates: if two navigations both load the same row (same primary key), you get back the same object instance. No-tracking queries by default do not deduplicate, so you can end up with two separate &lt;code&gt;Product&lt;/code&gt; instances representing the same database row after a join.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// No-tracking with identity resolution: faster than tracking, still deduplicates instances
var orders = await _context.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(o =&amp;gt; o.Lines)
    .ThenInclude(l =&amp;gt; l.Product)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;AsNoTrackingWithIdentityResolution()&lt;/code&gt; is the middle ground: no change-tracking bookkeeping, but a single pass of deduplication ensures you get one &lt;code&gt;Product&lt;/code&gt; instance per primary key even if it appears in multiple order lines. Use it any time you load a graph with potentially shared child entities and care about reference equality.&lt;/p&gt;

&lt;h3&gt;3. Updating data after a no-tracking query&lt;/h3&gt;
&lt;p&gt;The most common mistake with no-tracking is trying to modify a returned entity and calling &lt;code&gt;SaveChanges&lt;/code&gt;, expecting EF to detect the change. It will not; the entity is not in the change tracker, so nothing happens.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Wrong: entity is not tracked, SaveChanges does nothing
var product = await _context.Products
    .AsNoTracking()
    .FirstAsync(p =&amp;gt; p.Id == id, ct);

product.Price = 49.99m; // change not detected
await _context.SaveChangesAsync(ct); // no UPDATE issued&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For disconnected update scenarios, use one of two patterns. If you have all the updated values, use &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; to issue a targeted &lt;code&gt;UPDATE&lt;/code&gt; without loading the row at all:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Preferred for simple updates: no load required
await _context.Products
    .Where(p =&amp;gt; p.Id == id)
    .ExecuteUpdateAsync(s =&amp;gt;
        s.SetProperty(p =&amp;gt; p.Price, 49.99m)
         .SetProperty(p =&amp;gt; p.UpdatedAt, DateTimeOffset.UtcNow),
        ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If you need to attach an already-materialized entity and mark specific properties as modified, use the &lt;code&gt;Attach&lt;/code&gt; and &lt;code&gt;Entry&lt;/code&gt; pattern:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Attach pattern: re-registers the entity and marks only the changed columns
var product = new Product { Id = id, Price = 49.99m, UpdatedAt = DateTimeOffset.UtcNow };
_context.Attach(product);
_context.Entry(product).Property(p =&amp;gt; p.Price).IsModified = true;
_context.Entry(product).Property(p =&amp;gt; p.UpdatedAt).IsModified = true;
await _context.SaveChangesAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;4. Projections supersede the tracking question&lt;/h3&gt;
&lt;p&gt;If you project directly to a DTO using &lt;code&gt;Select&lt;/code&gt;, neither tracking mode matters for performance: EF Core does not materialize entities at all, so there is nothing to track. &lt;code&gt;AsNoTracking()&lt;/code&gt; on a projection query has no measurable effect.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// AsNoTracking here is harmless but redundant — no entity is materialized
var dtos = await _context.Products
    .Where(p =&amp;gt; p.CategoryId == categoryId)
    .Select(p =&amp;gt; new ProductDto(p.Id, p.Name, p.Price))
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Project to DTOs whenever the caller needs only a subset of columns. It is faster than loading full entities in either tracking mode.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Which one should you choose?&lt;/div&gt;

&lt;p&gt;The rule is simple: use tracking when you intend to modify the loaded entity within the same &lt;code&gt;DbContext&lt;/code&gt; lifetime; use no-tracking everywhere else.&lt;/p&gt;

&lt;div class=&quot;pbi-decision&quot;&gt;
  &lt;h4&gt;Use tracking queries when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;You load an entity, modify properties, and call &lt;code&gt;SaveChanges&lt;/code&gt; in the same request.&lt;/li&gt;
    &lt;li&gt;You need EF Core to automatically detect which properties changed (without specifying them manually).&lt;/li&gt;
    &lt;li&gt;You are working with complex graphs where EF needs to track state across multiple related entities.&lt;/li&gt;
    &lt;li&gt;You are using the default lazy-loading proxies, which require tracked entities.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision b&quot;&gt;
  &lt;h4&gt;Use no-tracking queries when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;You are building read-only endpoints: GET lists, detail views, reports, feeds.&lt;/li&gt;
    &lt;li&gt;You are loading entities to pass to a view or serialize to JSON without modification.&lt;/li&gt;
    &lt;li&gt;You are running background jobs that read large batches of data for processing or export.&lt;/li&gt;
    &lt;li&gt;You are loading navigation-heavy graphs and using &lt;code&gt;AsNoTrackingWithIdentityResolution&lt;/code&gt; to retain object deduplication.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;p&gt;A practical team convention: set &lt;code&gt;AsNoTracking&lt;/code&gt; as the default on the &lt;code&gt;DbContext&lt;/code&gt; for read services or query handlers, and opt in to tracking only in write handlers. This way the safe default is the fast one.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Set no-tracking as the context default for a read-optimized DbContext
public class ReadDbContext : AppDbContext
{
    public ReadDbContext(
        DbContextOptions&amp;lt;AppDbContext&amp;gt; options,
        ITenantProvider tenantProvider)
        : base(options, tenantProvider)
    {
        ChangeTracker.QueryTrackingBehavior =
            QueryTrackingBehavior.NoTracking;
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Quick reference summary&lt;/div&gt;

&lt;table class=&quot;pbi-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concern&lt;/th&gt;
      &lt;th&gt;Tracking&lt;/th&gt;
      &lt;th&gt;No-Tracking&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;Change detection on SaveChanges&lt;/td&gt;&lt;td&gt;Automatic&lt;/td&gt;&lt;td&gt;Not supported; use ExecuteUpdateAsync or Attach&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Memory per entity&lt;/td&gt;&lt;td&gt;Higher (snapshot + EntityEntry)&lt;/td&gt;&lt;td&gt;Lower (entity instance only)&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Identity resolution (dedup)&lt;/td&gt;&lt;td&gt;Automatic via identity map&lt;/td&gt;&lt;td&gt;Disabled by default; use AsNoTrackingWithIdentityResolution&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Generated SQL&lt;/td&gt;&lt;td&gt;Identical&lt;/td&gt;&lt;td&gt;Identical&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Safe for read-only endpoints&lt;/td&gt;&lt;td&gt;Yes, but wasteful&lt;/td&gt;&lt;td&gt;Yes, and optimal&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Required for lazy loading&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Projection queries (Select to DTO)&lt;/td&gt;&lt;td&gt;No benefit either way&lt;/td&gt;&lt;td&gt;No benefit either way&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Final take&lt;/div&gt;

&lt;p&gt;Tracking is the right default when you genuinely need it; no-tracking is the right default when you do not. The problem is that EF Core ships with tracking on, so you have to actively choose no-tracking for read-heavy endpoints, and most teams never do until a profiler shows them the cost.&lt;/p&gt;
&lt;p&gt;Teams doing classic CRUD with load-modify-save in a single request should leave tracking on and not overthink it. Teams building read APIs, CQRS query sides, or any endpoint that never modifies returned objects should make &lt;code&gt;AsNoTracking&lt;/code&gt; the default and opt in to tracking explicitly.&lt;/p&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;What&#39;s next?&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Profile your current endpoints:&lt;/strong&gt; Run a quick benchmark with and without &lt;code&gt;AsNoTracking()&lt;/code&gt; on your heaviest list queries and measure the difference in allocations using BenchmarkDotNet or dotnet-counters.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Try ExecuteUpdateAsync:&lt;/strong&gt; Replace any pattern where you load a single entity just to change one field with a direct &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; call and observe the reduction in round trips.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Read about concurrency:&lt;/strong&gt; The next post in this series covers how tracking interacts with concurrency tokens in PostgreSQL and how to handle &lt;code&gt;DbUpdateConcurrencyException&lt;/code&gt; gracefully.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or a scenario I did not cover? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/6450735206250143006/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/tracking-vs-no-tracking-queries-in-ef.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/6450735206250143006'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/6450735206250143006'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/tracking-vs-no-tracking-queries-in-ef.html' title='Tracking vs. No-Tracking Queries in EF Core: When Each One Hurts You'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-7765543617589089656</id><published>2026-04-22T14:41:49.844+05:45</published><updated>2026-04-22T14:41:49.844+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Database Design"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Fluent API"/><category scheme="http://www.blogger.com/atom/ns#" term="Many-to-Many"/><category scheme="http://www.blogger.com/atom/ns#" term="One-to-Many"/><category scheme="http://www.blogger.com/atom/ns#" term="Owned Entities"/><category scheme="http://www.blogger.com/atom/ns#" term="Relationships"/><title type='text'>EF Core Relationships Explained: One-to-Many, Many-to-Many, and Owned Entities</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;EF Core&#39;s relationship API has enough surface area that it is easy to configure something that compiles cleanly and then behaves wrong at runtime: a cascade delete you did not intend, a join table with the wrong primary key, or an owned entity serialized into a separate table when you expected column-inlining. Getting the configuration right the first time saves painful migrations later.&lt;/p&gt;
&lt;p&gt;This post covers the three relationship shapes you will use in almost every application: one-to-many, many-to-many (with and without a payload), and owned entities. For each shape you will see the entity classes, the fluent configuration, and the key behavioral detail that trips developers up.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK&lt;/li&gt;
  &lt;li&gt;EF Core 8 (&lt;code&gt;Microsoft.EntityFrameworkCore&lt;/code&gt; plus a provider)&lt;/li&gt;
  &lt;li&gt;A working &lt;code&gt;DbContext&lt;/code&gt; with migrations enabled&lt;/li&gt;
  &lt;li&gt;Familiarity with &lt;code&gt;OnModelCreating&lt;/code&gt; and the &lt;code&gt;ModelBuilder&lt;/code&gt; API&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; EF Core can infer many relationships by convention, but this post uses explicit fluent configuration throughout. Convention-based mapping is fragile under refactoring; fluent configuration is not.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; One-to-many relationships&lt;/div&gt;

&lt;p&gt;One-to-many is the most common relationship shape: a &lt;code&gt;Blog&lt;/code&gt; has many &lt;code&gt;Post&lt;/code&gt; records; each &lt;code&gt;Post&lt;/code&gt; belongs to exactly one &lt;code&gt;Blog&lt;/code&gt;. The principal entity (&lt;code&gt;Blog&lt;/code&gt;) holds the collection; the dependent entity (&lt;code&gt;Post&lt;/code&gt;) holds the foreign key.&lt;/p&gt;

&lt;h3&gt;Entity classes&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Url { get; set; } = string.Empty;

    public ICollection&amp;lt;Post&amp;gt; Posts { get; set; } = [];
}

public class Post
{
    public int Id { get; set; }
    public int BlogId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public DateTimeOffset PublishedAt { get; set; }

    public Blog Blog { get; set; } = null!;
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Fluent configuration&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&amp;lt;Post&amp;gt;()
        .HasOne(p =&amp;gt; p.Blog)
        .WithMany(b =&amp;gt; b.Posts)
        .HasForeignKey(p =&amp;gt; p.BlogId)
        .IsRequired()
        .OnDelete(DeleteBehavior.Cascade);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Always specify &lt;code&gt;OnDelete&lt;/code&gt; explicitly. EF Core defaults to &lt;code&gt;Cascade&lt;/code&gt; for required relationships and &lt;code&gt;ClientSetNull&lt;/code&gt; for optional ones, but the correct behavior depends on your domain and leaving it implicit is a migration-time surprise waiting to happen.&lt;/p&gt;

&lt;h3&gt;Querying with Include&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Load a blog with all its posts
var blog = await _context.Blogs
    .Include(b =&amp;gt; b.Posts)
    .FirstOrDefaultAsync(b =&amp;gt; b.Id == blogId, ct);

// Load posts with their parent blog (useful for display-only reads)
var posts = await _context.Posts
    .AsNoTracking()
    .Include(p =&amp;gt; p.Blog)
    .Where(p =&amp;gt; p.PublishedAt &amp;gt;= cutoff)
    .OrderByDescending(p =&amp;gt; p.PublishedAt)
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Never load a collection navigation just to count it. Use &lt;code&gt;.CountAsync(p =&amp;gt; p.BlogId == blogId)&lt;/code&gt; directly; EF Core translates it to a &lt;code&gt;COUNT(*)&lt;/code&gt; without loading any rows.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Many-to-many relationships&lt;/div&gt;

&lt;p&gt;Many-to-many maps each record in one table to zero or more records in another table, and vice versa. EF Core 5 and later can create the join table implicitly, but as soon as the join needs extra data, such as a timestamp or an ordering column, you must define an explicit join entity.&lt;/p&gt;

&lt;h3&gt;Implicit join table (no payload)&lt;/h3&gt;

&lt;p&gt;When the join carries no extra information, the shortest configuration is:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public ICollection&amp;lt;Tag&amp;gt; Tags { get; set; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ICollection&amp;lt;Post&amp;gt; Posts { get; set; } = [];
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;modelBuilder.Entity&amp;lt;Post&amp;gt;()
    .HasMany(p =&amp;gt; p.Tags)
    .WithMany(t =&amp;gt; t.Posts)
    .UsingEntity(j =&amp;gt; j.ToTable(&quot;PostTags&quot;));&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;EF Core creates the &lt;code&gt;PostTags&lt;/code&gt; join table with a composite primary key of &lt;code&gt;(PostsId, TagsId)&lt;/code&gt;. &lt;code&gt;UsingEntity&lt;/code&gt; lets you rename the table and columns without introducing a full entity class.&lt;/p&gt;

&lt;h3&gt;Explicit join entity (with payload)&lt;/h3&gt;

&lt;p&gt;When you need to store when a tag was applied, or who applied it, you need an entity class for the join row:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public DateTimeOffset TaggedAt { get; set; }
    public string TaggedBy { get; set; } = string.Empty;

    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;modelBuilder.Entity&amp;lt;PostTag&amp;gt;()
    .HasKey(pt =&amp;gt; new { pt.PostId, pt.TagId });

modelBuilder.Entity&amp;lt;Post&amp;gt;()
    .HasMany(p =&amp;gt; p.Tags)
    .WithMany(t =&amp;gt; t.Posts)
    .UsingEntity&amp;lt;PostTag&amp;gt;(
        right =&amp;gt; right
            .HasOne(pt =&amp;gt; pt.Tag)
            .WithMany()
            .HasForeignKey(pt =&amp;gt; pt.TagId),
        left =&amp;gt; left
            .HasOne(pt =&amp;gt; pt.Post)
            .WithMany()
            .HasForeignKey(pt =&amp;gt; pt.PostId));

// Update the navigation collections to use PostTag
public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public ICollection&amp;lt;PostTag&amp;gt; PostTags { get; set; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ICollection&amp;lt;PostTag&amp;gt; PostTags { get; set; } = [];
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once you add payload, replace the &lt;code&gt;ICollection&amp;lt;Tag&amp;gt;&lt;/code&gt; navigation with &lt;code&gt;ICollection&amp;lt;PostTag&amp;gt;&lt;/code&gt;. You gain access to the extra columns; you lose the convenience of direct cross-navigation. That is the intentional trade-off.&lt;/p&gt;

&lt;h3&gt;Adding a tag to a post&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var post = await _context.Posts.FindAsync(postId);
var tag  = await _context.Tags.FindAsync(tagId);

_context.Set&amp;lt;PostTag&amp;gt;().Add(new PostTag
{
    PostId   = post!.Id,
    TagId    = tag!.Id,
    TaggedAt = DateTimeOffset.UtcNow,
    TaggedBy = currentUser
});

await _context.SaveChangesAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Owned entities&lt;/div&gt;

&lt;p&gt;An owned entity is a class that has no identity of its own outside its owner. It maps to columns in the owner&#39;s table by default and is deleted automatically when the owner is deleted. Use owned entities for value objects: addresses, money, coordinates, contact details.&lt;/p&gt;

&lt;h3&gt;Defining owner and owned type&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = string.Empty;
    public DateTimeOffset PlacedAt { get; set; }
    public Address ShippingAddress { get; set; } = null!;
    public Address BillingAddress { get; set; } = null!;
}

public class Address
{
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
    public string Country { get; set; } = string.Empty;
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Fluent configuration with column name overrides&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;modelBuilder.Entity&amp;lt;Order&amp;gt;(order =&amp;gt;
{
    order.OwnsOne(o =&amp;gt; o.ShippingAddress, addr =&amp;gt;
    {
        addr.Property(a =&amp;gt; a.Street).HasColumnName(&quot;ShipStreet&quot;).IsRequired();
        addr.Property(a =&amp;gt; a.City).HasColumnName(&quot;ShipCity&quot;).IsRequired();
        addr.Property(a =&amp;gt; a.PostalCode).HasColumnName(&quot;ShipPostalCode&quot;).IsRequired();
        addr.Property(a =&amp;gt; a.Country).HasColumnName(&quot;ShipCountry&quot;).IsRequired();
    });

    order.OwnsOne(o =&amp;gt; o.BillingAddress, addr =&amp;gt;
    {
        addr.Property(a =&amp;gt; a.Street).HasColumnName(&quot;BillStreet&quot;).IsRequired();
        addr.Property(a =&amp;gt; a.City).HasColumnName(&quot;BillCity&quot;).IsRequired();
        addr.Property(a =&amp;gt; a.PostalCode).HasColumnName(&quot;BillPostalCode&quot;).IsRequired();
        addr.Property(a =&amp;gt; a.Country).HasColumnName(&quot;BillCountry&quot;).IsRequired();
    });
});&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Without explicit column name overrides, EF Core generates names in the form &lt;code&gt;ShippingAddress_Street&lt;/code&gt;, which is fine but can be verbose in a schema you share with other teams.&lt;/p&gt;

&lt;h3&gt;OwnsMany for collections of value objects&lt;/h3&gt;

&lt;p&gt;When an order can have multiple line items that are fully owned (not referenced from anywhere else), use &lt;code&gt;OwnsMany&lt;/code&gt;. EF Core maps the collection to a separate table with a foreign key back to the owner:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = string.Empty;
    public IList&amp;lt;OrderNote&amp;gt; Notes { get; set; } = [];
}

public class OrderNote
{
    public string Text { get; set; } = string.Empty;
    public DateTimeOffset AddedAt { get; set; }
}

// Configuration
modelBuilder.Entity&amp;lt;Order&amp;gt;()
    .OwnsMany(o =&amp;gt; o.Notes, note =&amp;gt;
    {
        note.ToTable(&quot;OrderNotes&quot;);
        note.WithOwner().HasForeignKey(&quot;OrderId&quot;);
        note.Property(n =&amp;gt; n.Text).IsRequired();
    });&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Owned entities cannot be queried directly via a &lt;code&gt;DbSet&lt;/code&gt;. You always access them through the owner. If you need to query the child type independently, it should be a regular entity with its own primary key, not an owned type.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Verifying the schema with a migration preview&lt;/div&gt;

&lt;p&gt;After writing your configuration, run a dry-run migration to verify EF Core generated the schema you intended before touching any database:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Add a migration to inspect the generated SQL
// dotnet ef migrations add InitialRelationships --project YourProject

// Preview the SQL without applying it
// dotnet ef migrations script --idempotent&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Look for three things: the foreign key columns with the correct names, the composite primary key on any join table, and the owned-entity columns inlined into the owner table (or separated into the expected table for &lt;code&gt;OwnsMany&lt;/code&gt;). If anything looks wrong, it is far cheaper to fix the configuration and drop the migration now than after it has been applied to a shared database.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;One-to-many is your default; configure the foreign key and delete behavior explicitly. Many-to-many with an implicit join table is convenient for pure link tables, but move to an explicit entity the moment the join needs any extra columns. Owned entities are the right tool for value objects: they keep the class model clean while mapping to flat table columns.&lt;/p&gt;

&lt;p&gt;Next in this series: Tracking vs. No-Tracking Queries in EF Core, covering where the change tracker hurts performance, when you genuinely need it, and how to update entities safely in a no-tracking world.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/7765543617589089656/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/ef-core-relationships-explained-one-to.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/7765543617589089656'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/7765543617589089656'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/ef-core-relationships-explained-one-to.html' title='EF Core Relationships Explained: One-to-Many, Many-to-Many, and Owned Entities'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-8136397394933449625</id><published>2026-04-22T14:13:00.463+05:45</published><updated>2026-04-22T14:13:00.463+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="DbContext"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Global Query Filters"/><category scheme="http://www.blogger.com/atom/ns#" term="LINQ"/><category scheme="http://www.blogger.com/atom/ns#" term="Multi-Tenancy"/><category scheme="http://www.blogger.com/atom/ns#" term="Soft Delete"/><title type='text'>Global Query Filters in EF Core: Soft Delete, Multi-Tenancy, and More</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;Almost every production application applies the same predicates to every query: exclude soft-deleted rows, scope results to the current tenant, hide unpublished records. Writing those predicates in every LINQ expression is repetitive and dangerous; miss one and you have a data leak or a cross-tenant exposure. EF Core&#39;s Global Query Filters let you register those predicates once on the entity type and have EF Core apply them automatically to every query that touches that type.&lt;/p&gt;
&lt;p&gt;By the end of this post you will have a working &lt;code&gt;DbContext&lt;/code&gt; that enforces both soft-delete and tenant-scoping filters automatically, supports injecting the tenant identity from the current HTTP request, and exposes clean per-query escape hatches via &lt;code&gt;IgnoreQueryFilters()&lt;/code&gt;.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;.NET 8 SDK or later&lt;/li&gt;
  &lt;li&gt;EF Core 8 (&lt;code&gt;Microsoft.EntityFrameworkCore&lt;/code&gt; plus a provider: &lt;code&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/code&gt; or &lt;code&gt;Microsoft.EntityFrameworkCore.SqlServer&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;A working &lt;code&gt;DbContext&lt;/code&gt; with at least one entity mapped to a table&lt;/li&gt;
  &lt;li&gt;Familiarity with fluent configuration inside &lt;code&gt;OnModelCreating&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; Global Query Filters were introduced in EF Core 2.0 and the API has not changed shape since. Everything in this post applies equally to EF Core 8 and EF Core 9.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; How Global Query Filters work&lt;/div&gt;

&lt;p&gt;A Global Query Filter is a LINQ predicate registered against an entity type in &lt;code&gt;OnModelCreating&lt;/code&gt;. EF Core appends it to every LINQ query that targets that entity, including queries reached through navigation properties. The filter is composed with any additional predicates the caller adds, so nothing about the behavior changes from the caller&#39;s perspective.&lt;/p&gt;

&lt;h3&gt;Registering a filter&lt;/h3&gt;

&lt;p&gt;The entire API surface is a single call on the entity builder:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&amp;lt;Article&amp;gt;()
        .HasQueryFilter(a =&amp;gt; !a.IsDeleted);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That one line means every &lt;code&gt;dbContext.Articles&lt;/code&gt; query now contains &lt;code&gt;WHERE IsDeleted = 0&lt;/code&gt; in the generated SQL without any caller needing to know about it. Only one filter is allowed per entity type; if you need to combine conditions, combine them in a single lambda.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Important:&lt;/strong&gt; A filter is registered on the entity type, not the table. If two entity types share a table via table splitting, register the filter on each entity type separately.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Soft delete: mark rows invisible without removing them&lt;/div&gt;

&lt;p&gt;Soft delete stores a flag on the row instead of issuing a &lt;code&gt;DELETE&lt;/code&gt; statement. Combined with a Global Query Filter, flagged rows become completely invisible to normal application queries. The data is preserved for auditing or restoration, and no call site needs to remember to filter it out.&lt;/p&gt;

&lt;h3&gt;The shared interface&lt;/h3&gt;

&lt;p&gt;Define an interface so any entity can opt in without repeating the same two properties:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface ISoftDelete
{
    bool IsDeleted { get; set; }
    DateTimeOffset? DeletedAt { get; set; }
}

public class Article : ISoftDelete
{
    public int Id { get; set; }
    public Guid TenantId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Registering the filter for all soft-delete entities at once&lt;/h3&gt;

&lt;p&gt;Rather than calling &lt;code&gt;HasQueryFilter&lt;/code&gt; on every entity individually, iterate the model and use &lt;code&gt;System.Linq.Expressions&lt;/code&gt; to build the predicate dynamically. This approach scales to any number of entities with no per-entity boilerplate:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using System.Linq.Expressions;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        if (!typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType))
            continue;

        var parameter  = Expression.Parameter(entityType.ClrType, &quot;e&quot;);
        var property   = Expression.Property(parameter, nameof(ISoftDelete.IsDeleted));
        var falseValue = Expression.Constant(false);
        var body       = Expression.Equal(property, falseValue);
        var lambda     = Expression.Lambda(body, parameter);

        modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Performing a soft delete&lt;/h3&gt;

&lt;p&gt;A generic helper method keeps the pattern consistent across repositories:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public async Task SoftDeleteAsync&amp;lt;T&amp;gt;(
    T entity,
    CancellationToken ct = default)
    where T : class, ISoftDelete
{
    entity.IsDeleted = true;
    entity.DeletedAt = DateTimeOffset.UtcNow;
    await _context.SaveChangesAsync(ct);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The entity stays in the database. Every subsequent query on that type simply never returns it because the filter hides it.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Multi-tenancy: scope every query to the current tenant automatically&lt;/div&gt;

&lt;p&gt;A tenant filter follows the same pattern as soft delete, but it needs to read the current tenant identifier at query time rather than at model-building time. Because the filter lambda can close over a service instance, EF Core re-reads the tenant value on every query, which means the filter stays correct across the lifetime of a scoped &lt;code&gt;DbContext&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;The tenant provider&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface ITenantProvider
{
    Guid TenantId { get; }
}

public class HttpContextTenantProvider : ITenantProvider
{
    private readonly IHttpContextAccessor _accessor;

    public HttpContextTenantProvider(IHttpContextAccessor accessor)
        =&amp;gt; _accessor = accessor;

    public Guid TenantId
    {
        get
        {
            var raw = _accessor.HttpContext?
                .User.FindFirstValue(&quot;tenant_id&quot;);

            return Guid.TryParse(raw, out var id) ? id : Guid.Empty;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Wiring the provider into DbContext&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class AppDbContext : DbContext
{
    private readonly ITenantProvider _tenantProvider;

    public AppDbContext(
        DbContextOptions&amp;lt;AppDbContext&amp;gt; options,
        ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantProvider = tenantProvider;
    }

    public DbSet&amp;lt;Article&amp;gt; Articles =&amp;gt; Set&amp;lt;Article&amp;gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&amp;lt;Article&amp;gt;()
            .HasQueryFilter(a =&amp;gt;
                a.TenantId == _tenantProvider.TenantId
                &amp;amp;&amp;amp; !a.IsDeleted);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The lambda closes over &lt;code&gt;_tenantProvider&lt;/code&gt;, the provider object itself, not a captured &lt;code&gt;Guid&lt;/code&gt; value. EF Core evaluates the expression tree and emits &lt;code&gt;_tenantProvider.TenantId&lt;/code&gt; as a SQL parameter, so the query plan is cache-friendly and the correct tenant value is read on every call.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Warning:&lt;/strong&gt; Never close over a plain &lt;code&gt;Guid&lt;/code&gt; variable captured at DbContext construction time. EF Core would treat it as a constant baked into the query, and all requests would silently query the same tenant regardless of who is calling.
&lt;/div&gt;

&lt;h3&gt;Registering the services&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped&amp;lt;ITenantProvider, HttpContextTenantProvider&amp;gt;();
builder.Services.AddDbContext&amp;lt;AppDbContext&amp;gt;(opt =&amp;gt;
    opt.UseNpgsql(builder.Configuration.GetConnectionString(&quot;Default&quot;)));&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Bypassing filters with IgnoreQueryFilters&lt;/div&gt;

&lt;p&gt;Some operations legitimately need unfiltered access: an admin panel that lists all tenants, a background cleanup job that hard-deletes soft-deleted rows older than 90 days, or a restore operation that retrieves a specific deleted record. Use &lt;code&gt;IgnoreQueryFilters()&lt;/code&gt; to opt out on a per-query basis.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Retrieve a soft-deleted article by ID for an admin restore
var deleted = await _context.Articles
    .IgnoreQueryFilters()
    .FirstOrDefaultAsync(a =&amp;gt; a.Id == id &amp;amp;&amp;amp; a.IsDeleted, ct);

// Super-admin: list all articles across all tenants
var all = await _context.Articles
    .IgnoreQueryFilters()
    .OrderByDescending(a =&amp;gt; a.Id)
    .ToListAsync(ct);

// Cleanup job: hard-delete rows soft-deleted more than 90 days ago
var cutoff = DateTimeOffset.UtcNow.AddDays(-90);
await _context.Articles
    .IgnoreQueryFilters()
    .Where(a =&amp;gt; a.IsDeleted &amp;amp;&amp;amp; a.DeletedAt &amp;lt; cutoff)
    .ExecuteDeleteAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;IgnoreQueryFilters()&lt;/code&gt; removes all registered filters for that query. If you need to bypass only the tenant filter but keep the soft-delete filter, the cleanest option is to replicate the soft-delete predicate inline in that specific query. Selective filter bypassing per-filter is not supported natively but is on the EF Core backlog.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;Global Query Filters let you encode cross-cutting data rules, soft delete, tenant scoping, publication gating, directly into the model. Every query that touches the entity inherits the filter automatically, and bypassing it is a deliberate, visible opt-out at the call site.&lt;/p&gt;

&lt;p&gt;Next in this series: EF Core Relationships Explained, covering one-to-many, many-to-many with and without payload, and owned entities as value objects.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/8136397394933449625/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/global-query-filters-in-ef-core-soft.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8136397394933449625'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/8136397394933449625'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/global-query-filters-in-ef-core-soft.html' title='Global Query Filters in EF Core: Soft Delete, Multi-Tenancy, and More'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-2199555095869054268</id><published>2026-04-19T19:32:00.001+05:45</published><updated>2026-04-19T19:32:12.531+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Keyset Pagination"/><category scheme="http://www.blogger.com/atom/ns#" term="Offset Pagination"/><category scheme="http://www.blogger.com/atom/ns#" term="Pagination"/><category scheme="http://www.blogger.com/atom/ns#" term="Performance"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Pagination in .NET 8 Web API with EF Core and PostgreSQL: Offset vs. Keyset</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-decision {
  border-left: 4px solid #2563eb;
  background: #f0f6ff;
  padding: 14px 18px; margin: 1em 0;
  border-radius: 0 6px 6px 0;
}
.pbi-decision.b {
  border-color: #16a34a;
  background: #f0fdf4;
}
.pbi-decision h4 {
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
  font-size: 14px; font-weight: 700; margin: 0 0 8px;
  color: #1e3a5f;
}
.pbi-decision.b h4 { color: #14532d; }
.pbi-decision ul { margin: 0; padding-left: 1.4em; }
.pbi-decision li { font-size: 14px; color: var(--ink-mid); margin-bottom: 4px; }
.pbi-table { width: 100%; border-collapse: collapse; margin: 1em 0 1.5em; font-size: 14px; }
.pbi-table th {
  background: var(--ink); color: var(--bg);
  padding: 10px 14px; text-align: left; font-weight: 600;
}
.pbi-table td { padding: 9px 14px; border-bottom: 1px solid var(--rule); color: var(--ink-mid); }
.pbi-table tr:nth-child(even) td { background: var(--code-bg); }
.pbi-table td:first-child { font-weight: 600; color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;If you have added pagination to a .NET 8 API recently, you almost certainly reached for &lt;code&gt;Skip&lt;/code&gt; and &lt;code&gt;Take&lt;/code&gt;. They are familiar, they work, and for a table with a few thousand rows no one notices the cost. Add a few hundred thousand rows, point a load test at page 500, and watch what happens to your query times and CPU on the PostgreSQL host.&lt;/p&gt;
&lt;p&gt;Offset pagination and keyset pagination both deliver pages of data, but they do it in fundamentally different ways with very different performance curves and API surface areas. This post compares them with real EF Core code against PostgreSQL so you can choose deliberately rather than by default.&lt;/p&gt;
&lt;p&gt;You will walk away with working implementations of both approaches, an understanding of where each one breaks down, and a decision framework you can use immediately.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; A quick history first&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Offset pagination&lt;/strong&gt; is as old as SQL itself. The &lt;code&gt;OFFSET n ROWS FETCH NEXT m ROWS ONLY&lt;/code&gt; clause (or PostgreSQL&#39;s &lt;code&gt;LIMIT m OFFSET n&lt;/code&gt;) instructs the database to scan through the first &lt;code&gt;n&lt;/code&gt; rows and discard them before returning the next &lt;code&gt;m&lt;/code&gt;. It is the natural fit for numbered pages and random access (&quot;jump to page 47&quot;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keyset pagination&lt;/strong&gt;, sometimes called cursor-based or seek-method pagination, avoids &lt;code&gt;OFFSET&lt;/code&gt; entirely. Instead of telling the database how many rows to skip, the query filters to rows whose sort key is greater than (or less than) the last value seen on the previous page. The database never scans rows you do not need.&lt;/p&gt;
&lt;p&gt;Both patterns sit on top of the same PostgreSQL B-tree index structures. The difference is how much of that index each query is forced to walk.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; The same task, two ways&lt;/div&gt;

&lt;p&gt;Both snippets retrieve a page of products ordered by &lt;code&gt;Id&lt;/code&gt;. The offset version accepts a page number; the keyset version accepts the last &lt;code&gt;Id&lt;/code&gt; seen on the previous page.&lt;/p&gt;

&lt;h3&gt;Offset pagination approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Request model
public record OffsetPageRequest(int Page = 1, int PageSize = 20);

// Response model
public record PagedResult&amp;lt;T&amp;gt;(List&amp;lt;T&amp;gt; Items, int TotalCount, int Page, int PageSize);

// EF Core query
public async Task&amp;lt;PagedResult&amp;lt;ProductSummary&amp;gt;&amp;gt; GetPageOffsetAsync(
    OffsetPageRequest request, CancellationToken ct)
{
    var query = _db.Products.OrderBy(p =&amp;gt; p.Id);

    var totalCount = await query.CountAsync(ct);

    var items = await query
        .Skip((request.Page - 1) * request.PageSize)
        .Take(request.PageSize)
        .Select(p =&amp;gt; new ProductSummary(p.Id, p.Name, p.Price))
        .ToListAsync(ct);

    return new PagedResult&amp;lt;ProductSummary&amp;gt;(items, totalCount, request.Page, request.PageSize);
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Keyset pagination approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Request model — cursor replaces page number
public record KeysetPageRequest(int? AfterCursor, int PageSize = 20);

// Response model — includes next cursor
public record CursorPageResult&amp;lt;T&amp;gt;(List&amp;lt;T&amp;gt; Items, int? NextCursor, bool HasMore);

// EF Core query
public async Task&amp;lt;CursorPageResult&amp;lt;ProductSummary&amp;gt;&amp;gt; GetPageKeysetAsync(
    KeysetPageRequest request, CancellationToken ct)
{
    // Fetch one extra row to determine whether a next page exists
    var items = await _db.Products
        .Where(p =&amp;gt; request.AfterCursor == null || p.Id &amp;gt; request.AfterCursor)
        .OrderBy(p =&amp;gt; p.Id)
        .Take(request.PageSize + 1)
        .Select(p =&amp;gt; new ProductSummary(p.Id, p.Name, p.Price))
        .ToListAsync(ct);

    var hasMore = items.Count &amp;gt; request.PageSize;
    if (hasMore) items.RemoveAt(items.Count - 1);

    var nextCursor = hasMore ? items[^1].Id : (int?)null;
    return new CursorPageResult&amp;lt;ProductSummary&amp;gt;(items, nextCursor, hasMore);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Notice that the keyset version fetches &lt;code&gt;PageSize + 1&lt;/code&gt; rows to detect the presence of a next page without a separate &lt;code&gt;COUNT&lt;/code&gt; query. It also never needs to know how many total rows exist. These two differences are where most of the performance gap comes from.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The real trade-offs&lt;/div&gt;

&lt;h3&gt;1. Performance at scale&lt;/h3&gt;
&lt;p&gt;PostgreSQL must scan and discard the first &lt;code&gt;n&lt;/code&gt; rows for every &lt;code&gt;OFFSET n&lt;/code&gt; query. On a table with 500,000 rows and a page size of 20, page 500 requires PostgreSQL to read and discard 9,980 rows before returning the 20 you need. The work grows linearly with page depth. For an indexed sort key, the overhead is index scans rather than heap scans, but the work still accumulates.&lt;/p&gt;
&lt;p&gt;Keyset pagination changes the access pattern entirely. The query is always &lt;code&gt;WHERE id &amp;gt; $cursor ORDER BY id LIMIT 20&lt;/code&gt;. PostgreSQL uses an index range scan starting at &lt;code&gt;$cursor&lt;/code&gt; and reads exactly 20 rows. Page depth becomes irrelevant because the cursor always puts the database at the right starting point in the index.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this matter to you?&lt;/strong&gt; On tables under roughly 50,000 rows with well-cached indexes, the difference is rarely measurable in practice. On tables over 500,000 rows with deep page access, the difference can be one to two orders of magnitude. Measure against your actual data distribution before optimizing.&lt;/p&gt;

&lt;h3&gt;2. Consistency across pages&lt;/h3&gt;
&lt;p&gt;Offset pagination has a classic consistency problem: if a row is inserted or deleted between page 1 and page 2, page 2 will skip or duplicate a row. PostgreSQL offers no snapshot isolation at the query level for paginated reads without explicit transaction management.&lt;/p&gt;
&lt;p&gt;Keyset pagination does not suffer from this problem in the typical case. Because it filters on a sort key value, insertions before the cursor position do not affect the next page, and deletions before the cursor position are simply absent. Deletions at or after the cursor can still cause a row to be skipped, but this is a much narrower window.&lt;/p&gt;

&lt;h3&gt;3. API surface complexity and client expectations&lt;/h3&gt;
&lt;p&gt;Offset pagination maps cleanly to what most API consumers expect: a page number, a page size, and a total count. Admin UIs, data export tools, and report generators need random access (&quot;export page 47&quot;) that keyset pagination cannot satisfy without traversing all prior pages.&lt;/p&gt;
&lt;p&gt;Keyset pagination trades random access for performance and consistency. Clients must store the cursor and send it back. &quot;Jump to page 47&quot; is not possible. This is an acceptable trade-off for feeds, infinite-scroll UIs, and high-volume API consumers, but it changes the client contract in a way that requires explicit buy-in.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Which one should you choose?&lt;/div&gt;

&lt;p&gt;Match the pagination strategy to the access pattern your clients actually need, not to what is easiest to implement.&lt;/p&gt;

&lt;div class=&quot;pbi-decision&quot;&gt;
  &lt;h4&gt;Choose offset pagination when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;Clients need random page access or user-controlled page numbers (admin dashboards, report generators)&lt;/li&gt;
    &lt;li&gt;The table is small or page depth rarely exceeds 20 to 30 pages in practice&lt;/li&gt;
    &lt;li&gt;Clients expect a total row count in the response for rendering pagination controls&lt;/li&gt;
    &lt;li&gt;The data is largely static and consistency skips between pages are not a concern&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision b&quot;&gt;
  &lt;h4&gt;Choose keyset pagination when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;The table has more than 100,000 rows and clients may page deep into the result set&lt;/li&gt;
    &lt;li&gt;The use case is a feed, infinite scroll, or a sequential data sync&lt;/li&gt;
    &lt;li&gt;You need consistent pages despite concurrent inserts and deletes&lt;/li&gt;
    &lt;li&gt;You want to eliminate the cost of a separate &lt;code&gt;COUNT(*)&lt;/code&gt; query on every page request&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;p&gt;Both strategies can coexist in the same API. A product catalog endpoint used by an admin UI is a natural fit for offset pagination. An order history feed consumed by a mobile app is a natural fit for keyset. The controller implementation for each is independent.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Composing both strategies on separate endpoints in the same controller
[HttpGet(&quot;admin&quot;)]
public Task&amp;lt;PagedResult&amp;lt;ProductSummary&amp;gt;&amp;gt; GetAdminPageAsync(
    [FromQuery] OffsetPageRequest req, CancellationToken ct) =&amp;gt;
    _paginationService.GetPageOffsetAsync(req, ct);

[HttpGet(&quot;feed&quot;)]
public Task&amp;lt;CursorPageResult&amp;lt;ProductSummary&amp;gt;&amp;gt; GetFeedAsync(
    [FromQuery] KeysetPageRequest req, CancellationToken ct) =&amp;gt;
    _paginationService.GetPageKeysetAsync(req, ct);&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Quick reference summary&lt;/div&gt;

&lt;table class=&quot;pbi-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concern&lt;/th&gt;
      &lt;th&gt;Offset pagination&lt;/th&gt;
      &lt;th&gt;Keyset pagination&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;PostgreSQL query cost at page 1&lt;/td&gt;&lt;td&gt;Low&lt;/td&gt;&lt;td&gt;Low&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;PostgreSQL query cost at page 500&lt;/td&gt;&lt;td&gt;High (scans and discards 9,980 rows)&lt;/td&gt;&lt;td&gt;Low (index seek at cursor position)&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Random page access&lt;/td&gt;&lt;td&gt;Supported&lt;/td&gt;&lt;td&gt;Not supported&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Total count in response&lt;/td&gt;&lt;td&gt;Requires a COUNT query&lt;/td&gt;&lt;td&gt;Not available without a COUNT query&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Consistency under concurrent writes&lt;/td&gt;&lt;td&gt;Rows can be skipped or duplicated&lt;/td&gt;&lt;td&gt;Consistent for inserts before cursor&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Client contract complexity&lt;/td&gt;&lt;td&gt;Simple: page number + page size&lt;/td&gt;&lt;td&gt;Moderate: cursor must be stored and sent&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Best fit&lt;/td&gt;&lt;td&gt;Admin UIs, reports, static data&lt;/td&gt;&lt;td&gt;Feeds, infinite scroll, large tables&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Final take&lt;/div&gt;

&lt;p&gt;Offset pagination is the right default for most admin and reporting endpoints where tables are small, page depth is shallow, and clients expect page numbers. It is not the right default for any endpoint where a large table will be paged through sequentially or at high volume.&lt;/p&gt;
&lt;p&gt;Reach for keyset pagination whenever the access pattern is sequential (feeds, syncs, infinite scroll) or whenever you measure offset queries degrading beyond acceptable latency thresholds under realistic data volumes. The cursor-based API contract is a minor client-side inconvenience that pays for itself quickly at scale.&lt;/p&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;What&#39;s next?&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Add sorting flexibility to keyset:&lt;/strong&gt; Multi-column keyset pagination (sorting by &lt;code&gt;name, id&lt;/code&gt; rather than just &lt;code&gt;id&lt;/code&gt;) requires a compound cursor. It is more complex but follows the same seek-method logic.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Benchmark on your data:&lt;/strong&gt; Run both implementations against a table seeded with 500,000 rows and compare query plans with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;. The index seek vs. index scan difference becomes visible immediately.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Explore EF Core compiled queries:&lt;/strong&gt; For high-frequency pagination endpoints, wrapping keyset queries in EF Core compiled queries eliminates the per-request LINQ expression tree compilation overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or a scenario I did not cover? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/2199555095869054268/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/pagination-in-net-8-web-api-with-ef.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/2199555095869054268'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/2199555095869054268'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/pagination-in-net-8-web-api-with-ef.html' title='Pagination in .NET 8 Web API with EF Core and PostgreSQL: Offset vs. Keyset'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-5408699360542894744</id><published>2026-04-19T19:28:00.004+05:45</published><updated>2026-04-19T19:28:49.898+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="Architecture"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="DbContext"/><category scheme="http://www.blogger.com/atom/ns#" term="Design Patterns"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Repository Pattern"/><category scheme="http://www.blogger.com/atom/ns#" term="Unit Testing"/><category scheme="http://www.blogger.com/atom/ns#" term="Web API"/><title type='text'>Repository Pattern vs. Direct DbContext in a .NET 8 Controller API</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-decision {
  border-left: 4px solid #2563eb;
  background: #f0f6ff;
  padding: 14px 18px; margin: 1em 0;
  border-radius: 0 6px 6px 0;
}
.pbi-decision.b {
  border-color: #16a34a;
  background: #f0fdf4;
}
.pbi-decision h4 {
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
  font-size: 14px; font-weight: 700; margin: 0 0 8px;
  color: #1e3a5f;
}
.pbi-decision.b h4 { color: #14532d; }
.pbi-decision ul { margin: 0; padding-left: 1.4em; }
.pbi-decision li { font-size: 14px; color: var(--ink-mid); margin-bottom: 4px; }
.pbi-table { width: 100%; border-collapse: collapse; margin: 1em 0 1.5em; font-size: 14px; }
.pbi-table th {
  background: var(--ink); color: var(--bg);
  padding: 10px 14px; text-align: left; font-weight: 600;
}
.pbi-table td { padding: 9px 14px; border-bottom: 1px solid var(--rule); color: var(--ink-mid); }
.pbi-table tr:nth-child(even) td { background: var(--code-bg); }
.pbi-table td:first-child { font-weight: 600; color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;If you have started a .NET 8 API project recently, someone on your team has probably asked it: do we wrap EF Core in a Repository, or do we inject the DbContext directly into controllers? Both approaches are used in production codebases. Both have genuine trade-offs. And the debate often generates more heat than signal because the answer depends on the kind of application you are building.&lt;/p&gt;
&lt;p&gt;This post examines both options with realistic code, identifies the actual trade-offs across testability, query expressiveness, and team ergonomics, and gives you a concrete decision framework so the conversation stays short next time.&lt;/p&gt;
&lt;p&gt;You will walk away with working code for both patterns, a clear set of scenarios where each one earns its keep, and a summary table you can paste into a design doc.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; A quick history first&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The Repository pattern&lt;/strong&gt; originates in Domain-Driven Design (Evans, 2003) and describes a collection-like abstraction over a domain&#39;s persistence layer. The pattern was widely adopted in .NET during the NHibernate and LINQ-to-SQL era, when data access code was genuinely hard to test in isolation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Direct DbContext usage&lt;/strong&gt; became the mainstream recommendation as EF Core matured. DbContext is already an abstraction over database access, DbSet already looks and behaves like a generic in-memory collection, and EF Core&#39;s in-memory and SQLite test providers make the DbContext itself testable without a Repository in front of it.&lt;/p&gt;
&lt;p&gt;Both approaches build on the same EF Core engine. The difference is whether your controller sees &lt;code&gt;IProductRepository&lt;/code&gt; or &lt;code&gt;CatalogDbContext&lt;/code&gt;.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; The same task, two ways&lt;/div&gt;

&lt;p&gt;Both snippets fetch a filtered, projected list of products. The first uses a Repository interface; the second injects the DbContext directly.&lt;/p&gt;

&lt;h3&gt;Repository pattern approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Abstraction
public interface IProductRepository
{
    Task&amp;lt;List&amp;lt;ProductSummary&amp;gt;&amp;gt; GetPageAsync(
        string? category, int page, int pageSize, CancellationToken ct = default);
}

// Implementation
public class ProductRepository : IProductRepository
{
    private readonly CatalogDbContext _db;
    public ProductRepository(CatalogDbContext db) =&amp;gt; _db = db;

    public Task&amp;lt;List&amp;lt;ProductSummary&amp;gt;&amp;gt; GetPageAsync(
        string? category, int page, int pageSize, CancellationToken ct) =&amp;gt;
        _db.Products
            .Where(p =&amp;gt; category == null || p.Category == category)
            .OrderBy(p =&amp;gt; p.Name)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(p =&amp;gt; new ProductSummary(p.Id, p.Name, p.Price))
            .ToListAsync(ct);
}

// Controller
[ApiController, Route(&quot;api/products&quot;)]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repo;
    public ProductsController(IProductRepository repo) =&amp;gt; _repo = repo;

    [HttpGet]
    public Task&amp;lt;List&amp;lt;ProductSummary&amp;gt;&amp;gt; GetAsync(
        [FromQuery] string? category, [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20, CancellationToken ct = default) =&amp;gt;
        _repo.GetPageAsync(category, page, pageSize, ct);
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Direct DbContext approach&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Controller — DbContext injected directly
[ApiController, Route(&quot;api/products&quot;)]
public class ProductsController : ControllerBase
{
    private readonly CatalogDbContext _db;
    public ProductsController(CatalogDbContext db) =&amp;gt; _db = db;

    [HttpGet]
    public Task&amp;lt;List&amp;lt;ProductSummary&amp;gt;&amp;gt; GetAsync(
        [FromQuery] string? category, [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20, CancellationToken ct = default) =&amp;gt;
        _db.Products
            .Where(p =&amp;gt; category == null || p.Category == category)
            .OrderBy(p =&amp;gt; p.Name)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(p =&amp;gt; new ProductSummary(p.Id, p.Name, p.Price))
            .ToListAsync(ct);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The query logic is identical. The difference is where it lives (a dedicated class vs. the controller action) and what the controller declares as its dependency.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; The real trade-offs&lt;/div&gt;

&lt;h3&gt;1. Testability&lt;/h3&gt;
&lt;p&gt;This is the most cited reason for adding a Repository, but it no longer holds the weight it once did. EF Core ships with an in-memory provider (&lt;code&gt;UseInMemoryDatabase&lt;/code&gt;) and integrates cleanly with SQLite in-memory mode for tests that need relational behavior. Both options let you test controller logic without a real database and without mocking an interface.&lt;/p&gt;
&lt;p&gt;That said, the Repository interface is still the right choice when you need to test a service class in complete isolation, without spinning up any EF Core infrastructure at all. Mocking &lt;code&gt;IProductRepository&lt;/code&gt; takes three lines; setting up an in-memory DbContext takes more.&lt;/p&gt;

&lt;h3&gt;2. Query expressiveness and the leaky abstraction problem&lt;/h3&gt;
&lt;p&gt;The Repository pattern works cleanly for simple CRUD. It starts to buckle when requirements grow. Callers start asking for &lt;code&gt;GetByIdWithDetailsAsync&lt;/code&gt;, &lt;code&gt;GetPageWithCategoryFilterAsync&lt;/code&gt;, &lt;code&gt;GetCountByCategoryAsync&lt;/code&gt;, and before long the interface has fifteen methods. The alternative is to return &lt;code&gt;IQueryable&amp;lt;T&amp;gt;&lt;/code&gt; from the Repository, which leaks the EF Core abstraction straight through the layer the Repository was supposed to hide.&lt;/p&gt;
&lt;p&gt;Direct DbContext usage sidesteps this entirely. The controller or service composes the query inline, using the full EF Core query API including &lt;code&gt;Include&lt;/code&gt;, &lt;code&gt;AsSplitQuery&lt;/code&gt;, &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt;, and PostgreSQL-specific operators exposed by Npgsql.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// A query that a Repository interface handles awkwardly
var result = await _db.Orders
    .AsSplitQuery()
    .Include(o =&amp;gt; o.Lines)
    .Where(o =&amp;gt; o.Status == OrderStatus.Pending
             &amp;amp;&amp;amp; o.PlacedAt &amp;lt; DateTimeOffset.UtcNow.AddDays(-7))
    .OrderByDescending(o =&amp;gt; o.PlacedAt)
    .Select(o =&amp;gt; new StaleOrderDto(o.Id, o.CustomerEmail, o.Lines.Count))
    .ToListAsync(ct);&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;3. Code volume, indirection, and onboarding cost&lt;/h3&gt;
&lt;p&gt;For a ten-entity API, the Repository pattern adds ten interfaces, ten implementation classes, and ten DI registrations. That is not inherently bad, but it is cost that needs to deliver proportional value. On a team of two or three developers building an internal API, the indirection often obscures more than it clarifies. On a larger team with strict bounded-context boundaries, the same interfaces become a meaningful contract between feature teams.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Which one should you choose?&lt;/div&gt;

&lt;p&gt;Use the context of your project, not a blanket rule, to make the call.&lt;/p&gt;

&lt;div class=&quot;pbi-decision&quot;&gt;
  &lt;h4&gt;Choose the Repository pattern when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;Your domain model is complex and you are applying DDD; repositories map to aggregate roots&lt;/li&gt;
    &lt;li&gt;You need to swap data sources (for example, a cache-backed implementation alongside a database-backed one)&lt;/li&gt;
    &lt;li&gt;Your team writes service classes with many dependencies and values the ability to mock them in unit tests without any EF Core setup&lt;/li&gt;
    &lt;li&gt;Multiple bounded contexts share entity types but need different persistence strategies per context&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;div class=&quot;pbi-decision b&quot;&gt;
  &lt;h4&gt;Choose direct DbContext when:&lt;/h4&gt;
  &lt;ul&gt;
    &lt;li&gt;You are building a CRUD-heavy or query-heavy API where queries vary widely per endpoint&lt;/li&gt;
    &lt;li&gt;Your team is small or the API is a single-bounded-context service&lt;/li&gt;
    &lt;li&gt;You want full access to EF Core features without working around interface boundaries&lt;/li&gt;
    &lt;li&gt;You use integration tests with a real (or SQLite in-memory) database and do not need interface mocks&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;p&gt;A practical middle ground is to keep DbContext in controllers for most operations, but extract a dedicated service class for any business operation that is called from more than one place. That service class can own a partial Repository interface scoped to only the methods it actually needs, rather than a complete repository per entity.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// A scoped service that encapsulates one business operation
// and exposes a minimal interface for testing
public interface IOrderFulfillmentService
{
    Task FulfillAsync(int orderId, CancellationToken ct = default);
}

public class OrderFulfillmentService : IOrderFulfillmentService
{
    private readonly CatalogDbContext _db;
    public OrderFulfillmentService(CatalogDbContext db) =&amp;gt; _db = db;

    public async Task FulfillAsync(int orderId, CancellationToken ct)
    {
        var order = await _db.Orders
            .Include(o =&amp;gt; o.Lines)
            .FirstOrDefaultAsync(o =&amp;gt; o.Id == orderId, ct)
            ?? throw new InvalidOperationException($&quot;Order {orderId} not found.&quot;);

        order.Status = OrderStatus.Shipped;
        order.FulfilledAt = DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync(ct);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Quick reference summary&lt;/div&gt;

&lt;table class=&quot;pbi-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concern&lt;/th&gt;
      &lt;th&gt;Repository pattern&lt;/th&gt;
      &lt;th&gt;Direct DbContext&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;Unit testability&lt;/td&gt;&lt;td&gt;Easy to mock with any mocking library&lt;/td&gt;&lt;td&gt;Requires in-memory or SQLite provider setup&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Integration testability&lt;/td&gt;&lt;td&gt;Works; no advantage over direct DbContext&lt;/td&gt;&lt;td&gt;Clean with EF Core test helpers&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Query flexibility&lt;/td&gt;&lt;td&gt;Constrained by interface shape; IQueryable leaks&lt;/td&gt;&lt;td&gt;Full EF Core API available inline&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Code volume&lt;/td&gt;&lt;td&gt;High: interfaces, impls, DI registrations&lt;/td&gt;&lt;td&gt;Low: one class, one dependency&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;DDD fit&lt;/td&gt;&lt;td&gt;Natural; maps to aggregate root access&lt;/td&gt;&lt;td&gt;Awkward for complex domain models&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;EF Core feature access&lt;/td&gt;&lt;td&gt;Limited without leaking IQueryable&lt;/td&gt;&lt;td&gt;Full, including bulk operations&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Multi-source support&lt;/td&gt;&lt;td&gt;Straightforward via implementation swap&lt;/td&gt;&lt;td&gt;Requires refactoring to add a second source&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Final take&lt;/div&gt;

&lt;p&gt;The Repository pattern solves a real problem. That problem is not &quot;how do I use EF Core.&quot; It is &quot;how do I isolate domain logic from any particular persistence technology.&quot; If you are not building for that isolation, the pattern adds cost without delivering its core benefit.&lt;/p&gt;
&lt;p&gt;Lean on direct DbContext injection for API projects that are CRUD-heavy, single-context, or built by small teams. Reach for repositories when your domain model is complex, your team practices DDD, or you have a genuine need to swap persistence strategies per context.&lt;/p&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;What&#39;s next?&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Read the EF Core testing documentation:&lt;/strong&gt; The official docs cover the SQLite in-memory approach and its trade-offs against full integration tests with a real database.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Explore the next post in this series:&lt;/strong&gt; Pagination in a .NET 8 Web API with EF Core and PostgreSQL, covering offset-based and keyset-based approaches and when each one breaks down at scale.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Try the scoped service pattern:&lt;/strong&gt; Extract one business operation from a controller into a dedicated service class and write a unit test against its interface. It is a useful calibration exercise for deciding where interface boundaries add real value.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or a scenario I did not cover? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/5408699360542894744/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/repository-pattern-vs-direct-dbcontext.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5408699360542894744'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5408699360542894744'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/repository-pattern-vs-direct-dbcontext.html' title='Repository Pattern vs. Direct DbContext in a .NET 8 Controller API'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-5951478441752134666</id><published>2026-04-19T19:25:00.000+05:45</published><updated>2026-04-19T19:25:37.185+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="ASP.NET Core"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="CI/CD"/><category scheme="http://www.blogger.com/atom/ns#" term="Database Deployment"/><category scheme="http://www.blogger.com/atom/ns#" term="DevOps"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core Migrations"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><title type='text'>EF Core Migrations with PostgreSQL: Patterns, Pitfalls, and CI-Safe Deployments</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;EF Core migrations feel straightforward until you add PostgreSQL to the picture. Enum type management, case-sensitive identifiers, schema-qualified objects, and the question of who runs &lt;code&gt;dotnet ef database update&lt;/code&gt; in production all demand answers before your first deployment. Getting those answers wrong produces corrupted migration histories, failed releases, and schema drift that is hard to untangle.&lt;/p&gt;
&lt;p&gt;This post covers how EF Core migrations work with Npgsql under the hood, the pitfalls that are specific to PostgreSQL, and the patterns that make migration-based deployments safe to run in CI/CD pipelines without surprises.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;A .NET 8 Web API project with &lt;code&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/code&gt; 8.x installed&lt;/li&gt;
  &lt;li&gt;EF Core CLI tool: &lt;code&gt;dotnet tool install --global dotnet-ef&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;A working DbContext with at least one entity and an existing migration history&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; The migration patterns in this post apply whether you are using controllers or minimal APIs. The deployment strategies apply to any hosting model: Azure App Service, Docker containers, or bare metal.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; How EF Core migrations work with Npgsql&lt;/div&gt;

&lt;p&gt;When you run &lt;code&gt;dotnet ef migrations add MigrationName&lt;/code&gt;, EF Core compares your current model against the compiled model snapshot stored in &lt;code&gt;Migrations/CatalogDbContextModelSnapshot.cs&lt;/code&gt;. The difference produces a new migration file with &lt;code&gt;Up&lt;/code&gt; and &lt;code&gt;Down&lt;/code&gt; methods. Npgsql translates those method calls into PostgreSQL-compatible SQL when you apply the migration.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// A generated migration file (simplified)
public partial class AddProductCategory : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn&amp;lt;string&amp;gt;(
            name: &quot;category&quot;,
            table: &quot;products&quot;,
            type: &quot;character varying(64)&quot;,
            maxLength: 64,
            nullable: false,
            defaultValue: &quot;&quot;);

        migrationBuilder.CreateIndex(
            name: &quot;ix_products_category&quot;,
            table: &quot;products&quot;,
            column: &quot;category&quot;);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropIndex(
            name: &quot;ix_products_category&quot;,
            table: &quot;products&quot;);

        migrationBuilder.DropColumn(
            name: &quot;category&quot;,
            table: &quot;products&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;EF Core records each applied migration in &lt;code&gt;__EFMigrationsHistory&lt;/code&gt;. Before running any migration, it checks this table and skips migrations already present. This is why the history table is the source of truth for schema state, not the migration files themselves.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; Naming conventions and organizing migration files&lt;/div&gt;

&lt;p&gt;Use descriptive names that make the migration&#39;s intent clear from the file listing alone. Avoid generic names like &lt;code&gt;Update1&lt;/code&gt; or &lt;code&gt;Fix&lt;/code&gt;; they are meaningless in a git blame six months later.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Good migration names
dotnet ef migrations add AddOrdersAndOrderLines
dotnet ef migrations add AddProductCategoryIndex
dotnet ef migrations add RenameCustomerEmailToContactEmail
dotnet ef migrations add SeedDefaultRoles&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For large teams where multiple feature branches produce migrations in parallel, each developer should generate migrations against a local database and rebase before merging. Two migrations generated from the same snapshot base will conflict in the snapshot file. Rebasing one branch on top of the other and regenerating its migration resolves the conflict cleanly.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Keep migrations small and focused on a single schema concern. A migration that adds three tables, renames two columns, and seeds reference data is hard to roll back safely. Splitting it into two or three migrations costs almost nothing and gives you meaningful rollback points.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; PostgreSQL-specific pitfalls&lt;/div&gt;

&lt;p&gt;Several PostgreSQL behaviors catch developers off guard after working primarily with SQL Server.&lt;/p&gt;

&lt;h3&gt;1. Case sensitivity and identifier quoting&lt;/h3&gt;

&lt;p&gt;PostgreSQL folds unquoted identifiers to lowercase. EF Core, by default, generates table and column names in PascalCase (for example, &lt;code&gt;&quot;Products&quot;&lt;/code&gt; and &lt;code&gt;&quot;CreatedAt&quot;&lt;/code&gt;). Npgsql quotes these identifiers in the generated DDL, which means your schema works but every query requires quoted names. If a developer runs a raw query without quoting, it silently targets different names.&lt;/p&gt;

&lt;p&gt;The fix is to establish snake_case naming in your entity configurations using &lt;code&gt;ToTable&lt;/code&gt; and &lt;code&gt;HasColumnName&lt;/code&gt;, or to apply a global naming convention through a model convention class.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Global snake_case convention using EF Core 8 model-building conventions
public class SnakeCaseNamingConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(
        IConventionModelBuilder builder,
        IConventionContext&amp;lt;IConventionModelBuilder&amp;gt; context)
    {
        foreach (var entity in builder.Metadata.GetEntityTypes())
        {
            entity.SetTableName(ToSnakeCase(entity.GetTableName()!));

            foreach (var property in entity.GetProperties())
                property.SetColumnName(ToSnakeCase(property.GetColumnName()!));

            foreach (var key in entity.GetKeys())
                key.SetName(ToSnakeCase(key.GetName()!));

            foreach (var index in entity.GetIndexes())
                index.SetDatabaseName(ToSnakeCase(index.GetDatabaseName()!));

            foreach (var fk in entity.GetForeignKeys())
                fk.SetConstraintName(ToSnakeCase(fk.GetConstraintName()!));
        }
    }

    private static string ToSnakeCase(string name) =&amp;gt;
        System.Text.RegularExpressions.Regex
            .Replace(name, &quot;(?&amp;lt;=[a-z0-9])([A-Z])&quot;, &quot;_$1&quot;)
            .ToLower();
}

// Register in DbContext
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =&amp;gt; new SnakeCaseNamingConvention());
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;2. Enum types&lt;/h3&gt;

&lt;p&gt;Npgsql supports PostgreSQL-native enum types through &lt;code&gt;MapEnum&lt;/code&gt;, but they require extra steps in migrations. When you rename a value or drop an enum type, PostgreSQL requires the column referencing it to be migrated first. The simpler approach for most APIs is to store enums as &lt;code&gt;text&lt;/code&gt; using &lt;code&gt;HasConversion&amp;lt;string&amp;gt;()&lt;/code&gt;. Reserve native enums for cases where you need database-level type checking or PostgreSQL functions that operate on them.&lt;/p&gt;

&lt;h3&gt;3. Schema-qualified tables&lt;/h3&gt;

&lt;p&gt;If your PostgreSQL database uses multiple schemas (for example, &lt;code&gt;catalog&lt;/code&gt; and &lt;code&gt;orders&lt;/code&gt;), set the default schema on the DbContext rather than qualifying every table name individually.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasDefaultSchema(&quot;catalog&quot;);
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;EF Core includes the schema in all generated DDL and in &lt;code&gt;__EFMigrationsHistory&lt;/code&gt;. If you change the default schema after migrations exist, generate a new migration; EF Core will emit the necessary &lt;code&gt;MoveTable&lt;/code&gt; calls.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; Applying migrations safely in CI/CD&lt;/div&gt;

&lt;p&gt;There are two mainstream strategies for applying EF Core migrations in a pipeline. Choose based on how much schema downtime you can tolerate.&lt;/p&gt;

&lt;h3&gt;Strategy A: Apply at application startup with MigrateAsync&lt;/h3&gt;

&lt;p&gt;This approach is simple and works well for small teams and staging environments. The application applies any pending migrations when it starts, before accepting traffic.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Program.cs  - apply migrations on startup
var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService&amp;lt;CatalogDbContext&amp;gt;();
    await db.Database.MigrateAsync();
}

app.UseAuthorization();
app.MapControllers();
app.Run();&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The downside is that if two instances start simultaneously (common in rolling deployments), both will try to acquire a migration lock. Npgsql handles this gracefully with an advisory lock, but the startup time increases proportionally to the number of pending migrations.&lt;/p&gt;

&lt;h3&gt;Strategy B: Dedicated migration step in the pipeline&lt;/h3&gt;

&lt;p&gt;For production deployments, run migrations as a separate pipeline step before the new application version is deployed. This decouples schema changes from application restarts and gives you a clear gate to stop the pipeline if the migration fails.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// A minimal migration runner console app or a separate entrypoint
// Add this to your csproj: &amp;lt;ItemGroup&amp;gt;&amp;lt;ProjectReference Include=&quot;..\Catalog.Api\Catalog.Api.csproj&quot; /&amp;gt;&amp;lt;/ItemGroup&amp;gt;

using Catalog.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddDbContext&amp;lt;CatalogDbContext&amp;gt;(options =&amp;gt;
    options.UseNpgsql(Environment.GetEnvironmentVariable(&quot;ConnectionStrings__Catalog&quot;)));

var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService&amp;lt;CatalogDbContext&amp;gt;();
await db.Database.MigrateAsync();

Console.WriteLine(&quot;Migrations applied.&quot;);&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; In a Docker-based pipeline, build a separate migration image from your project and run it as an init container or a pipeline job step before rolling out the main API containers. The migration container exits after applying changes; your API containers never need database admin credentials at runtime.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;6&lt;/span&gt; Generating and reviewing idempotent SQL scripts&lt;/div&gt;

&lt;p&gt;Before any production deployment, generate an idempotent SQL script and review it manually (or have a DBA review it). An idempotent script checks &lt;code&gt;__EFMigrationsHistory&lt;/code&gt; before applying each migration, so it is safe to run multiple times without duplicating changes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;dotnet ef migrations script --idempotent --output ./deploy/migrations.sql&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The generated file contains one SQL block per migration, wrapped in an existence check against the history table. Commit this file to source control alongside your migration files so reviewers can see the exact DDL that will run in production without needing a local EF Core toolchain.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Example of idempotent guard generated by EF Core
IF NOT EXISTS(SELECT 1 FROM &quot;__EFMigrationsHistory&quot; WHERE &quot;MigrationId&quot; = &#39;20240315120000_AddOrdersAndOrderLines&#39;)
BEGIN
    CREATE TABLE orders ( ... );
    INSERT INTO &quot;__EFMigrationsHistory&quot; (&quot;MigrationId&quot;, &quot;ProductVersion&quot;)
    VALUES (&#39;20240315120000_AddOrdersAndOrderLines&#39;, &#39;8.0.0&#39;);
END;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; For PostgreSQL, EF Core emits DO $$ BEGIN ... END $$ blocks instead of IF NOT EXISTS syntax. The idempotency logic is functionally equivalent; the exact syntax depends on the provider.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;You now have a clear picture of how EF Core migrations interact with PostgreSQL, where the common pitfalls are (case sensitivity, enum types, schema qualification), and two deployment strategies to choose from based on your team&#39;s needs. The idempotent script workflow gives you a reviewable, repeatable artifact for every deployment.&lt;/p&gt;

&lt;p&gt;The next post in this series tackles a question that comes up on almost every API project: should you wrap your DbContext in a Repository pattern, or use it directly in your controllers? Both approaches are defensible and the right answer depends on factors that are worth examining carefully.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/5951478441752134666/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/ef-core-migrations-with-postgresql.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5951478441752134666'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/5951478441752134666'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/ef-core-migrations-with-postgresql.html' title='EF Core Migrations with PostgreSQL: Patterns, Pitfalls, and CI-Safe Deployments'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-9014153206090669340.post-1121885241042012322</id><published>2026-04-19T19:18:00.001+05:45</published><updated>2026-04-19T19:18:12.228+05:45</updated><category scheme="http://www.blogger.com/atom/ns#" term=".NET 8"/><category scheme="http://www.blogger.com/atom/ns#" term="C#"/><category scheme="http://www.blogger.com/atom/ns#" term="Database Design"/><category scheme="http://www.blogger.com/atom/ns#" term="DbContext"/><category scheme="http://www.blogger.com/atom/ns#" term="EF Core Modeling"/><category scheme="http://www.blogger.com/atom/ns#" term="Entity Framework Core"/><category scheme="http://www.blogger.com/atom/ns#" term="Fluent API"/><category scheme="http://www.blogger.com/atom/ns#" term="IEntityTypeConfiguration"/><category scheme="http://www.blogger.com/atom/ns#" term="Npgsql"/><category scheme="http://www.blogger.com/atom/ns#" term="PostgreSQL"/><title type='text'>Designing Your DbContext and Entity Models for PostgreSQL in .NET 8</title><content type='html'>&lt;style&gt;
.pbi-sec-divider { border: none; border-top: 1px dashed var(--rule); margin: 2em 0; }
.pbi-h2-numbered {
  display: flex; align-items: center; gap: 10px;
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
}
.pbi-h2-numbered .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; min-width: 28px;
  background: var(--ink); color: var(--bg);
  font-size: 13px; font-weight: 600; border-radius: 50%;
  font-family: &#39;DM Sans&#39;, Helvetica, Arial, sans-serif;
}
.pbi-h2-plain {
  font-family: &#39;Fraunces&#39;, Georgia, serif;
  font-size: 22px; font-weight: 600; color: var(--ink);
  margin: 2em 0 0.6em;
  border-left: 3px solid var(--accent); padding-left: 10px;
}
.pbi-callout {
  background: var(--code-bg); border-left: 3px solid var(--accent);
  padding: 12px 16px; margin: 1.2em 0; border-radius: 0 4px 4px 0;
  font-size: 14px; color: var(--ink-mid);
}
.pbi-callout strong { color: var(--ink); }
.pbi-cta {
  margin-top: 2em; padding: 16px 20px;
  border: 0.5px solid var(--rule); border-radius: 6px;
  background: var(--ghost); font-size: 14px; color: var(--ink-mid);
}
&lt;/style&gt;

&lt;p&gt;A first-draft EF Core entity is easy to write and easy to regret six months later. Column type mismatches, missing indexes, and a DbContext clogged with hundreds of lines of Fluent API calls are all fixable problems, but they are far cheaper to avoid upfront. This post covers the model design patterns that hold up well when PostgreSQL is your target database.&lt;/p&gt;
&lt;p&gt;By the end you will have a DbContext organized with &lt;code&gt;IEntityTypeConfiguration&lt;/code&gt; classes, column types chosen deliberately for PostgreSQL semantics, and indexes defined in the right layer of the application.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;1&lt;/span&gt; Prerequisites&lt;/div&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;The project from the first post in this series, or any .NET 8 Web API with &lt;code&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/code&gt; installed&lt;/li&gt;
  &lt;li&gt;EF Core CLI tool: &lt;code&gt;dotnet tool install --global dotnet-ef&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Basic familiarity with EF Core Fluent API conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Note:&lt;/strong&gt; All examples target .NET 8 with nullable reference types enabled and &lt;code&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/code&gt; 8.x. The &lt;code&gt;LegacyTimestampBehavior&lt;/code&gt; AppContext switch is assumed to be off, which is the default for new projects.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;2&lt;/span&gt; What EF Core infers by convention&lt;/div&gt;

&lt;p&gt;Understanding the convention defaults lets you apply Fluent API only where you need to override them, which keeps configuration classes focused.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// A convention-friendly pair of entities
public class Order
{
    public int Id { get; set; }               // PK inferred from &quot;Id&quot;
    public string CustomerEmail { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public OrderStatus Status { get; set; }
    public DateTimeOffset PlacedAt { get; set; }
    public List&amp;lt;OrderLine&amp;gt; Lines { get; set; } = [];
}

public class OrderLine
{
    public int Id { get; set; }
    public int OrderId { get; set; }          // FK inferred from nav + &quot;TypeNameId&quot; pattern
    public Order Order { get; set; } = null!;
    public string Sku { get; set; } = string.Empty;
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

public enum OrderStatus { Pending, Confirmed, Shipped, Cancelled }&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;EF Core infers the primary key from a property named &lt;code&gt;Id&lt;/code&gt; or &lt;code&gt;&amp;lt;TypeName&amp;gt;Id&lt;/code&gt;. It infers a foreign key when a navigation property and a matching &lt;code&gt;&amp;lt;TypeName&amp;gt;Id&lt;/code&gt; shadow property exist on the dependent entity. String properties without &lt;code&gt;HasMaxLength&lt;/code&gt; become &lt;code&gt;text&lt;/code&gt; columns in PostgreSQL, which is valid but worth making explicit in any column that benefits from a database-enforced length constraint.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;3&lt;/span&gt; IEntityTypeConfiguration: one class per entity&lt;/div&gt;

&lt;p&gt;Rather than putting all Fluent API calls in &lt;code&gt;OnModelCreating&lt;/code&gt;, implement &lt;code&gt;IEntityTypeConfiguration&amp;lt;T&amp;gt;&lt;/code&gt; in a dedicated class per entity. Each class lives in a &lt;code&gt;Data/Configurations&lt;/code&gt; folder and is discovered automatically through assembly scanning.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Data/Configurations/OrderConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class OrderConfiguration : IEntityTypeConfiguration&amp;lt;Order&amp;gt;
{
    public void Configure(EntityTypeBuilder&amp;lt;Order&amp;gt; builder)
    {
        builder.ToTable(&quot;orders&quot;);

        builder.Property(o =&amp;gt; o.CustomerEmail)
            .IsRequired()
            .HasMaxLength(256);

        builder.Property(o =&amp;gt; o.Total)
            .HasColumnType(&quot;numeric(18,4)&quot;);

        builder.Property(o =&amp;gt; o.Status)
            .HasConversion&amp;lt;string&amp;gt;()
            .HasMaxLength(32);

        builder.Property(o =&amp;gt; o.PlacedAt)
            .HasColumnType(&quot;timestamp with time zone&quot;);

        builder.HasIndex(o =&amp;gt; o.CustomerEmail)
            .HasDatabaseName(&quot;ix_orders_customer_email&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Storing the enum as a string with &lt;code&gt;HasConversion&amp;lt;string&amp;gt;()&lt;/code&gt; makes the column readable without a lookup table and keeps migrations straightforward. If you prefer PostgreSQL-native enum types, Npgsql supports them via &lt;code&gt;MapEnum&lt;/code&gt;, but that approach carries extra migration discipline that the next post covers in detail.&lt;/p&gt;

&lt;h3&gt;OrderLine configuration&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Data/Configurations/OrderLineConfiguration.cs
public class OrderLineConfiguration : IEntityTypeConfiguration&amp;lt;OrderLine&amp;gt;
{
    public void Configure(EntityTypeBuilder&amp;lt;OrderLine&amp;gt; builder)
    {
        builder.ToTable(&quot;order_lines&quot;);

        builder.Property(l =&amp;gt; l.Sku)
            .IsRequired()
            .HasMaxLength(64);

        builder.Property(l =&amp;gt; l.UnitPrice)
            .HasColumnType(&quot;numeric(18,4)&quot;);

        builder.HasIndex(l =&amp;gt; new { l.OrderId, l.Sku })
            .HasDatabaseName(&quot;ix_order_lines_order_sku&quot;);

        builder.HasOne(l =&amp;gt; l.Order)
            .WithMany(o =&amp;gt; o.Lines)
            .HasForeignKey(l =&amp;gt; l.OrderId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt; Register configurations via assembly scan&lt;/div&gt;

&lt;p&gt;Call &lt;code&gt;ApplyConfigurationsFromAssembly&lt;/code&gt; in &lt;code&gt;OnModelCreating&lt;/code&gt; to pick up every &lt;code&gt;IEntityTypeConfiguration&amp;lt;T&amp;gt;&lt;/code&gt; class in the assembly automatically.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Data/CatalogDbContext.cs
using Microsoft.EntityFrameworkCore;

public class CatalogDbContext : DbContext
{
    public CatalogDbContext(DbContextOptions&amp;lt;CatalogDbContext&amp;gt; options)
        : base(options) { }

    public DbSet&amp;lt;Order&amp;gt; Orders =&amp;gt; Set&amp;lt;Order&amp;gt;();
    public DbSet&amp;lt;OrderLine&amp;gt; OrderLines =&amp;gt; Set&amp;lt;OrderLine&amp;gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Adding a new entity now requires only creating a new configuration class. The DbContext itself stays unchanged. This is the single biggest quality-of-life improvement you can make in a DbContext that will grow over time.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;5&lt;/span&gt; PostgreSQL type mappings worth being explicit about&lt;/div&gt;

&lt;p&gt;Npgsql provides sensible defaults, but a few mappings need an explicit call to behave correctly in production.&lt;/p&gt;

&lt;h3&gt;Decimal precision&lt;/h3&gt;
&lt;p&gt;EF Core maps &lt;code&gt;decimal&lt;/code&gt; to &lt;code&gt;numeric&lt;/code&gt; but does not set precision and scale unless you tell it to. Without an explicit &lt;code&gt;HasColumnType(&quot;numeric(p,s)&quot;)&lt;/code&gt;, the generated column uses the database&#39;s default precision, which can cause silent rounding. Always set precision and scale for money or measurement columns.&lt;/p&gt;

&lt;h3&gt;DateTime vs DateTimeOffset&lt;/h3&gt;
&lt;p&gt;With &lt;code&gt;LegacyTimestampBehavior&lt;/code&gt; off (the default in new .NET 8 projects), Npgsql maps &lt;code&gt;DateTime&lt;/code&gt; only if it is UTC-kind and emits a compile-time warning for anything else. Use &lt;code&gt;DateTimeOffset&lt;/code&gt; for timestamps that cross time zones and map them to &lt;code&gt;timestamp with time zone&lt;/code&gt; explicitly in your configuration.&lt;/p&gt;

&lt;h3&gt;text vs character varying&lt;/h3&gt;
&lt;p&gt;In PostgreSQL, &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;varchar&lt;/code&gt; have identical storage and performance. Use &lt;code&gt;text&lt;/code&gt; for open-ended string columns and call &lt;code&gt;HasMaxLength&lt;/code&gt; only where you want the database to enforce a maximum length.&lt;/p&gt;

&lt;h3&gt;jsonb columns&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Mapping a metadata bag to jsonb
builder.Property(o =&amp;gt; o.Metadata)
    .HasColumnType(&quot;jsonb&quot;)
    .HasDefaultValueSql(&quot;&#39;{}&#39;&quot;);&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Npgsql can serialize and deserialize .NET types directly to and from &lt;code&gt;jsonb&lt;/code&gt;. Indexing a &lt;code&gt;jsonb&lt;/code&gt; column with a GIN index is possible through raw SQL in a migration, which the next post demonstrates.&lt;/p&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-numbered&quot;&gt;&lt;span class=&quot;n&quot;&gt;6&lt;/span&gt; Verify with a migration&lt;/div&gt;

&lt;p&gt;Generate the migration and inspect the SQL before applying it to any shared environment.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;dotnet ef migrations add AddOrdersSchema
dotnet ef migrations script --idempotent&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In the generated script, check that every &lt;code&gt;decimal&lt;/code&gt; column shows the correct &lt;code&gt;numeric(p,s)&lt;/code&gt;, that all timestamp columns are &lt;code&gt;timestamp with time zone&lt;/code&gt;, and that your named indexes appear with the names you defined. Once satisfied, apply with &lt;code&gt;dotnet ef database update&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;pbi-callout&quot;&gt;
  &lt;strong&gt;Tip:&lt;/strong&gt; Table names in these examples use lowercase snake_case (&lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;order_lines&lt;/code&gt;). PostgreSQL folds unquoted identifiers to lowercase, so using snake_case in your Fluent API avoids the quoting gymnastics that arise when EF Core generates PascalCase table names by default.
&lt;/div&gt;

&lt;hr class=&quot;pbi-sec-divider&quot;/&gt;

&lt;div class=&quot;pbi-h2-plain&quot;&gt;Wrapping up&lt;/div&gt;

&lt;p&gt;You now have a DbContext built around per-entity &lt;code&gt;IEntityTypeConfiguration&lt;/code&gt; classes, column types locked in deliberately for PostgreSQL, and a consistent naming strategy that avoids identifier quoting issues at runtime.&lt;/p&gt;

&lt;p&gt;The next post goes deeper into migrations: how EF Core and Npgsql generate them, the pitfalls specific to PostgreSQL (enum types, schema-qualified names, case sensitivity), and how to apply migrations safely in a CI/CD pipeline without blocking deployments or corrupting the migration history.&lt;/p&gt;

&lt;div class=&quot;pbi-cta&quot;&gt;
  Got a question or ran into a problem? Drop a comment below and I will reply.
&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://dotnetanaya.blogspot.com/feeds/1121885241042012322/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/designing-your-dbcontext-and-entity.html#comment-form' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/1121885241042012322'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/9014153206090669340/posts/default/1121885241042012322'/><link rel='alternate' type='text/html' href='http://dotnetanaya.blogspot.com/2026/04/designing-your-dbcontext-and-entity.html' title='Designing Your DbContext and Entity Models for PostgreSQL in .NET 8'/><author><name>Anaya</name><uri>http://www.blogger.com/profile/12254533988544956397</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry></feed>