<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
<channel>
<title><![CDATA[ elmah.io Blog - .NET Technical tutorials/guides and new features ]]></title>
<description><![CDATA[ On the elmah.io blog, the team behind elmah.io blog about new features and technologies either used to build elmah.io or of general interest. ]]></description>
<link>https://blog.elmah.io</link>
<image>
    <url>https://blog.elmah.io/favicon.png</url>
    <title>elmah.io Blog - .NET Technical tutorials/guides and new features</title>
    <link>https://blog.elmah.io</link>
</image>
<lastBuildDate>Tue, 11 Aug 2026 09:46:13 +0200</lastBuildDate>
<atom:link href="https://blog.elmah.io" rel="self" type="application/rss+xml"/>
<ttl>60</ttl>

    <item>
        <title><![CDATA[ Solving bugs with elmah.io and Claude Code - a real-life example ]]></title>
        <description><![CDATA[ Learn how integrating elmah.io with Claude Code via MCP transforms bug fixing into a streamlined, automated process—from receiving error notifications directly in the terminal to writing, pushing, and verifying production fixes in a single continuous developer workflow. ]]></description>
        <link>https://blog.elmah.io/solving-bugs-with-elmah-io-and-claude-code-a-real-life-example/</link>
        <guid isPermaLink="false">6a60576be1f4d70001573f63</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Thomas Ardal ]]></dc:creator>
        <pubDate>Tue, 11 Aug 2026 09:45:20 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/aaeaae70-5196-4cc9-abcf-d9740e41a681.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/solving-bugs-with-elmah-io-and-claude-code-a-real-life-example/">https://blog.elmah.io/solving-bugs-with-elmah-io-and-claude-code-a-real-life-example/</a></p> <p>I spend most of my day in Claude Code these days. Most of my development processes changed after having access to my own personal assistant. In this post, I'll show you a real-life example of how bug fixes are often done on elmah.io now. I hope it will inspire someone to optimize their workflow and get even more out of their elmah.io subscription.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-1.png" class="kg-image" alt="Solving bugs with elmah.io and Claude Code - a real-life example" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-1.png 1500w" sizes="(min-width: 720px) 720px"></figure><p>Monitoring elmah.io itself and fixing bugs when errors occur are a large part of keeping elmah.io's quality high. We use various ways to monitor that everything is working as intended, the big one being elmah.io itself. We receive new errors from the various websites and services through elmah.io and a shared Slack channel. In the old days, fixing errors was mostly a manual task, where only the build and publish part were fully automated. We would look up the error manually through the elmah.io UI and inspect the various pieces of information, trying to understand what was going wrong. Alongside the Source Code window in elmah.io, we would have Visual Studio open with the solution containing the failing code.</p><p>With the release of the elmah.io MCP server, this changed towards a much more automated approach. If you haven't run into MCP yet, the short version is that it's a protocol that lets AI tools like Claude Code call out to external services in a structured, secure way. Instead of me copy-pasting a stack trace into a chat window and asking "what does this mean", Claude Code can now go and look at the actual error itself, plus the uptime checks, heartbeats, and deployments around it. It's less "ask an AI about your errors" and more "give your AI tool the same access to elmah.io that you have". Let's try to set it up.</p><h2 id="connecting-claude-code-to-elmahio">Connecting Claude Code to elmah.io</h2><p>You'll need Claude Code installed and an elmah.io account, and everything happens from the terminal, so there's no config file to hand-edit and no API key to generate and paste in somewhere. API keys are also supported on the MCP server, but for this post, we'll use the UI to sign in.</p><p>First, register the MCP server:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/install-elmah-io-mcp-in-claude-code.png" class="kg-image" alt="Add elmah.io MCP server to Claude Code" loading="lazy" width="1987" height="559" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/install-elmah-io-mcp-in-claude-code.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/install-elmah-io-mcp-in-claude-code.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/install-elmah-io-mcp-in-claude-code.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/install-elmah-io-mcp-in-claude-code.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>This just tells Claude Code that a server called <code>elmahio</code> exists and where to find it. It doesn't authenticate anything yet. To do that, launch Claude Code and run <code>/mcp</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/elmah-io-mcp-needs-authentication.png" class="kg-image" alt="Run /mcp to see a list of configured MCP servers" loading="lazy" width="1987" height="818" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/elmah-io-mcp-needs-authentication.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/elmah-io-mcp-needs-authentication.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/elmah-io-mcp-needs-authentication.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/elmah-io-mcp-needs-authentication.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>You'll see a list of configured MCP servers, and <code>elmahio</code> should show up with a status of "needs authentication". Select it and click <strong>Authenticate</strong>, which opens a browser window with a normal OAuth login screen:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/mcp-elmah-io-authentication.png" class="kg-image" alt="elmah.io login screen" loading="lazy" width="1816" height="1210" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/mcp-elmah-io-authentication.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/mcp-elmah-io-authentication.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/mcp-elmah-io-authentication.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/mcp-elmah-io-authentication.png 1816w" sizes="(min-width: 720px) 720px"></figure><p>Sign in with your elmah.io account like you would on app.elmah.io, approve the request, and you're done. Claude Code doesn't see your password, just a token scoped to your account.</p><p>To make sure it actually worked, run <code>/mcp list</code> inside Claude Code:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/elmah-io-mcp-connected.png" class="kg-image" alt="elmah.io MCP server connected" loading="lazy" width="1987" height="818" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/elmah-io-mcp-connected.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/elmah-io-mcp-connected.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/elmah-io-mcp-connected.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/elmah-io-mcp-connected.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>Check that <code>elmahio</code> shows up as "connected". If it doesn't, the most common issue in my experience is that the OAuth flow got closed before finishing. Running <code>/mcp</code> again and re-authenticating should fix it.</p><p>If you'd rather not write your own prompts and just want a working setup out of the box, there's also an <a href="https://docs.elmah.io/elmah-io-ai-plugin/" rel="noreferrer">AI Plugin</a> that installs the MCP server together with a handful of ready-made skills and a background monitoring agent. I'll stick to the raw MCP setup here, since that's what most of the examples below build on.</p><h2 id="what-a-real-fix-looks-like-end-to-end">What a real fix looks like end to end</h2><p>Rather than list out the individual things you can ask, let's stick to the scenario specified in the beginning: receiving a new error notification. It usually starts with a Slack message or an email from elmah.io saying a new error was just logged in production. Instead of opening a browser tab, I switch to Claude Code, which already has the MCP server connected. I'll open Claude Code in the base directory containing subfolders for all of elmah.io's sites and services. Then ask it to fetch the error details:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/pull-error-details-from-elmah-io.png" class="kg-image" alt="Pull error details" loading="lazy" width="1987" height="818" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/pull-error-details-from-elmah-io.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/pull-error-details-from-elmah-io.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/pull-error-details-from-elmah-io.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/pull-error-details-from-elmah-io.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>The first time you do this, Claude Code will ask the elmah.io MCP for a list of logs you have access to, to try and find the right log. After running through this workflow for each individual project and log, Claude will remember which log belongs to which project. You can also tell Claude the mapping between folders and log IDs up front and have it save it to memory.</p><p>Claude goes and fetches the actual message, stack trace, URL, and whatever custom data got attached. The same thing I'd otherwise be looking at on app.elmah.io.</p><p>From there I'll usually just ask it to try and fix the error:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/can-you-fix-the-error.png" class="kg-image" alt="Can you fix the error" loading="lazy" width="1987" height="818" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/can-you-fix-the-error.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/can-you-fix-the-error.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/can-you-fix-the-error.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/can-you-fix-the-error.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>Because Claude Code already has the codebase open, it has both the error and the code that caused it in the same context. It can go find the line the stack trace points to, work out why it's throwing, and make the change. This is the part that still feels a little unreal to type out, but it's genuinely how it goes most of the time now. I'm reading a diff instead of writing one.</p><p>Once I'm happy with the fix, I'll have it commit and push, the same way I would with any other change it makes for me. No copy-pasting a stack trace into a chat window, no switching context to go write the fix myself. The whole loop from "here's an error" to "here's a pushed commit" happens in one conversation. I always do a code review of the changes and often still launch the project locally to check that everything is working as expected. In many cases, testing on our staging environment is just as good. Claude also extends or writes a new unit test to verify the fix when it makes sense. And using the <a href="https://github.com/microsoft/playwright-mcp" rel="noreferrer">Playwright MCP server</a>, I often let it take the application for a spin in the browser.</p><p>The part I think people skip when they talk about AI fixing bugs is what happens <strong>after</strong> it's live. That's where I use elmah.io's MCP server just as much. Once the fix has deployed, I'll go back to the same Claude Code session and ask it to monitor if new errors are being logged:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/monitor-if-new-errors-are-logged.png" class="kg-image" alt="Monitor if new errors are being logged" loading="lazy" width="1987" height="818" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/monitor-if-new-errors-are-logged.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/monitor-if-new-errors-are-logged.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/monitor-if-new-errors-are-logged.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/monitor-if-new-errors-are-logged.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>If it's a web app, I'll also ask it to check the uptime checks for that service:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/check-uptime-check.png" class="kg-image" alt="Also check uptime checks" loading="lazy" width="1987" height="818" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/check-uptime-check.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/check-uptime-check.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/check-uptime-check.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/check-uptime-check.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>This is to make sure the deploy itself didn't break anything unrelated. If it's a background service instead, like a scheduled job, Azure Function, or something without a URL to ping, I'll ask about heartbeats instead, since that's how we track whether those are still checking in on schedule:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/check-heartbeats.png" class="kg-image" alt="Also check heartbeats" loading="lazy" width="1987" height="818" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/check-heartbeats.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/check-heartbeats.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1600/2026/07/check-heartbeats.png 1600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/check-heartbeats.png 1987w" sizes="(min-width: 720px) 720px"></figure><p>That follow-up step is a small thing, but it's the one that made this feel trustworthy enough to actually use for real fixes.</p><h2 id="conclusion">Conclusion</h2><p>We build the range of dashboards on app.elmah.io because sometimes, that's the right interface for looking at errors. But the better AI clients are becoming, a good chunk of the time I actually want to look at an error, I'm already in Claude Code doing something else. Switching tools costs more than the two seconds it sounds like. Having elmah.io reachable from the same place I'm already working is a small thing, but it's the kind of small thing that changes how often you actually check on your errors instead of waiting for them to page you.</p><p>In this post, I showed a simple example where Claude Code was able to fix everything from just looking at the error and the codebase. Of course, in real life, it's a cooperation between me as the developer who knows a lot of details about the elmah.io code base and the AI. Simple bugs can be fixed fast by the AI, while more complex issues require more input from me.</p><p>If you want to try it yourself, the <a href="https://docs.elmah.io/add-mcp-server-to-claude-code/" rel="noreferrer">docs page</a> has the same setup steps in reference form, along with the equivalent instructions for Claude Desktop and other MCP-compatible tools.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Modern authentication in ASP.NET Core with 2FA and passkey ]]></title>
        <description><![CDATA[ This walkthrough builds a .NET 10 ASP.NET Core Web API (TwoFaNET10) implementing two-factor authentication and WebAuthn/FIDO2 passkey login, using Microsoft.AspNetCore.Identity.EntityFrameworkCore with Npgsql PostgreSQL, JWT bearer tokens, and a custom ApplicationUser extending IdentityUser. ]]></description>
        <link>https://blog.elmah.io/modern-authentication-in-asp-net-ore-with-2fa-and-passkey/</link>
        <guid isPermaLink="false">6a1be1799009b00001bb4d15</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 04 Aug 2026 09:11:23 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/modern-authentication-in-asp.net-core-with-2FA-and-passkey-o-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/modern-authentication-in-asp-net-ore-with-2fa-and-passkey/">https://blog.elmah.io/modern-authentication-in-asp-net-ore-with-2fa-and-passkey/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>As much as the web is evolving, cyber threats are also growing. Securing user details with a single password may not be enough to stop bad guys. Modern applications require stronger authentication. Two-factor authentication (2FA) and passkeys are two effective ways to apply efficient login. In today's post, I will walk you through how to implement 2FA and passkeys in an ASP.NET Core API. Well, that is going to be a long ride, so stay with me.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/modern-authentication-in-asp.net-core-with-2FA-and-passkey-o.png" class="kg-image" alt="Modern authentication in ASP.NET Core with 2FA and passkey" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/modern-authentication-in-asp.net-core-with-2FA-and-passkey-o.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/modern-authentication-in-asp.net-core-with-2FA-and-passkey-o.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/modern-authentication-in-asp.net-core-with-2FA-and-passkey-o.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="what-is-two-factor-authentication-2fa">What is Two-Factor Authentication (2FA)?</h2><p><a href="https://www.microsoft.com/en-us/security/business/security-101/what-is-two-factor-authentication-2fa" rel="noreferrer">Two-Factor Authentication</a> is a security method that requires a user to provide two forms of identity verification. Unlike the traditional password login, 2FA requires users to confirm their identity using two distinct factors. Its 2-step authentication prevents unauthorized access even if the password is compromised. </p><h2 id="what-is-passkey">What is Passkey?</h2><p>A passkey is a highly secure, passwordless authentication method that provides a simple and secure way to sign in to applications. Instead of password authentication, it uses public-key cryptography. The private key stays securely on the user's device and is unlocked using biometrics (Face ID, fingerprint) or device PIN. Passkeys are based on the WebAuthn (Web Authentication API) standard and follow the FIDO2 specification.</p><h2 id="implementing-2fa-and-passkey-in-aspnet-core-net-10-api">Implementing 2FA and Passkey in ASP.NET Core .NET 10 API</h2><p>I will go with the same project to implement both authentication methods. The following steps are shared between both types.</p><p><strong>Step 1: Create a project</strong></p><pre><code class="language-console">dotnet new web -n TwoFaNET10</code></pre><p><strong>Step 2: Add Swagger</strong></p><p>.NET 10 does not contain Swagger by default. However, I will use it for testing the API.</p><pre><code class="language-console">dotnet add package Swashbuckle.AspNetCore </code></pre><pre><code class="language-csharp">if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options =&gt;
    {
        options.SwaggerEndpoint("/openapi/v1.json", "v1");
    });
}</code></pre><h3 id="two-factor-authentication-in-net-10">Two-factor authentication in .NET 10</h3><p><strong>Step 1: Install required packages </strong></p><pre><code class="language-console">dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Identity.UI
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL</code></pre><p><strong>Step 2: Create models</strong></p><pre><code class="language-csharp">using Microsoft.AspNetCore.Identity;

namespace TwoFaNET10.Models;

public class ApplicationUser : IdentityUser
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public string FullName =&gt;
        $"{FirstName} {LastName}".Trim();
}</code></pre><p><code>IdentityUser</code> is provided by AspNetCore's Identity namespace. Here I am creating the sub-class <code>ApplicationUser</code> with <code>IdentityUser</code> as its parent. It is a built-in user model containing necessary fields such as <code>Email</code> and <code>UserName</code>. With Microsoft's own user class, I don't have to implement password hashing, login, registration, or other authentication operations.</p><p><strong>DTO for registering a user</strong></p><pre><code class="language-csharp">public class RegisterDtoInp
{
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}</code></pre><p><strong>Login model </strong></p><pre><code class="language-csharp">public class LoginDtoInp
{
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public bool RememberMe { get; set; } 
}</code></pre><p><strong>TwoFactorLoginDtoInp</strong></p><pre><code class="language-csharp">public class TwoFactorLoginDtoInp
{
    public string TwoFactorCode { get; set; } = string.Empty;
    public bool RememberMe { get; set; }
    public bool RememberMachine { get; set; } 
}</code></pre><p><strong>Model for recovery of 2FA.</strong></p><pre><code class="language-csharp">public class RecoveryCodeLoginDtoInp
{
    public string RecoveryCode { get; set; } = string.Empty;
}</code></pre><p><strong>Step 3: Create the Data context</strong> </p><pre><code class="language-csharp">using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TwoFaNET10.Models;

namespace TwoFaNET10.Data;

public class ApplicationDbContext: IdentityDbContext&lt;ApplicationUser&gt;
{
    public ApplicationDbContext(DbContextOptions&lt;ApplicationDbContext&gt; options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
    }
}</code></pre><p>By inheriting from <code>IdentityDbContext&lt;ApplicationUser&gt;</code>, I am leveraging the Identity namespace again to manage Identity tables using <code>ApplicationUser</code>.</p><p><strong>Step 4: Add Database connection details</strong></p><p>In <code>appsettings.json</code> add:</p><pre><code class="language-JSON">"ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=TwoFaDb;Username=postgres;Password=pass"
},</code></pre><p>In <code>Program.cs</code>:</p><pre><code class="language-csharp">builder.Services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString));</code></pre><p><strong>Step 5: Define Controller for the 2FA endpoints</strong></p><p>We injected Identity's classes <code>SignInManager</code> and <code>UserManager</code> into the controller. As their names indicate, the former handles authentication operations, while the latter abstracts away users' methods.</p><pre><code class="language-csharp">[HttpPost("register")]
public async Task&lt;IActionResult&gt; Register([FromBody] RegisterDtoInp model)
{
    if (!ModelState.IsValid)
        return ValidationProblem(ModelState);

    var user = new ApplicationUser
    {
        UserName = model.Email,
        Email = model.Email,
        FirstName = model.FirstName,
        LastName = model.LastName,
        CreatedAt = DateTime.UtcNow
    };

    var result = await userManager.CreateAsync(user, model.Password);

    if (result.Succeeded)
    {
        logger.LogInformation("New user registered: {Email}", model.Email);

        return Ok(new
        {
            Success = true,
            Email = model.Email,
            Message = "User registered successfully."
        });
    }

    return BadRequest(result.Errors.Select(x =&gt; x.Description));
}</code></pre><p><code>userManager</code>'s <code>CreateAsync</code> method handles all the user creation abstraction itself. Registration includes validating and hashing the password and saving the user, all within a single method.</p><pre><code class="language-csharp">[HttpPost("login")]
public async Task&lt;IActionResult&gt; Login([FromBody] LoginDtoInp model)
{
    if (!ModelState.IsValid)
        return ValidationProblem(ModelState);

    var result = await signInManager.PasswordSignInAsync(
        model.Email,
        model.Password,
        model.RememberMe,
        lockoutOnFailure: true);

    if (result.Succeeded)
    {
        logger.LogInformation("User {Email} logged in.", model.Email);

        return Ok(new
        {
            Success = true,
            Message = "Login successful."
        });
    }

    if (result.RequiresTwoFactor)
    {
        return Ok(new
        {
            RequiresTwoFactor = true,
            RememberMe = model.RememberMe
        });
    }

    if (result.IsLockedOut)
    {
        return BadRequest(new
        {
            Message = "Account locked out due to multiple failed attempts. Try again later."
        });
    }

    return Unauthorized(new
    {
        Message = "Invalid email or password."
    });
}</code></pre><p>Again, we don't need to peek inside the <code>PasswordSignInAsync</code>. Surprisingly, it will handle login regardless of whether 2FA is enabled. If 5 login attempts go wrong, Identity will lock the account for 5 minutes.</p><pre><code class="language-csharp">[Authorize]
[HttpGet("2fa/setup-uri")]
public async Task&lt;IActionResult&gt; Get2FaUri()
{
    var user = await userManager.GetUserAsync(User);

    if (user == null)
        return Unauthorized();

    var key = await userManager.GetAuthenticatorKeyAsync(user);

    if (string.IsNullOrEmpty(key))
    {
        await userManager.ResetAuthenticatorKeyAsync(user);
        key = await userManager.GetAuthenticatorKeyAsync(user);
    }

    var email = user.Email;

    var uri = $"otpauth://totp/TwoFaNET10:{email}?secret={key}&amp;issuer=TwoFaNET10&amp;digits=6";

    return Ok(new
    {
        qrUri = uri,
        secret = key
    });
}</code></pre><p>This endpoint generates the secret key and QR Code URI that the user scans in their authenticator app. The action is authorized, and the user must be logged in to use this endpoint. First, await <code>userManager.GetUserAsync(User)</code> gets the current user. Again, using <code>userManager</code> to get the user authenticator secret from <code>GetAuthenticatorKeyAsync</code>. If the user does not have one, then <code>ResetAuthenticatorKeyAsync</code> creates a new one. Next, you get a URI:</p><pre><code class="language-csharp">$"otpauth://totp/TwoFaNET10:{email}secret{key}&amp;issuer=TwoFaNET10
&amp;digits=6";</code></pre><p>This <code>qrUri</code> can be used by a library or any other QR code generator to create a QR code.</p><pre><code class="language-csharp">[Authorize]
[HttpPost("2fa/enable")]
public async Task&lt;IActionResult&gt; Enable2FA([FromBody] string code)
{
    var user = await userManager.GetUserAsync(User);

    if (user == null)
        return Unauthorized();

    var result = await userManager.VerifyTwoFactorTokenAsync(
        user,
        TokenOptions.DefaultAuthenticatorProvider,
        code);

    if (!result)
        return BadRequest("Invalid code");

    await userManager.SetTwoFactorEnabledAsync(user, true);

    return Ok(new
    {
        Success = true,
        Message = "2FA enabled"
    });
}</code></pre><p>The enable endpoint is responsible for verifying the QR code. After the user scans the QR code, it will take the 6-digit code you got from the authenticator app. <code>VerifyTwoFactorTokenAsync</code> will check that the code is valid, while the <code>SetTwoFactorEnabledAsync</code> method will enable it for the user. Hence, 2FA is now enabled, and on your next login, ASP.NET Core Identity will prompt you for the code.</p><pre><code class="language-csharp">[HttpPost("2fa/login")]
public async Task&lt;IActionResult&gt; TwoFactorLogin([FromBody] TwoFactorLoginDtoInp model)
{
    if (!ModelState.IsValid)
        return ValidationProblem(ModelState);

    var user = await signInManager.GetTwoFactorAuthenticationUserAsync();

    if (user is null)
        return Unauthorized(new
        {
            Message = "2FA session expired."
        });

    var code = model.TwoFactorCode
        .Replace(" ", "")
        .Replace("-", "");

    var result = await signInManager.TwoFactorAuthenticatorSignInAsync(
        code,
        model.RememberMe,
        model.RememberMachine);

    if (result.Succeeded)
    {
        logger.LogInformation("User {Id} logged in with 2FA.", user.Id);

        return Ok(new
        {
            Success = true,
            Message = "2FA login successful."
        });
    }

    if (result.IsLockedOut)
    {
        return BadRequest(new
        {
            Message = "Account locked out."
        });
    }

    return Unauthorized(new
    {
        Message = "Invalid authenticator code."
    });
}</code></pre><p>In the chronology, next is the login method. <code>GetTwoFactorAuthenticationUserAsync</code> will keep the user after verifying the password while waiting for the one-time password (OTP). I kept the OTP code flexible by allowing spaces or '-' before verifying it with <code>TwoFactorAuthenticatorSignInAsync</code>. Identity will verify the incoming code against the stored secret to authenticate the sign-in. The field <code>RememberMe</code> extends the login session, while <code>RememberMachine</code> prevents the need to enter an OTP on the same device next time.</p><pre><code class="language-csharp">[HttpPost("2fa/recovery-code")]
public async Task&lt;IActionResult&gt; LoginWithRecoveryCode(
    [FromBody] RecoveryCodeLoginDtoInp model)
{
    if (!ModelState.IsValid)
        return ValidationProblem(ModelState);

    var user = await signInManager.GetTwoFactorAuthenticationUserAsync();

    if (user is null)
        return Unauthorized(new
        {
            Message = "2FA session expired."
        });

    var recoveryCode = model.RecoveryCode.Replace(" ", "");

    var result =
        await signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);

    if (result.Succeeded)
    {
        logger.LogInformation(
            "User {Id} logged in with a recovery code.",
            user.Id);

        return Ok(new
        {
            Success = true,
            Message = "Recovery code login successful."
        });
    }

    if (result.IsLockedOut)
    {
        return BadRequest(new
        {
            Message = "Account locked out."
        });
    }

    return Unauthorized(new
    {
        Message = "Invalid recovery code."
    });
}</code></pre><p>The final endpoint is an emergency button. It uses a recovery code generated earlier to set up 2FA again in case the authenticator app is unavailable, or the mobile is lost. <code>TwoFactorRecoveryCodeSignInAsync</code> checks whether the recovery code is unused, since it is a one-time recovery option.</p><p><strong>Step 6: Inject Identity into the dependencies</strong></p><p>In <code>Program.cs</code> add the following code:</p><pre><code class="language-csharp">builder.Services.AddIdentity&lt;ApplicationUser, IdentityRole&gt;()
    .AddEntityFrameworkStores&lt;ApplicationDbContext&gt;()
    .AddDefaultTokenProviders();</code></pre><p><strong>Step 7: Run migrations</strong></p><pre><code class="language-console">dotnet ef migrations add Initial</code></pre><p>To reflect the migration.</p><pre><code class="language-console">dotnet ef database update</code></pre><p><strong>Step 8: Run the project</strong></p><pre><code class="language-console">dotnet run</code></pre><p>Our Swagger UI is ready.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-37.png" class="kg-image" alt="Swagger" loading="lazy" width="1335" height="526" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-37.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-37.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-37.png 1335w" sizes="(min-width: 720px) 720px"></figure><p>First, register a user.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-35.png" class="kg-image" alt="Register user request" loading="lazy" width="688" height="499" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-35.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-35.png 688w"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-36.png" class="kg-image" alt="Register user response" loading="lazy" width="795" height="199" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-36.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-36.png 795w" sizes="(min-width: 720px) 720px"></figure><p>Now log in.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-38.png" class="kg-image" alt="Login request" loading="lazy" width="1335" height="475" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-38.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-38.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-38.png 1335w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-39.png" class="kg-image" alt="Login response" loading="lazy" width="610" height="212" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-39.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-39.png 610w"></figure><p>If the credentials are wrong.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-40.png" class="kg-image" alt="Login request" loading="lazy" width="1311" height="489" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-40.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-40.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-40.png 1311w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-41.png" class="kg-image" alt="Login response" loading="lazy" width="678" height="199" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-41.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-41.png 678w"></figure><p>On multiple invalid attempts, we get.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-20.png" class="kg-image" alt="Login response" loading="lazy" width="986" height="364" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-20.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-20.png 986w" sizes="(min-width: 720px) 720px"></figure><p>Two-factor authentication URI.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-42.png" class="kg-image" alt="Setup URI request" loading="lazy" width="1323" height="321" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-42.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-42.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-42.png 1323w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-43.png" class="kg-image" alt="Setup URI response" loading="lazy" width="1283" height="374" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-43.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-43.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-43.png 1283w" sizes="(min-width: 720px) 720px"></figure><p>Still, my user is already logged in. The endpoint is decorated with <code>[Authorize]</code>. I am using an external website to generate the QR code. You can use the <code>QRCoder</code> package in .NET as well, but now let's keep things simple.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-44.png" class="kg-image" alt="QR code" loading="lazy" width="1338" height="488" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-44.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-44.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-44.png 1338w" sizes="(min-width: 720px) 720px"></figure><p>I scanned the code in my Microsoft Authenticator app, and the account was added. The one-time password code is visible to me, and I can enable 2FA through the API.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-45.png" class="kg-image" alt="Enable 2FA request" loading="lazy" width="1321" height="429" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-45.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-45.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-45.png 1321w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-46.png" class="kg-image" alt="Enable 2FA response" loading="lazy" width="511" height="223"></figure><p>2FA is now enabled. Let's log in again.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-47.png" class="kg-image" alt="Login request" loading="lazy" width="1316" height="500" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-47.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-47.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-47.png 1316w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-48.png" class="kg-image" alt="Login response" loading="lazy" width="647" height="228" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-48.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-48.png 647w"></figure><p>You can see the difference: earlier, login was direct without 2FA and immediately returned. </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-39.png" class="kg-image" alt="Login successful" loading="lazy" width="610" height="212" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-39.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-39.png 610w"></figure><p>But now we have to use 2FA login.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-49.png" class="kg-image" alt="Login request" loading="lazy" width="695" height="498" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-49.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-49.png 695w"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-50.png" class="kg-image" alt="Login response" loading="lazy" width="765" height="354" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-50.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-50.png 765w" sizes="(min-width: 720px) 720px"></figure><h3 id="passkey-authentication-in-net-10">Passkey authentication in .NET 10</h3><p>.NET allows passkey implementation for WebAuthn/FIDO compliance. Let's go through it step by step.</p><p><strong>Step 1: Install packages</strong></p><pre><code class="language-console">dotnet add package Fido2
dotnet add package Microsoft.AspNetCore.Session</code></pre><p>The <code>Fido2</code> package handles passkey tasks of enabling passkey, Windows Hello, Face ID, and passwordless authentication. While <code>AspNetCore.Session</code> provides session storage for Fido2 challengers.</p><p><strong>Step 2: Create models</strong></p><p><strong>StoredCredential</strong></p><pre><code class="language-csharp">using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace TwoFaNET10.Models;

public class StoredCredential
{
    public int Id { get; set; }

    [Required]
    public string UserId { get; set; } = string.Empty;

    [Required]
    public byte[] CredentialId { get; set; } = Array.Empty&lt;byte&gt;();

    [Required]
    public byte[] PublicKey { get; set; } = Array.Empty&lt;byte&gt;();

    public uint SignatureCounter { get; set; }

    public string CredType { get; set; } = string.Empty;

    public DateTime RegDate { get; set; } = DateTime.UtcNow;

