<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>Thu, 29 Oct 2026 13:19:18 +0100</lastBuildDate>
<atom:link href="https://blog.elmah.io" rel="self" type="application/rss+xml"/>
<ttl>60</ttl>

    <item>
        <title><![CDATA[ Why AI-Generated code still needs tests ]]></title>
        <description><![CDATA[ AI tools turn ideas into working apps in minutes, but they don&#39;t know your business rules, architecture or security requirements. Generated code can look right and still hide gaps, so it needs tests and human review. This post covers where it falls short and how to use AI safely. ]]></description>
        <link>https://blog.elmah.io/why-ai-generated-code-still-needs-tests/</link>
        <guid isPermaLink="false">6aafa6fc19a4980001ded35a</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Wed, 28 Oct 2026 07:39:57 +0100</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/why-ai-generated-code-still-needs-tests-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/why-ai-generated-code-still-needs-tests/">https://blog.elmah.io/why-ai-generated-code-still-needs-tests/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>AI has made life easier for us developers. Dozens of AI tools are getting more advanced every day, shortening and speeding up the development cycle. Converting an idea into a concrete MVP is faster than ever before. Intelligent models turn your ideas into a working project with just a few prompts. End users or product owners can see the application, use the beta version, and give feedback on the spot. Designers can visualize and shape the system themselves, while developers can use AI to build features or debug. Whether you are a mobile developer, front-end or back-end engineer, AI may be part of daily tasks, from development to debugging, from understanding a feature to optimizing it. These are the fruits of the 5th Industrial Revolution. However, those quick machine helps are not always reliable and need testing. In today's blog post, I will explore where and why AI-generated code can't be reliable.</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/09/why-ai-generated-code-still-needs-tests-o-1.png" class="kg-image" alt="Why AI-Generated code still needs tests" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/09/why-ai-generated-code-still-needs-tests-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/09/why-ai-generated-code-still-needs-tests-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/why-ai-generated-code-still-needs-tests-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="ai-coding-is-no-longer-just-a-tool-but-an-ecosystem">AI coding is no longer just a tool but an ecosystem</h2><p>Modern models offer a variety of ease in the application process.</p><ul><li>Design assistants like Claude Design and Figma Make help you draw interfaces quickly that earlier took hours of work.</li><li>Prompt-to-application workflows turn natural-language descriptions into working apps. Google AI Studio, Lovable, RapidNative, Replit Agent, v0, Bolt, and Base44 are part of this long list.</li><li>AI-enabled coding assistants such as GitHub Copilot, Cursor, JetBrains Junie, Gemini Code Assist, and Amazon Q Developer do much of the job while you write code.</li><li>Independent agents are also part of development, and agents like OpenAI Codex, Devin, Claude Code, and Google Jules need no introduction.</li></ul><p>I have listed a handful of names out of hundreds. All of these enable fast-paced development for both experienced and new developers. While AI tools are purpose-built platforms, their operations overlap significantly. A design tool may generate working application code, while a prompt-to-app platform may provision authentication, databases, and hosting.</p><h2 id="why-do-companies-use-ai-in-development">Why do companies use AI in development?</h2><p>One big advantage AI brings to organizations is the speed of work. In a traditional setup, teams defined an idea in requirements documents, then refined it in planning meetings. Planning leads to technical requirements, agile sprints, and development. Meanwhile, stakeholders had to wait for a tangible demo and feedback to fall back into the cycle. AI agents allow organizations to make a prototype application in the starting phase and present it to stakeholders. The team can demonstrate a feature, take feedback, and update accordingly on something real rather than just graphs and designs. This cuts down on the process, saving not only time but also the money that each software cycle spends repeating after feedback.</p><p>Another major reason corporates opt for AI is human resource efficiency. Modern AI models assist in coding, help with debugging, write boilerplate and repetitive code themselves, and even provide suggestions throughout the process. A developer, with the use of AI, can do the job of multiple developers, which results in a dramatic increase in developers' capability. For the same reason, many development teams are shrinking across organizations.</p><h2 id="why-does-ai-generated-code-need-tests">Why does AI-generated code need tests?</h2><p>So, coming to our original question, why can't we fully rely on AI-generated code? Let's look at the issues AI may bring with generated code.</p><h3 id="ai-generated-code-looks-more-trustworthy-than-it-is">AI-Generated code looks more trustworthy than it is</h3><p>AI-generated code is different from hand-written code. AI tools do not return generic snippets. They compose the snippets shaped by the user prompt, surrounding code, and other contextual formats. The output looks familiar and native to the project, which feels trustworthy. Developers may overlook edge cases and blind spots. If you pass code for debugging, the AI responds with the correct code but sometimes affects other parts of the application that depend on it, and you may not notice.</p><h3 id="ai-code-needs-stronger-validation">AI code needs stronger validation</h3><p><a href="https://www.gitclear.com/ai_assistant_code_quality_2025_research" rel="noreferrer">GitClear's 2025 analysis</a> of 150 million lines of code states that code churn has sharply increased in the post-AI era. Much unvalidated code made it to production, then was modified or reverted within 2 weeks, showing how unreliable AI-generated code can be. One <a href="https://www.youtube.com/watch?v=6l4tQcyknR4">HackerRank experiment</a> found that a senior developer who combined a traditional approach with AI produced a better application than a junior developer and a pure vibe coder. Agents don't understand the system's requirements, user context, and business logic but rely on the prompt and surrounding code. AI may write hallucinated logic due to a lack of application and organizational knowledge. When a developer writes code, they understand what a function can and cannot do. They know the organization's requirements better through client interaction, research, and experience. These aspects may be missing from the model's training data. AI doesn't consider edge cases or specific business logic.</p><h3 id="generated-code-is-not-a-production-application">Generated code is not a production application</h3><p>As we know, AI is remarkable at building prototypes and generating snippets faster. You get well-placed buttons, interactive dashboards filled with data, and inputs that take and save data via AI in minutes. That speed can create the impression of real progress and make an application seem nearly finished. But visibility isn't all an application needs. A production-grade application needs tons of bullet points fulfilled, such as:</p><ul><li>How does the authentication work?</li><li>Can a user access information of another user?</li><li>Is there a logging trail of actions for traceability and debugging?</li><li>How does it perform when the user base grows?</li><li>What happens when invalid data is submitted?</li><li>How does a service recover from failure?</li><li>Are its features extendable and loosely coupled?</li></ul><p>Unfortunately, our quick solution often lacks several of them. AI gives results within the scope of the prompt asked or the context it can access. It lacks architectural constraints and design consistency across the entire application. AI doesn't know which authentication your application uses. It does not have an idea of how the business will control access to users, how you should validate input, or what clients or end-users said in the meeting. Many people ran into problems when their AI-generated app attracted a bigger user base, because the AI had no idea what future scalability would require. Only a human can plan for these aspects.</p><h3 id="ai-can-accelerate-technical-debt">AI Can Accelerate Technical Debt</h3><p>Code generated for a feature today can differ tomorrow for the same query. Now think of other parts that AI gave you, they can drastically differ from your application's standard practices and conventions. Variable names can follow different formats, and architecture can be separated from what other parts do in AI-assisted code. Database queries can be duplicated because AI doesn't know whether a similar query has already been written. Scattered configurations, inefficient API calls, lack of reusability, and unnecessary dependencies are common add-on issues in AI code. If you rely only on the working status, these underlying mismatches can become hurdles during maintenance or when you extend the feature. The same speed of work for which you choose AI can harm the product. You get more code generated than the team can understand. So the bottleneck shifts from writing code to maintenance. Revisiting and modifying can take longer while a production deadline is on your head. The same was found in the <a href="https://www.gitclear.com/ai_assistant_code_quality_2025_research" rel="noreferrer">code quality analysis</a> discussed above.</p><h3 id="security-risks-shipped-with-the-code">Security risks shipped with the code</h3><p>AI-generated code may do its primary task of a responsive page, authenticating a user or sending emails with Twilio, but it may overlook the security bounds of standard development. Security and data risks such as broken access control, security misconfiguration, insecure design, exposed configuration and API keys are common problems that automated code leaves in the application.</p><p>AI agents today are more like coding partners that understand requirements, scan files, connect to databases, create cloud resources, open pull requests, or deploy applications. The more the access array grows, the more likely errors or incorrect instructions become.</p><p>Beyond issues with generated code, risks can come from the developer's side as well. During development, developers may share critical code, files, screenshots, database schema, or sensitive information like passwords and API keys.</p><h2 id="how-to-benefit-from-ai-generated-code-without-compromising-your-application">How to benefit from AI-generated code without compromising your application?</h2><p>We have seen the issues with the involvement of AI in the modern development cycle. Now, let's move to diagnosing how you can avoid those issues while leveraging AI.</p><h3 id="artificial-intelligence-is-your-colleague-not-a-de-facto-ruler">Artificial intelligence is your colleague, not a de facto ruler</h3><p>Treat AI agents and assistants as a co-developer. More safely, as a junior developer whom you will not give full responsibility to create A-to-Z of your application. You will not ask them to create a feature without prior knowledge of standards, architecture, naming conventions, existing components, approved third-party packages and licenses. Use AI models for coding based on organization-specific and application context, boundaries, acceptance criteria and review. An AI-embedded software process should define:</p><ul><li>How changes should be structured and reviewed, including branching, pull requests, code ownership and approval requirements.</li><li>API and integration conventions, including naming, versioning, error responses, pagination and backward compatibility.</li><li>Database and data-access conventions, including migration practices, transaction handling, query performance and indexing expectations.</li><li>Dependency and package policies, including approved sources, versioning, vulnerability scanning and license requirements.</li><li>Configuration and secrets management, including which values belong in configuration, environment variables or secret stores and which must never be committed.</li><li>Performance and scalability expectations, including response-time targets, resource limits and acceptable database or API usage.</li><li>Observability requirements, including structured logging, metrics, tracing, health checks and what information must not appear in logs.</li><li>Deployment and rollback requirements, including how changes move between environments and how failed releases are reversed.</li><li>Documentation requirements, including which architectural decisions, public APIs and non-obvious business rules must be documented.</li></ul><p>A guided input to LLMs will avoid hallucinated logic and bound them within the standardized approach.</p><h3 id="understand-the-ai-service-before-using-it">Understand the AI service before using it</h3><p>First, the decision makers need to understand the account type, configuration and contractual terms through which a model is used. Some key considerations are:</p><ul><li>Whether submitted content may be used to train or improve the provider's models.</li><li>How long prompts, source code, generated output and agent sessions are retained.</li><li>Where data is processed and stored, and what data-processing agreements and safeguards apply.</li><li>What security, privacy and data-isolation controls are provided by the selected account or plan.</li><li>Which repositories, files and connected services the AI tool is permitted to access.</li><li>Whether administrators can manage permissions, monitor usage and remove organizational data.</li><li>What happens to the organization's information when the account or service is closed.</li></ul><h3 id="standardized-use-of-ai-within-the-organization">Standardized use of AI within the organization</h3><p>The organization should restrict AI not only in development but also for those who will use it. The new kind of development brought new requirements. Developers should be given clear guidelines for using approved AI products for company tasks, covering at least</p><ul><li>Use only approved AI tools and account types for company work; personal accounts should not be used for organizational data.</li><li>Understand what data can be shared. Source code, credentials, customer information, personal data and confidential business information should only be submitted when explicitly permitted.</li><li>Use the appropriate data classification. Public and synthetic data may have fewer restrictions, while confidential or personal data may require additional controls or may be prohibited entirely.</li><li>Keep secrets out of prompts and repositories. API keys, passwords, tokens, private certificates and other credentials should never be provided to an AI tool.</li><li>Limit agent permissions. Give coding agents access only to the repositories, files, tools and environments required for their task.</li><li>Review generated code before use. Developers remain responsible for checking correctness, security, dependencies, licensing and compliance with project standards.</li><li>Do not blindly execute generated commands. Commands that modify databases, delete files, change infrastructure or access external systems should be reviewed before execution.</li><li>Use isolated environments for experimentation. AI-generated changes should be tested in development or staging environments before reaching production.</li><li>Follow existing development controls. AI-generated changes should go through the same testing, code review, CI/CD and approval processes as manually written code.</li><li>Report suspected data exposure or unsafe output. Developers should know how to report accidental disclosure, insecure generated code, or inappropriate AI behavior.</li></ul><h3 id="developer-reviewhuman-involvement-is-necessary">Developer review - human involvement is necessary</h3><p>After AI develops a feature, debugs a problem or generates code, you should not merge it into production. We've seen that code isn't fully trustworthy and needs validation. A developer understands what that code should do and what it should not do. You and your team know the organization's requirements better through client interaction, research, documentation and experience. A human review is essential to confirm that AI didn't invent a method that was already defined, write a separate database query you already have, follow security and file standards, and more. Another approach to validate generated code is to cross-validate logic from one model against another. We at elmah.io follow this to ensure the models did the job efficiently according to the guided protocols.</p><h3 id="test-ai-generated-code-with-ai-powered-tests">Test AI-generated code with AI-powered tests</h3><p>It is like checking machine-provided code with your own machine police, so to speak. Traditional testing is not sufficient for the new era's development practices. Your existing test suite reflects the code paths you anticipated before the AI introduced anything new. When AI-generated changes add new branches, error conditions or logic paths, those scenarios may fall outside the existing test coverage. Tests can still pass and the coverage report can still look healthy, while important behavior remains untested. The gap may only become visible when something fails in production. AI-powered tests can catch those gaps and also find hallucinated logic. These tests directly exercise the business rule that serves a filter after human review and before production. Practically, it is not difficult to write that many handwritten tests matching the speed of AI-powered development. Intelligent analysis can catch context loopholes, business-logic misses, and security leaks at the door.</p><h2 id="conclusion">Conclusion</h2><p>AI-assisted development changes the economics of software development. Teams can explore ideas faster, generate routine code in seconds and make changes across an application with less manual effort. But faster code generation does not remove the engineering work required to determine whether that code is correct, secure, maintainable and appropriate for production.</p><p>The important distinction is between generating code and assuring software. AI can produce an implementation, but it does not remove the need to understand the requirements, consider failure modes, test behavior, and review the consequences of a change. We explored different shortcomings in AI-generated code, such as alteration of dependent behavior, hallucinated logic, security leaks and missing organization-specific knowledge. We discussed a few practices to overcome those shortcomings. Developers should set guidelines for AI code and review it after it generates code. Organizations should not treat AI as a replacement for established engineering practices. Instead, AI should operate within them. Give developers approved tools, clear boundaries, appropriate access and well-defined review processes. Use automation to catch predictable problems and human judgment for decisions that require context. AI can make development faster. Engineering discipline determines whether that speed produces software you can trust. Existing tests are valuable, but they only protect the scenarios they cover. When AI introduces new branches, dependencies, or assumptions, the surrounding validation needs to evolve as well.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Memory-efficient JSON in .NET 10 web APIs with PipeReader ]]></title>
        <description><![CDATA[ Deserializing a JSON payload with a million objects strains memory and the garbage collector. .NET 10 adds PipeReader support to System.Text.Json, letting the serializer read straight from a pipeline. This post benchmarks it against streams on execution time, allocations, and GC collections. ]]></description>
        <link>https://blog.elmah.io/memory-efficient-json-in-net-10-web-apis-with-pipereader/</link>
        <guid isPermaLink="false">6a82ef4f3a1ed10001d194e1</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 20 Oct 2026 09:05:45 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/memory-efficient-web-apis-using-dot.net-10-zero-copy-json-improvements-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/memory-efficient-json-in-net-10-web-apis-with-pipereader/">https://blog.elmah.io/memory-efficient-json-in-net-10-web-apis-with-pipereader/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>What happens when your ASP.NET Core API needs to deserialize a JSON payload containing one million objects? The JSON serializer has a lot of work to do, such as building the resulting object graph and managing memory allocation. That can put noticeable pressure on memory and the garbage collector (GC). .NET 10 introduces <code>PipeReader</code> support in <code>System.Text.Json</code>, providing a pipeline-based way to feed JSON data directly into the serializer. It provides an alternative to the traditional stream-based processing.</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/09/memory-efficient-web-apis-using-dot.net-10-zero-copy-json-improvements-o-1.png" class="kg-image" alt="Memory-efficient web APIs using .NET 10's zero-copy JSON improvements" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/09/memory-efficient-web-apis-using-dot.net-10-zero-copy-json-improvements-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/09/memory-efficient-web-apis-using-dot.net-10-zero-copy-json-improvements-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/memory-efficient-web-apis-using-dot.net-10-zero-copy-json-improvements-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><p>In this article, I will show the differences between the two approaches and test them against execution time, memory allocation, and GC load.</p><h2 id="what-is-zero-copy">What is zero-copy?</h2><p>Zero-copy refers to the technique of processing data across memory spaces without making redundant copies in memory. By avoiding duplicate copies, the program drastically reduces CPU overhead, conserves RAM, and eliminates Garbage Collector (GC) pressure, achieving a remarkable boost in performance.</p><h2 id="analyzing-net-10-web-api-json-improvements">Analyzing .NET 10 web API JSON improvements</h2><p>As always, I will keep things simple. I am using a minimal API with a JSON dataset, let's begin.</p><p><strong>Step 1: Create project</strong></p><pre><code class="language-console">dotnet new webapi -n ZeroCopyJsonDemo -f net10.0