    public Guid AaGuid { get; set; }

    public byte[] UserHandle { get; set; } = Array.Empty&lt;byte&gt;();

    public string? FriendlyName { get; set; }

    [ForeignKey(nameof(UserId))]
    public ApplicationUser User { get; set; } = null!;
}</code></pre><p>It stores a user's passkey information. <code>CredentialId</code> is a unique identifier of the passkey. When a user logs in, the server looks up the incoming credential ID. The <code>Publickey</code> is saved on the server that verifies using the private key saved on the user's device. <code>CredentialType</code> is another important field specifying the type of WebAuthn credential, in our case, it is <code>PublicKey</code>.</p><p><strong>CompletePasskeyRegistrationDtoInp</strong></p><pre><code class="language-csharp">using Fido2NetLib;

namespace TwoFaNET10.Models.Dtos;

public class CompletePasskeyRegistrationDtoInp
{
    public AuthenticatorAttestationRawResponse AttestationResponse { get; set; } = null!;
    public string? FriendlyName { get; set; }
}</code></pre><p><strong>PasskeyAssertionRequest</strong></p><pre><code class="language-csharp">namespace TwoFaNET10.Models.Dtos;

public record PasskeyAssertionRequest(string? Username);</code></pre><p>A simple value type was enough here, so I used a <a href="https://blog.elmah.io/exploring-c-records-and-their-use-cases/" rel="noreferrer">record</a>.</p><p><strong>Step 3: Add a new table to the context</strong></p><pre><code class="language-csharp">public DbSet&lt;StoredCredential&gt; StoredCredentials =&gt; Set&lt;StoredCredential&gt;();</code></pre><p><strong>Step 4: Add Fido2 configurations </strong></p><p>Add the following object to <code>appsettings.json</code>.</p><pre><code class="language-JSON">"Fido2": {
  "ServerDomain": "localhost",
  "ServerName": "TwoFaNET10",
  "Origins": [
    "http://localhost:5220"
  ]
},</code></pre><p>And, in <code>Program.cs</code> add the following code.</p><pre><code class="language-csharp">builder.Services.AddFido2(options =&gt;
{
    options.ServerDomain = builder.Configuration["Fido2:ServerDomain"] ?? "localhost";
    options.ServerName = builder.Configuration["Fido2:ServerName"] ?? "TwoFactorAuth";
    options.Origins = builder.Configuration.GetSection("Fido2:Origins").Get&lt;HashSet&lt;string&gt;&gt;()
                      ?? [];
    options.TimestampDriftTolerance = builder.Configuration.GetValue&lt;int&gt;("Fido2:TimestampDriftTolerance", 300000);
});</code></pre><p><strong>Step 5: Add Distributed cache </strong></p><p>Passkeys are set in the session cache, so injecting the cache in <code>Program.cs</code>. For production APIs, use an <a href="https://blog.elmah.io/caching-strategies-in-asp-net-core/" rel="noreferrer">in-memory cache</a> or Redis.</p><pre><code class="language-csharp">builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
builder.Services.AddHttpContextAccessor();

app.UseSession();</code></pre><p>The HTTP context is needed to fetch the logged-in user's session.</p><p><strong>Step 6: Define Passkey service</strong></p><p>Service interface</p><pre><code class="language-csharp">using Fido2NetLib;
using TwoFaNET10.Models;

namespace TwoFaNET10.Services;

public interface IPasskeyService
{
    Task&lt;CredentialCreateOptions&gt; GetAttestationOptionsAsync(ApplicationUser user);

    Task&lt;(bool Success, string Error)&gt; MakeCredentialAsync(
        AuthenticatorAttestationRawResponse attestationResponse,
        ApplicationUser user,
        string? friendlyName);

    Task&lt;AssertionOptions&gt; GetAssertionOptionsAsync(string? username);

    Task&lt;(bool Success, string UserId, string Error)&gt; MakeAssertionAsync(
        AuthenticatorAssertionRawResponse assertionResponse);
}</code></pre><p>Implementation</p><pre><code class="language-csharp">using System.Text;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.EntityFrameworkCore;
using TwoFaNET10.Data;
using TwoFaNET10.Models;

namespace TwoFaNET10.Services;

public class PasskeyService(
    IFido2 fido2,
    ApplicationDbContext db,
    IHttpContextAccessor httpContextAccessor,
    ILogger&lt;PasskeyService&gt; logger) : IPasskeyService
{
    private const string AttestationKey = "fido2.attestationOptions";
    private const string AssertionKey = "fido2.assertionOptions";
    public async Task&lt;CredentialCreateOptions&gt; GetAttestationOptionsAsync(ApplicationUser user)
    {
        var existingCredentials = await db.StoredCredentials
            .Where(c =&gt; c.UserId == user.Id)
            .ToListAsync();

        var excludeCredentials = existingCredentials
            .Select(c =&gt; new PublicKeyCredentialDescriptor(c.CredentialId))
            .ToList();

        var fido2User = new Fido2User
        {
            Id = Encoding.UTF8.GetBytes(user.Id),
            Name = user.Email!,
            DisplayName = user.FullName
        };

        var authenticatorSelection = new AuthenticatorSelection
        {
            UserVerification = UserVerificationRequirement.Required,
            ResidentKey = ResidentKeyRequirement.Required,
            RequireResidentKey = true
        };

        var options = fido2.RequestNewCredential(new RequestNewCredentialParams
        {
            User = fido2User,
            AuthenticatorSelection = authenticatorSelection,
            ExcludeCredentials = excludeCredentials,
            AttestationPreference = AttestationConveyancePreference.None
        });

        // DO NOT rely on session in production (still ok for dev)
        Session.SetString(AttestationKey, options.ToJson());

        return options;
    }
    
    public async Task&lt;(bool Success, string Error)&gt; MakeCredentialAsync(
        AuthenticatorAttestationRawResponse attestationResponse,
        ApplicationUser user,
        string? friendlyName)
    {
        try
        {
            var json = Session.GetString(AttestationKey);
            if (string.IsNullOrEmpty(json))
                return (false, "Session expired. Please try again.");

            var options = CredentialCreateOptions.FromJson(json);

            var existingCredIds = await db.StoredCredentials
                .Select(x =&gt; x.CredentialId)
                .ToListAsync();

            var credential = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
            {
                AttestationResponse = attestationResponse,
                OriginalOptions = options,

                IsCredentialIdUniqueToUserCallback = (args, ct) =&gt;
                {
                    return Task.FromResult(
                        !existingCredIds.Any(id =&gt; id.SequenceEqual(args.CredentialId))
                    );
                }
            });

            db.StoredCredentials.Add(new StoredCredential
            {
                UserId = user.Id,
                CredentialId = credential.Id,
                PublicKey = credential.PublicKey,
                SignatureCounter = credential.SignCount,
                CredType = credential.Type.ToString(),
                RegDate = DateTime.UtcNow,
                AaGuid = credential.AaGuid,
                UserHandle = credential.User.Id,
                FriendlyName = string.IsNullOrWhiteSpace(friendlyName)
                    ? "My Passkey"
                    : friendlyName
            });

            await db.SaveChangesAsync();

            return (true, string.Empty);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error making credential for user {UserId}", user.Id);
            return (false, ex.Message);
        }
    }
    
    public async Task&lt;AssertionOptions&gt; GetAssertionOptionsAsync(string? username)
    {
        var allowedCredentials = new List&lt;PublicKeyCredentialDescriptor&gt;();

        if (!string.IsNullOrWhiteSpace(username))
        {
            var user = await db.Users.FirstOrDefaultAsync(u =&gt; u.Email == username);
            if (user is not null)
            {
                var userCredentials = await db.StoredCredentials
                    .Where(c =&gt; c.UserId == user.Id)
                    .ToListAsync();

                allowedCredentials = userCredentials
                    .Select(c =&gt; new PublicKeyCredentialDescriptor(c.CredentialId))
                    .ToList();
            }
        }
        var assertionOpt = new GetAssertionOptionsParams
        {
            AllowedCredentials = allowedCredentials,
            UserVerification = UserVerificationRequirement.Preferred
        };
        var options = fido2.GetAssertionOptions(assertionOpt);

        Session.SetString(AssertionKey, options.ToJson());
        return options;
    }
    
    public async Task&lt;(bool Success, string UserId, string Error)&gt; MakeAssertionAsync(
        AuthenticatorAssertionRawResponse assertionResponse)
    {
        try
        {
            var json = Session.GetString(AssertionKey);
            if (string.IsNullOrEmpty(json))
                return (false, string.Empty, "Session expired. Please try again.");

            var options = AssertionOptions.FromJson(json);

            var storedCred = await db.StoredCredentials
                .FirstOrDefaultAsync(c =&gt; c.CredentialId == assertionResponse.RawId);

            if (storedCred is null)
                return (false, string.Empty, "Passkey not found.");

            var result = await fido2.MakeAssertionAsync(new MakeAssertionParams
            {
                AssertionResponse = assertionResponse,
                OriginalOptions = options,
                StoredPublicKey = storedCred.PublicKey,
                StoredSignatureCounter = storedCred.SignatureCounter,

                IsUserHandleOwnerOfCredentialIdCallback = (args, ct) =&gt;
                {
                    return Task.FromResult(
                        storedCred.UserHandle.SequenceEqual(args.UserHandle)
                    );
                }
            });

            storedCred.SignatureCounter = result.SignCount;
            await db.SaveChangesAsync();

            return (true, storedCred.UserId, string.Empty);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error verifying passkey assertion");
            return (false, string.Empty, ex.Message);
        }
    }
    
    private ISession Session =&gt;
        httpContextAccessor.HttpContext?.Session
        ?? throw new InvalidOperationException("HttpContext session is not available.");
}</code></pre><p><code>GetAttestationOptionsAsync</code> generates the WebAuthn registration challenge for a logged-in user. The method converts their credential IDs into <code>excludeCredentials</code> after fetching the user's existing passkeys. Then it builds <code>Fido2User</code> from user ID, email, and display name into the format <code>Fido2NetLib</code> needs. Setting <code>UserVerification</code> and <code>ResidentKey</code> to Required will enforce a biometric PIN and enable passwordless login later. <code>fido2.RequestNewCredential</code> builds an <code>CredentialCreateOptions</code> object that is saved in the session and also returned to the API response. The response object contains the Fido user, challenge, timeout milliseconds, and other fields.</p><p><code>MakeCredentialAsync</code> verifies and stores a newly created passkey. First, it gets <code>CredentialCreateOptions</code> from the session. Now it loads all the credential IDs from the database for a uniqueness check. <code>fido2.MakeNewCredentialAsync</code> validates challenges. origin matches, public key, and verifies <code>CredentialId</code>'s uniqueness. <code>IsCredentialIdUniqueToUserCallback</code> ensures the credential isn't already registered, though it is very unlikely, <code>Fido2NetLib</code> requires its implementation. Upon success, passkey details such as credential ID, public key, initial sign counter, and AAGUID (which identifies the authenticator model) are saved in the database.</p><p><code>GetAssertionOptionsAsync</code> starts the login process by generating a WebAuthn challenge. If a username is provided, we first retrieve the user's details and allowed credentials. Then the server sends the list of allowed credentials to the browser, which it uses for authentication. If there is no username, the list will be empty, and we will move towards passwordless login. With a username, the server tells the authenticator which credential(s) to use. Without a username, the authenticator lets the user choose a passkey for the website and signs the server's challenge. The server then verifies the signed response using the stored public key.</p><p><code>MakeAssertionAsync</code> verifies the login attempt and identifies the user. It retrieves <code>AssertionOptions</code> from the session and looks up the <code>StoredCredential</code> row by matching <code>CredentialId == assertionResponse.RawId</code>. If no matching credential exists, login fails immediately. <code>fido2.MakeAssertionAsync</code> validates the challenge and origin, verifies the signature using the stored public key, and checks the sign counter for clone detection. <code>IsUserHandleOwnerOfCredentialIdCallback</code> verifies if the credentials belong to the correct user. Finally, after updating the <code>SignatureCounter</code>, I save the changes to the database.</p><p>A lambda expression is setting the session from the HTTP context.</p><p><strong>Step 7: Add Controller methods</strong></p><pre><code class="language-csharp">[Authorize]
[HttpPost("passkey/attestation-options")]
public async Task&lt;IActionResult&gt; GetPasskeyAttestationOptions()
{
    var user = await userManager.GetUserAsync(User);

    if (user is null)
        return Unauthorized(new { Success = false, Error = "User not found." });

    var options = await passkeyService.GetAttestationOptionsAsync(user);

    return Ok(options);
}

[Authorize]
[HttpPost("passkey/attestation-verify")]
public async Task&lt;IActionResult&gt; VerifyPasskeyAttestation(
    [FromBody] CompletePasskeyRegistrationDtoInp request)
{
    var user = await userManager.GetUserAsync(User);

    if (user is null)
        return Unauthorized(new { Success = false, Error = "User not found." });

    var (success, error) = await passkeyService.MakeCredentialAsync(
        request.AttestationResponse,
        user,
        request.FriendlyName);

    if (!success)
        return BadRequest(new { Success = false, Error = error });

    return Ok(new
    {
        Success = true,
        Message = "Passkey registered successfully."
    });
}

[HttpPost("passkey/assertion-options")]
public async Task&lt;IActionResult&gt; GetPasskeyAssertionOptions(
    [FromBody] PasskeyAssertionRequest request)
{
    var options = await passkeyService.GetAssertionOptionsAsync(request.Username);
    return Ok(options);
}

[HttpPost("passkey/assertion-verify")]
public async Task&lt;IActionResult&gt; PasskeyAssertionVerify(
    [FromBody] AuthenticatorAssertionRawResponse assertionResponse)
{
    var (success, userId, error) =
        await passkeyService.MakeAssertionAsync(assertionResponse);

    if (!success)
        return BadRequest(new { Success = false, Error = error });

    var user = await userManager.FindByIdAsync(userId);

    if (user is null)
        return NotFound(new { Success = false, Error = "User not found." });

    await signInManager.SignInAsync(user, false);

    return Ok(new { Success = true });
}
</code></pre><p><code>attestation-options</code> starts passkey registration. It is an authenticated endpoint. Registering a passkey is an account management action, user must login before adding a new credential to that account. Calls <code>GetAttestationOptionsAsync</code> and returns the options JSON directly to the browser, which feeds it into <code>navigator.credentials.create()</code>.</p><p><code>attestation-verify</code> ties the new credential to the currently logged-in user. It calls <code>MakeCredentialAsync</code> with the browser's attestation response, the current user, and an optional friendly name (e.g. "My Phone"). Validates credentials with Fido2, saves it in the database, and returns success or failure. As a result, the passkey is now registered for the user and stored in the database. The device also stores the private key.</p><p><code>assertion-options</code> is the actual passkey login endpoint. The user logs in here; hence, it is unauthenticated. It calls the underlying <code>GetAssertionOptionsAsync</code> method and returns the challenge options to the browser for <code>navigator.credentials.get()</code>.</p><p><code>assertion-verify</code> is the final step in the login process. It relies on the service's <code>MakeAssertionAsync</code> method to retrieve the userId and success. After confirming the success in the service layer, it fetches the user and signs them in.</p><p><strong>Step 8: Run migrations</strong></p><pre><code class="language-console">dotnet ef migrations add PasskeyInitial</code></pre><p>To reflect the migration.</p><pre><code class="language-console">dotnet ef database update</code></pre><p>A glance at the database schema.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-22.png" class="kg-image" alt="Schema" loading="lazy" width="336" height="246"></figure><p><strong>Step 9: Run the project</strong></p><pre><code class="language-console">dotnet run</code></pre><p>Let's run the project again and be unauthorized.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-17.png" class="kg-image" alt="checkAuth request" loading="lazy" width="1319" height="327" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-17.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-17.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-17.png 1319w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-18.png" class="kg-image" alt="checkAuth response" loading="lazy" width="1071" height="373" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-18.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-18.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-18.png 1071w" sizes="(min-width: 720px) 720px"></figure><p>Now the passkey.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-51.png" class="kg-image" alt="Swagger" loading="lazy" width="1320" height="319" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-51.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-51.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-51.png 1320w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-52-1.png" class="kg-image" alt="Swagger" loading="lazy" width="747" height="476" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-52-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-52-1.png 747w" sizes="(min-width: 720px) 720px"></figure><p><code>navigator.credentials.create()</code> and <code>navigator.credentials.get()</code> are browser WebAuthn APIs, not HTTP calls. Swagger can hit your <code>attestation-options</code>/<code>assertion-options</code> endpoints fine as they're just JSON, but the actual "talk to the authenticator/fingerprint/phone/security key" step only exists in a browser's JS engine, and you cannot trigger that from Swagger's UI or curl.</p><p>Run the following in the browser console.</p><pre><code class="language-js">function base64UrlToBase64(input) {
  input = input.replace(/-/g, '+').replace(/_/g, '/');
  const pad = input.length % 4;
  if (pad) input += '='.repeat(4 - pad);
  return input;
}

function bufferToBase64url(buffer) {
  const bytes = new Uint8Array(buffer);
  let str = '';
  for (const b of bytes) str += String.fromCharCode(b);
  return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// 1. Get attestation options (must be logged in via session/cookie)
const options = await fetch("/api/Account/passkey/attestation-options", {
  method: "POST",
  headers: { "Content-Type": "application/json" }
}).then(r =&gt; r.json());

// 2. Convert challenge and user.id to ArrayBuffer
options.challenge = Uint8Array.from(
  atob(base64UrlToBase64(options.challenge)),
  c =&gt; c.charCodeAt(0)
);
options.user.id = Uint8Array.from(
  atob(base64UrlToBase64(options.user.id)),
  c =&gt; c.charCodeAt(0)
);

if (options.excludeCredentials) {
  options.excludeCredentials = options.excludeCredentials.map(c =&gt; ({
    ...c,
    id: Uint8Array.from(atob(base64UrlToBase64(c.id)), c =&gt; c.charCodeAt(0))
  }));
}

// 3. Create credential - THIS is what triggers the "create a passkey" prompt
const credential = await navigator.credentials.create({ publicKey: options });

// 4. Build payload and send to attestation-verify
const attestationResponse = {
  id: credential.id,
  rawId: bufferToBase64url(credential.rawId),
  type: credential.type,
  clientExtensionResults: credential.getClientExtensionResults(),
  response: {
    clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
    attestationObject: bufferToBase64url(credential.response.attestationObject),
    transports: credential.response.getTransports ? credential.response.getTransports() : []
  }
};

const verifyResult = await fetch("/api/Account/passkey/attestation-verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    attestationResponse: attestationResponse,
    friendlyName: "My Phone"
  })
}).then(r =&gt; r.json());

console.log(verifyResult);</code></pre><p>The script first calls attestation-options with the logged-in user and gets the user details and challenge. The server has stored the challenge and sent it to the browser. For further use, convert the received <code>Base64Url</code> strings to <code>Uint8Array</code>, as WebAuthn APIs expect ArrayBuffer. <code>navigator.credentials.create()</code> cannot work with strings, so we had to go the extra mile. In the said function, the browser inputs the options containing challenge, user, and RP information. The browser calls Windows Hello or Face ID (Windows Hello in our case) to prompt for a passkey. It stores the private key safely and returns a credential object to the browser. Then I created <code>attestationResponse</code> by converting the binary credential data into JSON using <code>bufferToBase64url</code> before sending it to <code>attestation-verify</code>. The server will validate the challenge and return a success or failure message. The user's passkey is registered now.</p><p>A Window Hello appeared where I added my passkey.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/Screenshot-2026-06-13-124045.png" class="kg-image" alt="Windows Hello" loading="lazy" width="449" height="292"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/Screenshot-2026-06-13-124139.png" class="kg-image" alt="Windows Hello" loading="lazy" width="449" height="562"></figure><p>Now the passkey is saved, and the browser console shows. </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-15-1.png" class="kg-image" alt="Result" loading="lazy" width="449" height="24"></figure><p>Now assertion.</p><pre><code class="language-js">const options = await fetch("/api/Account/passkey/assertion-options", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username: "ali@gmaail.com" })
}).then(r =&gt; r.json());

options.challenge = Uint8Array.from(atob(base64UrlToBase64(options.challenge)), c =&gt; c.charCodeAt(0));
options.allowCredentials = options.allowCredentials.map(c =&gt; ({
  ...c,
  id: Uint8Array.from(atob(base64UrlToBase64(c.id)), c =&gt; c.charCodeAt(0))
}));

const assertion = await navigator.credentials.get({ publicKey: options });

const assertionResponse = {
  id: assertion.id,
  rawId: bufferToBase64url(assertion.rawId),
  type: assertion.type,
  clientExtensionResults: assertion.getClientExtensionResults(),
  response: {
    clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
    authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
    signature: bufferToBase64url(assertion.response.signature),
    userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : null
  }
};

const verifyResult = await fetch("/api/Account/passkey/assertion-verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(assertionResponse)
}).then(r =&gt; r.json());

console.log(verifyResult);</code></pre><p><code>assertion-options</code> will do its job of loading the user's passkeys from <code>StoredCredentials</code> and stores a newly generated challenge in the session. The browser gets an object containing the challenge and allowed credentials. Similar to the earlier script, convert the received data into binary in the input of <code>navigator.credentials.get</code> that is responsible for getting PIN, passkey, or touch finger sensor from user prompt. Windows Hello finds the credential specified in <code>allowCredentials</code> verifies user input, retrieves the private key, and signs the challenge. Note that the private key never leaves Windows Hello and is used to sign the challenge. <code>assertionResponse</code> is prepared and passed as a parameter in <code>assertion-verify</code> by which the server verifies the signature using the public key and user data. Finally, it creates an authentication cookie, and the user is now logged in.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-21.png" class="kg-image" alt="Windows Hello" loading="lazy" width="443" height="556"></figure><p>Once the passkey was inserted, the follow appeared in the console.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-16-1.png" class="kg-image" alt="Result" loading="lazy" width="229" height="28"></figure><p>Now our authorized endpoint will reply with a success.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-19.png" class="kg-image" alt="Swagger" loading="lazy" width="552" height="338"></figure><h2 id="conclusion">Conclusion</h2><p>The advent of 2FA and passkeys significantly improves both security and user experience. Both play a brilliant role in reducing password vulnerabilities in modern applications. .NET provides extensive support for integrating these security measures into projects. In this post, I discussed 2FA and passkeys, followed by a step-by-step implementation in an ASP.NET Core API.</p><p>Code: <a href="https://github.com/elmahio-blog/TwoFaNET10">https://github.com/elmahio-blog/TwoFaNET10</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Simplifying file logging in ASP.NET Core with Serilog ]]></title>
        <description><![CDATA[ Add file logging to an ASP.NET Core API with Serilog: configure the File sink, enable daily rolling logs, move settings to appsettings.json, control log levels, and enrich entries with machine name and user context. Includes a startup-exception logging example and production tips. ]]></description>
        <link>https://blog.elmah.io/simplifying-file-logging-in-asp-net-core-with-serilog/</link>
        <guid isPermaLink="false">6a22f60ccd964a0001d467e4</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 16 Jun 2026 07:52:12 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/simplifying-file-logging-in-asp.net-core-with-serilog-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/simplifying-file-logging-in-asp-net-core-with-serilog/">https://blog.elmah.io/simplifying-file-logging-in-asp-net-core-with-serilog/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>Imagine a bug suddenly arises in the frontend from an endpoint. The endpoint has multiple layers and involves dozens of files. Now, you are tasked to resolve that bug, those piles of code make it difficult to pinpoint the cause of the error. Some exceptions, like 'Object reference not set to an instance of an object" are vague and hard to detect. You need a detailed log of API operations to identify the exact code and reason for the error. Serilog is one fine choice for logging that we will use to implement file logging in ASP .NET Core API.  </p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/simplifying-file-logging-in-asp.net-core-with-serilog-o-1.png" class="kg-image" alt="Simplifying file logging in ASP.NET Core with Serilog" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/simplifying-file-logging-in-asp.net-core-with-serilog-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/simplifying-file-logging-in-asp.net-core-with-serilog-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/simplifying-file-logging-in-asp.net-core-with-serilog-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="why-use-serilog">Why use Serilog?</h2><p>Serilog is a powerful logging library for .NET applications. It generates structured logs that are both human- and machine-readable, and you can easily filter and query them. Besides, it provides an array of sink options, including Console, File, SQL, Elasticsearch, and elmah.io.</p><h2 id="implementing-serilog-file-logging-in-asp-net-core-api">Implementing Serilog file logging in ASP .NET Core API</h2><p>I will start with a basic ASP.NET 10 API with simple logging code. Later, I will try to cover important use cases for real-world applications. </p><p><strong>Step 1: Create an API project</strong></p><pre><code class="language-console">dotnet new web -n SerilogFileLog </code></pre><p><strong>Step 2: Install required packages</strong></p><pre><code class="language-console">dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.File</code></pre><p>They are Serilog packages, and later on, logs will be written to files.</p><p>I am fond of Swagger, but .NET 10 does not provide Swagger Ui by default, hence I am using the package. To keep the work similar to the readers, I will use Swagger, as most developers are already familiar with it.</p><pre><code class="language-console">dotnet add package Swashbuckle.AspNetCore </code></pre><p><strong>Step 3: Define Controller</strong></p><pre><code class="language-csharp">using Microsoft.AspNetCore.Mvc;

namespace SerilogFileLog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class WeatherController : ControllerBase
{
    private readonly ILogger&lt;WeatherController&gt; _logger;

    public WeatherController(ILogger&lt;WeatherController&gt; logger)
    {
        _logger = logger;
    }

    [HttpGet]
    public IActionResult Get()
    {
        _logger.LogInformation("Weather endpoint called");

        return Ok("Hello Serilog");
    }
}</code></pre><p>The controller is simple, so we can focus on our main topic for now. <code>ILogger</code> object <code>_logger</code> is responsible for writing messages to logs. A naive way would be <code>Log.Information("Weather endpoint called");</code>using Serilog's global logger, which does not require injection. However, it does not give the context of the class where the error originates. Injecting a logger in classes is recommended for production environments.</p><p><strong>Step 4: Configure Program.cs</strong></p><pre><code class="language-csharp">using Serilog;
var builder = WebApplication.CreateBuilder(args);

Log.Logger = new LoggerConfiguration()
    .WriteTo.File("logs/log.txt")
    .CreateLogger();

builder.Host.UseSerilog();

builder.Services.AddOpenApi();

// Services
builder.Services.AddControllers();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options =&gt;
        {
            options.SwaggerEndpoint("/openapi/v1.json", "v1");
        }
    );
}

app.UseHttpsRedirection();