cd ZeroCopyJsonDemo</code></pre><p><strong>Step 2: Create models</strong></p><p>Reading model</p><pre><code class="language-csharp">namespace ZeroCopyJsonDemo.Models;

public class TelemetryReading
{
    public DateTime Timestamp { get; set; }

    public double Temperature { get; set; }

    public double Humidity { get; set; }
}</code></pre><p>Model for request</p><pre><code class="language-csharp">public class TelemetryRequest
{
    public string DeviceId { get; set; } = string.Empty;

    public List&lt;TelemetryReading&gt; Readings { get; set; } = [];
}</code></pre><p><strong>Step 3: Create a large JSON file</strong></p><p>To simulate a large JSON dataset, I am adding an endpoint that does it</p><pre><code class="language-csharp">app.MapGet("/create-json", () =&gt;
{
    var payload = new TelemetryRequest
    {
        DeviceId = "device-001",
        Readings = Enumerable.Range(1, 1_000_000)
            .Select(i =&gt; new TelemetryReading
            {
                Timestamp = DateTime.UtcNow.AddSeconds(i),
                Temperature = 20 + i % 10,
                Humidity = 40 + i % 20
            })
            .ToList()
    };

    var json = JsonSerializer.SerializeToUtf8Bytes(payload);

    File.WriteAllBytes("large.json", json);

    return Results.Ok(new
    {
        SizeMB = Math.Round(json.Length / 1024d / 1024d, 2)
    });
});</code></pre><p>It generates 1 million readings and writes them to a JSON file.</p><p><strong>Step 4: Write testing methods</strong></p><pre><code class="language-csharp">static async Task&lt;object&gt; MeasureNormal()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    var allocatedBefore = GC.GetTotalAllocatedBytes(true);

    var gen0Before = GC.CollectionCount(0);
    var gen1Before = GC.CollectionCount(1);
    var gen2Before = GC.CollectionCount(2);

    var stopwatch = Stopwatch.StartNew();

    await using var stream = File.OpenRead("large.json");

    var result =
        await JsonSerializer.DeserializeAsync&lt;TelemetryRequest&gt;(stream);

    stopwatch.Stop();

    var allocatedAfter = GC.GetTotalAllocatedBytes(true);

    return new
    {
        ExecutionTimeMs = stopwatch.Elapsed.TotalMilliseconds,

        AllocatedMemoryMB = Math.Round(
            (allocatedAfter - allocatedBefore) / 1024d / 1024d, 2),

        Gen0Collections = GC.CollectionCount(0) - gen0Before,
        Gen1Collections = GC.CollectionCount(1) - gen1Before,
        Gen2Collections = GC.CollectionCount(2) - gen2Before
    };
}</code></pre><p>For a fair benchmark, I enforced garbage collection. It will test a normal traditional approach. Then, recorded memory allocation and time with a stopwatch. The line:</p><pre><code class="language-csharp">await using var stream = File.OpenRead("large.json");</code></pre><p>opens the JSON file as a normal .NET <code>Stream</code>. While, the next line deserializes it using <code>System.Text.Json</code> into <code>TelemetryRequest</code> returning as a result.</p><p>Finally, I got the total memory allocation, time elapsed and <a href="https://blog.elmah.io/how-net-garbage-collector-works-and-when-you-should-care/" rel="noreferrer">Garbage collector</a> usage by the differences. The</p><pre><code class="language-csharp">/ 1024d / 1024d</code></pre><p>converts bytes to MB.</p><pre><code class="language-csharp">static async Task&lt;object&gt; MeasurePipeline()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    var allocatedBefore = GC.GetTotalAllocatedBytes(true);

    var gen0Before = GC.CollectionCount(0);
    var gen1Before = GC.CollectionCount(1);
    var gen2Before = GC.CollectionCount(2);

    var stopwatch = Stopwatch.StartNew();

    await using var stream = File.OpenRead("large.json");

    var reader = PipeReader.Create(stream);

    var result =
        await JsonSerializer.DeserializeAsync&lt;TelemetryRequest&gt;(reader);

    await reader.CompleteAsync();

    stopwatch.Stop();

    var allocatedAfter = GC.GetTotalAllocatedBytes(true);

    return new
    {
        ExecutionTimeMs = stopwatch.Elapsed.TotalMilliseconds,

        AllocatedMemoryMB = Math.Round(
            (allocatedAfter - allocatedBefore) / 1024d / 1024d, 2),

        Gen0Collections = GC.CollectionCount(0) - gen0Before,
        Gen1Collections = GC.CollectionCount(1) - gen1Before,
        Gen2Collections = GC.CollectionCount(2) - gen2Before
    };
}</code></pre><p>Again, enforcing garbage collection so both tests start from the same state. Now instead of giving the <code>Stream</code> directly to <code>JsonSerializer</code>, we give it the <code>PipeReader</code>. Then <code>System.Text.Json</code>'s serializer deserializes directly from the <code>PipeReader</code>. One important part is to complete the pipe reader after we are done with.</p><pre><code class="language-csharp">await reader.CompleteAsync();</code></pre><p><strong>Step 5: Create benchmark endpoint</strong></p><pre><code class="language-csharp">app.MapGet("/benchmark", async () =&gt;
{
    var normal = await MeasureNormal();
    var pipeline = await MeasurePipeline();

    return Results.Ok(new
    {
        Normal = normal,
        Pipeline = pipeline
    });
});</code></pre><p>That is a simple, minimal endpoint to run both measures.</p><p><strong>Step 6: Run the project</strong></p><pre><code class="language-console">dotnet run -c Release</code></pre><p>For benchmarking, I ran it in Release mode.</p><p><strong>Step 7: Prepare the JSON file with the endpoint</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/09/image-3.png" class="kg-image" alt="create-json" loading="lazy" width="475" height="189"></figure><p>The endpoint generated a<code>large.json</code> file of size 72MB which is not ideal to represent a real large file but is considerable for the demo.</p><p><strong>Step 8: Navigate to the benchmarking endpoint</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/09/image-2.png" class="kg-image" alt="Result" loading="lazy" width="555" height="477"></figure><p>Run <code>/benchmark</code> 5–10 times, ignore the first run because of JIT warm-up, and compare the later results. A single execution is not a reliable performance benchmark.</p><p>We're creating a pipeline reader around the file stream. .NET 10 allows <code>System.Text.Json</code> to deserialize JSON directly from a <code>PipeReader</code>. So instead of us doing something like:</p><pre><code class="language-csharp">var bytes = await ReadEntireRequestBody();</code></pre><p>and then passing those bytes to the JSON serializer, we're allowing the serializer to consume the pipeline. So functionally, both endpoints do the same thing. We're not changing the API's behavior. We're changing how the request body is consumed. We utilized the .NET 10 power of<code>PipeReader</code> that allows <code>System.Text.Json</code> to consume JSON from a pipeline, which can help avoid unnecessary buffering and copying in suitable workloads.</p><p>One improvement is in garbage collector usage. During deserialization, .NET has to create a very large object graph that requires allocations. To clean temporary allocations and other working data, GC has to jump in. While the buffering/consumption model of <code>PipeReader</code> can process the input incrementally without creating enough additional GC-triggering pressure during this run. The lower GC pressure resulted in 21% faster results.</p><h2 id="conclusion">Conclusion</h2><p>We discovered one major <a href="https://blog.elmah.io/tag/whats-new-in-net-10/" rel="noreferrer">.NET 10 improvement</a> in JSON processing. The pipe reader with <code>System.Text.Json</code> changes the game for large dataset parsing. With the example API project, we observed changes in execution time and GC pressure, showing that .NET 10 optimized the job with its zero-copy capabilities. If you're deserializing large JSON payloads or using a high-throughput endpoint, prefer the <code>PipeReader</code> overload. You trade a small amount of extra raw allocation to avoid LOH churn and GC pauses, which is almost always the better trade in a hot request path than its traditional counterpart.</p><p>Code: <a href="https://github.com/elmahio-blog/ZeroCopyJsonDemo.git">https://github.com/elmahio-blog/ZeroCopyJsonDemo.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ 10 MCP servers you need to install now as a .NET developer ]]></title>
        <description><![CDATA[ Ten MCP servers I run daily as a .NET developer, so Claude works from live data instead of guesses. The list covers NuGet, GitHub, Azure, Azure DevOps, Playwright and Cloudflare plus a few community picks, with an example prompt for each and notes on keeping access read-only where it matters. ]]></description>
        <link>https://blog.elmah.io/10-mcp-servers-you-need-to-install-now-as-a-net-developer/</link>
        <guid isPermaLink="false">6a62107c0e291f0001115743</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Thomas Ardal ]]></dc:creator>
        <pubDate>Tue, 13 Oct 2026 08:37:56 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/10-mcp-servers-you-need-to-install-now-as-a-dot.net-developer.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/10-mcp-servers-you-need-to-install-now-as-a-net-developer/">https://blog.elmah.io/10-mcp-servers-you-need-to-install-now-as-a-net-developer/</a></p> <p>As a .NET developer, I use a growing list of MCP servers every day to give Claude and other AI coding assistants access to real, live data instead of guesses. Some ship from Microsoft, some from the vendor of the tool itself, and a few are community projects that quietly make everything better. In this post, I'll go through 10 that I'd install right now.</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/08/10-mcp-servers-you-need-to-install-now-as-a-dot.net-developer-1.png" class="kg-image" alt="10 MCP servers you need to install now as a .NET developer" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/10-mcp-servers-you-need-to-install-now-as-a-dot.net-developer-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/10-mcp-servers-you-need-to-install-now-as-a-dot.net-developer-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/10-mcp-servers-you-need-to-install-now-as-a-dot.net-developer-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><p>Without further ado, let's dig into the list. In no particular order.</p><h2 id="nuget-mcp">NuGet MCP</h2><p>Let Claude check for the latest version of a package, review known vulnerabilities and supply chain risks before adding a dependency, and even upgrade or fix vulnerable packages directly in your project files. I don't have to guess at a version number or trust stale training data anymore - the assistant can just go look it up.</p><p>Example:</p><pre><code class="language-console">&gt; What's the latest version of Serilog.Sinks.Elasticsearch, any known vulnerabilities?</code></pre><p>Learn more: <a href="https://learn.microsoft.com/en-us/nuget/concepts/nuget-mcp-server">https://learn.microsoft.com/en-us/nuget/concepts/nuget-mcp-server</a></p><h2 id="elmahio-mcp">elmah.io MCP</h2><p>Query your elmah.io error logs, uptime checks, heartbeats, and deployments directly from Claude. Since we build our own error logging and uptime monitoring service, this one's obviously close to home (I even wrote a full post about using it to <a href="https://blog.elmah.io/solving-bugs-with-elmah-io-and-claude-code-a-real-life-example/" rel="noreferrer">fix a real production bug</a>). It lets Claude query recent log messages, find the noisiest errors in a log, check uptime and heartbeat status, and pull usage stats - all without me switching over to app.elmah.io. Genuinely one of the tools I reach for the most these days.</p><p>Example:</p><pre><code class="language-console">&gt; Get me the details of the latest error in the elmah.io.api log.</code></pre><p>Learn more: <a href="https://mcp.elmah.io/">https://mcp.elmah.io/</a></p><h2 id="github-mcp">GitHub MCP</h2><p>Gives Claude access to GitHub repositories, issues, pull requests, and workflow runs through the GitHub API. Claude and similar tools can already call this API over plain HTTP requests, but installing the MCP server makes a real difference: it also unlocks private repositories, and returns structured data instead of scraped HTML.</p><p>Example:</p><pre><code class="language-console">&gt; List the open pull requests on elmahio/Elmah.Io.AspNetCore that don't have a review yet.</code></pre><p>Learn more: <a href="https://github.com/github/github-mcp-server">https://github.com/github/github-mcp-server</a></p><h2 id="azure-mcp">Azure MCP</h2><p>Pull information from your Azure environment to get information about production deployments and configuration. Make sure to install it with the <code>--read-only</code> flag if you, like me, don't want an AI assistant able to change your cloud infrastructure. If you already have the az CLI installed and authenticated on your machine, Claude will figure out on its own that it can just shell out to az to make changes anyway - read-only mode on the MCP server doesn't stop that 😅. Block usage of the az command too if you actually want that guarantee.</p><p>Example:</p><pre><code class="language-console">&gt; What App Service plan is elmah.io.app running on, and what's it costing us this month?</code></pre><p>Learn more: <a href="https://github.com/Azure/azure-mcp">https://github.com/Azure/azure-mcp</a></p><h2 id="azure-devops-mcp">Azure DevOps MCP</h2><p>Gives Claude access to Azure DevOps work items, repos, pipelines, and more, directly from your editor or terminal. Microsoft's HTTP-based remote server currently only works from Visual Studio and VS Code, see <a href="https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops">https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops</a>. For anything else - Claude Code included - use the CLI-based version instead until that changes.</p><p>Example:</p><pre><code class="language-console">&gt; What tasks are assigned to me in the current sprint?</code></pre><p>Learn more: <a href="https://github.com/microsoft/azure-devops-mcp">https://github.com/microsoft/azure-devops-mcp</a></p><h2 id="playwright-mcp">Playwright MCP</h2><p>Gives Claude control of a real browser - navigating pages, clicking, filling in forms, and reading back what's on screen. It's genuinely a little crazy that Claude can write its own test plan for a change, launch a real browser, click through your app, and verify the result - all on its own. This is the one that turns "I think this works" into "I watched it work."</p><p>Example:</p><pre><code class="language-console">&gt; Open the dashboard, log in, and confirm the uptime chart renders without console errors.</code></pre><p>Learn more: <a href="https://github.com/microsoft/playwright-mcp">https://github.com/microsoft/playwright-mcp</a></p><h2 id="microsoft-learn-mcp">Microsoft Learn MCP</h2><p>For looking up official documentation. It's already built into Visual Studio (and possibly VS Code), but I had to install it manually for other tools like Claude Code.</p><p>Example:</p><pre><code class="language-console">&gt; What's the recommended way to configure rate limiting in ASP.NET Core 10?</code></pre><p>Learn more: <a href="https://learn.microsoft.com/en-us/training/support/mcp">https://learn.microsoft.com/en-us/training/support/mcp</a></p><p>Worth also checking out Context7 for the same kind of up-to-date library documentation lookup, across a much wider set of frameworks and libraries than just Microsoft's own.</p><p>Learn more: <a href="https://github.com/upstash/context7">https://github.com/upstash/context7</a></p><h2 id="postman-mcp">Postman MCP</h2><p>Gives Claude direct access to your Postman collections and API specs - creating and managing collections, running existing tests, editing environment variables, and generating client code straight from an API definition. I haven't put this one through its paces myself yet, but it ships with a few tiers of tools (Minimal, Code, Full, Learn) so you can pick how much access to hand over.</p><p>Example:</p><pre><code class="language-console">&gt; Run the 'Create log message' request from my elmah.io collection against the staging environment.</code></pre><p>Learn more: <a href="https://www.postman.com/product/mcp-server/">https://www.postman.com/product/mcp-server/</a></p><h2 id="dotnetmetadatamcpserver">dotnetmetadatamcpserver</h2><p>A community project that uses reflection to expose the actual type metadata of your .NET project and its NuGet dependencies - assemblies, namespaces, types, methods, properties, constructors, all of it - plus NuGet package search and version/dependency lookups. It solves a real problem: instead of hallucinating a method signature from a package it's never seen, Claude can just ask.</p><p>Example:</p><pre><code class="language-console">&gt; What overloads does AddHttpClient have in this project's referenced Microsoft.Extensions.Http?</code></pre><p>Learn more: <a href="https://github.com/v0v1kkk/dotnetmetadatamcpserver">https://github.com/v0v1kkk/dotnetmetadatamcpserver</a></p><h2 id="cloudflare-mcp">Cloudflare MCP</h2><p>Gives Claude access to the full Cloudflare API (WAF, DNS, Workers, and more). No built-in read-only mode, though. Everything is gated by whatever credential you connect with. For read-only, skip the OAuth login and create a Cloudflare API token scoped to only Read permission groups (no Edit). Watch out for this open bug: <a href="https://github.com/cloudflare/mcp/issues/95">https://github.com/cloudflare/mcp/issues/95</a> - Claude Code (and other MCP clients) do OAuth discovery before sending custom headers, so a bearer token never actually reaches the server, and it silently falls back to a full-access OAuth login instead. Took me a minute to figure out why my read-only token kept getting ignored 🤦🏻‍♂️</p><p>A safer alternative until that's fixed could be to skip the MCP server for read-only use cases and call the Cloudflare API directly with the scoped token. The read-only restriction is then enforced by Cloudflare on the token itself, not by trusting the MCP client - which is what I ended up doing.</p><p>Example:</p><pre><code class="language-console">&gt; List the DNS records for the elmah.io zone.</code></pre><p>Learn more: <a href="https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/">https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/</a></p><p>That's the list for now. If you're running a favorite MCP server as a .NET developer that didn't make the cut, let me know. I'm always looking for the next one to add to the rotation.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Designing APIs for AI agents: your API has a new user ]]></title>
        <description><![CDATA[ AI agents are a new kind of API consumer: they call endpoints, retry requests, and chain multi-step workflows on their own. This post covers practical ASP.NET Core patterns for building agent-ready APIs, including DTOs, idempotency, pagination, machine-readable errors, auth, and llms.txt. ]]></description>
        <link>https://blog.elmah.io/designing-apis-for-ai-agents-your-api-has-a-new-user/</link>
        <guid isPermaLink="false">6a8991b46df87f0001762906</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 06 Oct 2026 08:16:05 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/designing-apis-for-ai-agents-your-api-has-a-new-kind-of-user-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/designing-apis-for-ai-agents-your-api-has-a-new-user/">https://blog.elmah.io/designing-apis-for-ai-agents-your-api-has-a-new-user/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>For years, APIs have had one primary audience: applications. A frontend calls an endpoint, receives a response, and the developer handles whatever happens next. All of it was from a human user. Now, we have a new player: AI. Instead of simply following predefined application logic, an agent can decide which endpoints to call, determine the order of operations, retry failed requests, and carry out multi-step tasks based on a user's goal. Your API is no longer just serving an application, it may be serving an AI system capable of taking action. Companies like Stripe, Twilio, and Shopify have already launched dedicated&nbsp;<a href="https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro" rel="noreferrer">Model Context Protocol</a>&nbsp;servers&nbsp;to capture agent-driven traffic.</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/09/designing-apis-for-ai-agents-your-api-has-a-new-kind-of-user-o-1.png" class="kg-image" alt="Designing APIs for AI agents: your API has a new user" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/09/designing-apis-for-ai-agents-your-api-has-a-new-kind-of-user-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/09/designing-apis-for-ai-agents-your-api-has-a-new-kind-of-user-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/designing-apis-for-ai-agents-your-api-has-a-new-kind-of-user-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><p>In this article, I will share practical patterns that can make an ASP.NET Core API easier for AI agents to discover, understand, and use safely. This isn't a complete guide, but a checklist you can follow to make APIs AI-compatible.</p><h2 id="design-an-asp-net-core-api-for-ai-agents">Design an ASP .NET Core API for AI agents</h2><p>Let's incorporate the necessary points into an API.</p><p><strong>Step 1: Create project</strong></p><pre><code class="language-console">dotnet new webapi -n AgentReadyApi
cd AgentReadyApi</code></pre><p><strong>Step 2: Install the required packages</strong></p><pre><code class="language-console">dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL        
dotnet add package Microsoft.EntityFrameworkCore                
dotnet add package Scalar.AspNetCore</code></pre><p>A notable NuGet package is <code>Scalar.AspNetCore</code> apart from those that provide EF Core and the database. I will use Scalar UI with the OpenAPI document to display and test endpoints.</p><p><strong>Step 3: Create models and enum</strong></p><p>For our database tables, add an Invoice table.</p><pre><code class="language-csharp">public class Invoice
{
    public Guid Id { get; set; }

    public string CustomerId { get; set; } = null!;

    public decimal Amount { get; set; }

    public string Currency { get; set; } = "USD";

    public InvoiceStatus Status { get; set; }

    public string ClientReference { get; set; } = null!;

    public DateTime CreatedAt { get; set; }

    public DateTime? UpdatedAt { get; set; }
}</code></pre><p>Make sure to create the Status enum.</p><pre><code class="language-csharp">public enum InvoiceStatus
{
    Draft = 1,
    Finalized = 2,
    Paid = 3,
    Cancelled = 4
}</code></pre><p>Status is necessary to restrict input to fixed values rather than an open-ended string.</p><p><strong>Step 4: Set up DbContext</strong></p><pre><code class="language-csharp">public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions&lt;AppDbContext&gt; options)
        : base(options)
    {
    }
    
    public DbSet&lt;IdempotencyRecord&gt; IdempotencyRecords =&gt; Set&lt;IdempotencyRecord&gt;();
    public DbSet&lt;Invoice&gt; Invoices =&gt; Set&lt;Invoice&gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&lt;Invoice&gt;(entity =&gt;
        {
            entity.HasKey(x =&gt; x.Id);

            entity.Property(x =&gt; x.CustomerId)
                .IsRequired()
                .HasMaxLength(100);

            entity.Property(x =&gt; x.Currency)
                .IsRequired()
                .HasMaxLength(3);

            entity.Property(x =&gt; x.Amount)
                .HasPrecision(18, 2);

            entity.Property(x =&gt; x.Status)
                .HasConversion&lt;string&gt;();

            entity.Property(x =&gt; x.CreatedAt)
                .IsRequired();
        });
    }
}</code></pre><p>Well, I have used the fluent API to configure the necessary model. Alternatively, you can use attributes as well.</p><h3 id="create-strong-data-transfer-objects-dtos">Create strong Data Transfer Objects (DTOs)</h3><p>Don't let an AI agent discover your database model. Introduce DTOs for the input.</p><pre><code class="language-csharp">public class CreateInvoiceRequest
{
    /// &lt;summary&gt;
    /// Customer that owns the invoice.
    /// &lt;/summary&gt;
    [Required]
    public string CustomerId { get; init; } = null!;

    /// &lt;summary&gt;
    /// Invoice amount. Must be greater than zero.
    /// &lt;/summary&gt;
    [Range(0.01, 999999999)]
    public decimal Amount { get; init; }

    /// &lt;summary&gt;
    /// Three-letter ISO currency code.
    /// &lt;/summary&gt;
    [Required]
    [StringLength(3, MinimumLength = 3)]
    public string Currency { get; init; } = "USD";

    /// &lt;summary&gt;
    /// Unique client-side reference used for reconciliation.
    /// &lt;/summary&gt;
    [Required]
    public string ClientReference { get; init; } = null!;
}</code></pre><p>Also, for operations like update and cancel, we need separate DTOs.</p><pre><code class="language-csharp">public class CancelInvoiceRequest
{
    /// &lt;summary&gt;
    /// The unique identifier of the cancellation intent previously created for the invoice.
    /// &lt;/summary&gt;
    public Guid IntentId { get; init; }

    /// &lt;summary&gt;
    /// The unique identifier of the invoice to cancel.
    /// &lt;/summary&gt;
    public Guid InvoiceId { get; init; }

    /// &lt;summary&gt;
    /// The date and time when the cancellation intent expires.
    /// &lt;/summary&gt;
    public DateTime ExpiresAt { get; init; }
}</code></pre><h3 id="enrich-openapi-documentation">Enrich OpenAPI documentation </h3><p>Give a summary of each property so the machine user can connect the dots predictively. Use attributes like <code>[Required]</code> and <code>[StringLength]</code> to enrich the document with data validation so AI knows the data limits. Sometimes you need to <a href="https://blog.elmah.io/custom-model-validation-attributes-in-asp-net-core/" rel="noreferrer">define an attribute for your business logic</a>.</p><h3 id="write-machine-readable-error-responses">Write machine-readable error responses</h3><p>Standard HTTP status codes are insufficient for AI agents. We need proper responses for both successful and error cases.</p><p>Invoice response class.</p><pre><code class="language-csharp">public class InvoiceResponse
{
    public Guid Id {get; set; }
    public string CustomerId {get; set; }
    public decimal Amount {get; set; }

    public string Currency {get; set; }
    public string Status {get; set; }

    public DateTime CreatedAt {get; set; }
    public string ClientReference { get; set; } = null!;
}</code></pre><p>Paginated response.</p><pre><code class="language-csharp">public record PaginatedResponse&lt;T&gt;(
    IReadOnlyList&lt;T&gt; Items,
    string? NextCursor
);</code></pre><p>For consistency and reusability, I added a generic response class.</p><p><strong>Step 5: Create Services</strong></p><pre><code class="language-csharp">public interface IInvoiceService
{
    Task&lt;PaginatedResponse&lt;InvoiceResponse&gt;&gt; GetInvoicesAsync(
        string? status,
        int limit,
        string? cursor,
        CancellationToken cancellationToken);

    Task&lt;InvoiceResponse&gt; CreateAsync(
        CreateInvoiceRequest request,
        string idempotencyKey,
        CancellationToken cancellationToken);

    Task&lt;InvoiceResponse&gt; GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task&lt;InvoiceResponse&gt; FinalizeAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task&lt;CancelInvoiceRequest&gt; CreateCancellationIntentAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task&lt;InvoiceResponse&gt; CancelAsync(
        CancelInvoiceRequest input,
        CancellationToken cancellationToken);
}</code></pre><p>The implementation.</p><pre><code class="language-csharp">public sealed class InvoiceService : IInvoiceService
{
    private readonly AppDbContext _db;

    public InvoiceService(AppDbContext db)
    {
        _db = db;
    }
    
    public async Task&lt;PaginatedResponse&lt;InvoiceResponse&gt;&gt; GetInvoicesAsync(
        string? status,
        int limit,
        string? cursor,
        CancellationToken cancellationToken)
    {
        if (limit &lt;= 0)
        {
            throw new ApiException(
                StatusCodes.Status400BadRequest,
                "INVALID_LIMIT",
                "Limit must be greater than zero.");
        }
    
        if (limit &gt; 100)
        {
            throw new ApiException(
                StatusCodes.Status400BadRequest,
                "LIMIT_TOO_LARGE",
                "Limit cannot be greater than 100.");
        }
    
        var query = _db.Invoices
            .AsNoTracking()
            .AsQueryable();
    
        // Optional status filter
        if (!string.IsNullOrWhiteSpace(status))
        {
            if (!Enum.TryParse&lt;InvoiceStatus&gt;(
                    status,
                    ignoreCase: true,
                    out var invoiceStatus))
            {
                throw new ApiException(
                    StatusCodes.Status400BadRequest,
                    "INVALID_STATUS",
                    $"Invalid invoice status '{status}'.");
            }
    
            query = query.Where(x =&gt; x.Status == invoiceStatus);
        }
    
        // Cursor pagination
        if (!string.IsNullOrWhiteSpace(cursor))
        {
            if (!Guid.TryParse(cursor, out var cursorId))
            {
                throw new ApiException(
                    StatusCodes.Status400BadRequest,
                    "INVALID_CURSOR",
                    "The supplied cursor is invalid.");
            }
    
            query = query.Where(x =&gt; x.Id &gt; cursorId);
        }
    
        var invoices = await query
            .OrderBy(x =&gt; x.Id)
            .Take(limit + 1)
            .ToListAsync(cancellationToken);
    
        var hasMore = invoices.Count &gt; limit;
    
        if (hasMore)
        {
            invoices = invoices.Take(limit).ToList();
        }
    
        var items = invoices
            .Select(MapToResponse)
            .ToList();
    
        string? nextCursor = null;
    
        if (hasMore &amp;&amp; invoices.Count &gt; 0)
        {
            nextCursor = invoices[^1].Id.ToString();
        }
    
        return new PaginatedResponse&lt;InvoiceResponse&gt;(
            items,
            nextCursor);
    }
    
    public async Task&lt;InvoiceResponse&gt; CreateAsync(
        CreateInvoiceRequest request,
        string idempotencyKey,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(idempotencyKey))
        {
            throw new ApiException(
                StatusCodes.Status400BadRequest,
                "IDEMPOTENCY_KEY_REQUIRED",
                "Idempotency-Key header is required.");
        }

        var existing = await _db.Invoices
            .AsNoTracking()
            .FirstOrDefaultAsync(
                x =&gt; x.ClientReference == request.ClientReference,
                cancellationToken);

        if (existing is not null)
        {
            return MapToResponse(existing);
        }

        var invoice = new Invoice
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            Amount = request.Amount,
            Currency = request.Currency.ToUpperInvariant(),
            ClientReference = request.ClientReference,
            Status = InvoiceStatus.Draft,
            CreatedAt = DateTime.UtcNow
        };

        _db.Invoices.Add(invoice);

        await _db.SaveChangesAsync(cancellationToken);

        return MapToResponse(invoice);
    }

    public async Task&lt;InvoiceResponse&gt; GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        var invoice = await _db.Invoices
            .AsNoTracking()
            .FirstOrDefaultAsync(
                x =&gt; x.Id == id,
                cancellationToken);

        if (invoice is null)
        {
            throw new ApiException(
                StatusCodes.Status404NotFound,
                "INVOICE_NOT_FOUND",
                "The requested invoice was not found.");
        }

        return MapToResponse(invoice);
    }

    public async Task&lt;InvoiceResponse&gt; FinalizeAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        var invoice = await _db.Invoices
            .FirstOrDefaultAsync(
                x =&gt; x.Id == id,
                cancellationToken);

        if (invoice is null)
        {
            throw new ApiException(
                StatusCodes.Status404NotFound,
                "INVOICE_NOT_FOUND",
                "The requested invoice was not found.");
        }

        if (invoice.Status != InvoiceStatus.Draft)
        {
            throw new ApiException(
                StatusCodes.Status409Conflict,
                "INVALID_INVOICE_STATE",
                "Only draft invoices can be finalized.");
        }

        invoice.Status = InvoiceStatus.Finalized;
        invoice.UpdatedAt = DateTime.UtcNow;

        await _db.SaveChangesAsync(cancellationToken);

        return MapToResponse(invoice);
    }

    public async Task&lt;CancelInvoiceRequest&gt;
        CreateCancellationIntentAsync(
            Guid id,
            CancellationToken cancellationToken)
    {
        var invoice = await _db.Invoices
            .AsNoTracking()
            .FirstOrDefaultAsync(
                x =&gt; x.Id == id,
                cancellationToken);

        if (invoice is null)
        {
            throw new ApiException(
                StatusCodes.Status404NotFound,
                "INVOICE_NOT_FOUND",
                "The requested invoice was not found.");
        }

        if (invoice.Status == InvoiceStatus.Paid)
        {
            throw new ApiException(
                StatusCodes.Status409Conflict,
                "INVOICE_ALREADY_PAID",
                "A paid invoice cannot be cancelled.");
        }

        if (invoice.Status == InvoiceStatus.Cancelled)
        {
            throw new ApiException(
                StatusCodes.Status409Conflict,
                "INVOICE_ALREADY_CANCELLED",
                "The invoice is already cancelled.");
        }

        return new CancelInvoiceRequest
        {
            IntentId = Guid.NewGuid(),
            InvoiceId = invoice.Id,
            ExpiresAt = DateTime.UtcNow.AddMinutes(5)
        };
    }

    public async Task&lt;InvoiceResponse&gt; CancelAsync(
        CancelInvoiceRequest input,
        CancellationToken cancellationToken)
    {
        if (input.IntentId == Guid.Empty)
        {
            throw new ApiException(
                StatusCodes.Status400BadRequest,
                "INVALID_INTENT",
                "A valid cancellation intent is required.");
        }

        var invoice = await _db.Invoices
            .FirstOrDefaultAsync(
                x =&gt; x.Id == input.InvoiceId,
                cancellationToken);

        if (invoice is null)
        {
            throw new ApiException(
                StatusCodes.Status404NotFound,
                "INVOICE_NOT_FOUND",
                "The requested invoice was not found.");
        }

        if (invoice.Status == InvoiceStatus.Paid)
        {
            throw new ApiException(
                StatusCodes.Status409Conflict,
                "INVOICE_ALREADY_PAID",
                "A paid invoice cannot be cancelled.");
        }

        if (invoice.Status == InvoiceStatus.Cancelled)
        {
            throw new ApiException(
                StatusCodes.Status409Conflict,
                "INVOICE_ALREADY_CANCELLED",
                "The invoice is already cancelled.");
        }

        invoice.Status = InvoiceStatus.Cancelled;
        invoice.UpdatedAt = DateTime.UtcNow;

        await _db.SaveChangesAsync(cancellationToken);

        return MapToResponse(invoice);
    }

    private static InvoiceResponse MapToResponse(
        Invoice invoice)
    {
        return new InvoiceResponse
        {
            Id = invoice.Id,
            CustomerId = invoice.CustomerId,
            Amount = invoice.Amount,
            Currency = invoice.Currency,
            Status = invoice.Status.ToString().ToLowerInvariant(),
            ClientReference = invoice.ClientReference,
            CreatedAt = invoice.CreatedAt
        };
    }
}</code></pre><h3 id="add-pagination-and-limits">Add pagination and limits</h3><p>Prevent agents from fetching thousands of records and burning the context by limiting record access. In the implementation above, I allowed 1 to 100 records to be fetched at a time, which is practical for a single fetch. Notice this part.</p><pre><code class="language-csharp">if (limit &lt;= 0)
{
    throw new ApiException(
        StatusCodes.Status400BadRequest,
        "INVALID_LIMIT",
        "Limit must be greater than zero.");
}