app.MapControllers();
app.Run();</code></pre><p>So, I am adding a file <code>logs/log.txt</code> for the log, followed by <code>builder.Host.UseSerilog();</code> injecting Serilog. I used <code>app.UseSwaggerUI</code> for Swagger UI here.</p><p><strong>Step 5: run the project</strong></p><pre><code class="language-console">dotnet run</code></pre><p><strong>Result</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-3.png" class="kg-image" alt="API" loading="lazy" width="1330" height="401" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-3.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-3.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-3.png 1330w" sizes="(min-width: 720px) 720px"></figure><p>A new log is created in the solution explorer.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-2.png" class="kg-image" alt="Solution Explorer" loading="lazy" width="240" height="269"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image.png" class="kg-image" alt="File" loading="lazy" width="1267" height="374" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image.png 1267w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-1.png" class="kg-image" alt="File" loading="lazy" width="1166" height="278" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-1.png 1166w" sizes="(min-width: 720px) 720px"></figure><h3 id="rolling-day-logs">Rolling day logs</h3><p>A good practice in logging is to keep daily logs in a separate file. Serilog allows us to do so. Just change the logging snippet.</p><pre><code class="language-csharp">Log.Logger = new LoggerConfiguration()
    .WriteTo.File(
        "logs/log-.txt",
        rollingInterval: RollingInterval.Day
        )
    .CreateLogger();</code></pre><p>A new daily file will be created.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-4.png" class="kg-image" alt="Logs" loading="lazy" width="204" height="77"></figure><h3 id="logging-configurations-from-appsettingsjson">Logging configurations from appsettings.json</h3><p>Let's move to a production-level example. I will add the log details in appsettings. </p><pre><code class="language-JSON">{
  "Serilog": {
    "MinimumLevel": "Information",
    "WriteTo": [
      {
        "Name": "File",
        "Args": {
          "path": "logs/log-.log",
          "rollingInterval": "Day"
        }
      }
    ]
  },
 //Other appsetting fields
}</code></pre><p><code>MinimumLevel</code> controls the lowest log level that will be written. Also, I moved the rolling interval here. Other options for rolling are </p><ul><li><code>Minute</code></li><li><code>Hour</code></li><li><code>Day</code>&nbsp;</li><li><code>Month</code></li><li><code>Year</code></li><li><code>Infinite</code></li></ul><p>and a slight change in the code.</p><pre><code class="language-csharp">Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .CreateLogger();</code></pre><h3 id="levels-of-serilog-logs">Levels of Serilog logs </h3><p>Serilog defines logs in different severity levels as follows, from lowest to highest</p><p><strong>Verbose</strong>: Detailed diagnostic logs<br><strong>Debug</strong>: Developer troubleshooting information<br><strong>Information</strong>: Normal application events<br><strong>Warning</strong>: Potential issue detected<br><strong>Error</strong>: Operation failed unexpectedly<br><strong>Fatal</strong>: Application cannot continue</p><p>By setting <code>MinimalLevel</code> to Information, we will ignore Verbose and Debug messages and will keep the higher ones. </p><p>Our new log file is.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-6.png" class="kg-image" alt="log" loading="lazy" width="227" height="104"></figure><p>And its content.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-5.png" class="kg-image" alt="log file" loading="lazy" width="1280" height="451" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-5.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-5.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-5.png 1280w" sizes="(min-width: 720px) 720px"></figure><h3 id="why-log-is-recommended-over-txt">Why <code>.log</code> is recommended over <code>.txt</code>?</h3><p>Log is an industry standard for application logs, and it is easy to identify log files. Many log viewers and monitoring tools automatically recognize <code>.log</code>.</p><h3 id="logging-on-errors">Logging on Errors</h3><p>Now, define a method in the controller that may throw an error.</p><pre><code class="language-csharp">[HttpGet("GetDivision")]
    public IActionResult GetDivision(int number)
    {
        try
        {
            _logger.LogInformation("GetDivision endpoint called with number: {Number}", number);

            int result = 10 / number;

            _logger.LogInformation(
                "Division completed successfully. Result: {Result}",
                result);

            return Ok(new
            {
                Success = true,
                Result = result
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex,
                "An error occurred in GetDivision. Input Number: {Number}",
                number);

            return StatusCode(StatusCodes.Status500InternalServerError, new
            {
                Success = false,
                Message = ex.Message
            });
        }
    }</code></pre><p> <code>LogError</code> in the catch block will write as an error message.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-10.png" class="kg-image" alt="API" loading="lazy" width="1294" height="482" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-10.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-10.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-10.png 1294w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-11.png" class="kg-image" alt="Result" loading="lazy" width="690" height="341" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-11.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-11.png 690w"></figure><p>Erroneous condition.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-8-1.png" class="kg-image" alt="API" loading="lazy" width="1298" height="447" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-8-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-8-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-8-1.png 1298w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-9.png" class="kg-image" alt="Result" loading="lazy" width="632" height="251" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-9.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-9.png 632w"></figure><p>Well, let's check the logs now.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-7.png" class="kg-image" alt="Log" loading="lazy" width="1152" height="429" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-7.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-7.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-7.png 1152w" sizes="(min-width: 720px) 720px"></figure><p>Or the complete message is. </p><pre><code class="language-log">2026-06-06 18:30:39.729 +05:00 [ERR] An error occurred in GetDivision. Input Number: 0
System.DivideByZeroException: Attempted to divide by zero.
   at SerilogFileLog.Controllers.WeatherController.GetDivision(Int32 number) in D:\Elmah.io\SerilogFileLog\SerilogFileLog\Controllers\WeatherController.cs:line 30
</code></pre><p>We can get the exact line number along with the error description.</p><h3 id="add-enrichers-and-output-templates">Add Enrichers and Output templates.</h3><p>Serilog allows capturing metadata, such as the requester's device and username. For that, use the package.</p><pre><code class="language-console">dotnet add package Serilog.Enrichers.Environment                                    </code></pre><p>There are two ways to include metadata in the logs, Either you can define in the <code>appsettings.json</code> will include an Enrich object and a template that defines the log message. </p><pre><code class="language-JSON">{
  "Serilog": {
    "MinimumLevel": "Information",
    "Enrich": [
      "WithMachineName",
      "WithEnvironmentUserName",
      "FromLogContext"
    ],
    "WriteTo": [
      {
        "Name": "File",
        "Args": {
          "path": "logs/log-.log",
          "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {MachineName} {EnvironmentUserName} {Message:lj}{NewLine}{Exception}",
          "rollingInterval": "Day"
        }
      }
    ]
  },
  //Other
}</code></pre><p>Or allowing the enrichment in the code.</p><pre><code class="language-csharp">Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .Enrich.WithMachineName()
    .Enrich.WithEnvironmentUserName()
    .Enrich.FromLogContext()
        .CreateLogger();</code></pre><p>Finally, it looks like.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-12.png" class="kg-image" alt="Log" loading="lazy" width="1175" height="488" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-12.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-12.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-12.png 1175w" sizes="(min-width: 720px) 720px"></figure><p>My PC and user name are added to the logs.</p><h3 id="logging-application-startup-events">Logging Application Startup Events</h3><p>Apart from operations errors, you can leverage Serilog's static methods to log errors that arise during startup. As there is no dependency when the application starts, you may not get the idea what happened, then comes the following.</p><pre><code class="language-csharp">using Serilog;

var builder = WebApplication.CreateBuilder(args);

// Configure and Enrich
Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .Enrich.WithMachineName()
    .Enrich.WithEnvironmentUserName()
    .Enrich.FromLogContext()
        .CreateLogger();

try
{
    Log.Information("Starting up!");
    
    builder.Host.UseSerilog();
    
    builder.Services.AddOpenApi();
    
    // Services
    builder.Services.AddControllers();
    
    var app = builder.Build();
    
    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi();
        app.UseSwaggerUI(options =&gt;
            {
                options.SwaggerEndpoint("/openapi/v1.json", "v1");
            }
        );
    }
    
    app.UseHttpsRedirection();
    throw new Exception("a startup error");
    app.MapControllers();
    app.Run();
    Log.Information("Stopped cleanly");
    return 0;
}
catch (Exception ex)
{
    Log.Fatal(ex, "An unhandled exception occurred during startup");
    return 1;
}
finally
{
    Log.CloseAndFlush();
}</code></pre><p>The <code>CloseAndFlush</code> method writes any pending log entries and releases resources by the Serilog. It is recommended to use the method to not to loose last logs before the application shuts down or crashes. I manually threw an error in between.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-13.png" class="kg-image" alt="Log" loading="lazy" width="1189" height="270" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/06/image-13.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/06/image-13.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/06/image-13.png 1189w" sizes="(min-width: 720px) 720px"></figure><h2 id="conclusion">Conclusion</h2><p>Logging is important for debugging an application and ensuring a seamless experience. Also, you can use logs to make critical business decisions. Today, I shared how to use Serilog file logging in .NET APIs. I began with basic file logging, progressed to scenarios of real applications, including daily rolling logs, and moved all configuration to <code>appsettings.json</code> standard practices. Additionally, I highlighted enrichers like <code>WithMachineName()</code> and <code>WithEnvironmentUserName()</code> to automatically attach valuable metadata to every log entry.</p><p>While file logging is perfect for testing Serilog locally, text files often aren't ideal in production. For live apps, streaming Serilog events to a cloud-based system like elmah.io centralizes your logs and provides real-time alerts before bugs impact your users.</p><p>Code: <a href="https://github.com/elmahio-blog/SerilogFileLog.git">https://github.com/elmahio-blog/SerilogFileLog.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ EF Core at scale: 5 ways to retrieve large datasets efficiently ]]></title>
        <description><![CDATA[ Five EF Core bulk-fetch methods from Z.EntityFramework.Extensions that bypass the 2,100-parameter limit of .Contains: WhereBulkContains, WhereBulkNotContains, BulkRead, WhereBulkContainsFilterList, and WhereBulkNotContainsFilterList, benchmarked on a 1M-row PostgreSQL dataset. ]]></description>
        <link>https://blog.elmah.io/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently/</link>
        <guid isPermaLink="false">69eef0327a4f0f000193fd32</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 09 Jun 2026 08:56:58 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently/">https://blog.elmah.io/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>EF Core (Entity Framework Core) is a fast ORM. However, once dataset entries increase by another 0, things start to break down. Fetching and filtering of data adds to <a href="https://blog.elmah.io/penhow-i-reduced-api-response-time-by-70-in-a-large-net-project/" rel="noreferrer">processing time</a>. Looking at this pitfall, I came up with a few EF Core methods that are specifically designed to work with bulk data. With 5 extension methods in EF Core, I will show how novelty can meet performance and clean code goals.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently-o-1.png" class="kg-image" alt="EF Core at scale: 5 ways to retrieve large datasets efficiently" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/ef-core-at-scale-5-ways-to-retrieve-large-datasets-efficiently-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="why-use-other-methods-than-ef-cores-contains">Why use other methods than EF Core's <code>.Contains</code>?</h2><p>EF Core provides <code>.Contains</code> a method for filtering and retrieval. However, it brings some issues. Firstly, only 2,100 parameters can be passed. What to do when you need to filter a large number of parameters? Splitting the query is one option here, but splitting queries into batches in SQL can be problematic in some complex situations. Also, it is not recommended to filter a single table with multiple database round-trips, as it consumes more memory and keeps database connections open longer. Hence, you will need a single way to fulfil the task. All the methods we will discuss today were aimed at solving these shortcomings.</p><h2 id="implementation-of-ef-core-methods-for-large-data-retrieval">Implementation of EF Core methods for large data retrieval</h2><p>To showcase each method, I will use a console application that connects to a PostgreSQL database containing up to 1 million records. Each method is suitable in its own arena.</p><p><strong>Step 1: Create the project</strong></p><pre><code class="language-console">dotnet new console -n EfCoreBulkFetchBenchmark
cd EfCoreBulkFetchBenchmark</code></pre><p><strong>Step 2: Install required packages</strong></p><pre><code class="language-console">dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Z.EntityFramework.Extensions.EFCore
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json</code></pre><p>All the 5 methods are extensions of EF Core provided by the library <code>Z.EntityFramework.Extensions.EFCore</code>.</p><p><strong>Step 3:</strong> <strong>Create&nbsp;<code>appsettings.json</code></strong></p><p>Adding&nbsp;<code>appsettings.json</code>&nbsp;with a database connection string.</p><pre><code class="language-json">{
  "ConnectionStrings": {
    "PostgresConnection": "Host=localhost;Port=5432;Database=shopDb;Username=postgres;Password=1234"
  }
}</code></pre><p>To add appsettings.json to the project, we need to specify it in <code>.proj</code></p><pre><code class="language-XML">&lt;ItemGroup&gt;
    &lt;None Update="appsettings.json"&gt;
        &lt;CopyToOutputDirectory&gt;PreserveNewest&lt;/CopyToOutputDirectory&gt;
    &lt;/None&gt;
&lt;/ItemGroup&gt;</code></pre><p><strong>Step 4: Create models</strong></p><p>Customer</p><pre><code class="language-csharp">namespace EfCoreBulkFetchBenchmark.Models;

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Country { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public ICollection&lt;Order&gt; Orders {get;set;} = null!;
}</code></pre><p>Order</p><pre><code class="language-csharp">namespace EfCoreBulkFetchBenchmark.Models;

public class Order
{
    public int Id { get; set; }
    public int? CustomerId { get; set; }
    public decimal Amount { get; set; }
    public string Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public Customer Customer { get; set; }
}</code></pre><p><strong>Step 5: Configure ApplicationDbContext</strong></p><pre><code class="language-csharp">using EfCoreBulkFetchBenchmark.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace EfCoreBulkFetchBenchmark.Data;

public class ApplicationDbContext: DbContext
{
    public DbSet&lt;Order&gt; Orders =&gt; Set&lt;Order&gt;();
    public DbSet&lt;Customer&gt; Customers =&gt; Set&lt;Customer&gt;();

    private readonly string _connectionString;

    public ApplicationDbContext()
    {
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json")
            .Build();

        _connectionString = config.GetConnectionString("PostgresConnection");
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseNpgsql(_connectionString);
    }
}</code></pre><p>Here, I am getting the connection string from the <code>appsettings.json</code></p><p><strong>Step 6: Run migrations</strong></p><pre><code class="language-console">dotnet ef migrations add InitialCreate</code></pre><p>To reflect the migration</p><pre><code class="language-console">dotnet ef database update</code></pre><p><strong>Step 7: Seed data </strong></p><p>In a separate file, I have added code to insert a considerable amount of data</p><pre><code class="language-csharp">using EfCoreBulkFetchBenchmark.Models;

namespace EfCoreBulkFetchBenchmark.Data;

public class SeedData
{
    public static async Task Initialize(ApplicationDbContext context)
    {
        if (context.Orders.Any()) return;

        var random = new Random();
        var countries = new[] { "USA", "UK", "France", "Germany", "Pakistan" };
    
        var customers = Enumerable.Range(1, 100000)
            .Select(i =&gt; new Customer
            {
                Name = $"Customer {i}",
                Country = countries[random.Next(countries.Length)],
                CreatedAt = DateTime.UtcNow.AddDays(-random.Next(365)) // Added random creation date
            }).ToList();

        context.Customers.AddRange(customers);
        await context.SaveChangesAsync();

        var orders = new List&lt;Order&gt;();
        var rand = new Random();

        for (int i = 1; i &lt;= 1_000_000; i++)
        {
            orders.Add(new Order
            {
                CustomerId = customers[rand.Next(customers.Count)].Id,
                Amount = rand.Next(100, 10000),
                Status = i % 2 == 0 ? "Completed" : "Pending",
                CreatedAt = DateTime.UtcNow.AddDays(-rand.Next(100))
            });
        }

        context.Orders.AddRange(orders);
        await context.SaveChangesAsync();
    }
}</code></pre><p>In <code>Program.cs</code></p><pre><code class="language-csharp">using EfCoreBulkFetchBenchmark.Data;
using EfCoreBulkFetchBenchmark.Models;
using Microsoft.EntityFrameworkCore;

var context = new ApplicationDbContext();

await context.Database.EnsureCreatedAsync();

await SeedData.Initialize(context);</code></pre><p><strong>Step 8: Implement advanced EF Core methods</strong></p><p>Our database and code are ready, now we can see 5 ways to fetch data with a large dataset. </p><pre><code class="language-csharp">var orderIds = await context.Orders
    .OrderBy(x =&gt; x.Id)
    .Take(50000)
    .Select(x =&gt; x.Id)
    .ToListAsync();

// SMALL list for naive test (safe)
var smallOrderIds = await context.Orders
    .OrderBy(x =&gt; x.Id)
    .Take(2000)
    .Select(x =&gt; x.Id)
    .ToListAsync();</code></pre><p>To filter, I initialized some IDs. The first sample is large, while the <code>smallOrderIds</code> will be used in a naive approach where EF Core does not allow a large number of parameters.</p><p>The naive way using <code>.Contains</code> will be </p><pre><code class="language-csharp">var result = await context.Orders
    .Where(x =&gt; smallOrderIds.Contains(x.Id))
    .ToListAsync();

Console.WriteLine($"Total records : {result.Count}");</code></pre><h3 id="wherebulkcontains">WhereBulkContains</h3><pre><code class="language-csharp">var result2 = await context.Orders
    .WhereBulkContains(orderIds, x =&gt; x.Id)
    .ToListAsync();

Console.WriteLine($"Total records : {result2.Count}");</code></pre><p><code>WhereBulkContains</code> takes a list as a parameter and filters records based on all items in the input list. </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-23.png" class="kg-image" alt="WhereBulkContains" loading="lazy" width="395" height="81"></figure><p>The first parameter is the collection of values to filter, and the second parameter is a lambda expression that specifies the property to match. In our case, it is the ID of the orders as specified in the lambda expression.</p><p>We can do the same thing without a lambda expression. The method will by default filter over the primary key field</p><pre><code class="language-csharp">result2 = await context.Orders
    .WhereBulkContains(orderIds)
    .ToListAsync();</code></pre><p>You can filter in a one-to-one relationship with <code>.Include</code> as well like this</p><pre><code class="language-csharp">result2 = await context.Orders
.Include(x =&gt; x.Customer)
    .WhereBulkContains(x=&gt;
        x.Customer,  
        new List&lt;string&gt;()
        {
            "Pakistan",
            "France"
        },
        x =&gt; x.Name
    )
    .ToListAsync();
</code></pre><p>The filter now populates customers' data along with the orders. However, all orders are fetched, as there is no filter on orders, while only customers' data will be included whose countries match the given list.</p><p>For a one-to-many relation </p><pre><code class="language-csharp">var customers = await context.Customers
    .Include(x =&gt; x.Orders)
    .WhereBulkContains(x=&gt;
            x.Orders,  
        smallOrderIds,
        x =&gt; x.Id
    )
    .ToListAsync();
</code></pre><p><a href="https://entityframework-extensions.net/where-bulk-contains" rel="noreferrer"><code>WhereBulkContains</code></a> returns <code>IQueryable&lt;T&gt;</code>, which can be further chained with LINQ methods like <code>Where</code> and <code>Select</code>, or materialized with <code>ToListAsync</code>. Best suited for filtering by multiple values.</p><p><code>WhereBulkContains</code> supports basic types like&nbsp;<code>int, string</code>&nbsp;or&nbsp;<code>guid</code> not complex or composite types as the list parameters.</p><h3 id="wherebulknotcontains">WhereBulkNotContains&nbsp;</h3><pre><code class="language-csharp">var result3 = await context.Orders
    .WhereBulkNotContains(orderIds, x =&gt; x.Id)
    .ToListAsync();

Console.WriteLine($"Total records : {result3.Count}");</code></pre><p>You can think of an inverted method of WhereBulkContains. <a href="https://entityframework-extensions.net/where-bulk-not-contains" rel="noreferrer"><code>WhereBulkNotContains</code> </a>filters data that excludes the parameter list. </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-26.png" class="kg-image" alt="WhereBulkNotContains" loading="lazy" width="414" height="70"></figure><p>The result excluded all 2000 records that matched the input list. The same thing can be achieved by</p><pre><code class="language-csharp">result3 = await context.Orders
    .WhereBulkNotContains(orderIds)
    .ToListAsync();</code></pre><p>Unlike its inverse partner, input params allows any list type, including basic, entity, anonymous, and expando types. Also, it allows a one-to-many relation</p><pre><code class="language-csharp">result3 = await context.Orders
    .Include(x =&gt; x.Customer)
    .WhereBulkNotContains(x=&gt;
            x.Customer,  
        new List&lt;string&gt;()
        {
            "Pakistan",
            "France"
        },
        x =&gt; x.Name
    )
    .ToListAsync();</code></pre><p>While for one-to-many </p><pre><code class="language-csharp">var customers = await context.Customers
    .Include(x =&gt; x.Orders)
    .WhereBulkNotContains(x=&gt;
            x.Orders,  
        smallOrderIds,
        x =&gt; x.Id
    )
    .ToListAsync();</code></pre><h3 id="bulkread">BulkRead&nbsp;</h3><pre><code class="language-csharp">var inputOrders = orderIds
    .Select(id =&gt; new Order { Id = id }).ToList();
    
var result4 = await context.Orders
    .BulkReadAsync(inputOrders, x=&gt;x.Id);

Console.WriteLine($"Total records : {result4.Count}");</code></pre><p><a href="https://entityframework-extensions.net/bulk-read?utm_source=antondevtips&amp;utm_medium=newsletter&amp;utm_campaign=december-2025" rel="noopener">BulkRead</a>&nbsp;returns data that matches the given list. You can pass a list of any type as input and specify the column for filtering as a lambda expression. The final result is immediately returned. Think of it as <code>WhereBulkContains</code>&nbsp;but with immediate execution internally using &nbsp;<code>ToList</code>&nbsp;or&nbsp;<code>ToListAsync</code>&nbsp;method.   </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-25.png" class="kg-image" alt="BulkRead" loading="lazy" width="299" height="62"></figure><p>Similarly, we can take the default behaviour of using the primary key for filtering</p><pre><code class="language-csharp">var result4 = await context.Orders
    .BulkReadAsync(inputOrders);</code></pre><h3 id="wherebulkcontainsfilterlist">WhereBulkContainsFilterList&nbsp;</h3><pre><code class="language-csharp">var result5 = context.Orders
    .WhereBulkContainsFilterList(smallOrderIds, x =&gt; x.Id)
    .ToList();
    
Console.WriteLine($"Total records : {result5.Count}");</code></pre><p>It returns items from the list that already exist in the database. It filters just like <code>WhereBulkContains</code>, but it filters out items in the input list rather than the database. </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-27.png" class="kg-image" alt="WhereBulkContainsFilterList" loading="lazy" width="492" height="65"></figure><p>We filtered over 2000 IDs, and they are available in the database, so it simply returned all of them</p><pre><code class="language-csharp">result5 = context.Orders
    .WhereBulkContainsFilterList(smallOrderIds)
    .ToList();</code></pre><p>A useful application of this method is when we need to filter an input list while updating.</p><h3 id="wherebulknotcontainsfilterlist">WhereBulkNotContainsFilterList&nbsp;</h3><pre><code class="language-csharp">var result6 = context.Orders
    .WhereBulkNotContainsFilterList(
            new List&lt;int&gt;()
            {
                10000,
                100000,
                1000001,
                1000002,
                1000003
            }
        , x =&gt; x.Id)
    .ToList();
Console.WriteLine($"Total records : {result6.Count}");</code></pre><p><a href="https://entityframework-extensions.net/where-bulk-not-contains-filter-list" rel="noreferrer"><code>WhereBulkNotContainsFilterList</code></a> is the inverse of <a href="https://entityframework-extensions.net/where-bulk-contains-filter-list" rel="noreferrer"><code>WhereBulkContainsFilterList</code></a>, which returns items of the input list that do not exist in the table.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-28.png" class="kg-image" alt="WhereBulkNotContainsFilterList&nbsp;" loading="lazy" width="528" height="58"></figure><p>The table contains 1 million data and 3 items in the input Ids list did not match any of those IDs. A concise version using ids by default is</p><pre><code class="language-csharp">result6 = context.Orders
    .WhereBulkNotContainsFilterList(
        new List&lt;int&gt;()
        {
            10000,
            100000,
            1000001,
            1000002,
            1000003
        }
        )
    .ToList();</code></pre><p><code>WhereBulkNotContainsFilterList</code>&nbsp;has a key difference with <code>WhereBulkNotContains</code> where the prior one returns a filtered record from the input list, while the latter one returns filtered records from the database. While for the input list parameter, all types are allowed.</p><h2 id="conclusion">Conclusion</h2><p>We discovered 5 EF Core methods for data retrieval with their implementation on a real dataset. <code>WhereBulkContains</code>, <code>WhereBulkNotContains</code> <code>BulkRead</code>, <a href="https://entityframework-extensions.net/where-bulk-contains-filter-list" rel="noreferrer"><code>WhereBulkContainsFilterList</code></a>, <code>WhereBulkNotContainsFilterList</code> each are designed to handle fetching and filtering large data. Their <a href="https://blog.elmah.io/visualizing-linq-queries-with-linqpad-boost-your-ef-core-debugging/" rel="noreferrer">generated queries</a> perform the job efficiently, while spanning a few readable lines, making the efficiency easy to read. <code>Z.EntityFramework.Extensions.EFCore</code> offers a trial version for a limited time that can be extended with their <a href="https://entityframework-extensions.net/pricing" rel="noreferrer">plans</a>.</p><p>Code: <a href="https://github.com/elmahio-blog/EfCoreBulkFetchBenchmark">https://github.com/elmahio-blog/EfCoreBulkFetchBenchmark</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Designing high-throughput APIs for 1M requests/minute .NET ]]></title>
        <description><![CDATA[ Builds an ASP.NET Core Minimal API benchmarked with BenchmarkDotNet against a PostgreSQL Orders table, comparing a naive EF Core query using Include and Take(50000) against a raw Dapper query with pagination, indexing, and connection pooling to sustain 1M requests per minute. ]]></description>
        <link>https://blog.elmah.io/designing-high-throughput-apis-for-1m-requests-minute-net/</link>
        <guid isPermaLink="false">69c90e44a75a7f0001f920de</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 26 May 2026 08:36:25 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/designing-high-throughput-apis-for-1m-requests-per-minute-dot.net-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/designing-high-throughput-apis-for-1m-requests-minute-net/">https://blog.elmah.io/designing-high-throughput-apis-for-1m-requests-minute-net/</a></p> 
<!--kg-card-begin: html-->
<div class="toc">table of contents</div>
<!--kg-card-end: html-->
<p>Scaling horizontally is great… until the bill comes. Hence, as a startup or a big firm, you always need to be careful about billing while maintaining competitiveness. Every user who lands on your application needs high throughput. In today's post,&nbsp;I will share ways to revolutionize the performance of your .NET application. We will observe the benchmark after applying those techniques to the <a href="https://blog.elmah.io/penhow-i-reduced-api-response-time-by-70-in-a-large-net-project/" rel="noreferrer">response time</a>.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/designing-high-throughput-apis-for-1m-requests-per-minute-dot.net-o-1.png" class="kg-image" alt="Designing high-throughput APIs for 1M requests/minute .NET" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/04/designing-high-throughput-apis-for-1m-requests-per-minute-dot.net-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/04/designing-high-throughput-apis-for-1m-requests-per-minute-dot.net-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/designing-high-throughput-apis-for-1m-requests-per-minute-dot.net-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="high-throughput-net-api-implementation">High-throughput .NET API implementation</h2><p>For the rest of this post, I'll use an ASP.NET Core Minimal API and measure performance using <a href="https://blog.elmah.io/how-to-monitor-your-apps-performance-with-net-benchmarking/" rel="noreferrer">BenchmarkDotNet</a>. Let's get started.</p><p><strong>Step 1: Create the solution</strong></p><pre><code class="language-console">dotnet new sln -n OrdersPerformanceDemo
cd OrdersPerformanceDemo</code></pre><p><strong>Step 2: Add projects to the solution</strong></p><pre><code class="language-console">dotnet new web -n Orders.Api
dotnet new console -n Orders.Benchmark

dotnet sln add Orders.Api
dotnet sln add Orders.Benchmark</code></pre><p><strong>Step 3: Install packages</strong></p><pre><code class="language-console">cd Orders.Api

dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Dapper
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.FileExtensions</code></pre><p>Also, in the benchmark project, we will add the required packages:</p><pre><code class="language-console">cd ../Orders.Benchmark

dotnet add package BenchmarkDotNet
dotnet add package Npgsql
dotnet add package Dapper
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL</code></pre><p><strong>Step 4: Add Appsettings</strong></p><p>In the <code>appsettings.json</code> file add a connection string:</p><pre><code class="language-json">"ConnectionStrings": {
  "PostgresConnection": "Host=localhost;Port=5433;Database=shopDb;Username=postgres;Password=1234"
}</code></pre><p>And similarly, in <code>Program.cs</code> of the API project, include the following code:</p><pre><code class="language-csharp">var connectionString = builder.Configuration.GetConnectionString("PostgresConnection");

builder.Services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString));

builder.Services.AddScoped&lt;NpgsqlConnection&gt;(_ =&gt;
    new NpgsqlConnection(connectionString));</code></pre><p><strong>Step 5: Add models</strong></p><p><code>Customer</code> model.</p><pre><code class="language-csharp">namespace Orders.Api.Models;

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Country { get; set; }
}</code></pre><p><code>Order</code> model.</p><pre><code class="language-csharp">namespace Orders.Api.Models;

public class Order
{
    public int Id { get; set; }
    public int? CustomerId { get; set; }
    public decimal Amount { get; set; }
    public string Status { get; set; }
    public DateTime CreatedAt { get; set; }

    public Customer Customer { get; set; }
}</code></pre><p><strong>Step 6: Set up the DB context</strong></p><pre><code class="language-csharp">using Microsoft.EntityFrameworkCore;
using Orders.Api.Models;

namespace Orders.Api.Data;

public class ApplicationDbContext: DbContext
{
    public DbSet&lt;Order&gt; Orders =&gt; Set&lt;Order&gt;();
    public DbSet&lt;Customer&gt; Customers =&gt; Set&lt;Customer&gt;();

    public ApplicationDbContext(DbContextOptions&lt;ApplicationDbContext&gt; options)
        : base(options) { }
}</code></pre><p><strong>Step 7: Run migrations</strong></p><pre><code class="language-console">dotnet ef migrations add InitialCreate</code></pre><p>And reflect on it.</p><pre><code class="language-console">dotnet ef database update</code></pre><p>I already seeded a few thousand rows of data in the table.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-1.png" class="kg-image" alt="Orders count" loading="lazy" width="396" height="174"></figure><p><strong>Step 8: Add naive API</strong></p><pre><code class="language-csharp">app.MapGet("/orders/naive", async (ApplicationDbContext db) =&gt;
{
    var data = await db.Orders
        .Include(o =&gt; o.Customer)
        .Take(50000)
        .ToListAsync();

    return Results.Ok(data);
});</code></pre><p>This is one of the simplest and least efficient ways of fetching data as a worst-case threshold. Well, to save the huge pressure of a million records on the system and to save time, I took 50000 records.</p><p></p><p><strong>Step 9: Create a dapper endpoint</strong></p><pre><code class="language-csharp">app.MapGet("/orders/dapper", async (
    int? customerId,
    int page,
    int pageSize,
    NpgsqlConnection conn) =&gt;
{
    var sql = @"
        SELECT o.""Id"", o.""Amount"", o.""CreatedAt"", c.""Name""
        FROM ""Orders"" o
        JOIN ""Customers"" c ON o.""CustomerId"" = c.""Id""
        WHERE o.""Status"" = 'Completed'
        AND (@customerId IS NULL OR o.""CustomerId"" = @customerId)
        ORDER BY o.""CreatedAt"" DESC
        LIMIT @pageSize OFFSET @offset";

    var result = await conn.QueryAsync(sql, new
    {
        customerId,
        pageSize,
        offset = (page - 1) * pageSize
    });

    return Results.Ok(result);
});</code></pre><p>Now, we are ready to start implementing optimizations.</p><h3 id="database-indexing">Database indexing</h3><pre><code class="language-SQL">CREATE INDEX IF NOT EXISTS idx_completed_orders
    ON public."Orders" USING btree
    ("CustomerId" ASC NULLS LAST)
    TABLESPACE pg_default
    WHERE "Status" = 'Completed'::text;</code></pre><p>At the time of fetching, use the index to filter fast.</p><pre><code class="language-csharp">var query = db.Orders
    .AsNoTracking() 
    .Where(o =&gt; o.Status == "Completed");
</code></pre><h3 id="use-pagination">Use pagination</h3><p>Pagination is a win-win situation in database fetching. In most cases, APIs do not need to return large amounts of data in the response. Rather, there is a limit, usually 20 to 100, that mobile and web applications can display at once. Leverage this fact and add pagination to the fetches, significantly reducing the performance penalties. You only get what you need and do not burden yourself with extra.</p><pre><code class="language-csharp">var result = await query
    .OrderByDescending(o =&gt; o.CreatedAt)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .Select(o =&gt; new 
    {
        o.Id,
        o.Amount,
        o.CreatedAt,
        CustomerName = o.Customer.Name
    })
    .ToListAsync();</code></pre><h3 id="add-pagination-limits">Add Pagination Limits</h3><p>To prevent users from fetching large amounts of data at once, add a pagesize limit. When you are sure about the API's use cases.</p><pre><code class="language-csharp">pageSize = Math.Min(pageSize, 100);</code></pre><h3 id="use-dapper-for-hot-endpoints-only">Use Dapper for Hot Endpoints Only</h3><pre><code class="language-csharp">app.MapGet("/orders/dapper", async (
    int? customerId,
    int page,
    int pageSize,
    NpgsqlConnection conn) =&gt;
{
    var sql = @"
        SELECT o.""Id"", o.""Amount"", o.""CreatedAt"", c.""Name""
        FROM ""Orders"" o
        JOIN ""Customers"" c ON o.""CustomerId"" = c.""Id""
        WHERE o.""Status"" = 'Completed'
        AND (@customerId IS NULL OR o.""CustomerId"" = @customerId)
        ORDER BY o.""CreatedAt"" DESC
        LIMIT @pageSize OFFSET @offset";

    var result = await conn.QueryAsync(sql, new
    {
        customerId,
        pageSize,
        offset = (page - 1) * pageSize
    });

    return Results.Ok(result);
});</code></pre><p>Dapper uses a query that is less abstracted than EF Core. This offers a performance edge, and you can use Dapper for high traffic operations.</p><h3 id="response-compression">Response Compression</h3><pre><code class="language-csharp">builder.Services.AddResponseCompression();

app.UseResponseCompression();</code></pre><p>It compresses server responses before sending them to clients, reducing bandwidth usage and improving load times.</p><h3 id="avoid-over-fetching">Avoid Over-Fetching</h3><p>EF Core entities contain several navigation properties to other entities. In real cases, a single entity has a cluster of dependent entities, such as a User that has navigation with userRoles, languages, and approvals. Loading all of them can be a serious drain on memory and processing power. We have customers and an order table. However, if we need order data, then no need to involve other tables with code like this: <code>.Include(o =&gt; o.Customer)</code>. If you need to send OrderId and Status over the API, then avoid including the customer unnecessarily. </p><h3 id="no-tracking-for-faster-reads-with-ef-core">No tracking for faster reads with EF Core</h3><pre><code class="language-csharp">var query = db.Orders
    .AsNoTracking() 
    .Where(o =&gt; o.Status == "Completed");
</code></pre><p>EF Core tracks fetched data for change tracking. To remove this behavior, use <code>AsNoTracking</code> when you only fetch data and don't need to modify it. </p><h3 id="projection-to-reduce-response-fields">Projection to reduce response fields</h3><p>Reduce the fetched field to only the required ones. Fetching and sending unnecessary fields costs CPU time and network bandwidth. Use projection to get what you need.</p><pre><code class="language-csharp">var result = await query
    .OrderByDescending(o =&gt; o.CreatedAt) 
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .Select(o =&gt; new 
    {
        o.Id,
        o.Amount,
        o.CreatedAt,
        CustomerName = o.Customer.Name
    })
    .ToListAsync();</code></pre><h3 id="cache-for-frequently-accessed-data">Cache for frequently accessed data</h3><p>Cache is another breakthrough in our journey. Caching frequently accessed data in memory reduces database round-trip. I will show two types of caching.</p><p><strong>Output Cache</strong></p><p>Output caching stores the whole web response in memory, the serialized JSON,  headers, and status code next time you request the route with the same parameters. <code>AddOutputCache</code> registers output caching services in the DI container. <code>UseOutputCache</code> inserts the output-caching middleware into the request-processing pipeline. When a request comes in, this middleware intercepts it, checks if a valid cached response exists, and if so, returns it immediately. That way, the output caching reduces the whole cycle of hitting the database and serializing the response. In the <code>program.cs</code> inject the service. </p><pre><code class="language-csharp">builder.Services.AddOutputCache();</code></pre><pre><code class="language-csharp">app.UseOutputCache();</code></pre><p>The endpoint now looks like this:</p><pre><code class="language-csharp">app.MapGet("/orders/optimized_output", async (
    int? customerId,
    int page,
    int pageSize,
    ApplicationDbContext db,
    IMemoryCache cache) =&gt;
{
    // Technique: Limit page size (protect API)
    pageSize = Math.Min(pageSize, 100);

    var query = db.Orders
        .AsNoTracking() // Technique: No tracking (faster reads)
        .Where(o =&gt; o.Status == "Completed"); //Technique: Uses partial index

    if (customerId.HasValue)
        query = query.Where(o =&gt; o.CustomerId == customerId); // Technique: Uses index

    var result = await query
        .OrderByDescending(o =&gt; o.CreatedAt) //Technique: Sorting (optimize via index)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(o =&gt; new // Technique: Projection (avoid over-fetching)
        {
            o.Id,
            o.Amount,
            o.CreatedAt,
            CustomerName = o.Customer.Name
        })
        .ToListAsync();

    return Results.Ok(result);
})
.CacheOutput(p =&gt; 
    p.Expire(TimeSpan.FromSeconds(600))); // Technique: Output caching
</code></pre><p><code>.CacheOutput</code> contains a lambda expression that specifies the time to retain cached data.</p><p><strong>In-memory cache</strong></p><p>In most cases, output caching should be sufficient. But sometimes, you may want to look into creating manual, private caches in your code.</p><p>To start creating a cache, import the following namespace in the <code>Program.cs</code> file:</p><pre><code class="language-csharp">using Microsoft.Extensions.Caching.Memory;</code></pre><p>Inject caching:</p><pre><code class="language-csharp">builder.Services.AddMemoryCache();</code></pre><p>In our endpoint method, you inject a <code>IMemoryCache</code> object and set the cache key:</p><pre><code class="language-csharp">var cacheKey = $"orders:{customerId}:{page}:{pageSize}";</code></pre><p>upon every request. First, we check if the required data is in the <a href="https://blog.elmah.io/caching-strategies-in-asp-net-core/" rel="noreferrer">in-memory cache</a>. If found, we return it without going to the database.</p><pre><code class="language-csharp">if (cache.TryGetValue(cacheKey, out List&lt;object&gt; cached))
    return Results.Ok(cached);</code></pre><p>Otherwise, set the fetched result in cache.</p><pre><code class="language-csharp">cache.Set(cacheKey, result, TimeSpan.FromSeconds(30));
</code></pre><p>The endpoint now looks like this:</p><pre><code class="language-csharp">app.MapGet("/orders/optimized_inmemory", async (
    int? customerId,
    int page,
    int pageSize,
    ApplicationDbContext db,
    IMemoryCache cache) =&gt;
{
    // Technique: Limit page size (protect API)
    pageSize = Math.Min(pageSize, 100);

    // Technique: Cache key
    var cacheKey = $"orders:{customerId}:{page}:{pageSize}";

    // Technique: Memory cache (ultra fast)
    if (cache.TryGetValue(cacheKey, out List&lt;object&gt; cached))
        return Results.Ok(cached);

    var query = db.Orders
        .AsNoTracking() // Technique: No tracking (faster reads)
        .Where(o =&gt; o.Status == "Completed"); //Technique: Uses partial index

    if (customerId.HasValue)
        query = query.Where(o =&gt; o.CustomerId == customerId); //Technique: Uses index

    var result = await query
        .OrderByDescending(o =&gt; o.CreatedAt) //Technique: Sorting (optimize via index)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(o =&gt; new // Technique: Projection (avoid over-fetching)
        {
            o.Id,
            o.Amount,
            o.CreatedAt,
            CustomerName = o.Customer.Name
        })
        .ToListAsync();

    // Technique : Cache result
    cache.Set(cacheKey, result, TimeSpan.FromSeconds(600));

    return Results.Ok(result);
});</code></pre><p>Although I have used both output and memory caches in the app, the output cache is better suited to APIs, dashboards, and repetitive data. In-memory is optimal for expensive calculations, permissions, and non-HTTP scenarios.</p><p>Know the <a href="https://blog.elmah.io/caching-strategies-in-asp-net-core/" rel="noreferrer">caching strategy in detail</a> to better utilize it according to your application.</p><p><strong>Step 10: Set up code in the Benchmark project</strong></p><pre><code class="language-csharp">
using BenchmarkDotNet.Attributes;
using Microsoft.EntityFrameworkCore;
using Dapper;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Orders.Api.Data;
using Orders.Benchmark.Config;

namespace Orders.Benchmark;

[MemoryDiagnoser]
public class OrdersBenchmark
{
    private HttpClient _client;
    private string _baseUrl;

    [GlobalSetup]
    public void Setup()
    {
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json")
            .Build();

        _baseUrl = "http://localhost:5102";

        _client = new HttpClient
        {
            BaseAddress = new Uri(_baseUrl)
        };
    }

    [Benchmark]
    public async Task Naive()
    {
        var response = await _client.GetAsync("/orders/naive");
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadAsStringAsync();
    }

    [Benchmark]
    public async Task OptimizedInmemory()
    {
        var response = await _client.GetAsync("/orders/optimized_inmemory?page=1&amp;pageSize=50&amp;customerId=223");
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadAsStringAsync();
    }

    [Benchmark]
    public async Task OptimizedOutput()
    {
        var response = await _client.GetAsync("/orders/optimized_output?page=1&amp;pageSize=50&amp;customerId=223");
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadAsStringAsync();
    }

    [Benchmark]
    public async Task Dapper()
    {
        var response = await _client.GetAsync("/orders/dapper?page=1&amp;pageSize=50");
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadAsStringAsync();
    }
}</code></pre><p>We are setting up the client for the API project. Later, defined methods for each API endpoint.</p><p><strong>Step 11: Call the benchmark in the Program.cs </strong></p><pre><code class="language-csharp">using BenchmarkDotNet.Running;
using Orders.Benchmark;

BenchmarkRunner.Run&lt;OrdersBenchmark&gt;();</code></pre><p><strong>Step 12: Run and test</strong></p><p>One by one, running the project in different terminals.</p><pre><code class="language-console">cd Orders.Api
dotnet run</code></pre><p>And the <a href="https://blog.elmah.io/how-to-monitor-your-apps-performance-with-net-benchmarking/" rel="noreferrer">benchmark in the release mode</a>.</p><pre><code class="language-console">cd Orders.Benchmark
dotnet run -c Release</code></pre><p><strong>Result</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-33.png" class="kg-image" alt="Result" loading="lazy" width="1062" height="119" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-33.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-33.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-33.png 1062w" sizes="(min-width: 720px) 720px"></figure><p>Running again to check caching.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-34.png" class="kg-image" alt="Result" loading="lazy" width="1092" height="133" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-34.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-34.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-34.png 1092w" sizes="(min-width: 720px) 720px"></figure><p>The results speak about output caching performance in both cases. After accessing cached data, the results are even faster.</p><h2 id="external-factors-to-achieve-high-throughput-api">External factors to achieve High-Throughput API</h2><p>The greatest soldier with a weak sword cannot stand in the battle. Similarly, a very good code cannot perform well if it is not hosted properly. To achieve high throughput, you need to take care of the following external factors.</p><h3 id="choosing-the-right-infrastructure-and-hosting">Choosing the Right infrastructure and hosting</h3><p>When hosting your application, choose the right server with a few things in mind. Go with an option that offers more parallel request handling, high-performing RAM, and a location close to your users' geographic area. If your code takes 30 ms for the job but the server responds in 200 ms due to the user's geographic distance, it will be counted as slow. Besides, SSDs are faster than HDDs, so choose accordingly. High-speed storage has a significant impact on the request-response cycle.</p><h3 id="using-a-reverse-proxy-load-balancer">Using a reverse Proxy / Load balancer</h3><p>Load balancing is necessary for scalable systems. Never expose the API directly; instead, use HAProxy and NGINX for load balancing, SSL termination, and request buffering.</p><h3 id="background-processing">Background Processing </h3><p>Tasks such as analytics, email, and logging should be separate from main API calls to avoid blocking the request-response pipeline. </p><h3 id="rate-limit-api">Rate limit API</h3><p>Rate limiting is a performance and precautionary measure that limits a single user or tenant from overusing or abusing the application. Overuse can spike the server, slowing APIs for other users. <a href="https://blog.elmah.io/new-in-net-10-and-c-14-multi-tenant-rate-limiting/" rel="noreferrer">With .NET 10, rate limiting is even improved.</a></p><h2 id="conclusion">Conclusion</h2><p>Every system needs to achieve optimal performance. In this regard, I shared a few important techniques that a .NET API can use to achieve high throughput. However, testing with 1 million requests is difficult on a personal computer. However, we observed remarkable success with all the tactics, and you can improve the application by following them as well. I also discuss external factors that contribute to user experience and system response time. </p><p>Code: <a href="https://github.com/elmahio-blog/OrdersPerformanceDemo.git">https://github.com/elmahio-blog/OrdersPerformanceDemo.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ How to upload files in an ASP.NET Core Web API ]]></title>
        <description><![CDATA[ Shows how to build a file upload endpoint in a .NET 10 ASP.NET Core Web API using IFormFile and FileStream, add Swashbuckle.AspNetCore for OpenAPI/Swagger docs, cap uploads at 100 MB via MultipartBodyLengthLimit, and organize the code into clean, testable service layers with download support. ]]></description>
        <link>https://blog.elmah.io/how-to-upload-files-in-an-asp-net-core-web-api/</link>
        <guid isPermaLink="false">69f6e14fdda1b400015f476d</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Wed, 20 May 2026 09:47:16 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/how-to-upload-files-in-an-asp.net-core-web-api-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/how-to-upload-files-in-an-asp-net-core-web-api/">https://blog.elmah.io/how-to-upload-files-in-an-asp-net-core-web-api/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>Files are an integrated part of an application. From a social app to an ERP, some form of media exists in the ecosystem. .NET Core APIs provide built-in support for uploading and fetching documents. In today's post, I will show you how to cleanly and efficiently use .NET Core's file system classes to receive file uploads.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/how-to-upload-files-in-an-asp.net-core-web-api-o-1.png" class="kg-image" alt="How to upload files in an ASP.NET Core Web API" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/how-to-upload-files-in-an-asp.net-core-web-api-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/how-to-upload-files-in-an-asp.net-core-web-api-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/how-to-upload-files-in-an-asp.net-core-web-api-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="uploading-files-in-aspnet-core-api">Uploading files in ASP.NET Core API</h2><p>I am creating an API with .NET 10 to accomplish our goal.</p><p><strong>Step 1: Create a project</strong></p><pre><code class="language-console">dotnet new webapi -n fileUploadDemo
cd fileUploadDemo</code></pre><p><strong>Step 2: Create a controller</strong></p><pre><code class="language-csharp">using Microsoft.AspNetCore.Mvc;

namespace fileUploadDemo.Controllers;

[ApiController]
[Route("api/[controller]")]
public class FileController: ControllerBase
{
    [HttpPost("upload")]
    public async Task&lt;IActionResult&gt; Upload(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return BadRequest("No file uploaded.");
    
        var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
    
        if (!Directory.Exists(uploadsFolder))
            Directory.CreateDirectory(uploadsFolder);
    
        var filePath = Path.Combine(uploadsFolder, file.FileName);
    
        using (var stream = new FileStream(filePath, FileMode.Create))
        {
            await file.CopyToAsync(stream);
        }
    
        return Ok(new { file.FileName, file.Length });
    }
}</code></pre><p>The endpoint upload will save the file to the "Uploads" directory and return the filename and length. </p><p><strong>Step 3: Configure Program.cs</strong></p><pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();

app.UseStaticFiles();
app.MapControllers();

app.UseHttpsRedirection();

app.Run();</code></pre><p><code>app.UseStaticFiles();</code> enables the app to serve static files directly over HTTP</p><p>Make sure your <code>Uploads</code> folder is accessible.</p><p><strong>Step 4: Run and test</strong></p><pre><code class="language-console">dotnet run</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image.png" class="kg-image" alt="Test" loading="lazy" width="957" height="357" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image.png 957w" sizes="(min-width: 720px) 720px"></figure><p>Hence, our file is saved:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-1.png" class="kg-image" alt="Solution explorer" loading="lazy" width="317" height="194"></figure><p>So far, we have seen a naive, minimal approach to saving a file in an ASP.NET Core API. However, in real applications, you may split it up into layers, services, or however you prefer to structure applications.</p><h2 id="adding-openapiswagger-documentation">Adding OpenApi/Swagger documentation </h2><p>.NET 10 does not create OpenAPI (formerly Swagger) documentation like previous versions. So we have to add them. First, install the NuGet package:</p><pre><code class="language-console">dotnet add package Swashbuckle.AspNetCore</code></pre><p>Next, change <code>Program.cs</code>:</p><pre><code class="language-csharp">using Microsoft.AspNetCore.Http.Features;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI(c =&gt;
{
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "Upload V1");
});

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseStaticFiles();
app.MapControllers();
app.UseHttpsRedirection();

app.Run();</code></pre><p>The <code>AddOpenApi()</code> method registers with the OpenAPI document generation services. Then <code>AddEndpointsApiExplorer()</code> adds an API explorer service that discovers endpoints and provides metadata about routes, parameters, and return types. Without this, Swagger would show no APIs. <code>AddSwaggerGen()</code> configures Swashbuckle (Swagger for .NET) and generates a Swagger document. Besides, it adds schema generation for your DTOs. <code>UseSwagger()</code> registers middleware to serve the Swagger JSON document. You must register middleware before using SwaggerUI. The <code>UseSwaggerUI</code> configures the Swagger UI web interface and navigates the UI to the JSON document. It basically adds UI elements, such as the page and <em>Try out</em> buttons.</p><p><strong>Result</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-2.png" class="kg-image" alt="Swagger" loading="lazy" width="1344" height="228" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-2.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-2.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-2.png 1344w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-3.png" class="kg-image" alt="Swagger" loading="lazy" width="1316" height="470" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-3.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-3.png 1316w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-4.png" class="kg-image" alt="Result" loading="lazy" width="1281" height="313" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-4.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-4.png 1281w" sizes="(min-width: 720px) 720px"></figure><h2 id="restrict-the-file-size-limit">Restrict the file size limit</h2><p>It is recommended to set a maximum file size limit for uploaded files:</p><pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure&lt;FormOptions&gt;(options =&gt;
{
    options.MultipartBodyLengthLimit = 104857600; 
});</code></pre><p>With the property <code>MultipartBodyLengthLimit</code>, I set the maximum size of the multipart body to 100 MBs.</p><h2 id="allowing-formats-for-different-file-types">Allowing formats for different file types</h2><p>Each document type has a set of extensions: images are usually in JPG or PNG, and documents are usually in PDF or docx. For different purposes, we should validate the input. Like for a profile picture, users may not upload a docx or a PDF, while for a report, they should avoid JPG. To enforce it in our API, I will separate the image and document endpoints.</p><p><code>UploadImage</code> specified for uploading image files:</p><pre><code class="language-csharp">[HttpPost("UploadImage")]
public async Task&lt;IActionResult&gt; UploadImageAsync(IFormFile file)
{
    if (file == null || file.Length == 0)
        return BadRequest("No file uploaded.");

    var allowedExtensions = new[] { ".jpg", ".png" };
    var extension = Path.GetExtension(file.FileName);
    
    if (!allowedExtensions.Contains(extension))
    {
        var extensionsWithoutDots = allowedExtensions.Select(ext =&gt; ext.TrimStart('.'));
        return BadRequest($"Invalid file type. Allowed types are: {string.Join(", ", extensionsWithoutDots)}");
    }
    
    var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");

    if (!Directory.Exists(uploadsFolder))
        Directory.CreateDirectory(uploadsFolder);

    var filePath = Path.Combine(uploadsFolder, file.FileName);

    using (var stream = new FileStream(filePath, FileMode.Create))
    {
        await file.CopyToAsync(stream);
    }

    return Ok(new { file.FileName, file.Length });
}</code></pre><p><code>UploadDocument</code> method for uploading document files:</p><pre><code class="language-csharp">
    [HttpPost("UploadDocument")]
    public async Task&lt;IActionResult&gt; UploadDocumentAsync(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return BadRequest("No file uploaded.");

        var allowedExtensions = new[] { ".pdf", ".txt" };
        var extension = Path.GetExtension(file.FileName);
        
        if (!allowedExtensions.Contains(extension))
        {
            var extensionsWithoutDots = allowedExtensions.Select(ext =&gt; ext.TrimStart('.'));
            return BadRequest($"Invalid file type. Allowed types are: {string.Join(", ", extensionsWithoutDots)}");
        }
        
        var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
    
        if (!Directory.Exists(uploadsFolder))
            Directory.CreateDirectory(uploadsFolder);
    
        var filePath = Path.Combine(uploadsFolder, file.FileName);
    
        using (var stream = new FileStream(filePath, FileMode.Create))
        {
            await file.CopyToAsync(stream);
        }
    
        return Ok(new { file.FileName, file.Length });
    }</code></pre><p>The prior method only supports JPG and PNG, while the latter supports PDF and TXT. Lets test it from SwaggerUI:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-5.png" class="kg-image" alt="API" loading="lazy" width="1312" height="550" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-5.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-5.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-5.png 1312w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-6.png" class="kg-image" alt="API" loading="lazy" width="1297" height="346" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-6.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-6.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-6.png 1297w" sizes="(min-width: 720px) 720px"></figure><p>On a correct input:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-7.png" class="kg-image" alt="Upload png file" loading="lazy" width="1332" height="550" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-7.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-7.png 1332w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-8.png" class="kg-image" alt="Result" loading="lazy" width="1275" height="436" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-8.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-8.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-8.png 1275w" sizes="(min-width: 720px) 720px"></figure><p>If we inspect the Uploads folder, we can see the uploaded file:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-9.png" class="kg-image" alt="Result" loading="lazy" width="311" height="52"></figure><p>While the other method:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-10.png" class="kg-image" alt="Upload invalid file" loading="lazy" width="1335" height="547" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-10.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-10.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-10.png 1335w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-11.png" class="kg-image" alt="Bad request" loading="lazy" width="1288" height="344" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-11.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-11.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-11.png 1288w" sizes="(min-width: 720px) 720px"></figure><p>And for the allowed document:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-12.png" class="kg-image" alt="Upload valid document" loading="lazy" width="1320" height="551" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-12.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-12.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-12.png 1320w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-13.png" class="kg-image" alt="Upload result" loading="lazy" width="1282" height="434" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-13.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-13.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-13.png 1282w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-14.png" class="kg-image" alt="Result" loading="lazy" width="259" height="72"></figure><h2 id="renaming-the-file-to-standard-naming">Renaming the file to standard naming</h2><p>We have now successfully uploaded files. However, there is a problem: you may notice that file names are arbitrary and random.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-15.png" class="kg-image" alt="Uploads" loading="lazy" width="295" height="130"></figure><p>For real applications, we often want to control the names of the files to avoid someone uploading a Snapchat image of themselves with a weird-looking filename. Let's fix that.</p><p><strong>Step 1: Add model</strong></p><p>First, introduce a model to input images. It will contain a type specifying what the file is for.</p><p>Add File type enum:</p><pre><code class="language-csharp">namespace fileUploadDemo.Models.Enums;

public enum FileTypeEnum
{
    ProfilePicture = 1,
    PostImage = 2,
    Resume = 3
}</code></pre><p>File input:</p><pre><code class="language-csharp">using fileUploadDemo.Models.Enums;

namespace fileUploadDemo.Models.Dtos;

public class FileDtoInp
{
    public FileTypeEnum Type { get; set; }
    public IFormFile File { get; set; }
}</code></pre><p>To return, we will use a model instead of an anonymous object:</p><pre><code class="language-csharp">namespace fileUploadDemo.Models.Dtos;

public class FileDto
{
    public string FileName { get; set; } = string.Empty;
    public long Length { get; set; } 
}</code></pre><p><strong>Step 2: Add service layer</strong></p><p>So far, I have added everything to the controller, which is a <a href="https://blog.elmah.io/designing-business-rules-that-dont-leak-into-controllers/" rel="noreferrer">bad practice</a>. I'll add a service layer, but this can be implemented in whatever way you prefer.</p><p>Add a <code>IFileService</code> interface:</p><pre><code class="language-csharp">using fileUploadDemo.Models.Dtos;

namespace fileUploadDemo.Services.IServices;

public interface IFileService
{
    Task&lt;FileDto&gt; UploadImageAsync(FileDtoInp input);
    Task&lt;FileDto&gt; UploadDocumentAsync(FileDtoInp input);
}</code></pre><p>And a <code>FileService</code> implementation:</p><pre><code class="language-csharp">using fileUploadDemo.Models.Dtos;
using fileUploadDemo.Models.Enums;
using fileUploadDemo.Services.IServices;

namespace fileUploadDemo.Services;

public class FileService: IFileService
{
    private readonly string _uploadsFolder;

    public FileService(IWebHostEnvironment env)
    {
        _uploadsFolder = Path.Combine(env.ContentRootPath, "Uploads");
    }
    public async Task&lt;FileDto&gt; UploadImageAsync(FileDtoInp input)
    {
        if (input.File == null || input.File.Length == 0)
            throw new Exception("No file uploaded.");
        
        var allowedExtensions = new[] { ".jpg", ".png" };
        var extension = Path.GetExtension(input.File.FileName);
        
        if (!allowedExtensions.Contains(extension))
        {
            var extensionsWithoutDots = allowedExtensions.Select(ext =&gt; ext.TrimStart('.'));
            throw new Exception($"Invalid file type. Allowed types are: {string.Join(", ", extensionsWithoutDots)}");
        }
        
        if (!Directory.Exists(_uploadsFolder))
            Directory.CreateDirectory(_uploadsFolder);

        var fileName = $"{ GetFileName(input.Type) }{extension}";
        
        var filePath = Path.Combine(_uploadsFolder, fileName);
    
        using (var stream = new FileStream(filePath, FileMode.Create))
        {
            await input.File.CopyToAsync(stream);
        }

        return new 
            FileDto()
            {
                FileName = fileName, 
                Length = input.File.Length
            };
    }

    public async Task&lt;FileDto&gt; UploadDocumentAsync(FileDtoInp input)
    {
        if (input.File == null || input.File.Length == 0)
            throw new Exception("No file uploaded.");

        var allowedExtensions = new[] { ".pdf", ".txt" };
        var extension = Path.GetExtension(input.File.FileName);
        
        if (!allowedExtensions.Contains(extension))
        {
            var extensionsWithoutDots = allowedExtensions.Select(ext =&gt; ext.TrimStart('.'));
            throw new Exception($"Invalid file type. Allowed types are: {string.Join(", ", extensionsWithoutDots)}");
        }
        
        if (!Directory.Exists(_uploadsFolder))
            Directory.CreateDirectory(_uploadsFolder);
    
        var fileName = $"{ GetFileName(input.Type) }{extension}";
        var filePath = Path.Combine(_uploadsFolder, fileName);
    
        using (var stream = new FileStream(filePath, FileMode.Create))
        {
            await input.File.CopyToAsync(stream);
        }

        return new 
            FileDto()
            {
                FileName = fileName, 
                Length = input.File.Length
            };
    }
    
    private string GetFileName(FileTypeEnum input)
        =&gt; input switch
        {
            FileTypeEnum.ProfilePicture =&gt; $"PRF-{DateTime.UtcNow:yyMMddHHmmss}",
            FileTypeEnum.PostImage =&gt; $"PST-{DateTime.UtcNow:yyMMddHHmmss}",
            FileTypeEnum.Resume =&gt; $"RSM-{DateTime.UtcNow:yyMMddHHmmss}",
            _ =&gt; throw new ArgumentOutOfRangeException(nameof(input), input, null)
        };
}</code></pre><p>Let's understand what is happening here. We have methods for uploading images and documents, which first validate the file types. They actually abstracted the logic from the earlier upgrade of our application from controllers to services, <a href="https://blog.elmah.io/designing-business-rules-that-dont-leak-into-controllers/" rel="noreferrer">which is always recommended</a>. The main update here is the <code>GetFileName</code> method that returns a standardized name for each type. To make it collision-proof, I concatenated the current datetime into the name. Besides, the upload directory address is initialized in the constructor and used throughout the service. An even better way is to use it in appsettings.json and use the option pattern. Lets keep it simple for now. At last, I returned the FileDto model as output. You may also want to switch to not using exceptions for input validation, but I will leave that part up to you.</p><p><strong>Step 3: Register the new service</strong></p><pre><code class="language-csharp">builder.Services.AddScoped&lt;IFileService, FileService&gt;();</code></pre><p>In <code>Program.cs</code>I have registered the service dependency.</p><p><strong>Step 4: Update controller</strong></p><p>Now, the controller will inject and use the <code>IFileService</code> we just created: </p><pre><code class="language-csharp">using fileUploadDemo.Models.Dtos;
using fileUploadDemo.Services.IServices;
using Microsoft.AspNetCore.Mvc;