if (limit &gt; 100)
{
    throw new ApiException(
        StatusCodes.Status400BadRequest,
        "LIMIT_TOO_LARGE",
        "Limit cannot be greater than 100.");
}</code></pre><p>Here I throw exceptions with meaningful, contextual error messages when the input is out of limits. Next, while fetching, I get the paginated data.</p><pre><code class="language-csharp">.OrderBy(x =&gt; x.Id)
.Take(limit + 1)</code></pre><p>The cursor represents the position in the dataset, rather than a page number in traditional pagination. It is an opaque continuation token that tells the API where to resume retrieving a collection. Instead of asking an agent to calculate page numbers, the API returns a cursor with each response, and the agent sends that cursor back to retrieve the next batch. For operations that require bulk data, add a dedicated endpoint, but that rarely happens, and we usually don't transmit hundreds of rows over the network.</p><h3 id="designing-for-probabilistic-agent-behaviour">Designing for Probabilistic Agent Behaviour</h3><p>Because LLMs are probabilistic, agents can behave unpredictably, particularly in complex or long-running workflows. Clear empty-response semantics help agents distinguish between “no data exists” and “an error occurred.” Including links to related actions can further guide agents by exposing the possible next steps.</p><p>Define a custom exception</p><pre><code class="language-csharp">public class ApiException : Exception
{
    public int StatusCode { get; }
    public string ErrorCode { get; }

    public ApiException(
        int statusCode,
        string errorCode,
        string message)
        : base(message)
    {
        StatusCode = statusCode;
        ErrorCode = errorCode;
    }
}</code></pre><p>Create the exception handler.</p><pre><code class="language-csharp">using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace AgentReadyApi.Exceptions;
public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;

    public GlobalExceptionHandler(
        IProblemDetailsService problemDetailsService)
    {
        _problemDetailsService = problemDetailsService;
    }
    public async ValueTask&lt;bool&gt; TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var statusCode = exception is ApiException apiException
            ? apiException.StatusCode
            : StatusCodes.Status500InternalServerError;

        var errorCode = exception is ApiException apiEx
            ? apiEx.ErrorCode
            : "INTERNAL_SERVER_ERROR";

        httpContext.Response.StatusCode = statusCode;

        var problem = new ProblemDetails
        {
            Status = statusCode,

            Title = exception is ApiException
                ? exception.Message
                : "An unexpected error occurred.",

            Detail = exception is ApiException
                ? exception.Message
                : "An unexpected error occurred.",

            Type = $"https://api.invoice.com/problems/{errorCode}"
        };

        problem.Extensions["errorCode"] = errorCode;

        await _problemDetailsService.WriteAsync(
            new ProblemDetailsContext
            {
                HttpContext = httpContext,
                ProblemDetails = problem
            });

        return true;
    }
}</code></pre><pre><code class="language-csharp">builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler&lt;GlobalExceptionHandler&gt;();</code></pre><p>Add the middleware.</p><pre><code class="language-csharp">app.UseExceptionHandler();</code></pre><p>Now ASP.NET Core can produce RFC 7807-style responses.</p><pre><code class="language-JSON">{
  "type": "https://api.example.com/problems/invalid-invoice-state",
  "title": "Invalid invoice state",
  "status": 409,
  "detail": "A paid invoice cannot be cancelled.",
  "errorCode": "INVALID_INVOICE_STATE"
}</code></pre><p>Which is much better than sending "something went wrong".</p><h3 id="add-correlation-ids">Add correlation IDs</h3><p>Agents can make multiple requests and retries. You need to know which requests belong together.</p><pre><code class="language-csharp">public class CorrelationIdMiddleware
{
    private const string HeaderName = "X-Correlation-ID";

    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId =
            context.Request.Headers[HeaderName].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        context.Response.Headers[HeaderName] = correlationId;

        context.Items[HeaderName] = correlationId;

        await _next(context);
    }
}</code></pre><p>Inject the middleware.</p><pre><code class="language-csharp">app.UseMiddleware&lt;CorrelationIdMiddleware&gt;();</code></pre><h3 id="add-idempotency">Add Idempotency</h3><p>This is one of the most important parts for agents.</p><p>Suppose the agent sends:</p><pre><code>POST /api/invoices

Idempotency-Key: abc-123</code></pre><p>The network times out.</p><p>The agent doesn't know whether the invoice was created.</p><p>So it retries:</p><pre><code>POST /api/invoices

Idempotency-Key: abc-123</code></pre><p>Without idempotency:</p><pre><code>Invoice #1 created
Invoice #2 created</code></pre><p>which is bad.</p><p>With idempotency:</p><pre><code>Request 1 → Invoice #1 created
Request 2 → Existing Invoice #1 returned</code></pre><p>Use idempotency keys for operations that create irreversible side effects.</p><p>Create the <code>IdempotencyRecord</code> for the database table.</p><pre><code class="language-csharp">public class IdempotencyRecord
{
    [Key]
    public Guid Id { get; set; }

    public string Key { get; set; } = null!;

    public string RequestHash { get; set; } = null!;

    public int StatusCode { get; set; }

    public string ResponseBody { get; set; } = null!;

    public DateTime CreatedAt { get; set; }
}</code></pre><p>Add it in the DbContext</p><pre><code class="language-csharp">public DbSet&lt;IdempotencyRecord&gt; IdempotencyRecords =&gt; Set&lt;IdempotencyRecord&gt;();</code></pre><p>Add a unique index:</p><pre><code>modelBuilder.Entity&lt;IdempotencyRecord&gt;()
    .HasIndex(x =&gt; x.Key)
    .IsUnique();</code></pre><p>Create Controller</p><pre><code class="language-csharp">using AgentReadyApi.Data.Services;
using AgentReadyApi.Models.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

namespace AgentReadyApi.Controllers;
[ApiController]
[Route("api/invoice")]
public class InvoiceController : ControllerBase
{
    private readonly IInvoiceService _service;

    public InvoiceController(IInvoiceService service)
    {
        _service = service;
    }

    /// &lt;summary&gt;
    /// Lists invoices for the authenticated merchant.
    /// &lt;/summary&gt;
    [HttpGet]
    public async Task&lt;IActionResult&gt; GetInvoicesAsync(
        [FromQuery] string? status,
        [FromQuery] int limit = 20,
        [FromQuery] string? cursor = null,
        CancellationToken cancellationToken = default)
    {
        var result = await _service.GetInvoicesAsync(
            status,
            limit,
            cursor,
            cancellationToken);

        return Ok(result);
    }

    /// &lt;summary&gt;
    /// Creates a draft invoice.
    /// &lt;/summary&gt;
    [HttpPost]
    public async Task&lt;IActionResult&gt; CreateInvoiceAsync(
        [FromHeader(Name = "Idempotency-Key")]
        string idempotencyKey,
        CreateInvoiceRequest request,
        CancellationToken cancellationToken)
    {
        var result = await _service.CreateAsync(
            request,
            idempotencyKey,
            cancellationToken);

        return CreatedAtAction(
            nameof(GetInvoice),
            new { id = result.Id },
            new
            {
                result.Id,
                result.Status
            });
    }

    /// &lt;summary&gt;
    /// Gets an invoice by ID.
    /// &lt;/summary&gt;
    [HttpGet("{id:guid}")]
    public async Task&lt;IActionResult&gt; GetInvoice(
        Guid id,
        CancellationToken cancellationToken)
    {
        var invoice = await _service.GetByIdAsync(
            id,
            cancellationToken);

        return Ok(invoice);
    }

    /// &lt;summary&gt;
    /// Finalizes a draft invoice.
    /// &lt;/summary&gt;
    [HttpPost("{id:guid}/finalize")]
    public async Task&lt;IActionResult&gt; FinalizeAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        var result = await _service.FinalizeAsync(
            id,
            cancellationToken);

        return Ok(result);
    }

    /// &lt;summary&gt;
    /// Creates a cancellation intent.
    /// &lt;/summary&gt;
    [HttpPost("{id:guid}/cancellation-intent")]
    public async Task&lt;IActionResult&gt; CreateCancellationIntentAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        var intent = await _service.CreateCancellationIntentAsync(
            id,
            cancellationToken);

        return Ok(intent);
    }

    /// &lt;summary&gt;
    /// Cancels an invoice using a previously created intent.
    /// &lt;/summary&gt;
    [HttpPost("cancel")]
    public async Task&lt;IActionResult&gt; CancelAsync(
        [FromBody] CancelInvoiceRequest request,
        CancellationToken cancellationToken)
    {
        var result = await _service.CancelAsync(
            request,
            cancellationToken);

        return Ok(result);
    }
}</code></pre><p>With the create endpoint, we have <code>[FromHeader(Name = "Idempotency-Key")]</code> string idempotency key to implement idempotency. Cancellation intent is the alternative to delete because an AI agent could accidentally delete something.</p><p>Define each endpoint with a clear name and precise intent, rather than vague verbs.</p><h3 id="name-everything-consistently">Name everything consistently</h3><p>Maintain a consistent naming convention across the application. Inconsistent names can confuse the agent. If you use userId at one place, then don't do <code>user_id</code> or <code>userID</code> elsewhere, but stick to one rule.</p><h3 id="add-async-processing">Add async processing</h3><p>Make all operations that may wait asynchronous. Don't let agents wait 30 seconds by stopping the thread, although this is <a href="https://blog.elmah.io/demystifying-async-void-in-c-why-its-dangerous-and-when-its-okay/" rel="noreferrer">generally recommended</a> for a human audience too.</p><h3 id="llm-optimized-metadata">LLM-Optimized Metadata</h3><p>Another popular piece of information for AI agents is LLM-optimized metadata in the llms.txt file. The text file resides in the project's root directory and works as an index or guide for OpenAPI. Hundreds of organizations including Anthropic, Perplexity and Cursor, have adopted <code>llms.txt</code>.</p><p>Create a <code>llms.txt</code> file in the wwwroot directory. Put the metadata inside the file.</p><pre><code class="language-txt"># My API

&gt; REST API for managing customers, invoices, payments and orders.

## API Documentation

- OpenAPI specification: /openapi/v1.json
- Interactive documentation: /swagger

## Main API Areas

- Customers: /api/customers
- Invoices: /api/invoices
- Payments: /api/payments
- Orders: /api/orders

## Authentication

OAuth 2.0 / OpenID Connect.

## Agent Guidance

- Use the OpenAPI specification for the complete list of operations.
- Respect required permissions and scopes.
- Use Idempotency-Key for state-changing operations where required.
- Errors are returned using RFC 7807 Problem Details.</code></pre><p>Although we only created the invoice controller here, a real project usually has many more namespaces and controllers, like customers, payments, etc.</p><p>Allow static files in <code>Progam.cs</code></p><pre><code class="language-csharp">app.UseStaticFiles();</code></pre><p><code>llms.txt</code> is a discovery document for the agents, while OpenAPI is a machine-readable contract of your API. I can access the text 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/09/image-1.png" class="kg-image" alt="llms.txt" loading="lazy" width="1333" height="574" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/09/image-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/09/image-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/image-1.png 1333w" sizes="(min-width: 720px) 720px"></figure><p><strong>Run and test APIs</strong></p><p>Create endpoint.</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/08/image-20.png" class="kg-image" alt="Create" loading="lazy" width="1197" height="520" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-20.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-20.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-20.png 1197w" sizes="(min-width: 720px) 720px"></figure><p>Call it.</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/08/image-23.png" class="kg-image" alt="Response" loading="lazy" width="1357" height="424" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-23.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-23.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-23.png 1357w" 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/08/image-18.png" class="kg-image" alt="Body" loading="lazy" width="1352" height="442" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-18.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-18.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-18.png 1352w" sizes="(min-width: 720px) 720px"></figure><p>Where I used an idempotency key</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/08/image-19.png" class="kg-image" alt="Header" loading="lazy" width="485" height="186"></figure><p>Check GET methods for getting records</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/08/image-21.png" class="kg-image" alt="Get" loading="lazy" width="1223" height="497" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-21.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-21.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-21.png 1223w" sizes="(min-width: 720px) 720px"></figure><p>So we can access our new record </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/08/image-22.png" class="kg-image" alt="Response" loading="lazy" width="1351" height="436" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-22.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-22.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-22.png 1351w" sizes="(min-width: 720px) 720px"></figure><p>Similarly, we have other endpoints.</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/08/image-24.png" class="kg-image" alt="GetById" loading="lazy" width="1222" height="455" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-24.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-24.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-24.png 1222w" sizes="(min-width: 720px) 720px"></figure><h3 id="add-authentication-for-non-human-consumers">Add authentication for Non-Human Consumers</h3><p>Authentication is already recommended for any project, however, in an AI-ready app, it becomes a necessity. You have to secure your API because AI should be authenticated to access the app's internal data.</p><p>Standard OAuth 2.0 and OpenID Connect flows work here, as agents work best with established standards.</p><pre><code class="language-csharp">builder.Services
    .AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =&gt;
    {
        options.Authority =
            "https://auth.example.com";

        options.Audience =
            "agent-api";

        options.RequireHttpsMetadata = true;
    });</code></pre><p>Similarly, use the middleware</p><pre><code class="language-csharp">app.UseAuthentication();</code></pre><h3 id="define-fine-grained-permissions-for-agents">Define fine-grained permissions for agents</h3><p>Mostly, agents should not be given full API access. Use role-based access control (RBAC) to distinguish between agent types, where a read-only information retriever needs a different set of permissions while a planning agent needs broader write access. <a href="https://blog.elmah.io/how-to-implement-customized-role-permissions-in-asp-net-core/" rel="noreferrer">Adding an authorization policy with fine-grained permission</a> scoping prevents overprivileged agents.</p><pre><code class="language-csharp">builder.Services.AddAuthorization(options =&gt;
{
    options.AddPolicy(
        "InvoicesRead",
        policy =&gt; policy.RequireClaim(
            "scope",
            "invoices:read"));

    options.AddPolicy(
        "InvoicesWrite",
        policy =&gt; policy.RequireClaim(
            "scope",
            "invoices:write"));
});</code></pre><p>For the invoices, I have added two explicit permissions: read and write. Well, you may not hardcode them for a scalable application, there <a href="https://blog.elmah.io/how-to-implement-customized-role-permissions-in-asp-net-core/" rel="noreferrer">you need more customization and dynami</a>sm. Add to the pipeline.</p><pre><code class="language-csharp">app.UseAuthorization();</code></pre><p>Decorate the endpoints.</p><pre><code class="language-csharp">[Authorize(Policy = "InvoicesRead")]
[HttpGet]
public async Task&lt;IActionResult&gt; GetInvoicesAsync(
	[FromQuery] string? status,
	[FromQuery] int limit = 20,
	[FromQuery] string? cursor = null,
	CancellationToken cancellationToken = default) ...

[Authorize(Policy = "InvoicesWrite")]
[HttpPost]
public async Task&lt;IActionResult&gt; CreateInvoiceAsync(
	[FromHeader(Name = "Idempotency-Key")]
	string idempotencyKey,
	CreateInvoiceRequest request,
	CancellationToken cancellationToken) ...

[Authorize(Policy = "InvoicesRead")]
[HttpGet("{id:guid}")]
public async Task&lt;IActionResult&gt; GetInvoice(
	Guid id,
	CancellationToken cancellationToken) ...

[Authorize(Policy = "InvoicesWrite")]
[HttpPost("{id:guid}/finalize")]
public async Task&lt;IActionResult&gt; FinalizeAsync(
	Guid id,
	CancellationToken cancellationToken) ...
	
[Authorize(Policy = "InvoicesWrite")]
[HttpPost("{id:guid}/cancellation-intent")]
public async Task&lt;IActionResult&gt; CreateCancellationIntentAsync(
	Guid id,
	CancellationToken cancellationToken) ...

[Authorize(Policy = "InvoicesWrite")]
[HttpPost("cancel")]
public async Task&lt;IActionResult&gt; CancelAsync(
	[FromBody] CancelInvoiceRequest request,
	CancellationToken cancellationToken) ...