namespace fileUploadDemo.Controllers;

[ApiController]
[Route("api/[controller]")]
public class FileController: ControllerBase
{
    private readonly IFileService _fileService;

    public FileController(IFileService fileService)
    {
        _fileService = fileService;
    }
    
    [HttpPost("UploadImage")]
    public async Task&lt;IActionResult&gt; UploadImageAsync([FromForm] FileDtoInp input)
    {
        var result = await _fileService.UploadImageAsync(input);
        return Ok(result);
    }
    
    [HttpPost("UploadDocument")]
    public async Task&lt;IActionResult&gt; UploadDocumentAsync([FromForm] FileDtoInp input)
    {
        var result = await _fileService.UploadDocumentAsync(input);
        return Ok(result);
    }
}</code></pre><p><code>[FromForm]</code> specifies the input as form-data, not a JSON body, ensuring the endpoint receives files.</p><p><strong>Step 4: Run and test</strong></p><pre><code class="language-console">dotnet run</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-23.png" class="kg-image" alt="API" loading="lazy" width="1334" height="516" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-23.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-23.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-23.png 1334w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-21.png" class="kg-image" alt="Result" loading="lazy" width="1272" height="310" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-21.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-21.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-21.png 1272w" sizes="(min-width: 720px) 720px"></figure><p>Ensure the file is named according to our standard, regardless of its original name. </p><p>The same happens with the documents:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-24.png" class="kg-image" alt="API" loading="lazy" width="1320" height="463" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-24.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-24.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-24.png 1320w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-20.png" class="kg-image" alt="Result" loading="lazy" width="1290" height="434" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-20.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-20.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-20.png 1290w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-22.png" class="kg-image" alt="Uploaded files" loading="lazy" width="248" height="126"></figure><p>Apart from naming files with date and time, using a unique GUID or the username is also a common practice. Consider the following ways:</p><pre><code class="language-csharp">var fileName = $"{ Guid.NewGuid().ToString() }{extension}";</code></pre><p>or:</p><pre><code class="language-csharp">var fileName = $"{ _userContext.Username }{extension}";</code></pre><h2 id="creating-a-get-endpoint-to-fetch-a-file">Creating a GET endpoint to fetch a file</h2><p>We have covered several ways to upload a file in an ASP.NET Core API. Next requirement naturally arises: how to fetch the saved file? Let's design an endpoint for it.</p><p>A new model to return the file from the service to the controller could be implemented like this:</p><pre><code class="language-csharp">namespace fileUploadDemo.Models.Dtos;

public class FileResultDto
{
    public byte[] Content { get; set; } = default!;
    public string ContentType { get; set; } = string.Empty;
    public string FileName { get; set; } = string.Empty;
}</code></pre><p>Our service implementation should include a new method for fetching a file:</p><pre><code class="language-csharp">public async Task&lt;FileResultDto?&gt; GetFileAsync(string key)
{
    if (string.IsNullOrWhiteSpace(key) || key.Contains(".."))
        return null;

    var filePath = Path.Combine(_uploadsFolder, key);

    if (!System.IO.File.Exists(filePath))
        return null;

    var extension = Path.GetExtension(filePath).ToLower();

    var contentType = extension switch
    {
        ".jpg" or ".jpeg" =&gt; "image/jpeg",
        ".png" =&gt; "image/png",
        ".pdf" =&gt; "application/pdf",
        _ =&gt; "application/octet-stream"
    };

    var bytes = await System.IO.File.ReadAllBytesAsync(filePath);

    return new FileResultDto
    {
        Content = bytes,
        ContentType = contentType,
        FileName = key
    };
}</code></pre><p>After some initial string checks and combining the filename with the directory, we check whether the file exists. In the latter part, we convert the file into a MIME type based on the extension. <code>var bytes = await System.IO.File.ReadAllBytesAsync(filePath);</code> loads the entire file into memory and returns <code>byte[]</code>. If large files are uploaded, consider streaming instead. Finally, it packs the content and metadata into the response model and returns it.</p><p>Use it in the controller:</p><pre><code class="language-csharp">[HttpGet("GetFile")]
public async Task&lt;IActionResult&gt; GetFileAsync([FromQuery] string key)
{
    var result = await _fileService.GetFileAsync(key);

    if (result == null)
        return NotFound("File not found");

    return File(result.Content, result.ContentType, result.FileName);
}</code></pre><p><code>[FromQuery]</code> will prompt for the filename as a key param and pass it to the service. Once the data is received, the endpoint will return the actual file.</p><p><strong>Test</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-25.png" class="kg-image" alt="API" loading="lazy" width="1321" height="409" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/image-25.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/image-25.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-25.png 1321w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/image-26.png" class="kg-image" alt="Result with a Download file link" loading="lazy" width="599" height="230"></figure><p>Simply, the user can download the file.</p><h2 id="tips-for-using-file-uploading-in-aspnet-core">Tips for using file uploading in ASP.NET Core</h2><p>For production environments and scalable systems, consider the following aspects.</p><ul><li>For the production environment, use a cloud service such as Azure Blob or AWS S3 to save files for better reliability and speed.</li><li>Use the path in appsettings for security and good design.</li><li>Organize upload paths into separate directories for document- and module-wise uploads. Even separating directories for users is also a common practice, such as "images/Profile", "images/Posts", "documents/Resumes" or "images/john/".</li><li>Store metadata in a database for referencing and persistence. Creating an image-and-document table with a Type key is a great way to reference files to corresponding entities, such as User and Building, via ImageId and DocumentId. The tables can store other important information, such as file size, creation date, uploader user, file name, etc.</li><li>If your system requires a very large file upload, use chunk uploading.</li><li>Use file streaming for large file instead of buffer that can result in high RAM usage.</li><li>Enable progress tracking via SignalR.</li></ul><h2 id="conclusion">Conclusion</h2><p>The long story comes to an end, and we learned how to handle files in ASP.NET Core APIs. We started with minimal code and applied best practices when using images and documents. By following these tips, you can design a file structure securely and efficiently in a production-grade system.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ The complete guide to mastering Dapper micro-ORM in .NET ]]></title>
        <description><![CDATA[ Walks through building a .NET console app that uses Dapper, the micro-ORM created by Stack Overflow, with Npgsql to query a PostgreSQL database of Movie and Director records, covering a DbConnectionFactory setup, JOINs, aggregate functions, stored procedures, and transactions. ]]></description>
        <link>https://blog.elmah.io/the-complete-guide-to-mastering-dapper-micro-orm-in-net/</link>
        <guid isPermaLink="false">69e4b06542b0770001f2aad6</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Wed, 13 May 2026 08:34:36 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/the-complete-guide-to-mastering-dapper-micro-orm-in-dot.net-o-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/the-complete-guide-to-mastering-dapper-micro-orm-in-net/">https://blog.elmah.io/the-complete-guide-to-mastering-dapper-micro-orm-in-net/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>For developers who want to taste ORM but don't want to leave SQL either, Dapper is a perfect choice. Dapper runs SQL queries like ADO.NET but returns results as C# objects, like Entity Framework Core. Apart from its abstracting nature, you leverage high-speed data access. The feature set of Dapper is quite large and covers key areas of database work, such as JOINs, aggregate functions, database procedures and functions, transactions, etc. In today's post, I will walk through everything needed to get you started with Dapper.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/the-complete-guide-to-mastering-dapper-micro-orm-in-dot.net-o-3.png" class="kg-image" alt="The complete guide to mastering Dapper micro-ORM in .NET" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/05/the-complete-guide-to-mastering-dapper-micro-orm-in-dot.net-o-3.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/05/the-complete-guide-to-mastering-dapper-micro-orm-in-dot.net-o-3.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/05/the-complete-guide-to-mastering-dapper-micro-orm-in-dot.net-o-3.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="what-is-dapper">What is Dapper?</h2><p>Dapper is a micro-ORM (Object Relational Mapper) for .NET. Developed by Stack Overflow, Dapper offers higher performance database operations faster than full ORMs like Entity Framework Core (<a href="https://blog.elmah.io/new-in-net-10-and-c-14-ef-core-10s-faster-production-queries/" rel="noreferrer">EF Core</a>). It suits developers who work with ADO.NET because of its query commands, unlike EF Core, which operates on objects rather than SQL. SQL Queries are directly mapped to strongly typed objects like any ORM.</p><h2 id="why-use-dapper">Why Use Dapper?</h2><p>As Dapper is a micro-ORM that offers a thin abstraction between the database and applications, it introduces very little overhead. For these reasons, it is an optimal choice for high-performing scenarios. Similar to ADO .NET, you can get full control over SQL queries. You can think of it as the middle ground between ADO.NET and an ORM like EF Core, which uses data as strongly typed C# objects. One can <a href="https://blog.elmah.io/visualizing-linq-queries-with-linqpad-boost-your-ef-core-debugging/" rel="noreferrer">view the SQL generated by EF Core</a>, but can't granularly control it. Dapper uses minimal dependencies, keeping the architecture lightweight. That is one of the reasons Dapper best suits microservices, financial systems, web APIs, and modular APIs. </p><h2 id="commonly-used-dapper-methods">Commonly used Dapper methods</h2><p>To summarize the most commonly used Dapper methods, let's go ahead and create a new project.</p><p><strong>Step 1: Create a project</strong></p><pre><code class="language-console">dotnet new console -n UserDapperDemo
cd UserDapperDemo</code></pre><p><strong>Step 2: Install the required packages</strong></p><pre><code class="language-console">dotnet add package Dapper
dotnet add package Npgsql
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json</code></pre><p><code>Npgsql</code> provides a <code>NpgsqlConnection</code> class for connecting to PostgreSQL. The Configuration* packages are useful when we create and include <code>appsettings.json</code> in the project.</p><p><strong>Step 3: Create models</strong></p><pre><code class="language-csharp">namespace UserDapperDemo.Models;

public class Movie
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public int DirectorId { get; set; }
    public double Rating { get; set; }
}</code></pre><pre><code class="language-csharp">namespace UserDapperDemo.Models;

public class Director
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}</code></pre><p><strong>Step 4: Add <code>appsettings.json</code> and its configuration</strong></p><pre><code class="language-json">{
  "ConnectionStrings": {
    "PostgresConnection": "Host=localhost;Port=5432;Database=movieDb;Username=postgres;Password=1234"
  }
}</code></pre><p>In the <code>.proj</code> file add the following item group:</p><pre><code class="language-XML">    &lt;ItemGroup&gt;
        &lt;None Update="appsettings.json"&gt;
            &lt;CopyToOutputDirectory&gt;PreserveNewest&lt;/CopyToOutputDirectory&gt;
        &lt;/None&gt;
    &lt;/ItemGroup&gt;</code></pre><p><strong>Step 5: Configure connection factory</strong></p><pre><code class="language-csharp">using System.Data;
using Microsoft.Extensions.Configuration;
using Npgsql;

namespace UserDapperDemo.Data;

public class DbConnectionFactory
{
    
    private readonly string _connectionString;

    public DbConnectionFactory()
    {
        
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json")
            .Build();

        _connectionString = config.GetConnectionString("PostgresConnection");
    }

    public IDbConnection CreateConnection()
        =&gt; new NpgsqlConnection(_connectionString);
}</code></pre><p>It makes a centralized database connection point.</p><p><strong>Step 6: Prepare the database</strong></p><p>Run the following query for the database</p><pre><code class="language-SQL">CREATE TABLE IF NOT EXISTS public."Director"
(
    "Id" integer NOT NULL DEFAULT nextval('"Director_Id_seq"'::regclass),
    "Name" text COLLATE pg_catalog."default" NOT NULL,
    CONSTRAINT "Director_pkey" PRIMARY KEY ("Id")
)
CREATE TABLE IF NOT EXISTS public."Movie"
(
    "Id" integer NOT NULL DEFAULT nextval('"Movie_Id_seq"'::regclass),
    "Title" text COLLATE pg_catalog."default" NOT NULL,
    "DirectorId" integer,
    "Rating" numeric,
    CONSTRAINT "Movie_pkey" PRIMARY KEY ("Id"),
    CONSTRAINT "Movie_DirectorId_fkey" FOREIGN KEY ("DirectorId")
        REFERENCES public."Director" ("Id") MATCH SIMPLE
        ON UPDATE NO ACTION
        ON DELETE NO ACTION
)
</code></pre><p>So, our database tables look like:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-12.png" class="kg-image" alt="Tables" loading="lazy" width="185" height="72"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-14.png" class="kg-image" alt="Database" loading="lazy" width="433" height="265"></figure><p><strong>Step 7: Implement Dapper methods</strong></p><p>I will follow a repository pattern to test some of Dapper's most common methods. This is just for the sake of demoing common methods. Whether or not you want to implement repositories in your code is entirely up to you. Some people love repositories while others absolutely hate them. </p><p>The movie repository will be:</p><pre><code class="language-csharp">public class MovieRepo
{
    private readonly DbConnectionFactory _factory;
    public MovieRepo(DbConnectionFactory factory)
    {
        _factory = factory;
    }
    
    //methods
}
    </code></pre><h3 id="queryasynct">QueryAsync&lt;T&gt;()</h3><pre><code class="language-csharp">public async Task&lt;IEnumerable&lt;Movie&gt;&gt; GetAllMoviesAsync()
{
    using var connection = _factory.CreateConnection();

    return await connection.QueryAsync&lt;Movie&gt;(
        "SELECT * FROM \"Movie\""
    );</code></pre><p><code>QueryAsync</code> returns multiple rows of data asynchronously. It is used when we need to fetch multiple rows of data. Its synchronous counterpart is <code>Query&lt;&gt;()</code>.</p><h3 id="queryfirstasynct">QueryFirstAsync&lt;T&gt;()</h3><pre><code class="language-csharp">public async Task&lt;Movie&gt; GetFirstMovie()
{
    using var connection = _factory.CreateConnection();

    return await connection.QueryFirstAsync&lt;Movie&gt;(
        "SELECT * FROM \"Movie\" ORDER BY \"Id\" LIMIT 1"
    );
}
</code></pre><p>A single row-returning operation that returns the first row that matches the given condition. It expects the record to exist, otherwise, it throws an exception. <code>QueryFirst&lt;T&gt;()</code> is for synchronous operation. </p><h3 id="queryfirstordefaultasynct">QueryFirstOrDefaultAsync&lt;T&gt;()</h3><pre><code class="language-csharp">public async Task&lt;Movie?&gt; GetMovieByTitle(string title)
{
    using var connection = _factory.CreateConnection();

    return await connection.QueryFirstOrDefaultAsync&lt;Movie&gt;(
        "SELECT * FROM \"Movie\" WHERE \"Title\" = @Title",
        new { Title = title }
    );
}</code></pre><p>As the name suggests, it returns the first row that matches the condition, or otherwise returns the default value. A similar sync method is <code>QueryFirstOrDefault</code>.</p><h3 id="querysingleasynct">QuerySingleAsync&lt;T&gt;()</h3><pre><code class="language-csharp">public  async Task&lt;Movie&gt; GetMovieById(int id)
{
    using var connection = _factory.CreateConnection();

    return await connection.QuerySingleAsync&lt;Movie&gt;(
        "SELECT * FROM \"Movie\" WHERE \"Id\" = @Id",
        new { Id = id }
    );
}</code></pre><p><code>QuerySingleAsync</code> or <code>QuerySingle</code> (sync version) expects exactly one row to match the condition. If fewer or more are found, it throws an exception. <code>QuerySingle</code> is ideal when filtering a record by ID, as ID does not duplicate.</p><h3 id="querysingleordefaultasynct">QuerySingleOrDefaultAsync&lt;T&gt;()</h3><pre><code class="language-csharp">public  async Task&lt;Movie?&gt; GetMovieSafe(int id)
{
    using var connection = _factory.CreateConnection();

    return await connection.QuerySingleOrDefaultAsync&lt;Movie&gt;(
        "SELECT * FROM \"Movie\" WHERE \"Id\" = @Id",
        new { Id = id }
    );
}</code></pre><p>Returns one record matched or null. Use <code>QuerySingleOrDefault</code> if you want non-async. </p><h3 id="executeasync">ExecuteAsync</h3><pre><code class="language-csharp">public  async Task&lt;int&gt; InsertMovie(Movie movie)
{
    using var connection = _factory.CreateConnection();

    return await connection.ExecuteAsync(
        @"INSERT INTO ""Movie"" (""Title"", ""director_id"", ""rating"")
  VALUES (@Title, @DirectorId, @Rating)",
        movie
    );
}</code></pre><pre><code class="language-csharp">public async Task&lt;int&gt; UpdateMovieAsync(Movie movie)
{
    using var connection = _factory.CreateConnection();

    return await connection.ExecuteAsync(
        @"UPDATE ""Movie"" 
      SET ""Title"" = @Title, ""Rating"" = @Rating
      WHERE ""Id"" = @Id",
        movie
    );
}</code></pre><p><code>ExecuteAsync</code> runs the command and returns the number of rows affected. Specifically, this and its Execute methods are used for Insert, Update, and Delete operations. </p><h3 id="executescalarasync">ExecuteScalarAsync</h3><pre><code class="language-csharp">public async Task&lt;int&gt; GetMovieCount()
{
    using var connection = _factory.CreateConnection();

    return await connection.ExecuteScalarAsync&lt;int&gt;(
        "SELECT COUNT(*) FROM \"Movie\""
    );
}</code></pre><p><code>ExecuteScalarAsync</code> or <code>ExecuteScalar</code> returns a single value as a result of SQL aggregation functions such as <code>COUNT()</code>, <code>AVG()</code>, and <code>SUM()</code>. </p><h3 id="querymultipleasync">QueryMultipleAsync</h3><pre><code class="language-csharp">public async Task&lt;(IEnumerable&lt;Movie&gt;, IEnumerable&lt;Director&gt;)&gt; GetDashboard()
{
    using var connection = _factory.CreateConnection();

    var sql = @"
        SELECT * FROM ""Movie"";
        SELECT * FROM ""Director"";
    ";

    using var multi = await connection.QueryMultipleAsync(sql);

    var movies = multi.Read&lt;Movie&gt;();
    var directors = multi.Read&lt;Director&gt;();

    return (movies, directors);
}</code></pre><p>The method is complex, running multiple commands from a single command. <code>QueryMultiple</code> is the sync version of it. Instead of hitting the database multiple times, <code>QueryMultiple</code> lets you fetch multiple datasets in a single round-trip.</p><p><strong>Step 8: Set up <code>Program.cs</code></strong></p><pre><code class="language-csharp">using UserDapperDemo.Models;
using UserDapperDemo.Data;
using UserDapperDemo.Data.Repos;

var factory = new DbConnectionFactory();

var repo = new MovieRepo(factory);

// INSERT
repo.InsertMovie(new Movie
{
    Title = "Inception",
    DirectorId = 1,
    Rating = 9.0
});

Console.WriteLine("=== ALL MOVIES ===");
// QUERY
var movies = await repo.GetAllMoviesAsync();

foreach (var m in movies)
{
    Console.WriteLine($"Id: {m.Id}, Title: {m.Title}, Rating: {m.Rating}");
}

Console.WriteLine("\n=== SINGLE MOVIE (QueryFirst) ===");
// SINGLE
var movie1 = await repo.GetFirstMovie();

Console.WriteLine($"Id: {movie1.Id}, Title: {movie1.Title}, Rating: {movie1.Rating}");

Console.WriteLine("\n=== SINGLE MOVIE (QueryFirstOrDefault) ===");
// SINGLE
var movie2 = await repo.GetMovieByTitle("Memento");

Console.WriteLine($"Id: {movie2.Id}, Title: {movie2.Title}, Rating: {movie2.Rating}");

Console.WriteLine("\n=== SINGLE MOVIE (QuerySingle) ===");
// SINGLE
var movie3 = await repo.GetMovieById(1);

Console.WriteLine($"Id: {movie3.Id}, Title: {movie3.Title}, Rating: {movie3.Rating}");

Console.WriteLine("\n=== SINGLE MOVIE (QuerySingleOrDefault) ===");
// SINGLE
var movie4 = await repo.GetMovieSafe(1);

Console.WriteLine($"Id: {movie4.Id}, Title: {movie4.Title}, Rating: {movie4.Rating}");

Console.WriteLine("\n=== MOVIE COUNT (ExecuteScalar) ===");
// COUNT
var count = await repo.GetMovieCount();
Console.WriteLine($"Total Movies: {count}");

Console.WriteLine("\n=== DASHBOARD (QueryMultiple) ===");
// MULTIPLE
var (allMovies, directors) = await repo.GetDashboard();
foreach (var m in allMovies)
{
    Console.WriteLine($"Id: {m.Id}, Title: {m.Title}, Rating: {m.Rating}");
}

foreach (var d in directors)
{
    Console.WriteLine($"Id: {d.Id}, Name: {d.Name}");
}</code></pre><p>Here we are using <code>movieRepo</code> to call all of its methods.</p><p><strong>Step 9: Run and test</strong></p><pre><code class="language-console">dotnet run</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-15.png" class="kg-image" alt="Result" loading="lazy" width="584" height="322"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-16.png" class="kg-image" alt="Result" loading="lazy" width="594" height="502"></figure><h2 id="advanced-dapper-features">Advanced Dapper Features</h2><p>We have seen some of the most common dapper methods till now. However, Dapper offers ways to tackle them as well for complex scenarios.</p><h3 id="working-with-transactions">Working with transactions</h3><p>When inserting or updating data, an exception in between the operation can leave the database inconsistent. <a href="https://blog.elmah.io/3-essential-techniques-for-managing-transactions-in-ef-core/" rel="noreferrer">Transactions </a>rescue and atomize the data manipulation operations as one, either the whole block executes or fails and rolls back. Let's see how we can do it in Dapper.</p><pre><code class="language-csharp">public async Task CreateMovieWithDirector(Movie movie, string directorName)
{
    using var connection = _factory.CreateConnection();
    connection.Open();

    using var transaction = connection.BeginTransaction();

    try
    {
        var directorId = await connection.ExecuteScalarAsync&lt;int&gt;(
            @"INSERT INTO ""Director"" (""Name"")
          VALUES (@Name)
          RETURNING ""Id"";",
            new { Name = directorName },
            transaction
        );

        movie.DirectorId = directorId;

        await connection.ExecuteAsync(
            @"INSERT INTO ""Movie"" (""Title"", ""DirectorId"", ""Rating"")
          VALUES (@Title, @DirectorId, @Rating)",
            movie,
            transaction
        );

        transaction.Commit();
    }
    catch
    {
        transaction.Rollback();
        throw;
    }
}
</code></pre><p>I opened a transaction on the connection, then we first created the director before the movie, as the movie depends on the <code>directorId</code>. At last <code>transaction.Commit()</code> commits the transaction to write everything in the database, while the catch block rolls back the transaction using <code>transaction.Rollback()</code>. Note that I am using a parameterized query to keep the database safe from SQL injection attacks and unwanted data. </p><p>In <code>Program.cs</code>:</p><pre><code class="language-csharp">Console.WriteLine("\n=== Transaction ===");
await repo.CreateMovieWithDirector(
    new Movie()
    {
        Title = null,
        Rating = 9,
    },
    "Martin Scorsese"
);
</code></pre><p>I intentionally kept the title null, as per the repo method, the director will be added successfully, but during Movie creation, there is an error. The transaction will roll back the director insertion when the second half fails as well.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-18-1.png" class="kg-image" alt="Exception" loading="lazy" width="807" height="404" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/04/image-18-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-18-1.png 807w" sizes="(min-width: 720px) 720px"></figure><p>Upon correct values it will be done:</p><pre><code class="language-csharp">new Movie()
    {
        Title = "The Departed",
        Rating = 9
    },</code></pre><h3 id="using-stored-proceduresfunctions">Using Stored Procedures/Functions</h3><p>Stored procedures and functions encapsulate complex logic and provide reusable calls. For such detailed operations, you should not write a raw query spanning dozens of lines in the code that can be difficult to maintain and organize. The best way is to abstract into functions or procedures. Let's consider a function</p><pre><code class="language-SQL">CREATE FUNCTION public.get_movies_by_director(
	p_director_id integer)
    RETURNS TABLE("Id" integer, "Title" text, "Rating" numeric) 
    LANGUAGE 'plpgsql'
    COST 100
    VOLATILE PARALLEL UNSAFE
    ROWS 1000

AS $BODY$
BEGIN
    RETURN QUERY
    SELECT m."Id", m."Title", m."Rating"
    FROM "Movie" m
    WHERE m."DirectorId" = p_director_id;
END;
$BODY$;</code></pre><p>repo method that calls the function</p><pre><code class="language-csharp">public async Task&lt;IEnumerable&lt;Movie&gt;&gt; GetMoviesByDirector(int directorId)
{
    using var connection = _factory.CreateConnection();

    return await connection.QueryAsync&lt;Movie&gt;(
        @"SELECT * FROM get_movies_by_director(@DirectorId)",
        new { DirectorId = directorId }
    );
}</code></pre><pre><code class="language-csharp">var moviesByDirector = await repo.GetMoviesByDirector(1);

foreach (var m in moviesByDirector)
{
    Console.WriteLine($"{m.Id} - {m.Title} - {m.Rating}");
}</code></pre><p> Result:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-19.png" class="kg-image" alt="Result" loading="lazy" width="221" height="38"></figure><h3 id="bulk-operations-with-dapper">Bulk Operations with Dapper</h3><p>Bulk operations are important features supported by Dapper. When inserting a large amount of data, you do not want to hit the database separately for each record, nor should you. SQL allows bulk insertion or update at once, and you can run the query with <code>ExecuteAsync</code>.</p><pre><code class="language-csharp">public async Task BulkInsertMovies(IEnumerable&lt;Movie&gt; movies)
{
    using var connection = _factory.CreateConnection();

    await connection.ExecuteAsync(
        @"INSERT INTO ""Movie"" (""Title"", ""DirectorId"", ""Rating"")
          VALUES (@Title, @DirectorId, @Rating)",
        movies
    );
}</code></pre><p><code>Program.cs</code> call</p><pre><code class="language-csharp">await repo.BulkInsertMovies(new List&lt;Movie&gt;
{
    new Movie { Title = "Batman Begins", DirectorId = 1, Rating = 8.2 },
    new Movie { Title = "The Dark Knight", DirectorId = 1, Rating = 9.0 }
});</code></pre><h3 id="opt-between-buffered-and-unbuffered-query">Opt between Buffered and Unbuffered Query </h3><p>By default, Dapper loads everything into the buffer memory. The behavior can be problematic with large datasets, so choose unbuffered queries that stream results rather than storing them in memory. </p><pre><code class="language-csharp">var movies = await connection.QueryAsync&lt;Movie&gt;(
    sql,
    buffered: false
);</code></pre><h3 id="multi-mapping-handling-join-queries">Multi-Mapping (Handling JOIN Queries)</h3><p>JOINs are a common way to get data from multiple tables in SQL. That common feature is workable using Dapper as well. With the same <code>QueryAsync</code> you can run queries with JOIN commands. </p><pre><code class="language-csharp">public async Task&lt;IEnumerable&lt;MovieWithDirector&gt;&gt; GetMoviesWithDirectors()
{
    using var connection = _factory.CreateConnection();

    var sql = @"
    SELECT 
        m.""Id"", 
        m.""Title"", 
        m.""Rating"",
        d.""Name"" AS ""DirectorName""
    FROM ""Movie"" m
    JOIN ""Director"" d ON m.""DirectorId"" = d.""Id"";
";

    var result = await connection.QueryAsync&lt;MovieWithDirector&gt;(sql);

    return result;
}</code></pre><p>We need a model to get the results</p><pre><code class="language-csharp">namespace UserDapperDemo.Models;

public class MovieWithDirector
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public double Rating { get; set; }
    public string DirectorName { get; set; } = string.Empty;
}</code></pre><p>Simply printing them all after calling the repo method</p><pre><code class="language-csharp">var moviesWithDirectors = await repo.GetMoviesWithDirectors();

foreach (var m in moviesWithDirectors)
{
    Console.WriteLine($"{m.Id} - {m.Title} - {m.Rating} by {m.DirectorName}");
}</code></pre><p><strong>Result</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-20.png" class="kg-image" alt="Result" loading="lazy" width="590" height="184"></figure><h2 id="best-practices-when-using-dapper">Best Practices When Using Dapper</h2><p>Following some practices can make Dapper work better than usual. These ways make the application fast, maintainable, and secure.</p><h3 id="use-parameterized-queries">Use Parameterized Queries</h3><p>That is general advice to always use parameters to prevent SQL injection attacks. Apart from security, some special characters, such as "," and")", can cause errors when inserting or updating string values. </p><h3 id="prefer-async-methods">Prefer Async Methods</h3><p>To avoid thread block and support asynchronous, go with async versions like <code>QueryAsync</code> and <code>ExecuteAsync</code>. Applications usually run different threads in parallel, preferring async methods in Dapper. </p><h3 id="organize-sql-queries">Organize SQL Queries </h3><p>Good architecture makes a project maintainable. You should handle data access as well when using Dapper. <a href="https://blog.elmah.io/the-repository-pattern-is-simple-yet-misunderstood/" rel="noreferrer">Repository pattern</a> is a preferable choice however choose <a href="https://blog.elmah.io/repository-pattern-vs-specification-pattern-which-is-more-maintainable/" rel="noreferrer">as per your requirement</a>. Keeping long queries in files will also save mess in the code.</p><h3 id="manage-connections-efficiently">Manage Connections Efficiently</h3><p>Database connections are like portals to the database. Open and close them the right way with&nbsp;<code>using</code>&nbsp;statements or connection factories to ensure that connections open only once and are properly closed after each query.</p><h3 id="avoid-business-logic-in-the-sql-queries">Avoid Business logic in the SQL queries</h3><p>Validate the inputs and other business rules before passing them to SQL queries. This avoids unnecessary checks on SQL and keeps the single responsibility in the data access layer.on SQL and keep the single responsibility on the data access layer.</p><p>Avoid checks like </p><pre><code class="language-csharp">CASE WHEN rating &gt; 5 THEN 'Good' ELSE 'Bad'</code></pre><p>It is generally good to keep the repository or other data access separate from business rules.</p><h3 id="cache-frequently-accessed-data">Cache frequently-accessed data</h3><p>Database operations are always costly, try to reduce them as much as possible. Caching is one of the best ways to reduce database processing and delays. First, identify which data are most requested or don't change frequently. <a href="https://blog.elmah.io/caching-strategies-in-asp-net-core/" rel="noreferrer">Set up caching</a> and save them to provide quick access to users, rather than requesting the same data from the dataset every time.</p><p>You can follow some <a href="https://blog.elmah.io/how-net-handles-exceptions-internally-and-why-theyre-expensive/" rel="noreferrer">generic recommendations</a> for the application apart from Dapper.</p><h2 id="conclusion">Conclusion</h2><p>We rediscovered Dapper from its basic methods to its advanced features. Later, we reviewed some recommendations for using Dapper. Like any other feature, Dapper has its own world and definitions. I defined its commonly used methods and some of the advanced features. I shared a real example of how those methods and features address most database requirements. By following the tips in the article, you can scale up the use of the high-performance library.</p><p>Code: <a href="https://github.com/elmahio-blog/MovieDapperDemo.git">https://github.com/elmahio-blog/MovieDapperDemo.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Pattern matching in C#: Advanced scenarios you didn&#x27;t know ]]></title>
        <description><![CDATA[ Demonstrates advanced C# pattern matching using record types for User, Address, and Request, covering nested property patterns like user is { Address.City: &quot;Karachi&quot; }, negation with &#39;not&#39;, combined &#39;or&#39; cases, LINQ filtering by shape, and relational patterns like age is &gt; 18 and &lt; 60. ]]></description>
        <link>https://blog.elmah.io/pattern-matching-in-c-advanced-scenarios-you-didnt-know/</link>
        <guid isPermaLink="false">69db60464515690001427afc</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 05 May 2026 10:19:19 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/pattern-matching-in-csharp-advanced-scenarios-you-didnt-know-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/pattern-matching-in-c-advanced-scenarios-you-didnt-know/">https://blog.elmah.io/pattern-matching-in-c-advanced-scenarios-you-didnt-know/</a></p> 
<!--kg-card-begin: html-->
<div class="toc">table of contents</div>
<!--kg-card-end: html-->
<p>Pattern matching is not just condition checking. It reflects how you think as a developer. Matching and validation can be achieved in a naive, descriptive way. However, a cleaner approach stands out in terms of readability and sometimes performance. Pattern matching combines patterns to express complex logic in a single, readable line. In today's post, I will cover some advanced pattern-matching solutions that developers often miss.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/pattern-matching-in-csharp-advanced-scenarios-you-didnt-know-o-1.png" class="kg-image" alt="Pattern matching in C#: Advanced scenarios you didn’t know" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/04/pattern-matching-in-csharp-advanced-scenarios-you-didnt-know-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/04/pattern-matching-in-csharp-advanced-scenarios-you-didnt-know-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/pattern-matching-in-csharp-advanced-scenarios-you-didnt-know-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="implementation-of-pattern-matching-scenarios">Implementation of pattern-matching scenarios</h2><p>To show pattern matching in code, I will start by creating a console application to test out different examples.</p><p><strong>Step 1: Create the project</strong></p><pre><code class="language-console">dotnet new console -n PatternMatchingDemo
cd PatternMatchingDemo</code></pre><p><strong>Step 2: Create records</strong></p><p>For models, I will use records <a href="https://blog.elmah.io/exploring-c-records-and-their-use-cases/" rel="noreferrer">because we need value types</a> here. </p><pre><code class="language-csharp">namespace PatternMatchingDemo.Records;
public record Address(string City, string Country);
public record User(string Name, int Age, Address Address, List&lt;string&gt; Roles);
public record Request(string Source, int Priority);
public record Point(int X, int Y);</code></pre><p><strong>Step 3: Define collections</strong></p><p>To use pattern matching, we need a few collections. Defining an in-memory collection, while it will mimic any real data.</p><pre><code class="language-csharp">var users = new List&lt;User&gt;
{
    new("Ali", 25, new Address("Karachi", "Pakistan"), new List&lt;string&gt; { "Admin", "User" }),
    new("Sara", 17, new Address("Chicago", "United States"), new List&lt;string&gt; { "User" }),
    new("Kennedy", 65, new Address("London", "UK"), new List&lt;string&gt; { "Guest" })
};

var requests = new List&lt;Request&gt;
{
    new("System", 10),
    new("User", 3),
    new("System", 2)
};</code></pre><p><strong>Step 4: Use pattern matching for different requirements</strong></p><p>That's it for the setup. In the next sections, I will showcase pattern matching for different requirements and purposes.</p><h3 id="property-pattern-nested-matching">Property pattern (nested matching)</h3><pre><code class="language-csharp">foreach (var user in users)
{
    if (user is { Address.City: "Karachi" })
    {
        Console.WriteLine($"{user.Name} is from Karachi");
    }
}</code></pre><p>Property pattern provides a clean way to match an object's properties, even when they are nested. It helps in JSON matching and DTO validation without verbosity. A traditional way without a property pattern would be:</p><pre><code class="language-csharp">if (user != null &amp;&amp; user.Address != null &amp;&amp; user.Address.City == "Karachi" &amp;&amp; user.Age &gt; 18)</code></pre><h3 id="pattern-matching-with-not">Pattern matching with <code>not</code></h3><pre><code class="language-csharp">foreach (var user in users)
{
    if (user is not { Address.City: "Karachi" })
    {
        Console.WriteLine($"{user.Name} is NOT from Karachi");
    }
}</code></pre><p>This one is just the opposite of the previous pattern. It simply excludes the given condition and fetches all other records.</p><h3 id="matching-multiple-cases-in-one-pattern">Matching multiple cases in one pattern</h3><pre><code class="language-csharp">foreach (var user in users)
{
    if (user is { Address.City: "Karachi" or "Lahore" })
    {
        Console.WriteLine($"{user.Name} is from a major city");
    }
}</code></pre><p>The same property matching can be extended to multiple cases. Well, the pattern is very much descriptive itself, referring to what it actually does.</p><h3 id="pattern-matching-inside-linq">Pattern matching inside LINQ</h3><pre><code class="language-csharp">var adultsFromPakistan = users
    .Where(u =&gt; u is { Age: &gt; 18, Address.Country: "Pakistan" })
    .ToList();

foreach (var user in adultsFromPakistan)
{
    Console.WriteLine($"{user.Name} is adult from Pakistan");
}</code></pre><p>One of the most usable scenarios is pattern matching inside LINQ. It filters collections based on object shape and conditions.</p><h3 id="matching-partial-objects">Matching partial objects</h3><pre><code class="language-csharp">foreach (var user in users)
{
    if (user is { Name: "Ali" })
    {
        Console.WriteLine("Found Ali");
    }
}</code></pre><p>To match a condition, you don't even need to know the structure completely. As the example shows, you can check on a field as well.</p><h3 id="relational-logical-patterns">Relational + logical patterns</h3><pre><code class="language-csharp">foreach (var user in users)
{
    if (user.Age is &gt; 18 and &lt; 60)
    {
        Console.WriteLine($"{user.Name} is Adult");
    }
    else if (user.Age is &lt; 18 or &gt; 60)
    {
        Console.WriteLine($"{user.Name} is Special Age Group");
    }
}</code></pre><p>The relational pattern has made the comparison easier to read. Rather than just mathematical logical operators, you can use readable keywords. Apart from readability, it offers applications like comparing ages, checking a threshold, or checking a range.</p><h3 id="switch-expression">Switch expression</h3><pre><code class="language-csharp">foreach (var user in users)
{
    var category = user.Age switch
    {
        &lt; 13 =&gt; "Child",
        &lt; 20 =&gt; "Teen",
        &lt; 60 =&gt; "Adult",
        _ =&gt; "Senior"
    };

    Console.WriteLine($"{user.Name} =&gt; {category}");
}</code></pre><p> A switch case is a well-known way to compare multiple conditions. It is an ideal way of handling multiple cases instead of using a cluster of <code>else-if</code>. If you have some complex code to execute, then the following version is workable:</p><pre><code class="language-csharp">foreach (var user in users)
{
    string category;
    
    // Traditional switch statement with cases
    switch (user.Age)
    {
        case &lt; 13:
            category = "Child";
            break;
        case &lt; 20:
            category = "Teen";
            break;
        case &lt; 60:
            category = "Adult";
            break;
        default:
            category = "Senior";
            break;
    }
    
    Console.WriteLine($"{user.Name} =&gt; {category}");
}</code></pre><h3 id="type-condition-pattern"> Type + condition pattern</h3><pre><code class="language-csharp">object value = 150;

if (value is int number &amp;&amp; number &gt; 100)
{
    Console.WriteLine("Large number (old way)");
}

if (value is int and &gt; 100)
{
    Console.WriteLine("Large number (pattern matching way)");
}
</code></pre><p>The pattern checks type and condition simultaneously, liberating you from separate casting and checking logic. It is useful for object or dynamic data.</p><h3 id="list-pattern">List pattern</h3><pre><code class="language-csharp">int[] nums = { 1, 2, 3 };

if (nums is [1, 2, 3])
{
    Console.WriteLine("Exact match");
}

if (nums is [1, .., 3])
{
    Console.WriteLine("Starts with 1 and ends with 3");
}
</code></pre><p>List matching is available in C# 11 and later versions. You can match the array/list structure and content and validate without a loop. List patterns can be handy for validating sequences, checking API payload lists, and detecting start/end patterns.</p><h3 id="positional-pattern">Positional pattern</h3><pre><code class="language-csharp">var point = new Point(10, 20);

if (point is (10, 20))
{
    Console.WriteLine("Point matched (10,20)");
}
</code></pre><p>Another value comparison pattern matches objects based on their constructor/deconstructed values. Positional patterns are ideal for value types, such as records, because their comparisons are lightweight.</p><h3 id="combined-pattern">Combined pattern</h3><pre><code class="language-csharp">foreach (var user in users)
{
    if (user is
        {
            Age: &gt; 18,
            Address.City: "Lahore",
            Roles: ["User", ..]
        })
    {
        Console.WriteLine($"{user.Name} is eligible Lahore user");
    }
}</code></pre><p>This code combines different patterns. Actually, this is one of the most realistic scenarios in which complex objects and requirements are combined to implement different patterns.</p><h3 id="null-pattern">Null pattern</h3><pre><code class="language-csharp">User? maybeUser = null;

if (maybeUser is not null)
{
    Console.WriteLine("User exists");
}
else
{
    Console.WriteLine("User is null");
}
</code></pre><p>Null pattern is a cleaner and more readable alternative to the traditional <code>!= null</code>. Its usage spans large applications, input checks, and condition matching.</p><h3 id="guard-clause">Guard clause</h3><pre><code class="language-csharp">number = 7;

var result = number switch
{
    int n when n % 2 == 0 =&gt; "Even",
    int n when n % 2 != 0 =&gt; "Odd",
    _ =&gt; "Unknown"
};

Console.WriteLine(result);</code></pre><p>A guard clause allows an additional condition inside a switch case. It tackles complex branching logic and mathematical conditions, giving flexibility when patterns alone aren't enough.</p><h3 id="request-handling">Request handling</h3><pre><code class="language-csharp">foreach (var request in requests)
{
    var response = request switch
    {
        { Source: "System", Priority: &gt; 5 } =&gt; "Critical System Request",
        { Source: "User", Priority: &lt;= 5 } =&gt; "Normal User Request",
        _ =&gt; "Fallback"
    };

    Console.WriteLine($"{request.Source} ({request.Priority}) =&gt; {response}");
}</code></pre><p>Request handling is a remarkable way to implement business logic while keeping it readable within a switch statement. It has a ton of use cases, like event processing, request routing, and validating business rules. </p><p><strong>Step 5: Run and test</strong></p><pre><code class="language-console">dotnet run</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-8.png" class="kg-image" alt="Result" loading="lazy" width="618" height="519" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/04/image-8.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-8.png 618w"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-9.png" class="kg-image" alt="Result" loading="lazy" width="399" height="155"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-4.png" class="kg-image" alt="Result" loading="lazy" width="463" height="430"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/04/image-5.png" class="kg-image" alt="Result" loading="lazy" width="512" height="524"></figure><p>The results are expected and correct. However, we did the job more cleanly by combining patterns as needed.</p><h2 id="conclusion">Conclusion</h2><p>Pattern matching has tons of usage. From validation to condition check, it is prominent. Using the right pattern at the right place can save a lot of code. In today's post, I shared some scenarios and their pattern solutions. In real scenarios, you have to know the patterns and use a combination when needed. This is real art.</p><p>Code: <a href="https://github.com/elmahio-blog/PatternMatchingDemo.git">https://github.com/elmahio-blog/PatternMatchingDemo.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ 12 practices for optimizing PostgreSQL queries for large datasets ]]></title>
        <description><![CDATA[ Covers 12 PostgreSQL tuning techniques with EXPLAIN ANALYZE benchmarks, including adding a customer_id index that cuts a 240ms query, avoiding SELECT *, reordering JOINs so the smaller table filters first, using LIMIT, partial indexes like idx_completed_orders, and choosing efficient data types. ]]></description>
        <link>https://blog.elmah.io/12-practices-for-optimizing-postgresql-queries-for-large-datasets/</link>
        <guid isPermaLink="false">69b25d33db602000012ce9d0</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 21 Apr 2026 09:21:23 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/12-practices-for-optimizing-postgresql-queries-for-large-datasets-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/12-practices-for-optimizing-postgresql-queries-for-large-datasets/">https://blog.elmah.io/12-practices-for-optimizing-postgresql-queries-for-large-datasets/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>A database is the root of most applications. Anything displayed or any operations performed often rely on the database. So, to build a loyal client base, you have to think in terms of a database. In today's post, I will go through some key strategies you can apply to optimize your PostgreSQL database and win the performance war.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/12-practices-for-optimizing-postgresql-queries-for-large-datasets-o-1.png" class="kg-image" alt="12 practices for optimizing PostgreSQL queries for large datasets" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/12-practices-for-optimizing-postgresql-queries-for-large-datasets-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/12-practices-for-optimizing-postgresql-queries-for-large-datasets-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/12-practices-for-optimizing-postgresql-queries-for-large-datasets-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="create-indexes-on-frequently-queried-columns">Create indexes on frequently queried columns</h2><p>Index data structures allow a row to be looked up quickly without scanning the entire table.</p><p>Without Indexes:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE customer_id = 500;
</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-17.png" class="kg-image" alt="Query results" loading="lazy" width="697" height="441" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-17.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-17.png 697w"></figure><p>So, the execution time is 240 milliseconds.</p><p>After Index:</p><pre><code class="language-SQL">CREATE INDEX idx_orders_customer_id
ON orders(customer_id);</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-18.png" class="kg-image" alt="Query results" loading="lazy" width="767" height="404" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-18.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-18.png 767w" sizes="(min-width: 720px) 720px"></figure><h2 id="normalize-the-database-strategically">Normalize the database strategically</h2><p>Normalization is a crucial step in database design to reduce anomalies and redundancy. Simply break your table into maintainable tables with logically coherent columns.</p><p>Instead of keeping role information in the user table, introduce a separate <code>Role</code> table and map it to <code>User</code> to keep data integral and non-redundant.</p><p>Keep in mind that over-normalization can also lead to performance penalties. Joining tables is more expensive than querying a single table, so you should analyze which columns should be split into new, normalized tables.</p><h2 id="avoid-select">Avoid SELECT *</h2><p>Fetch only the required columns from the query.</p><p>Bad query with SELECT *:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE status = 'Completed';</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-20.png" class="kg-image" alt="Query results" loading="lazy" width="750" height="378" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-20.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-20.png 750w" sizes="(min-width: 720px) 720px"></figure><p>Now, with selective columns, let's say I only need <code>id</code> and <code>amount</code>, so I'm fetching only those columns:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT id, amount
FROM orders
WHERE status = 'Completed';</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-21.png" class="kg-image" alt="Query results" loading="lazy" width="790" height="361" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-21.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-21.png 790w" sizes="(min-width: 720px) 720px"></figure><p>In this example, the execution time is identical, but it reduces I/O and memory usage, especially as the database schema becomes more complex. If the columns you select are all part of an index, PostgreSQL can return the data directly from the index without touching the heap (the actual table file). In that case, this can improve performance as well as I/O.</p><h2 id="order-joins-properly">Order JOINs properly</h2><p>Ordering JOINs properly can also affect performance. Modern PostgreSQL uses a cost-based optimizer. It analyzes table statistics to determine the most efficient join order and method regardless of the order you write them in the query. But when dealing with older versions or if settings like <code>join_collapse_limit</code> is set manually, this still applies.</p><p>Consider this query:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders o
JOIN customers c
ON o.customer_id = c.id
WHERE c.country = 'USA';
</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-36.png" class="kg-image" alt="Query results" loading="lazy" width="737" height="307" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-36.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-36.png 737w" sizes="(min-width: 720px) 720px"></figure><p>Now calling the <code>customers</code> table before:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM customers c
JOIN orders o
ON c.id = o.customer_id
WHERE c.country = 'USA';
</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-35.png" class="kg-image" alt="Query results" loading="lazy" width="713" height="297" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-35.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-35.png 713w"></figure><p>The <code>orders</code> table has 1 million rows, while the clusters have 100k. When we fetch orders before then, we have a larger set to JOIN with customers. If we use customers first, then join with orders, fewer rows are filtered in the first stage. Filtering earlier reduces the rows participating in the join.</p><h2 id="use-limit-when-exploring-data">Use LIMIT When Exploring Data</h2><p>In most cases, you will not use thousands of data points at once. Usually, you fetch a chunk of data to display at once, which is practical for UI display. Using that information, only fetch the required amount of data:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders
ORDER BY created_at DESC;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-24.png" class="kg-image" alt="Query results" loading="lazy" width="744" height="201" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-24.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-24.png 744w" sizes="(min-width: 720px) 720px"></figure><p>While the efficient way:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 50;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-25.png" class="kg-image" alt="Query results" loading="lazy" width="767" height="339" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-25.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-25.png 767w" sizes="(min-width: 720px) 720px"></figure><p>Hence, fetching limited rows saves you in all ways.</p><h2 id="use-partial-indexes">Use partial indexes</h2><p>A naive way for the query would be:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE status = 'Completed';</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-26.png" class="kg-image" alt="Query results" loading="lazy" width="721" height="172" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-26.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-26.png 721w" sizes="(min-width: 720px) 720px"></figure><p>Creating a partial index:</p><pre><code class="language-SQL">CREATE INDEX idx_completed_orders
ON orders(customer_id)
WHERE status = 'Completed';</code></pre><p>Now the same query gives:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-27.png" class="kg-image" alt="Query results" loading="lazy" width="841" height="350" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-27.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-27.png 841w" sizes="(min-width: 720px) 720px"></figure><p>Hence, indexing reduced the execution time to half that of the prior query. Simply create an index for frequently filtered values to speed up.</p><h2 id="use-proper-data-types">Use Proper Data Types</h2><p>Each data type has its own storage size and comparison time. Columns like primary keys should be handled with care due to these differences. Ints are faster than text, so use int as the primary key column for faster comparisons.</p><p>Good advice is to use the smallest sufficient data type. For example, use <code>bigint</code> instead of <code>int</code> if you expect more than 2.1 billion rows, as changing a PK type later is a high-risk operation.</p><h2 id="avoid-functions-on-indexed-columns">Avoid Functions on Indexed Columns</h2><p>Functions on indexed columns prevent index usage:</p><pre><code class="language-SQL ">EXPLAIN ANALYZE
SELECT *
FROM customers
WHERE LOWER(name) = 'customer 100';</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-28.png" class="kg-image" alt="Query results" loading="lazy" width="727" height="204" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-28.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-28.png 727w" sizes="(min-width: 720px) 720px"></figure><p>Creating an index :</p><pre><code class="language-SQL">CREATE INDEX idx_customers_lower_name
ON customers(LOWER(name));</code></pre><p>Now, the same query gives:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-29.png" class="kg-image" alt="Query results" loading="lazy" width="796" height="229" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-29.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-29.png 796w" sizes="(min-width: 720px) 720px"></figure><p>We included the function in the index. Index can now support the function.</p><h2 id="partition-large-tables">Partition large tables </h2><p>Partitioning is another healthy way to handle large data sets. It divides a table into smaller ones:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE created_at BETWEEN '2025-01-01' AND '2025-06-30';</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-31.png" class="kg-image" alt="Query result" loading="lazy" width="872" height="189" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-31.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-31.png 872w" sizes="(min-width: 720px) 720px"></figure><p>Create a copy table where we will use partitioning:</p><pre><code class="language-SQL">CREATE TABLE public.orders_partitioned(
    id          serial NOT NULL ,
    customer_id integer,
    amount      numeric,
    status      text,
    created_at  timestamp without time zone NOT NULL,
    CONSTRAINT orders_partitioned_pkey PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);</code></pre><p>Create yearly partitions:</p><pre><code class="language-SQL">CREATE TABLE public.orders_2024
    PARTITION OF public.orders_partitioned
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

CREATE TABLE public.orders_2025
    PARTITION OF public.orders_partitioned
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

CREATE TABLE public.orders_2026
    PARTITION OF public.orders_partitioned
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
    
-- Default partition — catches ANY row that doesn't fit the ranges above
CREATE TABLE public.orders_default
    PARTITION OF public.orders_partitioned
    DEFAULT;
</code></pre><p>Copy the data into our partitioned table:</p><pre><code class="language-SQL">INSERT INTO public.orders_partitioned SELECT * FROM public.orders;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-32.png" class="kg-image" alt="Data inserted" loading="lazy" width="381" height="83"></figure><p>Just to check if everything is well:</p><pre><code class="language-SQL">SELECT
    inhrelid::regclass AS partition
FROM pg_inherits
WHERE inhparent = 'orders_partitioned'::regclass;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-33.png" class="kg-image" alt="Partitions" loading="lazy" width="157" height="142"></figure><p>We can see 4 partitions created. Now the same query after partition:</p><pre><code class="language-SQL">EXPLAIN ANALYZE
SELECT *
FROM orders_partitioned
WHERE created_at BETWEEN '2025-01-01' AND '2025-06-30';</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-34.png" class="kg-image" alt="Query result" loading="lazy" width="876" height="171" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-34.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-34.png 876w" sizes="(min-width: 720px) 720px"></figure><h2 id="use-a-transaction-for-bulk-operations">Use a transaction for bulk operations.</h2><p>Operations like inserting or updating multiple rows at once use transactions. Transaction groups multiple SQL operations into a single unit of work. Instead of committing after every statement, PostgreSQL commits only once at the end of the transaction:</p><pre><code class="language-SQL ">INSERT INTO orders(customer_id, amount, status)
VALUES (1,100,'Completed');

INSERT INTO orders(customer_id, amount, status)
VALUES (2,200,'Completed');

INSERT INTO orders(customer_id, amount, status)
VALUES (3,300,'Completed');</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-37.png" class="kg-image" alt="Transaction result" loading="lazy" width="341" height="85"></figure><p>The above one writes each row one by one. Each insertion triggers a WAL write, a disk sync, and a commit operation:</p><pre><code class="language-SQL ">BEGIN;

INSERT INTO orders(customer_id, amount, status)
VALUES (1,100,'Completed');

INSERT INTO orders(customer_id, amount, status)
VALUES (2,200,'Completed');

INSERT INTO orders(customer_id, amount, status)
VALUES (3,300,'Completed');

COMMIT;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-38.png" class="kg-image" alt="Transaction result" loading="lazy" width="319" height="75"></figure><p>The transaction was completed in less than half the time. That is the only difference, with only 3 inserts. Once you scale up to more rows, this difference becomes more pronounced. transaction commits only once, awakening the expensive operations once for all the rows. </p><h2 id="avoid-long-running-transactions">Avoid Long-Running Transactions</h2><p>Long-running transactions keep old row versions alive underp PostgreSQL's MVCC. This prevents VACUUM from cleaning dead rows, causing table bloat:</p><pre><code class="language-SQL">BEGIN;

SELECT *
FROM orders;

COMMIT;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-39.png" class="kg-image" alt="Transaction result" loading="lazy" width="391" height="87"></figure><pre><code class="language-SQL">BEGIN;

UPDATE orders
SET status = 'Completed'
WHERE id = 100;

COMMIT;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-40.png" class="kg-image" alt="transaction result" loading="lazy" width="326" height="92"></figure><p>Short, focused transactions don't last as long as the first one in our example did. Besides, VACUUM can reclaim space and shorter transactions lower lock contention.</p><h2 id="clean-the-table-and-index-bloats">Clean the table and index bloats</h2><p>Postgres <strong>table bloat</strong> occurs when a table contains dead rows that are no longer visible to any transaction but still occupy disk space. Postgres <code>DELETE</code> or <code>UPDATE</code> Operations create dead rows in the tables they modify. For example, if we run the following on an order row with a 400 amount:</p><pre><code class="language-SQL">UPDATE orders
SET amount = 1000
WHERE id = 55;</code></pre><p>Postgres keeps the older row but marks it as dead, and adds a new one. That means the deleted row will be there along with the new one. Such dead rows pile up, overwhelming the storage. PostgreSQL provides VACUUM to reclaim or reuse&nbsp;the storage.</p><p><strong>Index bloat</strong> occurs when indexes contain references to dead rows that still occupy space. Every update also updates indexes, so PostgreSQL keeps dead row indexes.</p><p>Bloated tables and indexes can significantly slow down queries and unnecessarily consume additional disk space, pressuring I/O.</p><p>We can simply fix table bloat manually:</p><pre><code class="language-SQL">VACUUM orders;</code></pre><p>This reclaims storage occupied by dead rows and ensures that the space can be reused:</p><pre><code class="language-SQL">VACUUM FULL orders;</code></pre><p>The above is an intensive counterpart that reclaims all storage occupied by dead rows, including the space at the end of the table. Be aware that a full vacuum will block all reads and writes to that table until it finishes. For large datasets, this can cause hours of downtime.</p><p>Usually, an AUTOVACUUM is by default enabled, which we can check </p><pre><code class="language-SQL">SHOW autovacuum;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-41.png" class="kg-image" alt="autovacuum" loading="lazy" width="151" height="68"></figure><p>You can configure the auto vacuum. Make sure it remains on, otherwise, leaving dead rows can eat up space, which is dangerous for the production database. You can simply do it by running:</p><pre><code class="language-SQL">ALTER SYSTEM SET autovacuum = 'on';
SELECT pg_reload_conf();</code></pre><p>How often should it run:</p><pre><code class="language-SQL">-- Set the naptime to 30 seconds for more aggressive cleanup
ALTER SYSTEM SET autovacuum_naptime = '30s';
-- Reload the configuration to apply changes without restarting the service
SELECT pg_reload_conf();</code></pre><p>When vacuum triggers:</p><pre><code class="language-SQL">autovacuum_vacuum_threshold = 50
autovacuum_vacuum_scale_factor = 0.2</code></pre><p>The above configuration means running the vacuum when the number of dead rows exceeds 50 and when 20% of the table size is reached.</p><p>Apply changes by running:</p><pre><code class="language-SQL">SELECT pg_reload_conf();</code></pre><p>For a table level:</p><pre><code class="language-SQL">ALTER TABLE orders
SET (
    autovacuum_vacuum_threshold = 50
    autovacuum_vacuum_scale_factor = 0.2
);</code></pre><p>To view how much storage your tables are taking, run:</p><pre><code class="language-SQL">SELECT
    relname,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC;</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-42.png" class="kg-image" alt="tables storage" loading="lazy" width="233" height="203"></figure><h2 id="conclusion">Conclusion</h2><p>As a popular database choice for scalable applications, PostgreSQL requires several performance optimization techniques to keep the system fast. In this post, I presented the 12 most insightful ways to speed up your database. From indexing and partitioning to projection and handling bloat. I shared them in detail and explained how to implement them.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ EF Core query translation: Why does some LINQ never become SQL? ]]></title>
        <description><![CDATA[ Explains why some EF Core LINQ queries fail to translate to SQL, using a Perfume model backed by PostgreSQL and Npgsql to show ToQueryString() output, how StartsWith becomes a SQL LIKE clause, and cases involving custom methods or reflection that force client-side evaluation instead. ]]></description>
        <link>https://blog.elmah.io/ef-core-query-translation-why-does-some-linq-never-become-sql/</link>
        <guid isPermaLink="false">69a7d1bf88698d00019ce93d</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 14 Apr 2026 09:52:54 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/ef-core-query-translation-why-does-some-linq-never-become-sql-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/ef-core-query-translation-why-does-some-linq-never-become-sql/">https://blog.elmah.io/ef-core-query-translation-why-does-some-linq-never-become-sql/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>We know that every Entity Framework Core (EF Core) LINQ query has a corresponding SQL query. That equivalent SQL is actually executed under the hood. Some LINQ expressions involve. NET-specific code, such as calling a method, using a reflection filter, or accessing files. You may have found several code blocks of your query that could not be translated. Why does it happen? In today's post, I will cover the reasons along with on-ground examples.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/ef-core-query-translation-why-does-some-linq-never-become-sql-o-1.png" class="kg-image" alt="EF Core query translation: Why does some LINQ never become SQL?" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/ef-core-query-translation-why-does-some-linq-never-become-sql-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/ef-core-query-translation-why-does-some-linq-never-become-sql-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/ef-core-query-translation-why-does-some-linq-never-become-sql-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="linq-queries-translation">LINQ Queries Translation</h2><p>We will write different LINQ queries in a real project and examine what results they yield. I will use a Console application with a PostgreSQL database as an example.</p><p><strong>Step 1: Create a project</strong></p><pre><code class="language-console">dotnet new console -n EfTranslationDemo
cd EfTranslationDemo</code></pre><p><strong>Step 2: Install NuGet packages</strong></p><pre><code class="language-console">dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL</code></pre><p><strong>Step 3: Model for the table</strong></p><p>This will be a single-table application consisting of the <code>Perfume</code> model.</p><pre><code class="language-csharp">public class Perfume
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public string Brand { get; set; } = string.Empty;

    public decimal Price { get; set; }

    public int Rating { get; set; }

    public DateTime ReleaseDate { get; set; }
}</code></pre><p><strong>Step 4: Create a database</strong></p><p>To speed up the process, I have already created the database and the <code>Perfumes</code> table.</p><pre><code class="language-SQL">CREATE DATABASE perfumedb;

CREATE TABLE public."Perfumes" (
    "Id" serial PRIMARY KEY,
    "Name" varchar(255) NOT NULL DEFAULT '',
    "Brand" varchar(255) NOT NULL DEFAULT '',
    "Price" decimal(10,2) NOT NULL,
    "Rating" integer NOT NULL,
    "ReleaseDate" timestamp NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')
);</code></pre><p><strong>Step 5: Set up the DB context</strong></p><pre><code class="language-csharp">using EfTranslationDemo.Models;
using Microsoft.EntityFrameworkCore;

namespace EfTranslationDemo.Data;

public class ApplicationDbContext: DbContext
{
    public DbSet&lt;Perfume&gt; Perfumes =&gt; Set&lt;Perfume&gt;();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options
            .UseNpgsql("Your Connection string with db name perfumedb")
            .LogTo(Console.WriteLine);
    }
}</code></pre><p><strong>Step 6: Seed data</strong></p><p>To begin with, I will seed some data programmatically.</p><pre><code class="language-csharp">using EfTranslationDemo.Data;
using EfTranslationDemo.Models;
using Microsoft.EntityFrameworkCore;