</code></pre><h2 id="architectural-considerations-for-ai-agent-integration">Architectural Considerations for AI Agent Integration</h2><p>AI agents introduce a different set of requirements than traditional API consumers. They can generate unpredictable traffic, execute multi-step workflows, consume large amounts of data, and operate with a degree of autonomy. Designing APIs for these workloads therefore requires consideration of scalability, resilience, security, and how agents discover and interact with available capabilities.</p><h3 id="keep-human-approval-involved">Keep human approval involved</h3><p>Along with authentication and authorization, the system should have human verification involved for any operation that updates sensitive or critical data or has an irreversible impact, keep human approval in the loop and don't let the agent execute the full workflow autonomously. We restrict agents with authorization as a low-privileged user, so similar to a low-privileged user, you usually add approvals from a higher authority.</p><h3 id="managing-bursty-agent-traffic">Managing Bursty Agent Traffic </h3><p>A particularly important aspect in AI-friendly APIs is that agentic traffic can be bursty and difficult to predict. An AI agent may call several endpoints to complete one task, such as fetching data, validating it, and then updating it, while a human user would do the same thing in fewer calls through the UI. Dynamic <a href="https://blog.elmah.io/new-in-net-10-and-c-14-multi-tenant-rate-limiting/" rel="noreferrer">rate limiting</a>, concurrency limits, and tiered quotas can help prevent agent traffic from overwhelming infrastructure or affecting human users. <a href="https://blog.elmah.io/how-to-bulk-insert-with-efcore/" rel="noreferrer">Bulk-operation</a> endpoints can further reduce unnecessary requests by allowing agents to process multiple items within a single operation rather than repeatedly calling individual endpoints. <a href="https://blog.elmah.io/elmah-io-documentation/" rel="noreferrer">Comprehensive logging and monitoring</a> of agent interactions also helps identify unusual patterns and potential abuse early.</p><h3 id="ethical-data-handling-for-ai-agents">Ethical Data Handling for AI Agents</h3><p>Machines are intelligent but do not always follow ethical guidelines like us humans. For an application, it is increasingly important that agents collect only the data they need. Decision-makers should minimize how long sensitive information is retained, and anonymize data when long-term storage is required. Data-handling practices should also be clearly documented to define clear boundaries for access. As agents use API data more for reasoning and decision-making, transparency around data sources, privacy, and potential bias becomes an important part of API design rather than merely a compliance requirement.</p><h3 id="model-context-protocol-mcp">Model Context Protocol (MCP) </h3><p>With the inception of AI, another API layer is introduced: MCP. MCP standardizes communication between AI clients and applications, safely abstracting the API to AI agents. <code>ModelContextProtocol.AspNetCore</code> package <a href="https://blog.elmah.io/building-your-first-mcp-server-in-asp-net-core/" rel="noreferrer">provides easy creation of MCP</a> or you can use a reliable provider to make your project AI-ready.</p><h3 id="infrastructure-for-agent-driven-scale">Infrastructure for Agent-Driven Scale</h3><p>Keep in mind that, unlike human users, a single agent can make hundreds of requests in a short period during a multi-step task. Try horizontal scaling, intelligent load balancing, <a href="https://blog.elmah.io/caching-strategies-in-asp-net-core/" rel="noreferrer">caching strategies</a>, and queue-based processing in the backend before traffic spikes degrade the experience for human consumers.</p><h3 id="building-agent-friendly-abstractions">Building Agent-Friendly Abstractions</h3><p>An API should not change so dramatically that AI finds it difficult to evolve and understand. Plugin architectures and function-oriented interfaces are good choices, letting you add new capabilities without disrupting existing workflows. More importantly, these abstractions should represent meaningful business capabilities rather than exposing low-level technical operations. Agents generally reason in terms of goals and actions, not database tables or internal implementation details.</p><h2 id="conclusion">Conclusion</h2><p>The way software interacts with APIs is evolving. Traditionally, developers wrote the logic that determined which endpoint to call, when to call it, and how to handle the response. AI agents can increasingly make those decisions themselves. Agents like coding agents, booking agents, business agents, and application bots complete tasks on behalf of the user. Your API project should catch this train. Making an API agent-ready isn't about adding an AI endpoint. It's about making the existing API predictable, discoverable, secure, and understandable to machines. I dug into some important foundations that make your backend agent-friendly.</p><p>Code: <a href="https://github.com/elmahio-blog/AgentReadyApi.git">https://github.com/elmahio-blog/AgentReadyApi.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Swagger vs. Scalar: A comparison for ASP.NET Core developers ]]></title>
        <description><![CDATA[ Swagger UI used to ship by default with ASP.NET Core API projects, but recent .NET versions replaced it with a bare OpenAPI document. This post compares Swagger and Scalar as documentation UIs, walks through setting up each one in .NET 10, and covers when to pick one over the other. ]]></description>
        <link>https://blog.elmah.io/swagger-vs-scalar-a-comprehensive-comparison-for-asp-net-core-developers/</link>
        <guid isPermaLink="false">6a785b30f2cded0001a18cab</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 22 Sep 2026 08:10:55 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/swagger-vs-scalar-a-comprehensive-comparison-for-asp.net-core-developers-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/swagger-vs-scalar-a-comprehensive-comparison-for-asp-net-core-developers/">https://blog.elmah.io/swagger-vs-scalar-a-comprehensive-comparison-for-asp-net-core-developers/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>Swagger UI used to come bundled with every new ASP.NET Core Web API project. Recent .NET versions replaced it with a bare OpenAPI document, leaving you to pick your own documentation UI, and Scalar has become one of the most popular choices. This post compares Swagger and Scalar, and walks through setting up each one in .NET 10.</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/08/swagger-vs-scalar-a-comprehensive-comparison-for-asp.net-core-developers-o-1.png" class="kg-image" alt="Swagger vs. Scalar: A comprehensive comparison for ASP.NET Core developers" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/swagger-vs-scalar-a-comprehensive-comparison-for-asp.net-core-developers-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/swagger-vs-scalar-a-comprehensive-comparison-for-asp.net-core-developers-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/swagger-vs-scalar-a-comprehensive-comparison-for-asp.net-core-developers-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><p>You should test APIs before giving them to the frontend team. API testing and documenting them together is a tedious task, especially if the application is large enough. Many people prefer Postman documentation. However, there are graphical representations too that require a little less effort than documenting and testing your APIs. Swagger has been a long-time partner in interactive API documentation. The .NET 8 API template included Swashbuckle by default to provide both the OpenAPI JSON and the Swagger UI. However, later versions have brought novelty. The API template now has a default OpenAPI document, which developers typically pair with modern UI tools like Scalar.</p><h2 id="difference-between-swagger-and-swagger-ui">Difference between Swagger and Swagger UI</h2><p>There is a lot of confusion about the exact separation, even though I myself used to think they are the same. Swagger is an open-source ecosystem of tools developed by SmartBear. It provides tools for designing, building, and testing APIs using the OpenAPI specification.</p><ul><li><strong>Swagger UI</strong>: A web-based interface consuming the OpenAPI document. It allows developers to visualize and test endpoints.</li><li><strong>Swagger Editor</strong>: A visual OpenAPI editor that allows writing and editing in YAML or JSON formats in a user-friendly manner.</li><li><strong>Swagger Codegen</strong>: An engine to generate server stubs and client SDKs in 40+ languages directly from OpenAPI definitions.</li><li><strong>SwaggerHub</strong>: A commercial platform for API design, collaboration, and governance of enterprise-grade API lifecycle management.</li></ul><h2 id="why-did-microsoft-remove-swashbuckle-from-its-default-template">Why did Microsoft remove Swashbuckle from its default template?</h2><p>Swashbuckle was a long-time companion of the ASP.NET Core standard and Microsoft's default for OpenAPI documentation. However, declining maintenance and departing contributors led to issues, and it had poor .NET 8 support. Microsoft removed it from default templates in .NET 9. In .NET 10, the third-party UI is no longer bundled by default in new Web API templates. Hence, .NET prioritizes developer experience and modernity by removing Swagger UI by default and allowing developers to opt for any other library that supports an OpenAPI document. Note that Scalar didn't replace OpenAPI; it provides a modern documentation UI for visualizing and interacting with your OpenAPI description.</p><h2 id="implementing-swagger-ui-and-scalar-in-aspnet-core-api">Implementing Swagger UI and Scalar in ASP.NET Core API</h2><p>Here's how each one is implemented in ASP.NET Core.</p><p><strong>Step 1: Create project</strong></p><pre><code class="language-console">dotnet new webapi SwaggerVsScalar
cd SwaggerVsScalar</code></pre><p>As I am working on .NET 10, the default <code>Program.cs</code> will look.</p><pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =&gt;
    {
        var forecast = Enumerable.Range(1, 5).Select(index =&gt;
                new WeatherForecast
                (
                    DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                    Random.Shared.Next(-20, 55),
                    summaries[Random.Shared.Next(summaries.Length)]
                ))
            .ToArray();
        return forecast;
    })
    .WithName("GetWeatherForecast");

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF =&gt; 32 + (int)(TemperatureC / 0.5556);
}</code></pre><p>As you can see, OpenAPI support is built in by default.</p><h3 id="add-swagger-ui"><strong>Add Swagger UI</strong></h3><p>As we already have an endpoint, we can display it in Swagger.</p><p><strong>Step 2: Add the package</strong></p><pre><code class="language-console">dotnet add package Swashbuckle.AspNetCore</code></pre><p><strong>Step 3: Configure Program.cs</strong></p><pre><code class="language-csharp">using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();


builder.Services.AddSwaggerGen(options =&gt;
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Swagger Demo API",
        Version = "v1",
        Description = "Demo API for comparing Swagger and Scalar"
    });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwagger();

    app.UseSwaggerUI(options =&gt;
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "Swagger Demo API v1");
    });
}

app.UseHttpsRedirection();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =&gt;
    {
        var forecast = Enumerable.Range(1, 5).Select(index =&gt;
                new WeatherForecast
                (
                    DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                    Random.Shared.Next(-20, 55),
                    summaries[Random.Shared.Next(summaries.Length)]
                ))
            .ToArray();
        return forecast;
    })
    .WithName("GetWeatherForecast");

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF =&gt; 32 + (int)(TemperatureC / 0.5556);
}</code></pre><p><code>Swashbuckle.AspNetCore</code> provides <code>AddSwaggerGen()</code> to register a service in the generation of the OpenAPI document, while <code>SwaggerDoc</code> specifies that an OpenAPI document called <code>v1</code> should be generated using this metadata.</p><p><code>app.UseSwagger()</code> makes the generated OpenAPI document available through HTTP and accessible at <code>/swagger/v1/swagger.json</code>.</p><p>The <code>app.UseSwaggerUI</code> is the actual Swagger UI and creates a web interface reading the document via the given path <code>/swagger/v1/swagger.json</code>. Finally, it displays it as an interactive API documentation page.</p><p><strong>Step 4: Run the project</strong></p><pre><code class="language-console">dotnet run</code></pre><p>The Swagger UI is visible.</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/08/image-1.png" class="kg-image" alt="Swagger" loading="lazy" width="1325" height="657" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-1.png 1325w" sizes="(min-width: 720px) 720px"></figure><h3 id="add-scalar-ui"><strong>Add Scalar UI</strong></h3><p><strong>Step 2: Add the package</strong></p><pre><code class="language-console">dotnet add package Scalar.AspNetCore</code></pre><p><strong>Step 3: Configure program file</strong></p><pre><code class="language-csharp">using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.UseHttpsRedirection();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =&gt;
    {
        var forecast = Enumerable.Range(1, 5).Select(index =&gt;
                new WeatherForecast
                (
                    DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                    Random.Shared.Next(-20, 55),
                    summaries[Random.Shared.Next(summaries.Length)]
                ))
            .ToArray();
        return forecast;
    })
    .WithName("GetWeatherForecast");

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF =&gt; 32 + (int)(TemperatureC / 0.5556);
}</code></pre><p><code>app.MapScalarApiReference()</code> registers Scalar's API documentation UI as an endpoint in the ASP.NET Core application. Navigating to <code>/scalar</code> will open the Scalar interface by reading the document exposed by <code>app.MapOpenApi()</code>.</p><p><strong>Step 4: Run the project</strong></p><pre><code class="language-console">dotnet run</code></pre><p>Now, the UI is accessible.</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/08/image-3.png" class="kg-image" alt="Scalar UI" loading="lazy" width="1354" height="611" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-3.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-3.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-3.png 1354w" sizes="(min-width: 720px) 720px"></figure><p>In the top-left corner is the menu that displays all endpoints using the OpenAPI 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/08/image-4.png" class="kg-image" alt="Scalar UI" loading="lazy" width="1341" height="409" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-4.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-4.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-4.png 1341w" sizes="(min-width: 720px) 720px"></figure><p>Scroll down to where the endpoints are displayed so we can test one.</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/08/image-5.png" class="kg-image" alt="Endpoint" loading="lazy" width="1287" height="482" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-5.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-5.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-5.png 1287w" sizes="(min-width: 720px) 720px"></figure><p>Let's test by clicking the Test Request button.</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/08/image-6.png" class="kg-image" alt="Calling API" loading="lazy" width="1353" height="436" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-6.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-6.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-6.png 1353w" sizes="(min-width: 720px) 720px"></figure><p>Here's the response I got.</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/08/image-7.png" class="kg-image" alt="Response" loading="lazy" width="1359" height="375" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-7.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-7.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-7.png 1359w" 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/08/image-8.png" class="kg-image" alt="Response" loading="lazy" width="1339" height="270" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-8.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-8.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-8.png 1339w" sizes="(min-width: 720px) 720px"></figure><h3 id="attach-openapi-metadata-to-the-endpoint">Attach OpenAPI metadata to the endpoint</h3><p>You can add more details to the endpoint for the OpenAPI document.</p><pre><code class="language-csharp">app
    .MapGet("/weatherforecast", () =&gt;
    {
        var forecast = Enumerable.Range(1, 5).Select(index =&gt;
                new WeatherForecast
                (
                    DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                    Random.Shared.Next(-20, 55),
                    summaries[Random.Shared.Next(summaries.Length)]
                ))
            .ToArray();
        return forecast;
    })
    .WithName("GetWeatherForecast")
    .WithSummary("weather data for the state")
    .WithDescription("This endpoint returns weather report for different counties.")
    .WithTags("My place")
    .Produces(200, typeof(object))
    .Produces(400)
    .ProducesProblem(500)
    .RequireAuthorization();</code></pre><p><code>WithSummary</code> provides a short summary for the endpoint, while <code>WithDescription</code> adds a longer description. <code>WithTags</code> categorizes endpoints, while the <code>Produces</code> fluent methods define successful and failure responses.</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/08/image-12.png" class="kg-image" alt="Scalar API" loading="lazy" width="1279" height="514" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-12.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-12.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-12.png 1279w" sizes="(min-width: 720px) 720px"></figure><h3 id="import-the-specification-into-postman"><strong>Import the Specification into Postman</strong></h3><p>The raw specification will be accessible at <code>/openapi/v1.json</code>, which is <a href="http://localhost:5282/openapi/v1.json">http://localhost:5282/openapi/v1.json</a> in our case.</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/08/image-11.png" class="kg-image" alt="API JSON" loading="lazy" width="1072" height="600" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-11.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-11.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-11.png 1072w" sizes="(min-width: 720px) 720px"></figure><p>Postman can automatically build request collections from this native endpoint using the following steps:</p><ol><li>Open Postman and select the&nbsp;Import&nbsp;button in the top-left sidebar.</li><li>Either import via Link: Choose the&nbsp;Link&nbsp;tab and paste the local URL.</li><li>Or via File: Save the JSON from the browser, then drag the file into the Postman import window.</li></ol><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-9.png" class="kg-image" alt="Import" loading="lazy" width="843" height="500" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-9.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-9.png 843w" sizes="(min-width: 720px) 720px"></figure><p>Our API document is imported.</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/08/image-10.png" class="kg-image" alt="Postman" loading="lazy" width="340" height="297"></figure><h3 id="add-a-descriptive-document-with-api-controller">Add a descriptive document with API controller</h3><p>Now, we've seen how the minimal API looks with Scalar. Although for Swagger, you may already know what it looks like with controllers. I will show how Scalar works with the API.</p><p><strong>Step 1: Create model</strong></p><pre><code class="language-csharp">namespace SwaggerVsScalar.Models;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}</code></pre><p><strong>Step 2: Add controller</strong></p><pre><code class="language-csharp">using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SwaggerVsScalar.Models;

[ApiController]
[Route("api/[controller]")]
[Tags("Products")]
public class ProductsController : ControllerBase
{
    private readonly List&lt;Product&gt; _products;

    public ProductsController()
    {
        _products = new List&lt;Product&gt;
        {
            new Product { Id = 1, Name = "Matcha" },
            new Product { Id = 2, Name = "Cappuccino" }
        };
    }