using var context = new ApplicationDbContext();

context.Database.EnsureCreated();

SeedData(context);

static void SeedData(ApplicationDbContext context)
{
    if (context.Perfumes.Any())
        return;

    context.Perfumes.AddRange(
        new Perfume { Name="Sauvage", Brand="Dior", Price=120, Rating=9, ReleaseDate=new DateTime(2018,1,1,0,0,0, DateTimeKind.Utc)},
        new Perfume { Name="Bleu De Chanel", Brand="Chanel", Price=150, Rating=10, ReleaseDate=new DateTime(2017,1,1,0,0,0, DateTimeKind.Utc)},
        new Perfume { Name="Aventus", Brand="Creed", Price=300, Rating=10, ReleaseDate=new DateTime(2015,1,1,0,0,0, DateTimeKind.Utc)},
        new Perfume { Name="F Black", Brand="Ferragamo", Price=60, Rating=7, ReleaseDate=new DateTime(2019,1,1,0,0,0, DateTimeKind.Utc)}
    );

    context.SaveChanges();
}</code></pre><p>Fast forward, the data is seeded to the database</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-2.png" class="kg-image" alt="Perfumes table" loading="lazy" width="837" height="162" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-2.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-2.png 837w" sizes="(min-width: 720px) 720px"></figure><p><strong>Step 7: LINQ queries testing</strong> </p><p><strong>1. Query with price filter</strong></p><p>A simple query to fetch perfumes with a price of more than 100. </p><pre><code class="language-csharp">Console.WriteLine("Full translable");
var query1 = context
    .Perfumes
    .Where(p =&gt; p.Price &gt; 100)
    .Select(p =&gt; new { p.Name, p.Price });

Console.WriteLine(query1.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-3.png" class="kg-image" alt="Query with price filter" loading="lazy" width="362" height="108"></figure><p><strong>2. Filter with Name</strong></p><p>We will filter the data with the name starting with 'B'.</p><pre><code class="language-csharp">var query2 = context
    .Perfumes
    .Where(p =&gt; p.Name.StartsWith("B"));

Console.WriteLine(query2.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-4.png" class="kg-image" alt="Filter with Name" loading="lazy" width="1055" height="112" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-4.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/image-4.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-4.png 1055w" sizes="(min-width: 720px) 720px"></figure><p><code>StartsWith()</code> translates to SQL's <code>LIKE</code> . Similarly, <code>EndsWith</code> and <code>Contains</code> are also translated.</p><p><strong>3. Client Side Execution</strong></p><p>Another translatable but performance-heavy solution is </p><pre><code class="language-csharp">var perfumes = context
    .Perfumes
    .ToList()
    .Where(p =&gt; p.Price &gt; 100);

Console.WriteLine(perfumes.Count());</code></pre><p>It loads all the data in memory first, then applies a filter on the stored data. Such a solution can cause significant problems, especially when the data is large.</p><p><strong>4. External list filter</strong></p><p>We can write a translatable filter with an external list</p><pre><code class="language-csharp">var brands = new List&lt;string&gt; { "Dior", "Creed" };

var query = context
    .Perfumes
    .Where(p =&gt; brands.Contains(p.Brand));

Console.WriteLine(query.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-5.png" class="kg-image" alt="External list filter" loading="lazy" width="1013" height="157" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-5.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/image-5.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-5.png 1013w" sizes="(min-width: 720px) 720px"></figure><p>EF Core converts the C# in-memory list into SQL constants. </p><p><strong>5. DateTime filter</strong></p><p>Any filter with parsing datetime will work the same as any other.</p><pre><code class="language-csharp">var query = context
    .Perfumes
    .Where(p =&gt; p.ReleaseDate.Year &gt; 2016);

Console.WriteLine(query.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-6.png" class="kg-image" alt="DateTime filter" loading="lazy" width="981" height="123" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-6.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-6.png 981w" sizes="(min-width: 720px) 720px"></figure><p>Common <code>DateTime</code> properties are supported.</p><p><strong>6. Custom method filter</strong></p><p>What you cannot do in a LINQ query is to filter by a method. LINQ can't find an SQL equivalent of it.</p><pre><code class="language-csharp">static bool IsLuxury(Perfume perfume)
{
    return perfume.Price &gt; 200;
}

var query = context
    .Perfumes
    .Where(p =&gt; IsLuxury(p));

Console.WriteLine(query.ToQueryString());
</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-7-1.png" class="kg-image" alt="Custom method filter" loading="lazy" width="942" height="203" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-7-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-7-1.png 942w" sizes="(min-width: 720px) 720px"></figure><p>EF Core cannot inspect the body of arbitrary C# methods.</p><p><strong>7. Regex matching</strong></p><p>We can simply put a regex pattern in the filter as well.</p><pre><code class="language-csharp">var query = context
    .Perfumes
    .Where(p =&gt; Regex.IsMatch(p.Name, "^A"));

Console.WriteLine(query.ToQueryString());

var result = query.ToList();
Console.WriteLine(result.Count());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-8.png" class="kg-image" alt="Regex matching" loading="lazy" width="971" height="159" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-8.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-8.png 971w" sizes="(min-width: 720px) 720px"></figure><p><strong>8. Non-Translatable Projection</strong></p><pre><code class="language-csharp">string GetCategory(decimal price)
{
    return price &gt; 150 ? "Luxury" : "Regular";
}

var query = context
    .Perfumes
    .Select(p =&gt; new
    {
        p.Name,
        Category = GetCategory(p.Price)
    });

Console.WriteLine(query.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-10-1.png" class="kg-image" alt="Non-Translatable Projection" loading="lazy" width="827" height="241" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-10-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-10-1.png 827w" sizes="(min-width: 720px) 720px"></figure><p>The right way</p><pre><code class="language-csharp">var query = context
    .Perfumes
    .Select(p =&gt; new
    {
        p.Name,
        Category = p.Price &gt; 150 ? "Luxury" : "Regular"
    });

Console.WriteLine(query.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-11.png" class="kg-image" alt="Non-Translatable Projection" loading="lazy" width="588" height="197"></figure><p><strong>9. Reflection</strong></p><p>.NET does not have an equivalent in SQL.</p><pre><code class="language-csharp">var query = context
    .Perfumes
    .Where(p =&gt; p.GetType().GetProperty("Price") != null);

Console.WriteLine(query.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-13.png" class="kg-image" alt="Reflection" loading="lazy" width="843" height="424" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-13.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-13.png 843w" sizes="(min-width: 720px) 720px"></figure><p><strong>10. File System Access</strong></p><pre><code class="language-csharp">var query = context
    .Perfumes
    .Where(p =&gt; File.Exists($"brands/{p.Brand}.txt"));

Console.WriteLine(query.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-12.png" class="kg-image" alt="File System Access" loading="lazy" width="838" height="277" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-12.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-12.png 838w" sizes="(min-width: 720px) 720px"></figure><p><strong>11. Random number generation</strong></p><pre><code class="language-csharp">var query = context
    .Perfumes
    .Where(p =&gt; Random.Shared.Next(0, 10) &gt; 5);

Console.WriteLine(query.ToQueryString());</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-14.png" class="kg-image" alt="Random number generation" loading="lazy" width="855" height="413" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-14.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-14.png 855w" sizes="(min-width: 720px) 720px"></figure><p>A filter using random number generation did not translate.</p><p><strong>12. string.Compare method</strong></p><p>One of the cool methods that we often use for string comparison is <code>string.Comparison</code>. Unfortunately, you cannot use it in a LINQ query.</p><pre><code class="language-csharp">var query = context
    .Perfumes
    .Where(p =&gt; 
        string.Compare(p.Name, "Sauvage", 
            StringComparison.OrdinalIgnoreCase) == 0
        );
var result  = await query.ToListAsync();
Console.WriteLine(query.ToQueryString());
</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-16.png" class="kg-image" alt="string.Compare method" loading="lazy" width="773" height="420" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-16.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-16.png 773w" sizes="(min-width: 720px) 720px"></figure><h2 id="why-does-ef-core-have-limitations-in-translating-linq-queries">Why does EF Core have limitations in translating LINQ queries?</h2><p>One question arises that even after years of improvement and enhancement, EF Core does not translate some common C# expressions in LINQ. Actually, LINQ does not merely read and execute any query. It first creates <a href="https://blog.elmah.io/expression-trees-in-c-building-dynamic-linq-queries-at-runtime/" rel="noreferrer">an expression tree of the query</a>. Next, these trees are converted to SQL. SQL queries are a declarative language that describes what data you want, not how to compute it step by step. So, methods' bodies are not parsed, as in our case with the <code>IsLuxury</code> method, where expressions do not analyze arbitrary method bodies for translation. Also, C# is a full programming language, whereas SQL lacks many of the features of a programming language. Neither can it read its logic, as SQL servers cannot execute .NET runtime code.</p><p>EF Core is responsible for translating an expression tree to database-specific SQL. We know that every database has its own SQL dialect. For example, <code>DATE_PART('year', column)</code> in a PostgreSQL expression, while the SQL Server equivalent is <code>YEAR(column)</code>. So EF must maintain different translators for each database. It does not rely on a single translator, so there are limitations in translating each and every query across diverse database providers. Finally, some .NET runtime features are specific to the program, such as random number generation, ordinal string comparison, and <a href="https://blog.elmah.io/4-real-life-examples-of-using-reflection-in-c/" rel="noreferrer">reflection,</a> which are impossible to translate.</p><h2 id="conclusion">Conclusion</h2><p>EF Core is a popular ORM and the default choice for many developers for database operations. EF Core uses LINQ for querying and enables you to use databases as if they were in C#. However, the underlying database cannot fully replicate a complete programming language like C#. Many programming expressions and filtering logic cannot be translated into a database query. Calling a method in a LINQ query, using reflection, and ordinal string comparison are examples of limitations in EF Core. SQL is specifically designed to fetch data and is not a programming language. It does not have the .NET runtime, so it cannot execute .NET features. In this post, I have gone through many examples that can be translated into SQL and some common queries that cannot.</p><p>Code: <a href="https://github.com/elmahio-blog/EfTranslationDemo.git">https://github.com/elmahio-blog/EfTranslationDemo.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Mapping database views in EF Core without breaking migrations ]]></title>
        <description><![CDATA[ Shows how to map a read-only PostgreSQL view named vw_product_summary into a ProductSummary entity in EF Core using ToView() and HasNoKey(), keeping the mapped view separate from the Product table so schema migrations don&#39;t break when the view definition changes. ]]></description>
        <link>https://blog.elmah.io/mapping-database-views-in-ef-core-without-breaking-migrations/</link>
        <guid isPermaLink="false">69c3cb472828cd0001f15a4b</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 07 Apr 2026 09:20:36 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/mapping-database-views-in-ef-core-without-breaking-migrations-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/mapping-database-views-in-ef-core-without-breaking-migrations/">https://blog.elmah.io/mapping-database-views-in-ef-core-without-breaking-migrations/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>Entity Framework Core (EF Core) is working fine in your project. But the moment you use views, the migration gets messy. As a developer, I know any problem in the migration is haunting. You have to update and take care of other migrations so they don't get disturbed. If not done well, database views can throw you into a pitfall. In today's post, I will walk through how to map views in EF Core without breaking migrations.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/mapping-database-views-in-ef-core-without-breaking-migrations-o-1.png" class="kg-image" alt="Mapping database views in EF core without breaking migrations" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/mapping-database-views-in-ef-core-without-breaking-migrations-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/mapping-database-views-in-ef-core-without-breaking-migrations-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/mapping-database-views-in-ef-core-without-breaking-migrations-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="what-is-a-database-view">What is a database view?</h2><p>A view is a named query that acts as a virtual table. It is defined using base tables or previously defined views and provides an abstraction of a complex query. Views do not store any data, but they execute the enclosed query. Views reduce client-side code by encapsulating logic and adding reusability. Although it supports data modifications like create and update in some cases, it is primarily used for read-only operations.</p><h2 id="mapping-database-views-with-ef-core">Mapping database views with EF Core</h2><p>Let's see, with the glasses from the actual project, how we can utilize views. I will use a console project with a PostgreSQL database.</p><p><strong>Step 1: Create the project</strong></p><pre><code class="language-console">dotnet new console -n EfCoreViewDemo
cd EfCoreViewDemo</code></pre><p><strong>Step 2: Install necessary NuGet packages</strong></p><pre><code class="language-console">dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json</code></pre><p>Configuration packages will be used to load appsettings.</p><p><strong>Step 3: Create&nbsp;<code>appsettings.json</code></strong></p><p>Adding&nbsp;<code>appsettings.json</code>&nbsp;with a database connection string.</p><pre><code class="language-json">{
  "ConnectionStrings": {
    "PostgresConnection": "Host=localhost;Port=5432;Database=productVDb;Username=postgres;Password=1234"
  }
}
</code></pre><p><strong>Step 4: Define Models</strong></p><p>Add the <code>Product</code> class:</p><pre><code class="language-csharp">namespace EfCoreViewDemo.Models;
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}</code></pre><p>The <code>ProductSummary</code> class will catch the results of the view:</p><pre><code class="language-csharp">namespace EfCoreViewDemo.Models;

public class ProductSummary
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}</code></pre><p> <strong>Step 5: Configure DbContext</strong></p><pre><code class="language-csharp">using EfCoreViewDemo.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace EfCoreViewDemo.Data;

public class ApplicationDbContext: DbContext
{
    public DbSet&lt;Product&gt; Products =&gt; Set&lt;Product&gt;();
    public DbSet&lt;ProductSummary&gt; ProductSummaries =&gt; Set&lt;ProductSummary&gt;();

    private readonly string _connectionString;

    public ApplicationDbContext()
    {
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json")
            .Build();

        _connectionString = config.GetConnectionString("PostgresConnection");
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseNpgsql(_connectionString);
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&lt;ProductSummary&gt;()
            .ToView("vw_product_summary")   
            .HasNoKey();                  
    }
}</code></pre><p>One important configuration is <code>ToView(...)</code> that specifies the <code>DbSet</code> map as a database view. Calling <code>ProductSummaries</code> will fetch data from the view named <code>vw_product_summary</code>. <code>HasNoKey()</code> specifies that views are read-only structures in the database and do not contain any primary key. If not specified, EF Core expects a primary key on the entity and throws an exception.</p><p><strong>Step 6: &nbsp;Set up&nbsp;<code>appsettings.json</code>&nbsp;in the project</strong></p><p>By default, a console app will expect the file in the bin directory. To read newly added appsettings from the root directory, add the following inside the&nbsp;<code>&lt;Project&gt;</code>&nbsp;tag of the application's project file:</p><pre><code class="language-xml">&lt;ItemGroup&gt;
  &lt;None Update="appsettings.json"&gt;
    &lt;CopyToOutputDirectory&gt;PreserveNewest&lt;/CopyToOutputDirectory&gt;
  &lt;/None&gt;
&lt;/ItemGroup&gt;</code></pre><p><strong>Step 7: Run migrations</strong></p><pre><code class="language-console">dotnet ef migrations add InitialCreate</code></pre><p>So the migration will look like</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-44.png" class="kg-image" alt="Migrations" loading="lazy" width="324" height="69"></figure><pre><code class="language-csharp">using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;

#nullable disable

namespace EfCoreViewDemo.Migrations
{
    /// &lt;inheritdoc /&gt;
    public partial class InitialCreate : Migration
    {
        /// &lt;inheritdoc /&gt;
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Products",
                columns: table =&gt; new
                {
                    Id = table.Column&lt;int&gt;(type: "integer", nullable: false)
                        .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
                    Name = table.Column&lt;string&gt;(type: "text", nullable: false),
                    Price = table.Column&lt;decimal&gt;(type: "numeric", nullable: false)
                },
                constraints: table =&gt;
                {
                    table.PrimaryKey("PK_Products", x =&gt; x.Id);
                });            
        }

        /// &lt;inheritdoc /&gt;
        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropTable(
                name: "Products");
        }
    }
}
</code></pre><p>But we skipped views from EF Core's tracking, so we need to add it manually in the <code>Up</code> method.</p><pre><code class="language-csharp">migrationBuilder.Sql(@"
  CREATE VIEW vw_product_summary AS
  SELECT ""Name"", ""Price""
  FROM ""Products"";
");</code></pre><p>While in the <code>Down</code> method:</p><pre><code class="language-csharp">migrationBuilder
    .Sql(@"DROP VIEW IF EXISTS vw_product_summary;");</code></pre><p>To reflect the migration</p><pre><code class="language-console">dotnet ef database update</code></pre><p>Now, the database looks like</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-45.png" class="kg-image" alt="Database" loading="lazy" width="216" height="250"></figure><p><strong>Step 8: Define program</strong></p><pre><code class="language-csharp">using EfCoreViewDemo.Data;
using EfCoreViewDemo.Models;

using var context = new ApplicationDbContext();

context.Products.Add(new Product { Name = "Laptop", Price = 1000 });
context.Products.Add(new Product { Name = "Keyboard", Price = 100 });
context.Products.Add(new Product { Name = "Headphone", Price = 150 });
context.Products.Add(new Product { Name = "Web cam", Price = 200 });
context.Products.Add(new Product { Name = "Mouse", Price = 50 });

context.SaveChanges();

var summaries = context.ProductSummaries.ToList();

foreach (var item in summaries)
{
    Console.WriteLine($"{item.Name} - {item.Price}");
}</code></pre><p>To summarize, I added 5 records manually. Later, I am calling <code>ProductSummaries</code>, which is a view.</p><p><strong>Step 9: Run the project</strong></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-43.png" class="kg-image" alt="Result" loading="lazy" width="210" height="174"></figure><p>Manual migration allows you to automate view definition without breaking migration functionality. A naive approach is to write views directly in the database. </p><p><strong>Step 10: Update the view</strong></p><p>Let's say a requirement came, and we need to update the view.</p><p>Create an empty migration:</p><pre><code class="language-csharp">dotnet ef migrations add UpdateProductSummaryView</code></pre><p>Manual query in migration will look like this:</p><pre><code class="language-csharp">using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace EfCoreViewDemo.Migrations
{
    /// &lt;inheritdoc /&gt;
    public partial class UpdateProductSummaryView : Migration
    {
        /// &lt;inheritdoc /&gt;
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.Sql(@"
                DROP VIEW IF EXISTS vw_product_summary;

                CREATE VIEW vw_product_summary AS
                SELECT ""Name"", ""Price"", ""Price"" * 0.9 AS ""DiscountedPrice""
                FROM ""Products"";
            ");
        }

        /// &lt;inheritdoc /&gt;
        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.Sql(@"
                DROP VIEW IF EXISTS vw_product_summary;

                CREATE VIEW vw_product_summary AS
                SELECT ""Name"", ""Price""
                FROM ""Products"";
            ");
        }
    }
}
</code></pre><p>Updating the model accordingly:</p><pre><code class="language-csharp">namespace EfCoreViewDemo.Models;

public class ProductSummary
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public decimal DiscountedPrice { get; set; }
}</code></pre><p>In <code>Program.cs</code>, the view call will become:</p><pre><code class="language-csharp">var summaries = context.ProductSummaries.ToList();

foreach (var item in summaries)
{
    Console.WriteLine($"{item.Name} - {item.Price} - {item.DiscountedPrice}");
}</code></pre><p>Hence, the result</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-46.png" class="kg-image" alt="Result" loading="lazy" width="321" height="174"></figure><h2 id="conclusion">Conclusion</h2><p>Database Views are great for enclosing complex logic as virtual tables. Developers often use it extensively in the application. However, views can make EF Core migration tracking tedious if not managed properly. I provided a solution in today's blog on how you can design views without harming migration.</p><p>Code: <a href="https://github.com/elmahio-blog/EfCoreViewDemo.git">https://github.com/elmahio-blog/EfCoreViewDemo.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ How .NET handles exceptions internally (and why they&#x27;re expensive) ]]></title>
        <description><![CDATA[ Benchmarks .NET exception handling with BenchmarkDotNet across 100,000 iterations, comparing a no-exception baseline, a try/catch path throwing InvalidOperationException, and a OneOf&lt;T&gt; discriminated-union return type to quantify the CLR&#39;s stack-unwinding and heap-allocation overhead. ]]></description>
        <link>https://blog.elmah.io/how-net-handles-exceptions-internally-and-why-theyre-expensive/</link>
        <guid isPermaLink="false">69a53c145070010001209946</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 31 Mar 2026 08:42:26 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/how-dotnet-handles-exceptions-internally-and-why-theyre-expensive-o-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/how-net-handles-exceptions-internally-and-why-theyre-expensive/">https://blog.elmah.io/how-net-handles-exceptions-internally-and-why-theyre-expensive/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>What really happens when you write <code>throw new Exception()</code> in .NET? <a href="https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/exceptions-and-performance" rel="noreferrer">Microsoft guidelines</a> state that </p><blockquote>When a member throws an exception, its performance can be orders of magnitude slower.&nbsp;</blockquote><p>It's not just a simple jump to a <code>catch</code> block, but a lot goes in CLR (Common Language Runtime). Expensive operations such as stack trace capture, heap allocations, and method unwinding occur each time. You will not want to use them in any hot paths. Today, In today's post, I will help you decide when exceptions are appropriate and when a simple alternative type might be better.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/how-dotnet-handles-exceptions-internally-and-why-theyre-expensive-o-3.png" class="kg-image" alt="How .NET handles exceptions internally (and why they're expensive)" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/how-dotnet-handles-exceptions-internally-and-why-theyre-expensive-o-3.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/how-dotnet-handles-exceptions-internally-and-why-theyre-expensive-o-3.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/how-dotnet-handles-exceptions-internally-and-why-theyre-expensive-o-3.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="what-is-an-exception">What is an Exception?</h2><p>An exception is an error condition or unexpected behaviour during the execution of a program. Exceptions can occur at runtime for various reasons, such as accessing a null object, dividing by zero, or requesting a file that is not found. A C# exception contains several properties, including a <strong>Message </strong>describing the cause of the exception. <a href="https://blog.elmah.io/understanding-net-stack-traces-a-guide-for-developers/" rel="noreferrer"><strong>StackTrace</strong></a><strong> </strong>contains the sequence of method calls that led to the exception in reverse call order to trace the exception source.</p><h2 id="how-does-an-exception-work">How does an exception work?</h2><p>The try block encloses the code prone to exceptions. try/catch protects the application from blowing up. Use the throw keyword to signal the error and throw an Exception object containing detailed information, such as a message and a <a href="https://blog.elmah.io/understanding-net-stack-traces-a-guide-for-developers/" rel="noreferrer">stack trace</a>. The caught exception allows the program to continue gracefully and notify the user where and what error occurred. When an error occurs, the CLR searches for a compatible&nbsp;<code>catch</code>&nbsp;block in the current method. If not found, it moves up the call stack to the calling method, and so on. Once a matching catch is found based on the exception type, control jumps to that block. In an unhandled exception situation where no compatible catch block is found, the application can terminate. Exception handling uses a heap to store the message. To look for a catch body, the CLR unwinds the stack by removing intermediate stack frames. The JIT must generate EH tables and add hidden control-flow metadata.</p><h2 id="what-is-oneoft-in-net">What is OneOf&lt;T&gt; in .NET?</h2><p><code>OneOf&lt;T&gt;</code>&nbsp;or&nbsp;<code>OneOf&lt;T1, T2, T...&gt;</code>&nbsp;represents a discriminated union containing all possible returns of an operation or a method. It contains an array of types, allowing a method to return one of several defined possibilities. The <code>OneOf</code> pattern provides you with fine-grained control and type safety.</p><h2 id="examine-exceptions-with-the-benchmark">Examine Exceptions with the benchmark.</h2><p>To truly understand it, let's create an application. I will use a console application.  </p><p><strong>Step 1: Create the project </strong></p><pre><code class="language-console">dotnet new console -n ExceptionBenchmark
cd ExceptionBenchmark</code></pre><p><strong>Step 2: Add necessary packages</strong></p><p>I am adding the <a href="https://blog.elmah.io/how-to-monitor-your-apps-performance-with-net-benchmarking/" rel="noreferrer">Benchmark</a> library along with <code>OneOf</code>, which is used for the <code>OneOf</code> return type.</p><pre><code class="language-console">dotnet add package BenchmarkDotNet
dotnet add package OneOf</code></pre><p><strong>Step 3: Set up the program.cs</strong></p><p>All the code is in the <code>Program.cs</code></p><pre><code class="language-csharp">using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using OneOf;

BenchmarkRunner.Run&lt;ExceptionBenchmarks&gt;();

[MemoryDiagnoser] 
public class ExceptionBenchmarks
{
    private const int Iterations = 100_000;
    private const int FailureEvery = 10;

    [Benchmark]
    public int NoException()
    {
        int failures = 0;

        for (int i = 1; i &lt;= Iterations; i++)
        {
            if (!DoWork_NoException(i))
                failures++;
        }

        return failures;
    }

    private bool DoWork_NoException(int i)
    {
        return i % FailureEvery != 0;
    }

    [Benchmark]
    public int WithException()
    {
        int failures = 0;

        for (int i = 1; i &lt;= Iterations; i++)
        {
            try
            {
                DoWork_WithException(i);
            }
            catch
            {
                failures++;
            }
        }

        return failures;
    }

    private void DoWork_WithException(int i)
    {
        if (i % FailureEvery == 0)
            throw new InvalidOperationException();
    }

    [Benchmark]
    public int WithOneOf()
    {
        int failures = 0;

        for (int i = 1; i &lt;= Iterations; i++)
        {
            var result = DoWork_WithOneOf(i);

            if (result.IsT1)
                failures++;
        }

        return failures;
    }

    private OneOf&lt;Success, Error&gt; DoWork_WithOneOf(int i)
    {
        if (i % FailureEvery == 0)
            return new Error("Error");

        return new Success("Passed");
    }

    private readonly struct Success
    {
        public string Message { get; }

        public Success(string message)
        {
            Message = message;
        }
    }

    private readonly struct Error
    {
        public string Message { get; }

        public Error(string message)
        {
            Message = message;
        }
    }
}</code></pre><p>The first method is simple with no exception. Then it throws an exception, and in subsequent methods, it finally returns an error object from <a href="https://blog.elmah.io/using-result-t-or-oneof-t-for-better-error-handling-in-net/" rel="noreferrer">OneOf</a>. To make it realistic, each method will observe with 10% error and 90% success rate, as <code>FailureEvery</code> is set to 10. <code>Success</code> and <code>Error</code> are value types to avoid allocations, since they only return the value from the method. </p><p><strong>Step 4: Run and test</strong></p><pre><code class="language-console">dotnet run -c Release</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-1.png" class="kg-image" alt="Benchmark results" loading="lazy" width="1040" height="199" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/image-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/image-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/image-1.png 1040w" sizes="(min-width: 720px) 720px"></figure><p>The best performer is the <code>NoException</code>. But that is not practical, you have to identify unexpected behaviour and report it in the code flow. Firstly, a naive approach is to use an exception. Using it adds a time cost and increases the <a href="https://blog.elmah.io/how-net-garbage-collector-works-and-when-you-should-care/" rel="noreferrer">Garbage collector's Gen 0</a> pressure. So, our alternative to exception is <code>OneOf</code>, which significantly saved time and memory. We can further add Objects to the <code>OneOf</code>, considering the possible return values of the method. </p><p>In exceptions, Stack tracing is very expensive, as it propagates stacks, captures method names, stores IL offsets, and inspects frames. Also, the JIT inlining is limited during exceptions. With <code>OneOf</code>I used a struct value type, so Gen 0 utilization is minimized. Neither does it fall for stack trace nor unwind it. Hence, the execution remains linear. </p><h2 id="when-can-i-use-an-exception-alternative">When can I use an exception alternative?</h2><p>In the following cases, exceptions can be replaced with <a href="https://blog.elmah.io/using-result-t-or-oneof-t-for-better-error-handling-in-net/" rel="noreferrer">Result or OneOf</a> in normal application flows.</p><ul><li>Business rule rejection, such as the customers cannot order out-of-stock items. You can return an error in response.</li><li>API validation, where you can simply return 400 with a custom message after figuring out all possible error cases.</li><li>High-throughput paths where you cannot afford an exception mechanism. </li><li>Validation failure, such as invalid email or mobile number input.</li><li>Data not found scenarios where you know either the request data will be available or will not be found. Simply, you can deal with both cases.  </li></ul><h2 id="when-is-an-exception-the-optimal-choice">When is an exception the optimal choice?</h2><p>You don't remove fire alarms from a building because they're loud. You just don't pull them every time someone burns toast. We have some situations where exceptions stand out even if they are expensive. </p><ul><li>Exceptions occur when the program falls into an impossible state, such as when a null database connection is used. You cannot proceed anywhere because the connection is not even initialized for some reason.</li><li>For environmental failures, you will opt for exceptions such as timeout failures, disk I/O failures, or database connection losses. </li><li>Programming bugs where your code falls into a dead end, and it cannot handle further. Conditions where your input case exhaust, such as you have order statuses of pending, cancelled, and confirmed, are enumerated with 1,2 and 3, respectively. There is no case apart from that, so you can simply throw an <code>ArgumentOutOfRangeException</code> or a custom exception in the default case.</li><li>If developing a library, use an exception to signal to the user what went wrong and halt normal execution. Here, you cannot force consumers to handle result types. </li></ul><h2 id="conclusion">Conclusion</h2><p>In high-performance systems, every allocation matters. Exceptions aim to provide a safeguard against anomalous conditions, but they can sometimes be a burden on memory and CPU. I put light on the exception of how much resource they can use for simple operations compared to their counterparts. We explored where it is suitable and where it can be replaced. In short, use exceptions for Unexpected, impossible, and environmental failures. While you can simply use <code>Result/OneOf</code> As an alternative, when conditions are expected, it is useful for business validation, user-driven errors, and high-frequency failures.</p><p>Code: <a href="https://github.com/elmahio-blog/ExceptionBenchmarks.git">https://github.com/elmahio-blog/ExceptionBenchmarks.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Designing business rules that don&#x27;t leak into controllers ]]></title>
        <description><![CDATA[ Refactors a fat ASP.NET Core OrdersController that enforces a five-orders-per-day rule directly inside CreateOrder into a testable IOrderService/OrderService pair, showing how moving validation like user-activity checks and order limits out of the controller keeps it thin and SRP-compliant. ]]></description>
        <link>https://blog.elmah.io/designing-business-rules-that-dont-leak-into-controllers/</link>
        <guid isPermaLink="false">69a45c305070010001209863</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 24 Mar 2026 09:59:38 +0100</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/designing-business-rules-that-dont-leak-into-controllers-o-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/designing-business-rules-that-dont-leak-into-controllers/">https://blog.elmah.io/designing-business-rules-that-dont-leak-into-controllers/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>APIs are the engine of modern applications. Your product belongs to any domain, either medical, banking, or IoT, APIs are most probable bricks in it. Good, maintainable, and reusable code promises a functional system. While a bad one makes maintenance and testing tedious. <a href="https://blog.elmah.io/16-common-mistakes-c-net-developers-make-and-how-to-avoid-them/" rel="noreferrer">There is so much to care about in your application</a>. In today's post, I will dig into writing a clean controller that fulfils the Single Responsibility Principle. We will see why exposing any business logic can harm the application and what exactly a controller should include. </p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/designing-business-rules-that-dont-leak-into-controllers-o-3.png" class="kg-image" alt="Designing business rules that don't leak into controllers" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/designing-business-rules-that-dont-leak-into-controllers-o-3.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/designing-business-rules-that-dont-leak-into-controllers-o-3.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/designing-business-rules-that-dont-leak-into-controllers-o-3.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="example-with-fat-controller-bad-design">Example with Fat Controller (bad design)</h2><p>Consider the following ASP.NET Core controller implementation:</p><pre><code class="language-csharp">using System;
using Microsoft.AspNetCore.Mvc;

namespace EcCommerce.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public OrdersController(ApplicationDbContext context)
    {
        _context = context;
    }
        
    [HttpPost]
    public async Task&lt;IActionResult&gt; CreateOrder(CreateOrderRequest request)
    {
        var user = await _context.Users
            .Include(u =&gt; u.Orders)
            .FirstOrDefaultAsync(u =&gt; u.Id == request.UserId);
    
        if (user == null)
            return NotFound("User not found");
    
        if (!user.IsActive)
            return BadRequest("User is not active");
    
        if (request.TotalAmount &lt;= 0)
            return BadRequest("Invalid order amount");
    
        var todayOrdersCount = user.Orders
            .Count(o =&gt; o.CreatedAt.Date == DateTime.UtcNow.Date);
    
        if (todayOrdersCount &gt;= 5)
            return BadRequest("Daily order limit exceeded");
    
        var order = new Order
        {
            UserId = user.Id,
            TotalAmount = request.TotalAmount,
            CreatedAt = DateTime.UtcNow
        };
    
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
    
        return Ok(order);
    }
}</code></pre><p>The first thing in the code is that it is piercing to the eyes. Also, the controller knows too much of business logic and is really fat. If any business logic changes are required, we need to update the controller. Let's say we have to increase the daily order limit from 5 to 50, then we will need to update it here. Testing is difficult too, any test, even for business logic, will be done on the controller. All of that violates the DRY (Don't Repeat Yourself) and Single Responsibility Principles, as the controller is not inherently dedicated to these tasks. The example may look like I have added too much logic here, which people usually avoid, but you still need to be clear about exactly what the controllers should have. Many developers get confused and write a few lines of business logic where they shouldn't. The controllers should handle HTTP requests, validate the mapping, and return responses.</p><h2 id="business-rules-dont-leak-into-controllers">Business Rules Don't Leak Into Controllers</h2><p>So, correcting the faulty controller here.</p><p><strong>Step 1: Create a Domain Service</strong></p><pre><code class="language-csharp ">public interface IOrderService
{
    Task&lt;Order&gt; CreateOrderAsync(int userId, decimal totalAmount);
}</code></pre><p>A very good way to encapsulate business logic is to introduce a service layer with an interface and its implementation, and inject them into the controller.</p><p><strong>Step 2: Implementation of the domain service</strong></p><pre><code class="language-csharp">public class OrderService : IOrderService
{
    private readonly ApplicationDbContext _context;

    public OrderService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task&lt;Order&gt; CreateOrderAsync(int userId, decimal totalAmount)
    {
        var user = await _context.Users
            .Include(u =&gt; u.Orders)
            .FirstOrDefaultAsync(u =&gt; u.Id == userId);

        if (user == null)
            throw new Exception("User not found");

        if (!user.IsActive)
            throw new Exception("User is not active");

        if (totalAmount &lt;= 0)
            throw new Exception("Invalid order amount");

        var todayOrdersCount = user.Orders
            .Count(o =&gt; o.CreatedAt.Date == DateTime.UtcNow.Date);

        if (todayOrdersCount &gt;= 5)
            throw new Exception("Daily order limit exceeded");

        var order = new Order
        {
            UserId = user.Id,
            TotalAmount = totalAmount,
            CreatedAt = DateTime.UtcNow
        };

        _context.Orders.Add(order);
        await _context.SaveChangesAsync();

        return order;
    }
}</code></pre><p>Sometimes, you can add a <a href="https://blog.elmah.io/the-repository-pattern-is-simple-yet-misunderstood/" rel="noreferrer">repository layer</a> below the services and inject it instead of using  <code>ApplicationDbContext</code> directly.</p><p><strong>Step 3: Thin Controller</strong></p><pre><code class="language-csharp">using System;
using Microsoft.AspNetCore.Mvc;

namespace EcCommerce.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }
        
    [HttpPost]
    public async Task&lt;IActionResult&gt; CreateOrder(CreateOrderRequest request)
    {
      
        var order = await _orderService
            .CreateOrderAsync(request.UserId, request.TotalAmount);
    
        return Ok(order);
    }
}</code></pre><p>Now, we have a soothing code. Not to forget dependency injection in your <code>Program.cs</code>:</p><pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);

// Other injections

builder.Services.AddScoped&lt;IOrderService, OrderService&gt;();
</code></pre><h2 id="clean-architecture-style-domain-driven">Clean Architecture Style (Domain-Driven)</h2><p>One more way you can deal is to define the logic in the domain itself.</p><p><strong>User Model </strong></p><pre><code class="language-csharp">public class User
{
    public bool IsActive { get; private set; }
    public List&lt;Order&gt; Orders { get; private set; } = new();

    public void CanPlaceOrder(decimal totalAmount)
    {
        if (!IsActive)
            throw new Exception("User is not active");

        if (totalAmount &lt;= 0)
            throw new Exception("Invalid order amount");

        var todayOrders = Orders
            .Count(o =&gt; o.CreatedAt.Date == DateTime.UtcNow.Date);

        if (todayOrders &gt;= 5)
            throw new Exception("Daily limit exceeded");
    }
}</code></pre><p><strong>Service </strong></p><pre><code class="language-csharp">public class OrderService : IOrderService
{
    private readonly ApplicationDbContext _context;

    public OrderService(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public async Task&lt;Order&gt; CreateOrderAsync(int userId, decimal totalAmount)
    {
        var user = await _context.Users
            .Include(u =&gt; u.Orders)
            .FirstOrDefaultAsync(u =&gt; u.Id == userId);
    
        if (user == null)
            throw new Exception("User not found");
    
        user.CanPlaceOrder(totalAmount);
    
        var order = new Order(userId, totalAmount);
    
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
    
        return order;
    }
}</code></pre><p><strong>Dependency injection</strong></p><pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);

// Other injections

builder.Services.AddScoped&lt;IOrderService, OrderService&gt;();
</code></pre><p>Now the service becomes cleaner. In our corrected versions, we addressed all issues seen in the first code. If a new requirement asks to change the daily limit, we will go to the service or the domain model in a later example, and the controller will remain unaffected in both cases. Testing units are also made easy with the exposed method in the service. If any endpoint requires similar <code>CreateOrder</code> operations, then we can simply use it from <code>OrderService</code>, complying with the DRY principle. Our controller is now clean, and it adheres to the Single Responsibility Principle. The controller can only focus on validating requests and generating responses, while other tasks are handled in the underlying layers.</p><h2 id="conclusion">Conclusion</h2><p>API controllers are responsible for exposing endpoints that your clients extensively rely on. They should validate the request, call the underlying layer like the service layer, and return the data. Keeping things in their place can help a lot with testing and maintenance, especially if your code is considerably large. One overlooked aspect is understanding exactly what a controller should contain. People often mistakenly add logic inside the controller method and fail to identify where to draw the line. I made it easy with this article and showed what potential problems can arise if business logic leaks inside the controller.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ When NOT to use the repository pattern in EF Core ]]></title>
        <description><![CDATA[ Builds an IMovieRepository/MovieRepository/MovieService layer around EF Core&#39;s DbContext with a GetTopRatedAsync(8.0) query, then argues the abstraction pays off only for complex domains like banking or insurance, while simple CRUD apps are often better served by DbContext directly. ]]></description>
        <link>https://blog.elmah.io/when-not-to-use-the-repository-pattern-in-ef-core/</link>
        <guid isPermaLink="false">6993547fa80af5000150cc37</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Wed, 18 Mar 2026 06:24:07 +0100</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/when-not-to-use-the-repository-pattern-in-ef-core-o-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/when-not-to-use-the-repository-pattern-in-ef-core/">https://blog.elmah.io/when-not-to-use-the-repository-pattern-in-ef-core/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>If you design an application with a data source, the repository pattern often comes to mind as a prominent choice. In fact, many developers see it as the default choice. However, the pattern is not helping every time. In this post, I will pinpoint some cases where the repository pattern is not the best choice.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/when-not-to-use-the-repository-pattern-in-ef-core-o-3.png" class="kg-image" alt="When NOT to use the repository pattern in EF Core" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/03/when-not-to-use-the-repository-pattern-in-ef-core-o-3.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/03/when-not-to-use-the-repository-pattern-in-ef-core-o-3.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/03/when-not-to-use-the-repository-pattern-in-ef-core-o-3.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="what-is-a-repository-pattern">What is a repository pattern?</h2><p>The <a href="https://blog.elmah.io/the-repository-pattern-is-simple-yet-misunderstood/" rel="noreferrer">repository pattern</a> is a design pattern that acts as an intermediate layer between data access and business logic. It abstracts the data source and implements the details, providing a clean representation of data manipulation as objects and lists.</p><p>Let us start by looking at how a repository pattern can be implemented with EF Core.</p><p>Start by adding a new model named <code>Movie</code>:</p><pre><code class="language-csharp">public class Movie
{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Director { get; set; } = string.Empty;
    public int ReleaseYear { get; set; }
    public double ImdbRating { get; set; }
    public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}</code></pre><p>Next, add a <code>IMovieRepository</code> interface with the basic methods for adding, getting, and saving movies:</p><pre><code class="language-csharp">public interface IMovieRepository
{
    Task AddAsync(Movie movie);
    Task&lt;Movie?&gt; GetByIdAsync(Guid id);
    Task&lt;List&lt;Movie&gt;&gt; GetTopRatedAsync(double minRating);
    Task SaveChangesAsync();
}</code></pre><p>Add an implementation of that interface using EF Core:</p><pre><code class="language-csharp">public class MovieRepository : IMovieRepository
{
    private readonly AppDbContext _context;

    public MovieRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task AddAsync(Movie movie)
    {
        await _context.Movies.AddAsync(movie);
    }

    public async Task&lt;Movie?&gt; GetByIdAsync(Guid id)
    {
        return await _context.Movies.FindAsync(id);
    }

    public async Task&lt;List&lt;Movie&gt;&gt; GetTopRatedAsync(double minRating)
    {
        return await _context.Movies
            .Where(m =&gt; m.ImdbRating &gt;= minRating)
            .OrderByDescending(m =&gt; m.ImdbRating)
            .ToListAsync();
    }

    public async Task SaveChangesAsync()
    {
        await _context.SaveChangesAsync();
    }
}</code></pre><p>Finally, I'll add a service class that shows how to use the movie repository:</p><pre><code class="language-csharp">public class MovieService
{
    private readonly IMovieRepository _repository;

    public MovieService(IMovieRepository repository)
    {
        _repository = repository;
    }

    public async Task&lt;Guid&gt; CreateMovieAsync(
        string title,
        string director,
        int releaseYear,
        double rating)
    {
        var movie = new Movie
        {
            Id = Guid.NewGuid(),
            Title = title,
            Director = director,
            ReleaseYear = releaseYear,
            ImdbRating = rating
        };

        await _repository.AddAsync(movie);
        await _repository.SaveChangesAsync();

        return movie.Id;
    }

    public async Task&lt;List&lt;Movie&gt;&gt; GetHighlyRatedMoviesAsync()
    {
        return await _repository.GetTopRatedAsync(8.0);
    }
}</code></pre><p>If you are writing CRUD applications, implementing a data layer like this probably looks very familiar.</p><h2 id="what-are-the-advantages-of-the-repository-pattern">What are the advantages of the Repository pattern?</h2><p>The repository pattern promises several key advantages.</p><ul><li>A clean separation of concerns where data access logic is centralized.</li><li>Reusability, where the same repo methods can be used without copying the same logic again.</li></ul><h2 id="when-to-use-the-repository-pattern">When to use the Repository pattern</h2><p>Like any tool, it offers leverage only when in the right place. If you smell any scent in your code, go for the repository pattern.</p><ul><li>When your application does not rely on simple data storage or fetching but requires enquiring logic such as validation, projection, object preparation, or calculations. Domains such as insurance, banking, healthcare, and IoT require calculations, so the repository pattern can be helpful.</li><li>The repository pattern can win for you if you are aggregating multiple data sources but presenting them as a single source to the upper layers. Usage of different data sources, such as MSSQL, Postgres, and external APIs, is kept hidden from the business logic layer. </li><li>The repository layer can be handy if an application demands sophisticated caching strategies and you don't want to pollute the business layers. Hence, the service layer can be unaware of how the cache is configured, or even of whether the data comes from the cache or another source.</li><li>For unit testing, you can employ the repository pattern, especially in error-critical systems such as financial systems, medical devices, and safety systems. Repositories enable you to test business logic in&nbsp;isolation&nbsp;by swapping real data access with test doubles. You can verify complex business rules, edge cases, and error handling without the overhead, unpredictability, and slowness of database tests.  </li></ul><h2 id="when-to-avoid-the-repository-pattern">When to avoid the Repository pattern</h2><p>Well, we have seen the usefulness of the repository pattern. Now, rejoining our original question, "In what conditions can you avoid the repository pattern?"</p><ul><li>If your app is just basic Create, Read, Update, Delete operations without complex business logic, you simply go without it. A simple creation or fetch will not require verbose code, and adding a new layer will overengineer it.</li></ul><p>For example, in the code, a repository pattern has simple operations:</p><pre><code class="language-csharp">public class UserRepository : IUserRepository 
{
    public User GetById(int id) =&gt; _context.Users.Find(id);
    public void Add(User user) =&gt; _context.Users.Add(user);
}</code></pre><ul><li>With an ORM, you can avoid the abstraction layer. Most ORMs, such as Entity&nbsp;Framework Core, NHibernate, and Doctrine, already implement the repository pattern using <code>DbSet</code> and <code>AppDbContext</code>. You can simply deal with entities like collections and objects. If you don't have to add conditions, validation, and projections in the operations, you can choose simplicity. When wrapping an ORM in repositories, you are often hiding powerful features (like <code>IQueryable</code> for deferred execution or <code>Include</code> for eager loading) behind a more restrictive interface.</li><li>Smaller projects also don't need to be tedious. If your project requires simple queries and consists of 10-15 tables, you are good to go without bombarding a small project with more code. </li><li>Any abstraction comes with overhead. In a performance-critical system, a repository may not be the best choice for the same reason. Repository layers can require memory allocation, additional method calls, or complex query translation, which may slow down the software. Repositories often lead to the N+1 query problem or over-fetching data because the repository method returns a generic object rather than a specific projection (<code>Select</code>) tailored to the view.</li><li>One more scenario where you can skip the repository pattern is in a microservice architecture. If a service is simple enough to have a small database and minimal operations, you don't need to trade off the repository pattern for maintenance and performance.</li><li>While preparing reporting and analytics data, the repository pattern can be unnecessary. Mostly, the stored procedures, raw SQL queries, and database-specific optimizations do the whole job for us. The code only calls those underlying queries and returns. To keep things maintainable and, of course, speedy, you can avoid one layer. </li></ul><h2 id="conclusion">Conclusion</h2><p>The repository pattern is something you have probably used on your development journey. Why not? It is one of the popular choices for abstracting data access. However, abstractions have a hidden cost that I highlighted in the blog. I identified a few scenarios where you can escape it and barely lose anything. If you still want to use the repository pattern without losing its limitation, the <a href="https://blog.elmah.io/repository-pattern-vs-specification-pattern-which-is-more-maintainable/" rel="noreferrer">specification pattern is another player that can work</a>. It allows for reusable query logic without the bloat of a traditional repository.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Why IEnumerable Can Kill Performance in Hot Paths ]]></title>
        <description><![CDATA[ Benchmarks IEnumerable against List, arrays, LINQ chains, and yield-based sequences in C# using BenchmarkDotNet with collection sizes of 1,000 and 100,000, showing how deferred execution and repeated enumeration can make IEnumerable up to 7x slower than a materialized List in hot paths. ]]></description>
        <link>https://blog.elmah.io/why-ienumerable-can-kill-performance-in-hot-paths/</link>
        <guid isPermaLink="false">69916569d370370001951f37</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 03 Mar 2026 07:04:34 +0100</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/02/why-ienumerable-can-kill-performance-in-hot-paths-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/why-ienumerable-can-kill-performance-in-hot-paths/">https://blog.elmah.io/why-ienumerable-can-kill-performance-in-hot-paths/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>For F1 racing, choosing the right car is as important as your expertise. No matter how skilled you are, if you race in an ordinary car, you can't stand out. You need to understand the race and use the F1 racing car. The same goes for programming. Going with the wrong option can hurt your application. If your application contains high-throughput junctions prone to bottlenecks, you are left with choices for different collections. Today, I will explore popular data collection options, including <code>IEnumerable</code> and <code>List</code>, and examine how <code>IEnumerable</code> can hinder hot paths.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/02/why-ienumerable-can-kill-performance-in-hot-paths-o-1.png" class="kg-image" alt="Why IEnumerable Can Kill Performance in Hot Paths" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/02/why-ienumerable-can-kill-performance-in-hot-paths-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/02/why-ienumerable-can-kill-performance-in-hot-paths-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/02/why-ienumerable-can-kill-performance-in-hot-paths-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="ienumerable-in-c">IEnumerable in C#</h2><p><code>IEnumerable</code> is an interface that represents a forward-only sequence of elements. It represents a behaviour that can be deferred or materialized based on implementation. It exposes a single method, <code>GetEnumerator()</code> that returns an <code>IEnumerator&lt;T&gt;</code> object you can iterate sequentially. However, it does not support indexing.</p><h2 id="list-in-c">List in C#</h2><p>A list is a dynamic collection that resizes automatically and allows access to elements by index. The <code>List</code> class provides methods such as <code>Add</code>, <code>Remove</code>, and <code>AddRange</code>. A <code>List</code> is materialized, loading all data into memory. In fact, List implements <code>IEnumerable</code>.</p><h2 id="ienumerable-analysis-with-benchmark">IEnumerable analysis with benchmark</h2><p>To observe actual ground effects, let's create a console application where we can benchmark <code>IEnumerable</code> with a concrete <code>List</code>. </p><p><strong>Step 1: Create the project</strong></p><pre><code class="language-console">dotnet new console -n IEnumerableBenchmark
cd IEnumerableBenchmark</code></pre><p><strong>Step 2: Install the BenchmarkDotNet library</strong></p><pre><code class="language-console">dotnet add package BenchmarkDotNet
</code></pre><p><strong>Step 3: Prepare the benchmarking code</strong></p><p>Here, we will define everything in the <code>Program.cs</code> file:</p><pre><code class="language-csharp">using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;

BenchmarkRunner.Run&lt;EnumerationBenchmarks&gt;();

[MemoryDiagnoser]
public class EnumerationBenchmarks
{
    private List&lt;int&gt; _list = null!;
    private IEnumerable&lt;int&gt; _interfaceEnumerable = null!;
    private IEnumerable&lt;int&gt; _linqEnumerable = null!;
    private IEnumerable&lt;int&gt; _yieldEnumerable = null!;
    private int[] _array = null!;

    [Params(1000, 100_000)]
    public int N;

    [GlobalSetup]
    public void Setup()
    {
        // Independent data sources
        _list = Enumerable.Range(1, N).ToList();

        _array = Enumerable.Range(1, N).ToArray();

        _interfaceEnumerable = Enumerable.Range(1, N);

        _linqEnumerable = Enumerable.Range(1, N)
                                    .Where(x =&gt; x % 2 == 0)
                                    .Select(x =&gt; x * 2);

        _yieldEnumerable = CreateYieldSequence(N);
    }

    private IEnumerable&lt;int&gt; CreateYieldSequence(int count)
    {
        for (int i = 1; i &lt;= count; i++)
        {
            if (i % 2 == 0)
                yield return i * 2;
        }
    }

    // -------------------------------
    // List - for (optimized)
    // -------------------------------
    [Benchmark(Baseline = true)]
    public int List_For()
    {
        int sum = 0;
        var list = _list;
        int count = list.Count;

        for (int i = 0; i &lt; count; i++)
        {
            var x = list[i];
            if (x % 2 == 0)
                sum += x * 2;
        }

        return sum;
    }

    // -------------------------------
    // List - foreach
    // -------------------------------
    [Benchmark]
    public int List_Foreach()
    {
        int sum = 0;

        foreach (var x in _list)
        {
            if (x % 2 == 0)
                sum += x * 2;
        }

        return sum;
    }

    // -------------------------------
    // IEnumerable - foreach
    // -------------------------------
    [Benchmark]
    public int IEnumerable_Foreach()
    {
        int sum = 0;

        foreach (var x in _interfaceEnumerable)
        {
            if (x % 2 == 0)
                sum += x * 2;
        }

        return sum;
    }

    // -------------------------------
    // Array - foreach
    // -------------------------------
    [Benchmark]
    public int Array_Foreach()
    {
        int sum = 0;

        foreach (var x in _array)
        {
            if (x % 2 == 0)
                sum += x * 2;
        }

        return sum;
    }

    // -------------------------------
    // LINQ Deferred Execution
    // -------------------------------
    [Benchmark]
    public int Linq_Deferred()
    {
        return _linqEnumerable.Sum();
    }

    // -------------------------------
    // Yield State Machine
    // -------------------------------
    [Benchmark]
    public int Yield_Enumeration()
    {
        int sum = 0;

        foreach (var x in _yieldEnumerable)
        {
            sum += x;
        }

        return sum;
    }

    // -------------------------------
    // Multiple Enumeration
    // -------------------------------
    [Benchmark]
    public int Multiple_Enumeration()
    {
        int sum = 0;

        if (_linqEnumerable.Any())
        {
            foreach (var x in _linqEnumerable)
            {
                sum += x;
            }
        }

        return sum;
    }

    // -------------------------------
    // Span&lt;T&gt;
    // -------------------------------
    [Benchmark]
    public int Span_For()
    {
        int sum = 0;
        var span = CollectionsMarshal.AsSpan(_list);

        for (int i = 0; i &lt; span.Length; i++)
        {
            var x = span[i];
            if (x % 2 == 0)
                sum += x * 2;
        }

        return sum;
    }
}
</code></pre><p>I defined a list and an <code>IEnumerable</code>. In the Setup, we will initialize each one with 1000 and 100000 elements. There are several other testers to get how <code>IEnumerable</code> performs as deferred and as materialized collections, as shown in the methods.</p><p><strong>Step 4: Run the project</strong></p><p>Let's run our project in release mode</p><pre><code class="language-console">dotnet run -c Release
</code></pre><p>So, the results show the cost of <code>IEnumerable</code>.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/02/image-20.png" class="kg-image" alt="Benchmark results" loading="lazy" width="1204" height="471" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/02/image-20.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/02/image-20.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/02/image-20.png 1204w" sizes="(min-width: 720px) 720px"></figure><p>As per the results, List outperformed <code>IEnumerable</code> by up to 7x. Thanks to contiguous memory allocation and JITs array optimization, arrays outclass the list, too. But flexibility and numerous handy methods make the list more usable in most scenarios. The single test does not prove that <code>IEnumerable</code> should be abandoned. In fact, you will not want to overload memory by loading the entire dataset if it is extremely large. Then the<code>IEnumerable</code>'s behaviour took effect. The <code>List</code> is a help for small to medium-sized collections and requires frequent manipulation, such as adding and removing items. As immediate execution loads data immediately, lists are ideal when you need data to be readily available and need frequent manipulation. </p><p>Using <code>IEnumerable</code> in hot paths slows performance and does not support data manipulation. Usually, applications do not load all data at once; instead, they use filtering or pagination when fetching data. In such cases, lists leverage immediate execution.</p><h2 id="conclusion">Conclusion</h2><p>We may not need to emphasize the importance of performance in any application. We already know the importance of keeping the operations as fast as possible. Identifying and mitigating the hot path is one link in this chain. I shared one underrated point about using the right collection in such bottleneck areas. Being cautious about <code>IEnumerable</code> in hot paths, can be good for the application and your peace of mind.</p><p>Code: <a href="https://github.com/elmahio-blog/IEnumerableBenchmark">https://github.com/elmahio-blog/IEnumerableBenchmark</a></p> ]]></content:encoded>
    </item>

</channel>
</rss>