    [HttpPost]
    [EndpointName("CreateProduct")]
    [EndpointSummary("Creates a new product")]
    [EndpointDescription("This endpoint creates a new product and returns the created product.")]
    [ProducesResponseType(typeof(Product), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public IActionResult CreateProduct(Product input)
    {
        _products.Add(input);

        return CreatedAtAction(
            nameof(GetProduct),
            new { id = input.Id },
            input);
    }

    [HttpGet]
    [EndpointName("GetProducts")]
    [EndpointSummary("Gets all products")]
    [EndpointDescription("This endpoint returns all available products.")]
    [ProducesResponseType(typeof(IEnumerable&lt;Product&gt;), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public IActionResult GetProducts()
    {
        return Ok(_products);
    }

    [HttpGet("{id}")]
    [EndpointName("GetProduct")]
    [EndpointSummary("Gets a product by ID")]
    [EndpointDescription("This endpoint returns a product matching the specified ID.")]
    [ProducesResponseType(typeof(Product), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public IActionResult GetProduct(int id)
    {
        var result = _products.FirstOrDefault(x =&gt; x.Id == id);

        if (result is null)
            return NotFound();

        return Ok(result);
    }
}</code></pre><p>Here, we have a controller with some in-memory values. A noticeable addition is the description attributes <code>[EndpointName]</code>, <code>[EndpointSummary]</code>, <code>[EndpointDescription]</code>, and <code>[ProducesResponseType]</code>, which are alternatives to the fluent API methods we used with the minimal API.</p><p><strong>Step 3: Configure Program.cs</strong></p><p>There won't be much change from our previous setup. We just need to inject controllers so the API controllers get mapped. The final file looks like this:</p><pre><code class="language-csharp">using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();

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

app.UseHttpsRedirection();

app.Run();</code></pre><p>After running the project, we get the following 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/08/image-13.png" class="kg-image" alt="Scalar UI" loading="lazy" width="1274" height="321" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-13.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-13.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-13.png 1274w" sizes="(min-width: 720px) 720px"></figure><p>Each endpoint looks like this.</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/08/image-14.png" class="kg-image" alt="Create" loading="lazy" width="1267" height="554" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-14.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-14.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-14.png 1267w" sizes="(min-width: 720px) 720px"></figure><p>Get All.</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/08/image-15.png" class="kg-image" alt="Get All" loading="lazy" width="1269" height="558" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-15.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-15.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-15.png 1269w" sizes="(min-width: 720px) 720px"></figure><p>GetById 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/08/image-16.png" class="kg-image" alt="GetById" loading="lazy" width="1264" height="544" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-16.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/image-16.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-16.png 1264w" sizes="(min-width: 720px) 720px"></figure><p>Finally, we can see the model.</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/08/image-17.png" class="kg-image" alt="Models" loading="lazy" width="741" height="346" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/image-17.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/image-17.png 741w" sizes="(min-width: 720px) 720px"></figure><h2 id="when-to-pick-swagger-and-when-to-pick-scalar">When to pick Swagger and when to pick Scalar?</h2><p>We have to peel back the main discussion point: if Swagger is legendary and Scalar is modern, when to choose which? Scalar offers a modern developer experience with its search-oriented interface. Scalar specifically highlights responsive UI, dark mode, navigation, and improved handling of large APIs. Its built-in sidebar navigation for instant search is an advantage over the standard Swagger UI experience. Moreover, Scalar maintains request history, environment variables, code snippets and an advanced request configuration. You can customize themes with a wide range of options available by default. Such features make it a competitive choice for modern applications, a choice earlier competitors lacked.</p><p>Swagger is a mature and trusted option used by legacy systems. It has been powering APIs since 2011 and has earned developers' familiarity. It is simple and ideal for users who want to "Try it out". For a small and simple API project, Swagger suits better without adding a new tool to navigate and learn. Besides, an existing system that already uses Swagger shouldn't rush to switch to a new option, where the team may get tangled with learning another tool.</p><h2 id="conclusion">Conclusion</h2><p>Scalar is a transformation in API documentation. For a long time, Swagger has been the default option for ASP.NET Core APIs that were developed using the template. However, in recent versions, Microsoft has removed the default Swagger and provided an OpenAPI document by default. The NuGet store offers several options for consuming the document, among which Scalar has emerged as a futuristic solution. Its features range from customized UI themes to search-friendly navigation. In this post, we discussed in depth the differences between Swagger and Scalar. We implemented each of them in .NET 10 to see the actual visual differences between them.</p><p>Code: <a href="https://github.com/elmahio-blog/SwaggerVsScalar">https://github.com/elmahio-blog/SwaggerVsScalar</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Using C# 14 to build &quot;Smart records&quot; for config &amp; settings models ]]></title>
        <description><![CDATA[ Traditional config classes let properties be mutated anywhere and accept invalid values with no warning. This article shows how to use C# 14&#39;s new extension-block syntax with records to build &quot;smart records&quot; - immutable, self-validating configuration models for cleaner, safer ASP.NET Core apps. ]]></description>
        <link>https://blog.elmah.io/using-c-14-to-build-smart-records-for-config-settings-models/</link>
        <guid isPermaLink="false">6a61b1f92d7775000131f002</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 08 Sep 2026 07:44:39 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/using-csharp-14-to-build-smart-records-for-config-and-settings-models-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/using-c-14-to-build-smart-records-for-config-settings-models/">https://blog.elmah.io/using-c-14-to-build-smart-records-for-config-settings-models/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>Think of your application's configuration as the <strong>control panel of an aircraft</strong>. Every switch, dial, and indicator influences how the system operates. A single incorrect setting can change the application's behavior in unexpected ways. In this post, I will build configuration models that are safer, cleaner, and easier to maintain using modern C# features.</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/08/using-csharp-14-to-build-smart-records-for-config-and-settings-models-o-1.png" class="kg-image" alt="Using C# 14 to build &quot;Smart records&quot; for config &amp; settings models" loading="lazy" width="1500" height="750" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/08/using-csharp-14-to-build-smart-records-for-config-and-settings-models-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/using-csharp-14-to-build-smart-records-for-config-and-settings-models-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/using-csharp-14-to-build-smart-records-for-config-and-settings-models-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><p>The first choice that comes to mind is mutable POCO (Plain Old CLR Object) classes for configuration. That intuition does the job, but its values can be changed unexpectedly or accept invalid values. With records, you can create configuration models that behave more like a protected cockpit. C# records enable configuration values to be validated and immutable, designed to prevent mistakes before takeoff.</p><h2 id="why-do-traditional-configuration-classes-become-a-problem">Why do traditional configuration classes become a problem?</h2><p>A typical configuration class often looks like this:</p><pre><code class="language-csharp">public class DatabaseSettings 
{ 
  public string ConnectionString { get; set; } 
  public int CommandTimeout { get; set; } 
  public bool EnableRetry { get; set; } 
}</code></pre><p>Although it looks simple, this design has several drawbacks.</p><p>Properties can be modified anywhere, leading to object inconsistency. No validation exists, and invalid values are accepted. It has no computed properties for a custom representation of the values. For any invalid value, the application will continue until it encounters the problem caused by the value.</p><p><a href="https://blog.elmah.io/exploring-c-records-and-their-use-cases/" rel="noreferrer">Records are data models</a> which by nature are immutable, making them ideal for configuration values. That is a big win for records, as we can only copy a record using the <code>with</code> keyword, but the original object remains untouched. Also, records can validate themselves, preventing invalid inputs while keeping the options pattern alive. Such validations not only keep technical blunders away, but implement business logic as well. They also work beautifully with pattern matching, so we can compare values cleanly. For all these reasons, I have called it smart records.</p><h2 id="using-smart-records-for-appsettings-configuration-in-aspnet-core">Using smart records for appsettings configuration in ASP.NET Core</h2><p><strong>Step 1: Create Api project.</strong></p><pre><code class="language-console">dotnet new webapi -n SmartRecordApi</code></pre><p><strong>Step 2: Add Configuration</strong></p><p>In <code>appsettings.json</code> add the following configuration:</p><pre><code class="language-json">"WeatherSettings": {
  "City": "Karachi",
  "TemperatureUnit": "Celsius",
  "RefreshInterval": 30,
  "EnableForecast": true
}</code></pre><p><strong>Step 3: Create a smart record</strong></p><p>Create a new file named <code>Configuration/WeatherSettings.cs</code>:</p><pre><code class="language-csharp">namespace SmartRecordDemo.Configuration;

public record WeatherSettings
{
    public required string City { get; init; }
    public required string TemperatureUnit { get; init; }
    public int RefreshInterval { get; init; }
    public bool EnableForecast { get; init; }
}

public static class WeatherSettingsExtensions
{
    extension(WeatherSettings settings)
    {
        public void Validate()
        {
            if (string.IsNullOrWhiteSpace(settings.City))
                throw new ArgumentException("City is required.");

            if (settings.RefreshInterval &lt;= 0)
                throw new ArgumentException(
                    "RefreshInterval must be greater than zero.");

            if (settings.TemperatureUnit is not ("Celsius" or "Fahrenheit"))
                throw new ArgumentException(
                    "TemperatureUnit must be Celsius or Fahrenheit.");
        }

        public string DisplayName =&gt;
            $"{settings.City} ({settings.TemperatureUnit})";
    }
}</code></pre><p>C#14 brought a new syntax for extension methods. Inside, <code>DisplayName</code> is a computed property for display, while <code>Validate</code> is a helper Property. The <code>Validate</code> method includes different checks to ensure self-data validity.</p><p><strong>Step 4: Register the options pattern</strong></p><p>In <code>Program.cs</code> include the following code:</p><pre><code class="language-csharp">using SmartRecordApi.Configurations;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

builder.Services
    .AddOptions&lt;WeatherSettings&gt;()
    .Bind(builder.Configuration.GetSection("WeatherSettings"))
    .PostConfigure(settings =&gt; settings.Validate());

builder.Services.AddControllers();

var app = builder.Build();

app.MapControllers();

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

app.UseHttpsRedirection();

app.Run();</code></pre><p><code>Bind()</code> reads from <code>appsettings.json</code> while <code>PostConfigure()</code> validates the settings after binding. Here, I called the <code>Validate</code> method at registration. As I will declare a new controller and will not use minimal API, I had to add <code>builder.Services.AddControllers</code> and <code>app.MapControllers</code>.</p><p><strong>Step 5: Add a controller</strong></p><pre><code class="language-csharp">using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SmartRecordApi.Configurations;

namespace SmartRecordApi.Controllers;

[ApiController]
[Route("api/weather")]
public class WeatherController : ControllerBase
{
    private readonly WeatherSettings _settings;

    public WeatherController(IOptions&lt;WeatherSettings&gt; options)
    {
        _settings = options.Value;
    }

    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            _settings.City,
            _settings.TemperatureUnit,
            _settings.RefreshInterval,
            _settings.EnableForecast,
            _settings.DisplayName
        });
    }
}</code></pre><p>Here, <code>WeatherSettings</code> is injected using the options pattern.</p><p><strong>Step 6: Run the project.</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/07/image-2.png" class="kg-image" alt="URL" loading="lazy" width="250" height="36"></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/07/image-3.png" class="kg-image" alt="Response" loading="lazy" width="399" height="241"></figure><p>Let's check validity. I have kept the city empty fow now:</p><pre><code class="language-json">"WeatherSettings": {
  "City": "",
  "TemperatureUnit": "Celsius",
  "RefreshInterval": 30,
  "EnableForecast": true
}</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/07/image-4.png" class="kg-image" alt="Result" loading="lazy" width="853" height="415" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-4.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-4.png 853w" sizes="(min-width: 720px) 720px"></figure><p>A traditional way to define the records is:</p><pre><code class="language-csharp">public record WeatherSettings
{
    public required string City { get; init; }
    public required string TemperatureUnit { get; init; }
    public int RefreshInterval { get; init; }
    public bool EnableForecast { get; init; }

    // Computed Property
    public string DisplayName =&gt;
        $"{City} ({TemperatureUnit})";

    // Helper Property
    public bool IsAutoRefreshEnabled =&gt;
        RefreshInterval &gt; 0;

    public void Validate()
    {
        if (string.IsNullOrWhiteSpace(City))
            throw new InvalidOperationException("City is required.");

        if (RefreshInterval &lt;= 0)
            throw new InvalidOperationException("RefreshInterval must be greater than zero.");

        if (TemperatureUnit is not ("Celsius" or "Fahrenheit"))
            throw new InvalidOperationException("TemperatureUnit must be Celsius or Fahrenheit.");
    }
}</code></pre><p>Here <code>DisplayName</code> is a computed property for display, <code>IsAutoRefreshEnabled</code> is a helper for <code>RefreshInterval</code> validation. While all other checks are in the <code>Validate</code> method. Although that works, it is from prior C# versions.</p><h2 id="best-practices">Best Practices</h2><ul><li>Write validations in the extension method for business requirements and data validity and use them in creation or startup.</li><li>Use computed properties instead of repeating calculations.</li><li>Group related settings into nested records.</li><li>Keep helper methods focused on configuration-related behavior.</li><li>Avoid putting unrelated business logic into configuration models.</li><li>Use the <code>with</code> expression when creating modified copies.</li><li>Combine records with the Options pattern for clean dependency injection.</li></ul><h2 id="conclusion">Conclusion</h2><p>Configuration models shouldn't just store data, they should protect it. Records are suited for such cases, i.e., making configuration objects immutable, self-validating, expressive, and easier to maintain. C# 14 puts the cherry on top with new extension method declarations. Instead of relying on mutable classes with scattered validation, records allow you to centralize rules, reduce bugs, and write cleaner ASP.NET Core applications. As modern C# evolves, embracing records for configuration is a simple yet powerful step toward building more reliable and maintainable software.</p><p>Code: <a href="https://github.com/elmahio-blog/SmartRecordApi">https://github.com/elmahio-blog/SmartRecordApi</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Getting started with RAG in ASP.NET Core ]]></title>
        <description><![CDATA[ AI hype is everywhere, but where do you start with .NET? In this beginner-friendly post, we build a simple RAG pipeline in an ASP.NET Core Web API using keyword matching instead of embeddings, covering retrieve, augment, and generate end to end, with vector embeddings saved for a later post. ]]></description>
        <link>https://blog.elmah.io/getting-started-with-rag-in-asp-net-core/</link>
        <guid isPermaLink="false">6a65b14f0e291f000111584c</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Wed, 02 Sep 2026 08:44:26 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/getting-started-with-rag-in-asp.net-core-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/getting-started-with-rag-in-asp-net-core/">https://blog.elmah.io/getting-started-with-rag-in-asp-net-core/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>Nowadays, everyone talks about AI. Building with AI, integrating AI into your application, and similar topics are hot topics. In today's post, I'll step into the .NET AI world by building a very simple Retrieval-Augmented Generation (RAG) implementation.</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/08/getting-started-with-rag-in-asp.net-core-o-1.png" class="kg-image" alt="Getting started with RAG in ASP.NET 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/08/getting-started-with-rag-in-asp.net-core-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/getting-started-with-rag-in-asp.net-core-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/getting-started-with-rag-in-asp.net-core-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><p>As it is our baby step, I intentionally designed a beginner-friendly introduction rather than a production-ready RAG implementation. We won't build a vector database or use embeddings yet. Instead, we'll use simple keyword matching to simulate the retrieval process and focus on understanding how the pieces of a RAG system fit together. </p><p>In future posts, we will climb the Everest of .NET AI, but for today sticking to the baby steps. Our idea is that instead of asking an AI model to answer a question using only what it already knows, we first retrieve relevant information from our own knowledge base and provide that information to the model as context.</p><h2 id="what-is-retrieval-augmented-generation-rag">What is Retrieval-Augmented Generation (RAG)?</h2><p><a href="https://aws.amazon.com/what-is/retrieval-augmented-generation/" rel="noreferrer">RAG</a> is an architecture for optimizing the performance of an&nbsp;artificial intelligence (AI)&nbsp;model by integrating it with external knowledge bases. RAG allows AI models to combine their powerful capabilities with a specific domain's or an organization's internal knowledge base to generate more relevant and accurate responses. </p><p>No matter how great an interior designer is, he cannot give you great design by just looking from the outside and not getting information about your home from inside. He needs to know what rooms look like and how they are placed. If you do not give him this information, he will make assumptions or give a very generic solution that sometimes proves wrong. A Large Language Model (LLM) works the same way, no matter how powerful Claude, ChatGPT, or any other model is, if you want it to work on your project, it needs its information. RAG basically combines AI's power with the organization's internal data to utilize it properly. Without the context, an AI model may rely on assumptions and generate information that may look credible but is not supported by your data. This is commonly known as an <strong>AI hallucination</strong>.</p><p>RAG helps AI propose concrete answers based on the data, otherwise, it can fall into guessing and lead to hallucinations.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-8.png" class="kg-image" alt="RAG architecture" loading="lazy" width="898" height="532" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-8.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-8.png 898w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Image by AWS</span></figcaption></figure><p>So RAG works in 3 steps: <strong>Retrieve ➡️ Augment ➡️ Generate</strong></p><p>We retrieve relevant information from our knowledge base, add it to the AI prompt as context, and then let the AI model generate an answer based on that information.</p><h2 id="starting-with-rag-in-an-aspnet-core-api">Starting with RAG in an ASP.NET Core API</h2><p>To take our first baby step, let's design an API with a simple RAG architecture.</p><p><strong>Step 1: Create Api project</strong></p><pre><code class="language-console">dotnet new webapi -n RAGPlayground</code></pre><p><strong>Step 2: Create an input model</strong></p><pre><code class="language-csharp">namespace RAGPlayground.Models;

public class QueryInp
{
    public string Question { get; set; } = string.Empty;
}</code></pre><p><strong>Step 3: Add a document store</strong></p><p>The document store will represent our knowledge base, exposing a method called <code>Search</code>.  </p><pre><code class="language-csharp">public interface IDocumentStore
{
    List&lt;string&gt; Search(string query);
}</code></pre><p>Its implementation is as follows:</p><pre><code class="language-csharp">public class DocumentStore : IDocumentStore
{
    private readonly List&lt;string&gt; _documents =
    [
        "Minimal APIs simplify endpoint development in ASP.NET Core.",
        "Entity Framework Core streamlines database operations.",
        "Background services process long-running tasks efficiently.",
        "Dependency injection improves application maintainability."
    ];

    public List&lt;string&gt; Search(string query)
    {
        return _documents
            .Select(doc =&gt; new
            {
                Document = doc,
                Score = CalculateScore(doc, query)
            })
            .OrderByDescending(x =&gt; x.Score)
            .Where(x =&gt; x.Score &gt; 0)
            .Take(3)
            .Select(x =&gt; x.Document)
            .ToList();
    }

    private int CalculateScore(string doc, string query)
    {
        var score = 0;

        var queryWords = query
            .ToLower()
            .Split(' ', StringSplitOptions.RemoveEmptyEntries);

        foreach (var word in queryWords)
        {
            if (doc.ToLower().Contains(word))
            {
                score++;
            }
        }

        return score;
    }
}</code></pre><p><code>DocumentStore</code> is a simple in-memory document repository that simulates the retrieval part of a RAG system. It stores a collection of documents and returns the most relevant ones based on keyword matching.</p><p>The <code>_documents</code> list contains the collection on which search is performed. Search involves four steps:</p><ul><li>Calculate a relevance score for every document.</li><li>Sort documents from highest score to lowest.</li><li>Remove documents with a score of zero.</li><li>Return the top three matching documents.</li></ul><p> The <code>CalculateScore</code> method converts the query string to lowercase before splitting it into individual words. Then it checks if each word appears in the document. The more query words found in the document, the higher the score becomes for that document.</p><p><strong>Step 4: Create RAG layer</strong></p><pre><code class="language-csharp">public interface IRagService
{
    Task&lt;string&gt; AskAsync(string question);
}</code></pre><p>The service implementation.</p><pre><code class="language-csharp">using RAGPlayground.Contracts;

namespace RAGPlayground.Services;

public class RagService: IRagService
{
    private readonly IDocumentStore _documentStore;

    public RagService(IDocumentStore documentStore)
    {
        _documentStore = documentStore;
    }

    public async Task&lt;string&gt; AskAsync(string question)
    {
        var context = _documentStore.Search(question);

        if (context is null || !context.Any())
        {
            return "No relevant information found.";
        }
        var prompt = $"""
                      Based only on the context below, answer the user's question.

                      If the answer cannot be found in the context, say:
                      "I don't have enough information."

                      Context:
                      {context.First()}

                      Question:
                      {question}
                      """;

        return await Task.FromResult(prompt);
    }
}</code></pre><p>The service layer is much simpler. It calls the underlying document store layer and returns the document with the highest score. The prompt variable concatenates context and question, mimicking the augmentation step of the RAG. </p><p><strong>Step 5: Add a controller</strong></p><pre><code class="language-csharp">using Microsoft.AspNetCore.Mvc;
using RAGPlayground.Contracts;
using RAGPlayground.Models;

namespace RAGPlayground.Controllers;

[ApiController]
[Route("api/[controller]")]
public class RagController : ControllerBase
{
    private readonly IRagService _ragService;

    public RagController(IRagService ragService)
    {
        _ragService = ragService;
    }

    [HttpPost("ask")]
    public async Task&lt;IActionResult&gt; Ask([FromBody] QueryInp input)
    {
        var answer = await _ragService.AskAsync(input.Question);
        return Ok(new { answer });
    }
}</code></pre><p>Now, I am exposing everything to the user via an <code>ask</code> endpoint.</p><p><strong>Step 6: Run the project</strong></p><pre><code class="language-console">dotnet run</code></pre><p>In Postman, we can test the <code>ask</code> endpoint:</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/09/image.png" class="kg-image" alt="Response" loading="lazy" width="1009" height="399" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/09/image.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/09/image.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/09/image.png 1009w" sizes="(min-width: 720px) 720px"></figure><p>If a word is not found, a fitting answer is provided:</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-6.png" class="kg-image" alt="Not found" loading="lazy" width="677" height="448" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-6.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-6.png 677w"></figure><p><strong>Step 7: Use simulation with an AI model</strong></p><p>In production, you will use an AI such as OpenAI, Grok, Azure OpenAI, etc. In that case, the service layer method will include a contextual prompt. The AI client service is </p><pre><code class="language-csharp">public interface IAiClient
{
    Task&lt;string&gt; GenerateAsync(string prompt);
}</code></pre><p>Its implementation is as follows:</p><pre><code class="language-csharp">public class AiClient : IAiClient
{
    public Task&lt;string&gt; GenerateAsync(string prompt)
    {
        // Call your actual AI provider here.
        // For example: OpenAI, Azure OpenAI, Claude, Grok, etc.

        throw new NotImplementedException(
            "Connect an AI provider here.");
    }
}</code></pre><p>Dependency injection registration: </p><pre><code class="language-csharp">builder.Services.AddScoped&lt;IAiClient, AiClient&gt;();</code></pre><p>The updated RagService will look like:</p><pre><code class="language-csharp">public class RagService: IRagService
{
    private readonly IDocumentStore _documentStore;
    private readonly IAiClient _aiClient;
    
    public RagService(IDocumentStore documentStore, IAiClient aiClient)
    {
        _documentStore = documentStore;
        _aiClient = aiClient;
    }

    public async Task&lt;string&gt; AskAsync(string question)
    {
        var context = _documentStore.Search(question);

        if (context is null || !context.Any())
        {
            return "No relevant information found.";
        }
        var prompt = $"""
                      Based only on the context below, answer the user's question.

                      If the answer cannot be found in the context, say:
                      "I don't have enough information."

                      Context:
                      {context.First()}

                      Question:
                      {question}
                      """;
        var answer = await _aiClient.GenerateAsync(prompt);

        return answer;
    }
}</code></pre><p>The answer will be fetched via the AI client you are using. Finally, the answer is generated by an AI client, which we have abstracted for this article; in future posts, we will use a real AI client to go one step further. That last stage represents the generation part of the RAG.</p><h2 id="what-to-consider-while-choosing-rag">What to consider while choosing RAG?</h2><p>While RAG enhances the accuracy of AI models, you need to keep in mind a few points before going forward:</p><ul><li>If the information your AI needs is very small and rarely changes, you can include it directly in the AI's system prompt instead of building a document search pipeline. Information like FAQs and product features is usually small enough to fit a few hundred tokens and send with every request. You don't need a RAG pipeline in such cases.</li><li>Structured questions such as how many customers onboarded last year or how much revenue our company generated are database queries rather than AI prompts. Consider&nbsp;function calling&nbsp;that lets the model query your APIs instead. </li><li>RAG is not good at answering questions that require analyzing an entire dataset, because RAG is designed to retrieve only the most relevant pieces, not every document. Don't use RAG for aggregation problems like finding this month's shopping trends or counting complaint categories.</li><li>A recommended approach to designing a RAG application is a layered approach, with prompt building, retrieval logic, and LLM integration residing in separate services. </li><li>To improve retrieval in a production-grade application, use embeddings instead of word matching. Once documents become embeddings, you need a vector database to store them.</li><li>Secure your API with JWT authentication and authorization to avoid information leaking to unauthorized users. <a href="https://blog.elmah.io/how-to-implement-customized-role-permissions-in-asp-net-core/" rel="noreferrer">Role-based Access Control (RBAC)</a> is useful for restricting access to the application.</li><li>For a SaaS application, isolate tenants to better track and limit prompts for each client. </li></ul><h2 id="conclusion">Conclusion</h2><p>RAG empowers AI's accuracy by connecting it with system-specific knowledge. We saw how to use RAG architecture in an ASP.NET Core API using a basic knowledge base. For a real AI LLM integration, we will continue from here by implementing the <code>AiClient</code> class. Later, we list best practices to consider to use it effectively.</p><p>Code: <a href="https://github.com/elmahio-blog/RAGPlayground.git">https://github.com/elmahio-blog/RAGPlayground.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Building your first MCP server in ASP.NET Core ]]></title>
        <description><![CDATA[ The Model Context Protocol (MCP) gives AI assistants a standardized way to interact with software. Learn how to construct your first MCP server in ASP.NET Core, expose C# tools using standard attributes, and test your setup end-to-end with the official MCP Inspector. ]]></description>
        <link>https://blog.elmah.io/building-your-first-mcp-server-in-asp-net-core/</link>
        <guid isPermaLink="false">6a67329d763a7b0001e4936b</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 25 Aug 2026 10:22:20 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/building-your-first-mcp-server-in-asp.net-core-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/building-your-first-mcp-server-in-asp-net-core/">https://blog.elmah.io/building-your-first-mcp-server-in-asp-net-core/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>Artificial Intelligence is no longer limited to answering questions. It can now interact with applications, retrieve data, and perform real tasks. In fact, tons of applications are already doing this. But for AI assistants to communicate with your software, they need a common language. The standard communication is Model Context Protocol (MCP). As the name suggests, it allows AI clients, such as ChatGPT, Claude, and Visual Studio Code, to discover and invoke tools exposed by your application. Instead of building custom integrations for every AI platform, you expose your application's capabilities once, and any MCP-compatible client can use them. In today's post, I will share how to build our first MCP server in ASP.NET Core, create a small book catalog, expose several MCP tools, and test them.</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/08/building-your-first-mcp-server-in-asp.net-core-o-1.png" class="kg-image" alt="Building your first MCP server in ASP.NET 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/08/building-your-first-mcp-server-in-asp.net-core-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/08/building-your-first-mcp-server-in-asp.net-core-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/08/building-your-first-mcp-server-in-asp.net-core-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="what-is-model-context-protocol-mcp">What is Model Context Protocol (MCP)?</h2><p>Model Context Protocol (MCP) is an open standard created by <a href="https://www.anthropic.com/news/model-context-protocol" rel="noopener">Anthropic</a>&nbsp;that provides a standard way for AI models to communicate with external tools and applications. MCP standardizes communication between AI clients and applications. Every MCP-compatible client speaks the same protocol. You implement your tools once, and any MCP client can discover and use them. Without MCP, each model can have different request formats, authentication methods, and tool descriptions.</p><h2 id="designing-an-mcp-server-in-aspnet-core-api">Designing an MCP server in ASP.NET Core API</h2><p>Let's jump right in and create our first MCP server.</p><p><strong>Step 1: Create project</strong></p><pre><code class="language-console">dotnet new webapi -n MCPPlayground</code></pre><p><strong>Step 2: Install MCP package</strong></p><pre><code class="language-console">dotnet add package ModelContextProtocol.AspNetCore</code></pre><p>The package enables the project to act as an MCP server.</p><p><strong>Step 3: Create model</strong></p><pre><code class="language-csharp">namespace MCPPlayground.Models;
public record Book(
    int Id,
    string Title,
    string Author,
    int Year,
    bool IsAvailable
);</code></pre><p>To keep it simple, I am using a value-type record. But for a production-grade application, <a href="https://blog.elmah.io/comparing-records-structs-and-classes-in-c-when-to-use-what/" rel="noreferrer">know your requirements</a>.</p><p><strong>Step 4: Add a repository</strong></p><p>Let's add a repository with in-memory data.</p><pre><code class="language-csharp">using MCPPlayground.Models;

namespace MCPPlayground.Data;

public class BookRepo
{
    private readonly List&lt;Book&gt; _books =
    [
        new(1, "Clean Code", "Robert C. Martin", 2008, true),
        new(2, "The Pragmatic Programmer", "Andrew Hunt", 1999, false),
        new(3, "Design Patterns", "Erich Gamma", 1994, true),
        new(4, "Domain-Driven Design", "Eric Evans", 2003, true),
        new(5, "Refactoring", "Martin Fowler", 2018, false)
    ];

    public List&lt;Book&gt; GetAll() =&gt; _books;

    public Book? GetById(int id)
        =&gt; _books.FirstOrDefault(x =&gt; x.Id == id);

    public List&lt;Book&gt; Search(string keyword)
        =&gt; _books
            .Where(x =&gt;
                x.Title.Contains(keyword, StringComparison.OrdinalIgnoreCase)
                    || x.Author.Contains(keyword, StringComparison.OrdinalIgnoreCase))
            .ToList();
}</code></pre><p>I am exposing 3 methods from the repo. Don't forget to add dependency injection in <code>Program.cs</code>:</p><pre><code class="language-csharp">builder.Services.AddSingleton&lt;BookRepo&gt;();</code></pre><p><strong>Step 5: Create MCP tools</strong></p><pre><code class="language-csharp">using MCPPlayground.Data;
using MCPPlayground.Models;
using ModelContextProtocol.Server;

namespace MCPPlayground.Tools;

[McpServerToolType]
public class BookTools(BookRepo repository)
{
    [McpServerTool]
    public IEnumerable&lt;Book&gt; GetAllBooks()
    {
        return repository.GetAll();
    }

    [McpServerTool]
    public Book? GetBookById(int id)
    {
        return repository.GetById(id);
    }

    [McpServerTool]
    public IEnumerable&lt;Book&gt; SearchBooks(string keyword)
    {
        return repository.Search(keyword);
    }
}</code></pre><p><code>[McpServerToolType]</code> marks the class as containing MCP tools. While <code>[McpServerTool]</code> marks a method that AI clients can invoke. Any method parameters automatically become tool inputs. C# method names are converted to MCP names like <code>get_all_books</code> if no custom name is specified for <code>[McpServerTool]</code>.</p><p><strong>Step 6: Setup MCP server configuration</strong></p><p>Add MCP configuration in <code>Program.cs</code>:</p><pre><code class="language-csharp">builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();</code></pre><p>Use the <code>MapMcp</code> method to map the <code>/mcp</code> URL which is the endpoint the AI clients are going to communicate with:</p><pre><code class="language-csharp">app.MapMcp("/mcp");</code></pre><p>The final look of the file is;</p><pre><code class="language-csharp">using MCPPlayground.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

builder.Services.AddOpenApi();

builder.Services.AddSingleton&lt;BookRepo&gt;();

var app = builder.Build();

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

app.UseHttpsRedirection();
app.MapMcp("/mcp");
app.Run();
</code></pre><p><strong>Step 7: Run the project</strong></p><pre><code class="language-console">dotnet run</code></pre><p>The project is running and listening at <a href="http://localhost:5082/mcp">http://localhost:5082/mcp</a></p><p>Unlike APIs, where you manually call REST endpoints, the MCP server allows AI models to discover and call the tools. I will use the MCP Inspector to test, which will automatically discover available tools from the server. Simply select a tool, provide its arguments, and execute it. This mirrors how AI assistants such as Cursor or Claude discover and invoke an application's capabilities through Model Context Protocol.</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-27.png" class="kg-image" alt="MCP flow" loading="lazy" width="528" height="516"></figure><p><strong>Step 8: Install MCP inspector</strong></p><p>For the MCP inspector, you need to have Node.js. Check if node and npm is installed by running:</p><pre><code class="language-console">node -v
npm -v</code></pre><p>If not already installed, go to the official website: <a href="https://nodejs.org/en" rel="noreferrer">https://nodejs.org</a>. Download the LTS (Long Term Support) version. Once the download completes:</p><ul><li>Run the installer.</li><li>Click Next until installation completes.</li><li>Keep "Add to PATH" checked (default).</li><li>Finish the installation.</li></ul><p>Verify the installation:</p><pre><code class="language-console">node -v
npm -v</code></pre><p>Then run the following in a new terminal:</p><pre><code class="language-console">npx @modelcontextprotocol/inspector@latest</code></pre><p>This will install and run the latest version of the inspector:</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-22.png" class="kg-image" alt="Console" loading="lazy" width="1271" height="241" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-22.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-22.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-22.png 1271w" sizes="(min-width: 720px) 720px"></figure><p>When the inspector is running, the browser opens up the UI:</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-23.png" class="kg-image" alt="MCP inspector" loading="lazy" width="1361" height="535" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-23.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-23.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-23.png 1361w" sizes="(min-width: 720px) 720px"></figure><p><strong>Step 9: Test the MCP server</strong></p><p>Add a new server for our application:</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-17.png" class="kg-image" alt="Adding a server" loading="lazy" width="906" height="428" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-17.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-17.png 906w" sizes="(min-width: 720px) 720px"></figure><p>You can use the following configuration for the 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/image-18.png" class="kg-image" alt="Adding server" loading="lazy" width="865" height="516" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-18.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-18.png 865w" sizes="(min-width: 720px) 720px"></figure><p>Make sure to turn the connected toggle button on:</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-11.png" class="kg-image" alt="Server" loading="lazy" width="1153" height="477" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-11.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-11.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-11.png 1153w" sizes="(min-width: 720px) 720px"></figure><p>All the tools I designed will be visible in the tools window:</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-12.png" class="kg-image" alt="Tools" loading="lazy" width="1171" height="514" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-12.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-12.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-12.png 1171w" sizes="(min-width: 720px) 720px"></figure><p>Calling the <code>get_all_books</code> tool:</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-13.png" class="kg-image" alt="Result" loading="lazy" width="1156" height="512" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-13.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-13.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-13.png 1156w" sizes="(min-width: 720px) 720px"></figure><p>Will show the in-memory data from the repository:</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-14.png" class="kg-image" alt="Result" loading="lazy" width="1159" height="511" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-14.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-14.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-14.png 1159w" sizes="(min-width: 720px) 720px"></figure><p>Another tool <code>get_book_by_id</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/image-15.png" class="kg-image" alt="get by id method" loading="lazy" width="1155" height="496" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-15.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-15.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-15.png 1155w" sizes="(min-width: 720px) 720px"></figure><p>Will show a single book from the repository:</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-16.png" class="kg-image" alt="Results" loading="lazy" width="1147" height="488" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-16.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-16.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-16.png 1147w" sizes="(min-width: 720px) 720px"></figure><p>We can also search book sby the <code>keyword</code> parameter:</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-20.png" class="kg-image" alt="search method" loading="lazy" width="1199" height="508" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-20.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-20.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-20.png 1199w" sizes="(min-width: 720px) 720px"></figure><p>The data is fetched successfully:</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-21.png" class="kg-image" alt="Result" loading="lazy" width="1162" height="503" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-21.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-21.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-21.png 1162w" sizes="(min-width: 720px) 720px"></figure><p><strong>Step 10: Add descriptions to the tools</strong></p><p>To better define each tool for AI model integration, it is recommended to add descriptions:</p><pre><code class="language-csharp">using System.ComponentModel;
using MCPPlayground.Data;
using MCPPlayground.Models;
using ModelContextProtocol.Server;

namespace MCPPlayground.Tools;

[McpServerToolType]
public class BookTools(BookRepo repository)
{
    [McpServerTool]
    [Description("Returns all books available in the library.")]
    public IEnumerable&lt;Book&gt; GetAllBooks()
    {
        return repository.GetAll();
    }

    [McpServerTool]
    [Description("Returns a single book by its unique identifier.")]
    public Book? GetBookById(
        [Description("The unique ID of the book.")] int id)
    {
        return repository.GetById(id);
    }

    [McpServerTool]
    [Description("Searches books by title or author.")]
    public IEnumerable&lt;Book&gt; SearchBooks(
        [Description("A keyword to search in the book title or author name.")] string keyword)
    {
        return repository.Search(keyword);
    }
}</code></pre><p>The descriptions are visible in the inspector:</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-24.png" class="kg-image" alt="get all" loading="lazy" width="1166" height="511" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image-24.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/image-24.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image-24.png 1166w" sizes="(min-width: 720px) 720px"></figure><p>That's it. We have now developed our first MCP server using ASP.NET Core and the <code>ModelContextProtocol.AspNetCore</code> NuGet package. In this post, I focused on testing the tools through the inspector. But in real life, your tools will be called from an AI client. How you set up an MCP server in your favorite client depends on what client you use. As an example, you would use the following command for Claude:</p><pre><code class="language-console">claude mcp add --transport http book-mcp http://localhost:5082/mcp</code></pre><h2 id="conclusion">Conclusion</h2><p>As AI assistants become part of everyday development, one challenge quickly appears: how should an AI securely and consistently interact with your applications? In this post, I tackled this question by guiding you on how to design an MCP server in ASP.NET Core, which is surprisingly straightforward. With just a few configuration lines and a couple of attributes, you can expose your application's capabilities as AI-callable tools.</p><p>As applications adopt more AI, learning how to build MCP servers will become an increasingly valuable skill for .NET developers. Whether you're integrating AI into internal business systems or creating intelligent developer tools, MCP serves as a standardized and extensible foundation for connecting language models to real-world functionality. Something we are already fully utilizing on <a href="https://mcp.elmah.io/" rel="noreferrer">elmah.io's MCP server</a>.</p><p>Code: <a href="https://github.com/elmahio-blog/MCPPlayground.git">https://github.com/elmahio-blog/MCPPlayground.git</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ What happens when you call await? A step-by-step runtime breakdown ]]></title>
        <description><![CDATA[ This step-by-step breakdown shows what happens when C# hits an await: the compiler generates an IAsyncStateMachine struct with a MoveNext() method splitting code at each await, using a GetDataCountAsync example with two awaited HttpClient calls to show how OnCompleted frees the calling thread. ]]></description>
        <link>https://blog.elmah.io/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown/</link>
        <guid isPermaLink="false">6a36cd62ee00ae00018b06d5</guid>
        <category><![CDATA[  ]]></category>
        <dc:creator><![CDATA[ Ali Hamza Ansari ]]></dc:creator>
        <pubDate>Tue, 18 Aug 2026 07:14:24 +0200</pubDate>
        <media:content url="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown-o.png" medium="image"/>
        <content:encoded><![CDATA[ <p>This blog post is originally published on <a href="https://blog.elmah.io/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown/">https://blog.elmah.io/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown/</a></p> 
<!--kg-card-begin: html-->
<div class="toc"></div>
<!--kg-card-end: html-->
<p>We are all using async-await in our applications. But one thing that bothers me every time is how it is different from sync and how it actually works. In today's post, I will clear up the confusion for you and 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/07/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown-o-1.png" class="kg-image" alt="What happens when you call await? A step-by-step runtime breakdown" 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/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown-o-1.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w1000/2026/07/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown-o-1.png 1000w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/what-happens-when-you-call-await-a-step-by-step-runtime-breakdown-o-1.png 1500w" sizes="(min-width: 1200px) 1200px"></figure><h2 id="synchronous-programming">Synchronous programming</h2><p>Synchronous programming is a sequential way of executing code, where each line runs one by one. The next line does not start until the previous one completes, leaving the whole application on hold.  </p><h2 id="asynchronous-programming">Asynchronous programming</h2><p>Asynchronous programming is a non-blocking approach to code execution that does not execute in sequence. It allows long-running tasks, such as fetching from the database or reading content from files, to run without stalling the whole application. It initiates those delaying tasks and continues running other code while waiting for the result. The <code>async</code>/<code>await</code> keywords help define asynchronous programming. <code>async</code> is used with a method that runs asynchronously, and <code>await</code> pauses execution until the task completes. Note: In UI apps, a <code>SynchronizationContext</code> ensures code resumes back on the UI thread, whereas in console apps or ASP.NET Core, it resumes on any free <code>ThreadPool</code> thread.</p><h2 id="why-do-we-need-asynchronous-programming">Why do we need asynchronous programming?</h2><p>Each .NET application runs on a&nbsp;main thread&nbsp;or request thread that&nbsp;starts&nbsp;your work.</p><ul><li>In a&nbsp;console app, it's the <code>Main</code> method.</li><li>In&nbsp;ASP.NET Core, it's the thread handling the incoming HTTP request.</li><li>In&nbsp;UI apps, it's the UI thread that displays the screen.</li></ul><p>It performs the big responsibility of s<em>taying responsive </em>and keeping the application moving. If this thread gets blocked, the application stops receiving HTTP requests, the UI freezes, and the application slows or breaks. So protecting this thread is critically important. Therefore, we go with asynchronous.</p><h2 id="a-brief-peek-at-asyncawait-execution">A brief peek at async/await execution</h2><pre><code class="language-csharp">public async Task&lt;int&gt; GetDataCountAsync()
{
    Console.WriteLine("Before await");

    var response = await httpClient.GetAsync("/users");
    var count = await response.Content.ReadAsAsync&lt;int&gt;();
    Console.WriteLine("After await");

    return count;
}</code></pre><p>The method runs synchronously until it reaches the first await.</p><p>When the compiler notices async/await code, it generates a state machine. More precisely, it generates a struct (or class, depending on optimization) implementing <code>IAsyncStateMachine</code>. The state machine stores the current state of the asynchronous method, from which it resumes execution later.</p><pre><code class="language-csharp">private struct GetDataCountAsyncStateMachine : IAsyncStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder&lt;int&gt; builder;
    private HttpClient httpClient;
    private TaskAwaiter&lt;HttpResponseMessage&gt; awaiter1;
    private TaskAwaiter&lt;int&gt; awaiter2;

    void MoveNext()
    {
        switch (state)
        {
            case 0: /* first await segment */
            case 1: /* second await segment */
            // ...
        }
    }
}</code></pre><p>The method pauses, runs in the background, and frees the main thread. The caller can continue doing other work, but any code that depends on the awaited result, cannot run until the await completes.</p><p>So, can we say that if I have 2 awaits, the state machine divides the method into 3 segments: before the 1st await, between the 1st and 2nd await, and after the 2nd await? When an await point is reached, the state machine yields control back to the caller and frees the thread. Once the underlying asynchronous operation completes, the runtime invokes <code>MoveNext()</code> again to execute the next segment.</p><h3 id="suspension-where-the-magic-actually-happens">Suspension: Where the Magic Actually Happens</h3><p>If the awaited task is not yet complete, the state machine calls:</p><pre><code class="language-csharp">awaiter.OnCompleted(stateMachine.MoveNext);</code></pre><p>This registers a continuation telling "call <code>MoveNext()</code> again when this task finishes" and then returns control to the caller. The calling thread is now free to do other work. This is the entire point of <code>async</code>/<code>await</code>: releasing the thread during the wait instead of blocking it.</p><p>At this point, <code>GetDataCountAsync()</code> returns an incomplete <code>Task&lt;int&gt;</code> to its caller, backed by the <code>AsyncTaskMethodBuilder</code>.</p><p>For network or disk I/O, no thread is actively running during the wait. The runtime hands the operation off to the OS and network hardware (via mechanisms like I/O Completion Ports). When the hardware receives the data, the OS signals the runtime, which queues the state machine to resume on an available ThreadPool thread.</p><p>Now, understand with an analogy. You have a few household chores, such as washing and drying clothes, cleaning the house, and cooking. If you proceed sequentially, each task will start only after the previous one is completed. Hence, the entire window will be occupied by a single task. However, that is time-consuming and inefficient. Here comes asynchronous tasking (async/await in programming terms), where you will leave time-consuming tasks on an automated thread and free yourself (the main thread) for other tasks in parallel. Once the delayed task is completed, you can get its results.</p><pre><code class="language-csharp">using System;
using System.Threading;
using System.Threading.Tasks;

public class HelloWorld
{
    static async Task WashAndDryClothes()
    {
        Log("WashAndDryClothes Started");

        string clothes = await WashClothes();

        Log($"Got result from WashClothes: {clothes}");

        await DryClothes(clothes);

        Log("WashAndDryClothes Finished");
    }

    static async Task&lt;string&gt; WashClothes()
    {
        Log("Washing clothes...");

        await Task.Delay(3000);

        Log("Washing completed");

        return "Clean Clothes";
    }

    static async Task DryClothes(string clothes)
    {
        Log($"Drying {clothes}...");

        await Task.Delay(2000);

        Log("Drying completed");
    }

    static async Task CleanHouse()
    {
        Log("Cleaning house...");

        await Task.Delay(4000);

        Log("Cleaning completed");
    }

    static async Task CookFood()
    {
        Log("Cooking food...");

        await Task.Delay(5000);

        Log("Cooking completed");
    }

    static void Log(string message)
    {
        Console.WriteLine(
            $"{DateTime.Now:HH:mm:ss.fff} | Thread {Thread.CurrentThread.ManagedThreadId} | {message}");
    }
    public static async Task  Main(string[] args)
    {
         Log("Main Started");

        Task task1 = WashAndDryClothes();
        Task task2 = CleanHouse();
        Task task3 = CookFood();

        Log("All tasks started");

        await Task.WhenAll(task1, task2, task3);

        Log("All tasks completed");
    }
}</code></pre><p>Each method represents a chore, and to mimic the time of the chore, I added <code>Task.Delay</code>. The result 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/07/image.png" class="kg-image" alt="Result" loading="lazy" width="606" height="313" srcset="https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/size/w600/2026/07/image.png 600w, https://storage.ghost.io/c/a8/df/a8df410f-f385-46fe-9464-e894f7952067/content/images/2026/07/image.png 606w"></figure><p>So Thread 1 just starts and goes to <code>WashClothes</code> which is called from <code>WashAndDryClothes</code>. The .NET ThreadPool already exists before the application starts. It has many worker threads, such as Thread 1, Thread 5, and Thread 6. At this moment, Thread 1 is executing<code>Main()</code>, while the other threads are simply idle, waiting for work. After logging <code>WashAndDryClothes Started</code> and <code>Washing clothes...</code> it finds that the method will take 3 seconds. Now, the thread does not wait, realizing that this operation will not finish immediately. Instead of waiting for 3 seconds, it does three things:</p><ol><li>It registers a 3 second callback in .NET's internal timer queue (which doesn't hold or block a thread while waiting).</li><li>It saves the current state of the <code>WashClothes</code> state machine (<code>state = 0</code>) along with everything needed to continue later.</li><li>It registers a continuation that says:</li></ol><blockquote>When this 3-second delay completes, call<strong> <code>WashClothes.MoveNext()</code>.</strong></blockquote><p>Thread 1 then immediately returns from <code>WashClothes()</code>, then returns from <code>WashAndDryClothes()</code>, and starts executing the later part of the in <code>Main()</code>. When it meets <code>CleanHouse();</code>, it logs <code>Cleaning house...</code> and sees a 4000 ms delay. The same three things happen:</p><ul><li>A 4-second timer is created.</li><li>The <code>CleanHouse</code> state machine saves its current state.</li><li>It registers:</li></ul><blockquote><strong>"</strong>When the 4-second delay completes, call<strong> <code>CleanHouse.MoveNext()</code>."</strong></blockquote><p>Thread 1 returns to the Main method again to handle other tasks. Now it goes to<code>CookFood()</code>, where it waits 5 seconds.</p><ul><li>An OS-managed 5-second timer is created.</li><li>The <code>CookFood</code> state machine saves its state (state = 0).</li><li>It registers:</li></ul><blockquote><strong>"</strong>When the 5-second delay completes, call<strong> <code>CookFood.MoveNext()</code>."</strong> </blockquote><p>Now all three tasks have been started.</p><p>Thread 1 executes.</p><pre><code>await Task.WhenAll(task1, task2, task3);</code></pre><p>Since none of the three tasks have finished yet, the <code>Main</code> state machine also saves its state and registers:</p><blockquote><strong>"</strong>When all three tasks complete, call<strong> <code>Main.MoveNext()</code>."</strong></blockquote><p>Now Thread 1 has nothing left to do, so it joins the other idle threads in the <code>ThreadPool</code> and becomes idle.</p><p>Well, after 3 seconds. The 3-second timer expires.</p><p>The OS/.NET timer system tells the <code>Task.Delay</code> object:</p><blockquote>"Your delay has finished."</blockquote><p>The <code>Task.Delay</code> completes, which queues the registered continuation:</p><pre><code>WashClothes.MoveNext()</code></pre><p>The ThreadPool now looks for any available worker thread. It finds that Thread 5 is available. It assigns the continuation to Thread 5.</p><p>Hence, Thread 5 executes:</p><pre><code>WashClothes.MoveNext()</code></pre><p><code>WashClothes</code> continues after the <code>await</code>, logs:</p><pre><code>Washing completed</code></pre><p>returns <code>"Clean Clothes"</code>, and completes. Because <code>WashAndDryClothes</code> was waiting for that result, its continuation is immediately ready too:</p><pre><code>WashAndDryClothes.MoveNext()</code></pre><p>Since Thread 5 is already running, it simply continues on the same thread. It executes:</p><pre><code>string clothes = awaiter.GetResult();</code></pre><p>Logs:</p><pre><code>Got result from WashClothes: Clean Clothes</code></pre><p>Then starts:</p><pre><code>await DryClothes(clothes);</code></pre><p>It reaches another <code>Task.Delay(2000)</code>, saves its state, registers another continuation, and Thread 5 becomes idle again.</p><p>After 4 seconds, the 4-second timer expires. The continuation.</p><pre><code>CleanHouse.MoveNext()</code></pre><p>is queued. The ThreadPool looks for a free worker and finds Thread 6 is idle.</p><p>Thread 6 executes.</p><pre><code>CleanHouse.MoveNext()</code></pre><p>It logs.</p><pre><code>Cleaning completed</code></pre><p>and finishes.</p><p>Now 5 seconds have passed. The two timers are now completing almost together. First, the <code>DryClothes</code> delay finishes. The ThreadPool looks for a free worker.</p><p>As Thread 5 was done with <code>WashAndDryClothes</code> and is free again.</p><p>It executes:</p><pre><code>DryClothes.MoveNext()</code></pre><p>Logs:</p><pre><code>Drying completed</code></pre><p><code>DryClothes</code> finishes, which immediately allows <code>WashAndDryClothes</code> to continue. Since Thread 5 is already executing, it continues directly into:</p><pre><code>WashAndDryClothes.MoveNext()</code></pre><p>and logs:</p><pre><code>WashAndDryClothes Finished</code></pre><p>Almost at the same time, the 5-second timer for <code>CookFood</code> expires. The continuation:</p><pre><code>CookFood.MoveNext()</code></pre><p>is queued.</p><p>Till now, Thread 6 is available and executes:</p><pre><code>Cooking completed</code></pre><p>Now all three tasks have completed. <code>Task.WhenAll</code> also becomes complete. Its registered continuation is:</p><pre><code>Main.MoveNext()</code></pre><p>Since Thread 6 is already executing, it continues directly into <code>Main.MoveNext()</code> and logs.</p><pre><code>All tasks completed</code></pre><p>The application is now finished.</p><h2 id="what-are-the-benefits-of-asynchronous-programming">What are the benefits of asynchronous programming?</h2><p>Because of its miraculous non-blocking nature, asynchronous programming offers key benefits for modern applications. </p><h3 id="better-cpu-utilization">Better CPU utilization</h3><p>In sequential execution, a thread is doing absolutely nothing while waiting for a resource, such as data or file content. Instead of wasting a thread, the thread becomes available for other requests.</p><h3 id="scalability">Scalability</h3><p>Another big benefit is the potential to scale your application. Imagine your API takes hundreds of milliseconds to process while different users access it at once. The non-blocking nature will allow all requests without stalling the server on a long-running database hit. </p><h3 id="better-responsiveness">Better responsiveness</h3><p>Definitely, this one is the key reason modern developers use async/await. UI thread remains free, and users can interact with the application without spoiling their experience.</p><h3 id="higher-server-throughput">Higher server throughput</h3><p>Imagine a server with 10 ThreadPool threads. Each request waits 2 seconds for SQL. In the synchronous world, each of them will occupy one thread, and the 11th thread has to wait. In the asynchronous world, a thread becomes available immediately without depending on others, and the thread pool assigns the next task to it without leaving any threads idle. </p><h3 id="efficient-memory-usage">Efficient memory usage</h3><p>Blocking a thread ties up its ~1 MB stack allocation doing nothing. Under high load, this forces the ThreadPool to spin up hundreds of threads, consuming gigabytes of memory. Async frees the thread immediately, keeping memory usage limited to a small heap-allocated state machine until the task completes.</p><h3 id="cleaner-code-than-callbacks">Cleaner code than callbacks</h3><p>Without it, you had to make callbacks for dependent calls like </p><pre><code class="language-csharp">GetData(result =&gt;
{
    Save(result, x =&gt;
    {
        SendEmail(x, y =&gt;
        {
            Console.WriteLine(y);
        });
    });
});</code></pre><p>async/await will let you do this with </p><pre><code class="language-csharp">var data = await GetData();

var saved = await Save(data);

await SendEmail(saved);</code></pre><h2 id="when-should-you-use-async">When should you use async?</h2><p>Asynchronous programming's non-blocking approach is strongly recommended for tasks such as database calls, <a href="https://blog.elmah.io/how-to-upload-files-in-an-asp-net-core-web-api/" rel="noreferrer">file I/O</a>, HTTP calls, network operations, and Cloud APIs. However, for any complex calculations, it does not help, like if the CPU is calculating something on the already-fetched data, because in any case the CPU has to perform the calculations.</p><h2 id="conclusion">Conclusion</h2><p>Async/await is a cornerstone in modern applications. Any task that delays is awaited, and that is the only recommended way to handle such tasks. We peeked inside async/await with analogous examples of how it worked. Apart from keeping the UI responsive, it pays off in CPU, memory, and scalability too. </p><p></p> ]]></content:encoded>
    </item>
    <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>

</channel>
</rss>