<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[FullStack City]]></title><description><![CDATA[Microsoft development blog covering frontend, backend, databases and Azure]]></description><link>https://fullstackcity.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1742122025267/1d5fb5ac-0b1f-4bc4-adc0-48b9b70ea37a.png</url><title>FullStack City</title><link>https://fullstackcity.com</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 14 Sep 2026 15:42:26 GMT</lastBuildDate><atom:link href="https://fullstackcity.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The AI N+1 Problem in .NET]]></title><description><![CDATA[Most .NET developers recognise an N+1 database query when they see one. AI features can recreate the same failure at a more expensive boundary. An application loads 300 support tickets, documents or p]]></description><link>https://fullstackcity.com/the-new-ai-n-1-problem-in-net</link><guid isPermaLink="true">https://fullstackcity.com/the-new-ai-n-1-problem-in-net</guid><category><![CDATA[AI]]></category><category><![CDATA[N+1]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[C#]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Tue, 08 Sep 2026 19:47:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/b418e309-9355-4c55-a382-9d61c389394a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most .NET developers recognise an N+1 database query when they see one. AI features can recreate the same failure at a more expensive boundary. An application loads 300 support tickets, documents or product descriptions, maps over the collection and calls a language model once for every item. The database query count looks fine. The code is asynchronous. A local test finishes quickly. Yet one logical operation has become 300 remote inference requests, each repeating the same instructions, competing for the same quota and creating another opportunity for throttling or partial failure. The usual response is to add <code>Task.WhenAll</code> and celebrate the lower elapsed time. That only makes the requests concurrent. It doesn't reduce their number, remove duplicated input tokens or define what should happen when 287 calls succeed and 13 fail. This is the N+1 model call problem. Solving it requires more than a faster loop.</p>
<h2>N+1 has moved beyond the database</h2>
<p>Imagine a service that classifies pending submissions. The first query is efficient and projects only the fields the classifier needs. The problem begins after the data leaves Entity Framework Core.</p>
<pre><code class="language-csharp">using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;

public sealed class SubmissionClassifier(
    SubmissionDbContext dbContext,
    IChatClient chatClient)
{
    public async Task&lt;IReadOnlyList&lt;ClassifiedSubmission&gt;&gt; ClassifyPendingAsync(
        CancellationToken stopToken)
    {
        var submissions = await dbContext.Submissions
            .Where(x =&gt; x.Status == SubmissionStatus.Pending)
            .Select(x =&gt; new ClassificationInput(x.Id, x.Description))
            .ToListAsync(stopToken);

        var calls = submissions.Select(async submission =&gt;
        {
            var response = await chatClient.GetResponseAsync(
                [
                    new ChatMessage(
                        ChatRole.System,
                        "Classify the submission as Billing, Technical or Other."),
                    new ChatMessage(ChatRole.User, submission.Text)
                ],
                cancellationToken: stopToken);

            return new ClassifiedSubmission(
                submission.Id,
                response.Text);
        });

        return await Task.WhenAll(calls);
    }
}
</code></pre>
<p>There is one database query followed by one model call for every submission. If the query returns 300 rows, the application sends 300 requests. Each request carries the same system instruction. Each one consumes a request from the provider's quota, opens another failure path and produces a separately variable response. <code>Task.WhenAll</code> can reduce wall clock time when the provider and network allow enough parallelism, but the amount of external work remains unchanged. It can also make the burst considerably sharper. As <code>Task.WhenAll</code> enumerates the sequence, each asynchronous lambda starts and reaches its first incomplete <code>await</code>. Without another control, hundreds of calls can be in flight before the first one completes. The shape is easy to miss because the fan out appears after a sensible database operation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/37e8a795-3e9d-4882-8914-30f63aeb8bf5.png" alt="" style="display:block;margin:0 auto" />

<p>A model call is also a less predictable unit of work than a typical database query. Latency varies with model load and output length. Providers commonly enforce both request based and token based limits. Responses can be syntactically successful while still omitting an item or returning an invalid classification. Retries may produce a different answer from the original attempt. For model backed code, call count belongs in the architectural design alongside token volume, batch size, concurrency and result semantics.</p>
<h2>Concurrency doesn't remove duplicated work</h2>
<p>Suppose every request contains a fixed system prompt of <code>S</code> tokens and one item averaging <code>I</code> input tokens. With <code>N</code> separate calls, the approximate input volume is:</p>
<p>[ N(S + I) ]</p>
<p>If the work can be expressed as one batch, the approximate input volume becomes:</p>
<p>[ S + NI + B ]</p>
<p><code>B</code> represents the JSON structure, identifiers and separators required to frame the batch. The item content still has to be sent, but the fixed instructions and schema are no longer repeated <code>N</code> times. Real billing is provider and model specific, so this isn't a price calculator. It shows where the avoidable work comes from. Long instructions, examples, tool descriptions and output schemas make the repeated component much larger than a six word system message. Request count has a separate effect. Many hosted services constrain requests per minute as well as tokens per minute. Azure OpenAI, for example, documents both request rate and token rate quota concepts. A workload can therefore remain below its token allocation and still be throttled because it was split into too many small calls. The current limits and allocation rules are described in <a href="https://learn.microsoft.com/en-us/azure/foundry/openai/quotas-limits">Azure OpenAI quotas and limits</a>.</p>
<p>Parallelism helps throughput only until one of those limits becomes the bottleneck. Past that point, it creates queueing, <code>429</code> responses and retry traffic. A concurrency setting of 50 doesn't mean the system has capacity for 50 calls. It means the caller is willing to create up to 50 simultaneous demands on a capacity owned somewhere else.</p>
<h2>Batch the business operation</h2>
<p>Classification, extraction, moderation and scoring often support batching because the same instruction is applied independently to many inputs. The important word is independently. A batch should preserve the identity of every item and make that independence explicit in the prompt.</p>
<p>The contract can remain ordinary C#.</p>
<pre><code class="language-csharp">public sealed record ClassificationInput(
    Guid Id,
    string Text);

public sealed record ClassificationResult(
    Guid Id,
    string Category,
    string? Reason);

public sealed record ClassificationBatchResponse(
    IReadOnlyList&lt;ClassificationResult&gt; Results);
</code></pre>
<p>The identifier is part of the model contract rather than metadata held only by the caller. Position alone is too weak. Models can reorder results, skip an item or produce an extra entry. If the application assumes that output element 17 corresponds to input element 17, a missing result can attach every subsequent answer to the wrong record. The prompt should request structured output and state that each item must be evaluated independently. The provider adapter can enforce a JSON schema when its model supports structured output. The application should still validate the deserialised response, because valid JSON doesn't prove that the model returned the expected IDs.</p>
<pre><code class="language-csharp">using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;

public sealed class ClassificationClient(IChatClient chatClient)
{
    private const string SystemPrompt = """
        Classify each input independently as Billing, Technical or Other.
        Return one result for every supplied id.
        Copy each id exactly and never combine information between inputs.
        Return a JSON object with a results array. Each result must contain
        id, category and reason properties.
        """;

    public async Task&lt;IReadOnlyList&lt;ClassificationResult&gt;&gt; ClassifyAsync(
        IReadOnlyList&lt;ClassificationInput&gt; inputs,
        CancellationToken stopToken)
    {
        var payload = JsonSerializer.Serialize(
            inputs,
            AppJsonSerializerContext.Default.ClassificationInputArray);

        var options = new ChatOptions
        {
            ResponseFormat = ChatResponseFormat.Json,
            Temperature = 0
        };

        var response = await chatClient.GetResponseAsync(
            [
                new ChatMessage(ChatRole.System, SystemPrompt),
                new ChatMessage(ChatRole.User, payload)
            ],
            options,
            cancellationToken: stopToken);

        var batch = JsonSerializer.Deserialize(
            response.Text,
            AppJsonSerializerContext.Default.ClassificationBatchResponse)
            ?? throw new InvalidOperationException(
                "The model returned an empty batch response.");

        return ValidateAndOrder(inputs, batch.Results);
    }

    private static IReadOnlyList&lt;ClassificationResult&gt; ValidateAndOrder(
        IReadOnlyList&lt;ClassificationInput&gt; inputs,
        IReadOnlyList&lt;ClassificationResult&gt; results)
    {
        var expectedIds = inputs
            .Select(x =&gt; x.Id)
            .ToHashSet();

        var resultsById = new Dictionary&lt;Guid, ClassificationResult&gt;();

        foreach (var result in results)
        {
            if (!expectedIds.Contains(result.Id))
            {
                throw new InvalidOperationException(
                    $"The model returned unknown id {result.Id}.");
            }

            if (!resultsById.TryAdd(result.Id, result))
            {
                throw new InvalidOperationException(
                    $"The model returned duplicate id {result.Id}.");
            }
        }

        var missingIds = expectedIds
            .Except(resultsById.Keys)
            .ToArray();

        if (missingIds.Length &gt; 0)
        {
            throw new IncompleteModelResponseException(missingIds);
        }

        return inputs
            .Select(x =&gt; resultsById[x.Id])
            .ToArray();
    }
}

[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(ClassificationInput[]))]
[JsonSerializable(typeof(ClassificationBatchResponse))]
internal partial class AppJsonSerializerContext : JsonSerializerContext;
</code></pre>
<p>This example uses the provider neutral <code>IChatClient</code> abstraction from <code>Microsoft.Extensions.AI</code>. The interface supports regular and streaming responses and can be composed with telemetry, caching, function invocation and custom middleware. Microsoft's current overview is available in the <a href="https://learn.microsoft.com/en-us/dotnet/ai/ichatclient"><code>IChatClient</code> documentation</a>. <code>ChatResponseFormat.Json</code> requests structured JSON without prescribing a schema. Where the provider and model support schema constrained output, the application can supply a <code>ChatResponseFormatJson</code> containing the actual schema instead. Provider support still needs to be verified, and application validation remains necessary. The domain service owns the expected result, the adapter owns the wire mechanism used to request it.</p>
<h2>Batch size is a token budget decision</h2>
<p>Replacing 300 calls with one enormous call exchanges one failure mode for another. A large batch can exceed the model's context window, leave too little room for output or produce a response too large to validate and retry economically. A fixed item count is a useful safety limit, but it isn't enough on its own. Ten short ticket subjects and ten pasted log files have completely different token footprints. The batch builder should consider both the number of items and their estimated token cost. Token counts depend on the tokenizer used by the selected model. Character or word counts are rough admission estimates and should include a conservative margin if an exact compatible tokenizer isn't available. <a href="https://learn.microsoft.com/en-us/dotnet/ai/how-to/use-tokenizers"><code>Microsoft.ML.Tokenizers</code></a> provides tokenisation components and token counting APIs for .NET, but the concrete tokenizer still needs to match the model closely enough for the limit being enforced. Keeping token measurement behind a small application abstraction avoids coupling the batching policy to one model family.</p>
<pre><code class="language-csharp">public interface ITokenCounter
{
    int Count(string text);
}

public sealed class TokenBatcher(
    ITokenCounter tokenCounter,
    int maxPromptTokens,
    int maxItems,
    int fixedPromptTokens)
{
    public IEnumerable&lt;IReadOnlyList&lt;ClassificationInput&gt;&gt; Create(
        IEnumerable&lt;ClassificationInput&gt; inputs)
    {
        var batch = new List&lt;ClassificationInput&gt;();
        var usedTokens = fixedPromptTokens;

        foreach (var input in inputs)
        {
            var serialised = JsonSerializer.Serialize(input);
            var itemTokens = tokenCounter.Count(serialised) + 8;

            if (fixedPromptTokens + itemTokens &gt; maxPromptTokens)
            {
                throw new InputExceedsModelBudgetException(input.Id);
            }

            var batchIsFull = batch.Count &gt;= maxItems;
            var tokenBudgetIsFull = usedTokens + itemTokens &gt; maxPromptTokens;

            if (batch.Count &gt; 0 &amp;&amp; (batchIsFull || tokenBudgetIsFull))
            {
                yield return batch.ToArray();
                batch.Clear();
                usedTokens = fixedPromptTokens;
            }

            batch.Add(input);
            usedTokens += itemTokens;
        }

        if (batch.Count &gt; 0)
        {
            yield return batch.ToArray();
        }
    }
}
</code></pre>
<p>The <code>maxPromptTokens</code> value should already reserve capacity for the expected response. If each classification can return a category and a short reason, the output allowance grows with batch size. A production policy can estimate that output separately and reduce the prompt budget accordingly. The unexplained <code>8</code> in this sample represents JSON framing and a safety allowance. In production it should be a named, measured option. Token estimates should be compared with provider reported usage so the margin can be corrected over time. A batcher that has never been checked against real requests is only expressing confidence, not enforcing a limit.</p>
<p>Oversized individual items need an explicit route. Truncating them silently can change the classification. Depending on the domain, the application may reject them, summarise them through a separate controlled workflow, split them into meaningful sections or send them to a model with a larger context window. That choice belongs to the feature, because it changes which evidence the model sees.</p>
<h2>A batch changes model behaviour</h2>
<p>Database batching is usually a transport optimisation. Model batching can change the answer. When several inputs share one context, the model can compare them even when the application didn't ask it to. A category used for an early item may influence a later item. A long or unusually phrased input can pull attention away from shorter neighbours. If one item contains hostile instructions, those instructions are now in the same prompt as other customers' data. Structured framing helps. Each item should be represented as data with an opaque identifier, and the system instruction should state that item content is untrusted and must be evaluated independently. This reduces ambiguity but doesn't create the hard isolation provided by separate requests.</p>
<p>That distinction is especially important for multi tenant or security sensitive workloads. Combining inputs from different trust domains can expand the effect of prompt injection and complicate data residency, logging and authorisation. Some workloads should be batched only within one tenant, one policy boundary or one sensitivity class. Others shouldn't share a model context at all. Batch size therefore has a quality dimension alongside cost and throughput. Evaluation should compare per item and batched results using realistic distributions, including a deliberately adversarial item placed beside ordinary ones. The largest batch that fits the context window is rarely the batch size with the best operational and behavioural properties.</p>
<h2>When calls must remain separate</h2>
<p>Some operations genuinely need one request per item. An input may consume most of the context window. Each call may use different tools, permissions or response schemas. Results may have strict latency requirements and need to complete independently. Security policy may prohibit multiple users' content from sharing one inference context. In those cases, the N calls may be intentional, but unbounded fan-out still isn't. <code>Parallel.ForEachAsync</code> provides a clear local concurrency boundary. The following version allows four model calls at a time and retains correlation by ID.</p>
<pre><code class="language-csharp">using System.Collections.Concurrent;

public sealed class IndividualClassificationRunner(
    ClassificationClient classificationClient)
{
    public async Task&lt;IReadOnlyList&lt;ClassificationResult&gt;&gt; ClassifyAsync(
        IReadOnlyList&lt;ClassificationInput&gt; inputs,
        CancellationToken stopToken)
    {
        var results = new ConcurrentDictionary&lt;Guid, ClassificationResult&gt;();

        await Parallel.ForEachAsync(
            inputs,
            new ParallelOptions
            {
                MaxDegreeOfParallelism = 4,
                CancellationToken = stopToken
            },
            async (input, itemStopToken) =&gt;
            {
                var batch = await classificationClient.ClassifyAsync(
                    [input],
                    itemStopToken);

                if (!results.TryAdd(input.Id, batch[0]))
                {
                    throw new InvalidOperationException(
                        $"Duplicate result for {input.Id}.");
                }
            });

        return inputs
            .Select(x =&gt; results[x.Id])
            .ToArray();
    }
}
</code></pre>
<p>The concurrency value shouldn't be copied from a blog post, including this one. It has to reflect provider quotas, average token weight, desired latency and the number of application instances. Four concurrent calls in one process become 80 when 20 replicas run the same code. The <a href="https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.parallel.foreachasync?view=net-10.0"><code>Parallel.ForEachAsync</code> API</a> controls one operation in one process. A limiter in the <code>IChatClient</code> pipeline protects all code paths using that client instance. Microsoft demonstrates this composition using <code>System.Threading.RateLimiting</code> in its <code>IChatClient</code> guidance. The two controls solve different scopes and are often useful together. A local loop prevents one bulk operation from consuming every permit. A shared client limiter stops unrelated features from collectively overwhelming the provider. In a scaled deployment, a provider aware gateway or distributed admission mechanism may be needed because an in memory semaphore cannot see calls made by other replicas.</p>
<p>Request concurrency is also an incomplete approximation when requests vary greatly in size. A 200 token classification and a 60,000 token analysis each occupy one permit but create very different quota pressure. Where token per minute limits dominate, admission should be weighted by estimated input and output tokens, then corrected using actual usage returned by the provider.</p>
<h2>Backpressure begins before the model client</h2>
<p>Bulk AI work shouldnt normally remain attached to an HTTP request while hundreds of items are processed. Client disconnects, reverse proxy timeouts and deployment restarts make that lifecycle too fragile. A durable work record gives the application somewhere to store ownership, attempts and partial progress. A bounded <code>Channel&lt;T&gt;</code> can be useful inside a single process because producers wait when its capacity is full. The <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/channels">current .NET channel documentation</a> describes this backpressure behaviour. A channel doesnt provide durability across restarts, so workloads that must survive process loss need a durable queue or database backed scheduler instead.</p>
<p>The important property is bounded admission. If the upstream system can enqueue work without limit while the model can process only a fixed token volume, the queue has merely moved into memory, a broker or a database table. Queue age will rise until the result is no longer useful. Capacity should be expressed in terms the business can observe. A system might accept 10,000 pending items while keeping the oldest item below a five minute service target. Once it can't meet that target, it can reject new bulk jobs, reduce their size, defer low priority work or route to a cheaper model. These are clearer behaviours than accepting everything and discovering the backlog from a billing alert.</p>
<h2>A successful response can still be partially failed</h2>
<p>HTTP success says that the provider returned a response. It doesn't say that every business item completed. A batch of 40 inputs may contain 38 valid results, omit one ID and duplicate another. The JSON can be syntactically valid and the model call can report a successful finish reason. Treating the batch as a single Boolean success either discards 38 useful results or allows corrupted correlation into the database. The execution model should record outcomes per item. Valid, known IDs can be accepted. Unknown and duplicate IDs should be quarantined as response contract violations. Missing IDs can be retried in a smaller batch. An invalid category may be permanently rejected or sent through a repair prompt, depending on the domain.</p>
<p>Retries should operate on the unresolved set rather than replaying every original item. Replaying the whole batch consumes extra tokens and can produce different answers for records that already succeeded. If the application does retry a completed item, it needs a policy for whether the newer answer replaces the earlier one. This makes an attempt identifier useful. Persist the model, prompt version, batch identifier, item identifier, provider request identifier where available, token usage and validation outcome. Those fields allow an operator to explain which invocation produced a stored classification and distinguish a transport retry from a deliberate re-evaluation. The retry batch should usually shrink. If a 50 item response repeatedly omits entries near its end, sending the same 50 item prompt again repeats the conditions that caused the failure. Retrying only the missing items reduces output pressure and isolates malformed content.</p>
<h2>Keep database transactions away from inference</h2>
<p>A model request shouldnt run inside a database transaction. Inference latency is variable, providers can throttle, and a retry policy can extend the call far beyond its usual duration. Holding locks or database connections throughout that period couples database health to external model capacity. A safer workflow claims a set of records in a short transaction, records the attempt and ownership version, then commits. The model call runs after the transaction closes. A second short transaction writes each result only if the record is still owned by that attempt and remains in the expected state.</p>
<p>This protects the application from late results. If an operator cancels and requeues the work, or another worker legitimately takes ownership after a lease expires, the earlier model response shouldn't overwrite the newer outcome. Optimistic concurrency, an attempt version or a fencing token can enforce that condition at the write boundary.</p>
<p>The same rule applies to cancellations. Cancelling <code>stopToken</code> stops waiting for cooperative operations, but it cannot prove that the remote provider performed no work. A request may finish after the caller has given up. Persisted state must decide whether a late result still belongs to the active attempt.</p>
<h2>Caching needs semantic keys</h2>
<p>Batching changes how caching should be approached. Caching the raw batch response by the entire prompt gives poor reuse because a different item order or one changed record creates a new key. It can also make a partial response appear authoritative on the next attempt. Per item caching can be valuable for deterministic extraction or classification, but the key needs more than the input text. The model identifier, prompt version, response schema, relevant options and policy context all influence the meaning of the result. If any of them changes, an old result may no longer be valid. <code>Microsoft.Extensions.AI</code> includes caching middleware in the <code>IChatClient</code> ecosystem, but cache policy remains an application decision. Highly creative outputs, security sensitive evaluations and decisions based on changing external tools may have little safe reuse. A cache hit is only useful when equivalence is defined precisely enough for the domain.</p>
<h2>Measure cost per accepted result</h2>
<p>Provider latency alone doesnt reveal an N+1 problem. A dashboard can show healthy 400 millisecond calls while one user action quietly creates 500 of them. Telemetry should connect model activity to the logical operation. For each job, record the number of source items, batches, model calls, retries and accepted results. Record batch size and token distributions, not just averages. Averages hide the single oversized input that causes most failures.</p>
<p>Cost per accepted result is more informative than cost per call. A larger batch may reduce request count but produce more omissions, driving repair calls and manual review. A smaller batch may cost slightly more in repeated instructions while delivering a higher valid result rate. The effective cost includes the recovery path. Throttle wait, queue age and permit utilisation reveal whether the application is applying backpressure before the provider rejects requests. Missing, unknown and duplicate output IDs should be explicit counters. They are contract failures, not log messages to be discovered during an incident. The OpenTelemetry integration described in the <code>IChatClient</code> documentation can capture the model client layer. Application metrics still need to describe item and batch semantics. Be careful with prompt and response capture: full model content can contain personal data, secrets or customer material, and high cardinality item IDs don't belong on metric labels.</p>
<h2>Test distributions rather than happy-path batches</h2>
<p>A unit test with three ten word inputs proves very little about a production batcher. Its difficult behaviour appears at boundaries: the item that exactly fills the token budget, the next item that starts a new batch, and the single input that exceeds the budget by itself. The classification client needs contract tests for missing, duplicate and unknown IDs. It should also be tested when results arrive in a different order, because ordering by response position must never become an accidental dependency. Cancellation should be exercised while batches remain outstanding, followed by a late completion to verify that the persistence boundary rejects obsolete work.</p>
<p>Evaluation data should contain the size and language distribution seen in production. Token density varies between inputs, as does expected output length. Include empty values, large pasted documents, malformed Unicode, embedded JSON, markup and text that attempts to instruct the model. Place the hostile sample beside ordinary inputs to detect cross item influence. Load tests need a representative number of application replicas. An in process concurrency limit can perform perfectly on one instance and exceed provider capacity immediately after horizontal scaling. The useful assertions concern total call rate, token admission, queue age and recovery from throttling, rather than raw requests per second alone.</p>
<h2>Decide at the operation boundary</h2>
<p>The easiest place to prevent N+1 model calls is the API presented to application code. If the domain operation classifies a collection, expose <code>ClassifyAsync(IReadOnlyList&lt;ClassificationInput&gt;)</code> rather than teaching every caller to loop over <code>ClassifyAsync(ClassificationInput)</code>. The collection shaped API gives the implementation room to batch, split by tokens, apply shared limits and report partial outcomes. An item shaped interface almost guarantees that batching will be rebuilt awkwardly above it. By the time a decorator sees individual calls, the logical collection, shared deadline and desired result policy may already have been lost.</p>
<p>The application should first decide whether inputs may share a model context. If they can, build batches using item and token limits, enforce identity in the response and measure quality as batch size changes. If they cannot, keep calls separate but apply bounded concurrency and shared admission control. In either case, persist enough attempt state to make retries and late results safe. The database version of N+1 taught developers to inspect what apparently innocent navigation property access does at the storage boundary. AI integration needs the same instinct. Whenever model invocation appears inside <code>Select</code>, <code>foreach</code> or a per row handler, count the calls created by the surrounding operation. Asynchronous code can hide the waiting. It cannot hide the bill, the quota or the recovery work.</p>
]]></content:encoded></item><item><title><![CDATA[Project Zenith and AMD Ryzen AI Halo]]></title><description><![CDATA[For the past two years, the phrase "AI PC" has mostly meant a normal laptop with an NPU, a Copilot key and a collection of features that could also have run in the cloud. Microsoft's Project Zenith an]]></description><link>https://fullstackcity.com/project-zenith-and-amd-ryzen-ai-halo</link><guid isPermaLink="true">https://fullstackcity.com/project-zenith-and-amd-ryzen-ai-halo</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[AI]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[Windows]]></category><category><![CDATA[project zenith]]></category><category><![CDATA[operating system]]></category><category><![CDATA[software development]]></category><category><![CDATA[Programming Blogs]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sun, 06 Sep 2026 15:00:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/ca094ba0-9367-4b8c-ba93-ac4c6d0a87ec.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>For the past two years, the phrase "AI PC" has mostly meant a normal laptop with an NPU, a Copilot key and a collection of features that could also have run in the cloud. Microsoft's Project Zenith and AMD's new Ryzen AI Halo hardware point towards something much more interesting, a Windows development machine designed to run substantial AI models locally as part of an everyday engineering workflow.</p>
<p>This is an important distinction. Project Zenith has already been described in some coverage as a new or stripped down version of Windows 11. Microsoft's own announcement is more precise. It calls Zenith a "ready to code" Windows 11 experience for developer class devices, with a preconfigured toolchain, developer friendly defaults and hardware capable of running large models locally. Microsoft has not announced a separate Windows edition, downloadable ISO or replacement for Windows 11 Pro. The first hardware platform will be AMD Ryzen AI Halo. This is AMD's new branded AI developer mini-PC, rather than simply another name for a processor generation. It pairs high end Ryzen AI compute with 128 GB of unified memory, full ROCm support and a choice of Windows or Linux. The result sits somewhere between a powerful workstation, a local inference server and the sort of developer box that would previously have lived in a cloud subscription. The software and hardware announcements make far more sense together than they do separately. Microsoft is defining what a Windows machine for local AI development should feel like. AMD is supplying a compact system with enough shared memory to make that experience useful.</p>
<h2>Project Zenith is a Windows developer experience</h2>
<p>Microsoft introduced the ideas behind Zenith at Build 2026 and formally announced the project in September. A qualifying device needs at least 64 GB of unified memory and more than 250 GB/s of memory bandwidth. Microsoft says these systems can run models with more than 30 billion parameters locally and without metered token charges. Those requirements tell us more about Zenith than the preinstalled applications do. This is not an attempt to put Copilot on another category of consumer PC. Microsoft is defining a class of machine for developers who want local coding models, agent runtimes and AI-assisted applications operating alongside their normal development stack.</p>
<p>The Windows setup arrives with the tools most developers would install during their first few hours with a new machine. Microsoft explicitly names Windows Terminal and Visual Studio Code, while the wider toolset shown with the announcement includes GitHub Copilot, PowerToys, WinAppCLI, Windows Dev Skills, PowerShell 7, Git, GitHub CLI, Azure CLI, Python, Node.js, WSL with Ubuntu and .NET 10. Its also configured with sensible engineering defaults. File extensions, hidden files and the full path are visible in File Explorer. Long path support is enabled. Recent file suggestions, sync provider tips, Start menu promotions and account notifications are reduced or disabled. The PowerToys Command Palette is available from the beginning. These are individually small changes, but together they remove a surprising amount of friction from setting up a Windows development environment.</p>
<p>The inclusion of WSL is particularly useful. Local AI tooling still tends to reach Linux first, especially around model serving, Python environments and GPU acceleration. A Windows machine that can move between .NET, native Windows applications and Linux based inference tooling without becoming two separate computers is a compelling proposition. Microsoft is also pushing WSL containers as a built in way to create and run Linux containers directly from Windows.</p>
<p>According to the <a href="https://blogs.windows.com/windowsdeveloper/2026/09/04/announcing-project-zenith-the-ready-to-code-windows-experience/">official Project Zenith announcement</a>, the environment remains customisable. Zenith provides the starting configuration rather than locking developers into Microsoft’s chosen editor, runtime or workflow.</p>
<h2>Ryzen AI Halo is the hardware platform, not just a chip name</h2>
<p>AMD Ryzen AI Halo is AMD's first branded AI developer platform: a compact mini-PC built specifically for local AI development. The current system uses a Ryzen AI Max+ 395 processor, 128 GB of LPDDR5X unified memory, integrated Radeon 8060S graphics and an NPU delivering up to 50 TOPS. AMD quotes up to 60 FP16 TFLOPS of GPU performance and supplies the machine with Windows 11 Pro or Linux. The distinction between the complete Halo machine and the processor inside it is worth preserving. Ryzen AI Halo is the system developers buy. Ryzen AI Max+ 395 is the processor powering the initial version. Reducing the product to an older silicon codename misses AMD's larger move into complete, validated developer hardware.</p>
<p>AMD has designed the system around its ROCm software stack and the frameworks developers already use, including PyTorch, vLLM, llama.cpp, Ollama, ComfyUI and LM Studio. The AMD Ryzen AI Developer Center provides access to validated configurations, playbooks, tools and updates. This is an attempt to sell a working development platform rather than hand developers a capable chip and leave them to assemble the software themselves. The <a href="https://www.amd.com/en/products/processors/desktops/ryzen/ryzen-ai-halo.html">AMD Ryzen AI Halo product page</a> says the current 128 GB machine can run models containing as many as 200 billion parameters. A next generation version using the Ryzen AI Max+ PRO 495 and supporting 192 GB of unified memory is also marked as coming soon. AMD says that version will allow as much as 160 GB to be assigned as graphics memory. The initial system is listed at $3,999 in the United States and is being sold through Micro Center. AMD currently describes it as available for purchase and use in the US, so buyers elsewhere should not assume there is already an official local sales route. Microsoft has also said that Project Zenith will arrive on further devices from OEM and silicon partners, which should eventually make the concept broader than this first AMD box.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/4324eca8-f300-45dd-ac03-30dcbe20638e.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Unified memory is the real AI specification</h2>
<p>The NPU number will attract attention because AI PCs have trained buyers to compare TOPS. For the workloads targeted by Ryzen AI Halo, memory is the more consequential specification. A model must fit somewhere before a CPU, GPU or NPU can run it. Conventional workstations divide system RAM and dedicated GPU memory into separate pools. A machine may have 128 GB of system memory while still being unable to load a model requiring more than the 16 GB or 24 GB attached to its graphics card. Moving data between those pools also adds overhead. Ryzen AI Halo's unified memory can be shared by the CPU and integrated GPU. That gives local inference workloads a much larger usable memory pool without requiring an extremely expensive discrete accelerator. It also explains Microsoft's 250 GB/s bandwidth requirement. Capacity allows the model to load; bandwidth has a major influence on how quickly inference can move through its weights.</p>
<p>This doesnt mean a $3,999 mini-PC suddenly performs like a rack of data centre GPUs. Parameter counts are also easy to misread. Whether a 120-billion or 200-billion parameter model fits depends on quantisation, context length, runtime overhead, model architecture and how much memory remains available to Windows and other applications. Fitting a model is only the first test. The generated tokens per second still need to be fast enough for the intended workflow. Microsoft's more conservative promise of capable 30B-plus models is then the better baseline for evaluating Zenith. Models in that range can support useful coding, retrieval, extraction and agent workloads while leaving enough headroom for an IDE, containers, databases and the rest of a real development environment.</p>
<h2>Local AI changes the developer workflow</h2>
<p>Most enterprise AI systems will continue to use cloud models. Frontier models improve rapidly, managed APIs remove infrastructure work, and cloud capacity can scale far beyond a desktop machine. Local AI adds another execution target rather than making those advantages disappear. The benefit is control over where each part of a workload runs. A coding agent can use a local model for repository navigation, classification, summarisation, test generation or repetitive tool decisions, then call a frontier cloud model for the difficult reasoning step. A document pipeline can perform initial extraction and redaction locally before sending a smaller, controlled payload to a hosted model. Developers can run large test suites without paying for every experimental prompt or placing source code in an external request. This hybrid approach also improves resilience. Local development can continue when a provider is rate limited, an API is unavailable or a team has exhausted its token budget. Models and prompts can be pinned for repeatable testing instead of silently changing beneath an evaluation. Sensitive prototypes can remain on the developer’s machine until the team has approved an external deployment path.</p>
<p>Agent development makes those economics more visible. A conventional chat request may involve one model call. An agent can make dozens or hundreds while planning, reading files, invoking tools, checking results and correcting itself. Microsoft argues that capable local models can absorb the continuous, lower value inference while frontier models remain available for frontier problems. The phrase is marketing friendly, but the architecture behind it is sound. Paying $3,999 upfront will not automatically cost less than using APIs. The calculation depends on utilisation, electricity, maintenance, model quality and developer time. A lightly used machine may never recover its cost. A team continuously running long agent sessions, evaluations or private inference could reach a very different result. Local inference also makes usage predictable: the marginal cost of another test run becomes close to zero once the hardware has been purchased.</p>
<h2>Windows is becoming an agent host</h2>
<p>The quieter part of the Zenith announcement may prove more important than the bundled tools. Microsoft says Zenith devices will benefit from its work on operating system enforced agent identity, Microsoft Execution Containers and enterprise manageability. Agents create a security problem that ordinary desktop applications do not fully capture. They can interpret untrusted content, decide which tool to call and perform a sequence of actions that was not written explicitly by a developer. Giving an agent the same identity, filesystem access and network permissions as its user is convenient during a demo and uncomfortable everywhere else. OS-enforced identities could allow each agent to operate as a distinct principal. Execution containers could constrain its files, processes and network access. Enterprise management could give organisations a consistent way to discover, configure and disable agents across a fleet. Microsoft has not yet supplied enough shipping detail to treat all of this as a finished security boundary, but placing it at operating system level is the right direction. Application libraries alone cannot reliably govern every agent and tool running on a developer workstation.</p>
<p>For .NET developers, this opens an interesting path. A local model server can sit beside an ASP.NET Core application, worker or Aspire environment without each experiment becoming a cloud infrastructure project. WSL can host Linux first model runtimes while Windows runs Visual Studio, SQL Server tooling and the rest of the Microsoft stack. If Microsoft exposes agent identity and containment through stable Windows APIs, .NET applications could participate in those controls rather than inventing their own local security model.</p>
<h2>The software stack still has to prove itself</h2>
<p>AMD hardware has not traditionally been the easiest route into local generative AI. Much of the ecosystem was built first around NVIDIA CUDA, and Windows support frequently arrived after Linux support. ROCm on Windows and integrated Radeon hardware has improved, but compatibility should be tested against the models, quantisation formats and runtimes a team intends to use. The presence of a Windows installer and a preconfigured application does not guarantee that every PyTorch extension, inference backend or fine tuning technique will perform identically across operating systems. Some workflows may still belong inside WSL or on the Linux image. Others may fall back to Vulkan or DirectML instead of using the most optimised ROCm path.</p>
<p>This is where the partnership between AMD and Microsoft becomes valuable. Zenith gives AMD a highly visible Windows target, while Ryzen AI Halo gives Microsoft hardware on which its local AI story can be tested against serious workloads. A validated combination of drivers, runtimes and developer tools can remove much of the uncertainty that has made local AI on PCs feel experimental. There are unanswered product questions too. Microsoft has not said whether Zenith will later be downloadable for existing qualifying PCs, offered through Windows setup, or remain an OEM factory configuration. It has not published final pricing for a Zenith equipped Halo device or confirmed whether current Ryzen AI Halo buyers will be able to add the complete Zenith experience themselves. Until Microsoft provides those details, buying the current Halo hardware and receiving Project Zenith should be treated as related decisions rather than assumed to be the same purchase.</p>
<h2>A credible Windows AI workstation at last</h2>
<p>Project Zenith will not transform every Windows developer into an AI engineer, and Ryzen AI Halo is too expensive and specialised to replace the ordinary development laptop. Together, however, they establish a credible new category. The interesting part is not that Windows ships with VS Code pinned to the taskbar. Developers can install a toolchain themselves. The advance comes from joining a calmer Windows configuration to hardware with enough unified memory and bandwidth to run useful models locally, then supporting it with WSL, ROCm and emerging operating system controls for agents. That combination changes local AI from an enthusiast setup into something an engineering team could evaluate as a standard development platform. It gives Windows developers a practical place to build hybrid AI systems, test agents without counting every token and keep selected workloads on the machine. It also creates meaningful competition for NVIDIA’s compact AI systems and Apple’s unified memory machines.</p>
<p>For years, the AI PC label has promised more intelligence without substantially changing what developers can run. Project Zenith and AMD Ryzen AI Halo finally push beyond that. They treat local models as part of the development environment itself, alongside the editor, terminal, containers and source control. That is a much stronger foundation for the next generation of Windows software than another Copilot button.</p>
]]></content:encoded></item><item><title><![CDATA[RAG's Dirty Secret]]></title><description><![CDATA[Most RAG examples make ingestion look like a preprocessing step. Thats ok for a demo because the documents usually never change. Production data behaves differently, a failed deployment stops an index]]></description><link>https://fullstackcity.com/rag-s-dirty-secret</link><guid isPermaLink="true">https://fullstackcity.com/rag-s-dirty-secret</guid><category><![CDATA[AI]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[software development]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[Programming Blogs]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sun, 06 Sep 2026 09:58:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/13581bdd-2b4e-48f7-930d-e5607467495e.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>Most RAG examples make ingestion look like a preprocessing step. Thats ok for a demo because the documents usually never change. Production data behaves differently, a failed deployment stops an indexing worker halfway through a document, an embedding model changes while millions of vectors still belong to the previous model. The vector index now contains another representation of application data, produced asynchronously through several fallible transformations. It has its own records, identifiers, schema, lifecycle, failure modes and operational state. Whatever product name appears on the invoice, the application has acquired another database. Calling it an index doesn't remove the distributed systems problem. It just makes that problem easier to miss.</p>
<h2>Your demo of RAG hides the truth</h2>
<p>A basic RAG flow is easy to describe. The application reads source data, extracts text, creates chunks, generates an embedding for each chunk and stores the result. A later request embeds the user's query, searches for similar chunks and passes the best matches to a language model. Microsoft's current <a href="https://learn.microsoft.com/en-us/dotnet/ai/conceptual/rag">.NET RAG documentation</a> describes the same basic path, process each source, chunk it, convert those chunks into a searchable form, store them and retain metadata that links the searchable representation back to its source. <code>Microsoft.Extensions.DataIngestion</code> now provides readers, processors, chunkers and vector-store writers for this pipeline, while <code>Microsoft.Extensions.VectorData</code> provides common CRUD and search abstractions across vector stores. Those libraries remove a useful amount of plumbing. They cannot decide what should happen when the source update succeeds and vectorisation fails, or when an old indexing attempt completes after a newer one. Those decisions belong to the application.</p>
<p>The simplest implementation often puts the two writes in the request path:</p>
<pre><code class="language-csharp">app.MapPut("/documents/{documentId:guid}", async (
    Guid documentId,
    UpdateDocumentRequest request,
    DocumentsDbContext dbContext,
    DocumentProjector projector,
    CancellationToken stopToken) =&gt;
{
    var document = await dbContext.Documents
        .SingleAsync(x =&gt; x.Id == documentId, stopToken);

    document.ReplaceContent(request.Content);
    await dbContext.SaveChangesAsync(stopToken);
    await projector.ProjectAsync(document.Id, stopToken);
    return Results.NoContent();
});
</code></pre>
<p>The code looks neat because its happy path reads in the same order as the requirement. It also creates a consistency gap immediately. The relational save and the vector-store update are independent remote operations. No ordinary database transaction covers both. If the first write succeeds and the second fails, the API reports failure even though the document changed. A client retry updates the source again and may create another version. If vectorisation succeeds but the HTTP connection disappears before the response reaches the client, the caller cannot tell whether anything happened. If several chunks are written before the vector store rejects the next one, search can observe a partially updated document. Moving <code>ProjectAsync</code> into a background task shortens the request, but it doesn't close any of those gaps. It changes where they occur.</p>
<h2>A vector index is a materialised search projection</h2>
<p>A better architectural classification is to treat the vector index as a materialised view of authoritative data. The source system owns the current document, its lifecycle and its access rules. The vector store holds a representation shaped specifically for semantic retrieval. The Azure Architecture Center's <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/materialized-view">Materialized View pattern</a> makes two points that fit RAG particularly well. A view can be stored separately from its source and optimised for a narrow set of queries. It should also be disposable and rebuildable from the source rather than updated as an independent authority. An embedding is plainly derived data. So is the extracted text from a PDF, the chunk boundary, an AI-generated summary and every piece of metadata copied into the search record. Each value depends on a particular document version and on a particular version of the projection pipeline.</p>
<p>That relationship gives the system a clear ownership rule. Business operations update the authoritative store. Projection workers read committed source state and create searchable representations. Retrieval treats vector matches as candidates and verifies them against current application state before those matches become model context.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/bec68bcf-16af-41fa-9093-111d61019896.png" alt="" style="display:block;margin:0 auto" />

<p>The verification step is important. Approximate nearest neighbour search answers which stored vectors are close to the query vector. It doesn't prove that the underlying documents still exist, that they remain current or that this user may read them.</p>
<h2>Every projection needs a source version</h2>
<p>Suppose a document is edited three times while a slow indexing worker is processing its first version. Without explicit versioning, whichever worker finishes last can overwrite the index. Completion order becomes the accidental consistency policy. Give every authoritative document a monotonically increasing version. Carry that version into every chunk produced from it. A chunk then identifies both the document and the exact source state from which it was derived.</p>
<p>Here is a deliberately small EF Core entity:</p>
<pre><code class="language-csharp">public sealed class Document
{
    private Document()
    {
    }

    public Guid Id { get; private set; }
    public Guid TenantId { get; private set; }
    public long Version { get; private set; }
    public string Content { get; private set; } = string.Empty;
    public bool IsDeleted { get; private set; }
    public DateTimeOffset UpdatedAt { get; private set; }

    public void ReplaceContent(string content, TimeProvider clock)
    {
        Content = content;
        Version++;
        UpdatedAt = clock.GetUtcNow();
    }

    public void Delete(TimeProvider clock)
    {
        IsDeleted = true;
        Version++;
        UpdatedAt = clock.GetUtcNow();
    }
}
</code></pre>
<p>The version belongs to the source record rather than the vector store. It advances for every change capable of altering retrieval, including content changes, deletion and any security metadata copied into the projection.</p>
<p>The vector record carries more than an embedding:</p>
<pre><code class="language-csharp">using Microsoft.Extensions.VectorData;

public sealed class DocumentChunk
{
    [VectorStoreKey]
    public string Key { get; init; } = string.Empty;
    [VectorStoreData]
    public Guid TenantId { get; init; }
    [VectorStoreData]
    public Guid DocumentId { get; init; }
    [VectorStoreData]
    public long DocumentVersion { get; init; }
    [VectorStoreData]
    public int ChunkNumber { get; init; }
    [VectorStoreData]
    public string Text { get; init; } = string.Empty;
    [VectorStoreData]
    public string ContentHash { get; init; } = string.Empty;
    [VectorStoreData]
    public string EmbeddingModel { get; init; } = string.Empty;
    [VectorStoreData]
    public string ProjectionSchema { get; init; } = string.Empty;
    [VectorStoreVector(
        dimensions: 1536,
        DistanceFunction = DistanceFunction.CosineSimilarity)]
    public ReadOnlyMemory&lt;float&gt; Vector { get; init; }
}
</code></pre>
<p>The exact attributes and supported filters depend on the selected <code>Microsoft.Extensions.VectorData</code> connector. The current <a href="https://learn.microsoft.com/en-us/dotnet/ai/vector-stores/how-to/build-vector-search-app">.NET vector search guidance</a> uses the same key, data and vector distinction, with <code>VectorStoreCollection&lt;TKey, TRecord&gt;</code> providing upsert and search operations. The chunk key should be deterministic. A value such as <code>tenantId/documentId/documentVersion/chunkNumber/projectionSchema</code> makes a repeated attempt overwrite the same logical record. A retry after an uncertain response doesn't create a second copy, and two document versions cannot silently overwrite one another.</p>
<pre><code class="language-csharp">private static string CreateChunkKey(
    Guid tenantId,
    Guid documentId,
    long documentVersion,
    int chunkNumber,
    string projectionSchema) =&gt;
    $"{tenantId:N}/{documentId:N}/{documentVersion}/{chunkNumber}/{projectionSchema}";
</code></pre>
<p>A random GUID generated during every attempt throws away that property. It makes duplicate detection, repair and deletion harder for no gain.</p>
<h2>Record the need to index in the source transaction</h2>
<p>Saving a document and publishing a message afterwards creates another two-write problem. The process can stop after the database commit but before message publication. The document is now current, yet no worker knows that its projection is missing. Record projection work in the same database transaction as the source change. This can be a compact work ledger owned by the document feature. It needs the document identity, the committed version, a status, attempt information and enough timing data for retries and operational queries.</p>
<pre><code class="language-csharp">public sealed class SearchProjectionWork
{
    private SearchProjectionWork()
    {
    }

    public Guid Id { get; private set; }
    public Guid DocumentId { get; private set; }
    public long DocumentVersion { get; private set; }
    public ProjectionWorkStatus Status { get; private set; }
    public int AttemptCount { get; private set; }
    public DateTimeOffset CreatedAt { get; private set; }
    public DateTimeOffset? NextAttemptAt { get; private set; }

    public static SearchProjectionWork Create(
        Guid documentId,
        long documentVersion,
        DateTimeOffset createdAt) =&gt;
        new()
        {
            Id = Guid.NewGuid(),
            DocumentId = documentId,
            DocumentVersion = documentVersion,
            Status = ProjectionWorkStatus.Pending,
            CreatedAt = createdAt
        };
}

public enum ProjectionWorkStatus
{
    Pending,
    Processing,
    Completed,
    Superseded,
    Failed
}
</code></pre>
<p>The command handler changes the document and adds the work record before calling <code>SaveChangesAsync</code> once:</p>
<pre><code class="language-csharp">public sealed record UpdateDocumentCommand(Guid DocumentId, string Content);

public sealed class UpdateDocumentHandler(
    DocumentsDbContext dbContext,
    TimeProvider clock)
{
    public async Task Handle(
        UpdateDocumentCommand command,
        CancellationToken stopToken)
    {
        var document = await dbContext.Documents
            .SingleAsync(x =&gt; x.Id == command.DocumentId, stopToken);

        document.ReplaceContent(command.Content, clock);

        dbContext.SearchProjectionWork.Add(
            SearchProjectionWork.Create(
                document.Id,
                document.Version,
                clock.GetUtcNow()));

        await dbContext.SaveChangesAsync(stopToken);
    }
}
</code></pre>
<p>After that transaction commits, the application has either both the new document version and its projection request, or neither. A worker can poll the ledger, or a relay can publish the work identifier to a broker for lower latency. The database row remains the recoverable record if broker delivery fails. This doesn't make the relational database and vector store participate in one transaction. It removes the dangerous interval in which committed source state can become permanently invisible to the indexing process.</p>
<h2>Build immutable versions before publishing them</h2>
<p>Writing chunks directly over the currently searchable records allows a half-finished attempt to leak into retrieval. The safer approach writes a new, immutable document version alongside the old one. The worker loads the requested source version, creates all chunks, generates all embeddings and upserts records whose keys include that version. Only after every required chunk has been stored does it mark the version as published in the authoritative database.</p>
<p>The publication state can remain very small:</p>
<pre><code class="language-csharp">public sealed class SearchProjectionState
{
    private SearchProjectionState()
    {
    }

    public Guid DocumentId { get; private set; }
    public long? PublishedDocumentVersion { get; private set; }
    public string? ProjectionSchema { get; private set; }
    public DateTimeOffset? PublishedAt { get; private set; }
}
</code></pre>
<p>The distinction between stored and published is deliberate. A process can stop after writing two of ten chunks. Those two records exist, but retrieval will reject them because the corresponding version was never published. If the process stops after writing all ten chunks but before updating <code>SearchProjectionState</code>, the retry writes the same ten deterministic keys and then publishes. If publication succeeds but cleanup of the old version fails, both versions remain in the vector store, but only the current published version is eligible to reach the prompt. The projector can depend on narrow application interfaces while the infrastructure layer adapts a concrete <code>VectorStoreCollection&lt;string, DocumentChunk&gt;</code>:</p>
<pre><code class="language-csharp">public interface IVectorChunkStore
{
    Task UpsertAsync(
        IReadOnlyCollection&lt;DocumentChunk&gt; chunks,
        CancellationToken stopToken);

    Task DeleteDocumentVersionAsync(
        Guid tenantId,
        Guid documentId,
        long documentVersion,
        CancellationToken stopToken);
}

public interface IDocumentChunker
{
    IReadOnlyList&lt;string&gt; Split(string content);
}
</code></pre>
<p>The implementation below focuses on the consistency boundary rather than connector specific batching:</p>
<pre><code class="language-csharp">using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.AI;

public sealed class DocumentProjector(
    DocumentsDbContext dbContext,
    IDocumentChunker chunker,
    IEmbeddingGenerator&lt;string, Embedding&lt;float&gt;&gt; embeddingGenerator,
    IVectorChunkStore vectorStore,
    ProjectionPublisher publisher)
{
    private const string EmbeddingModel = "text-embedding-3-small";
    private const string ProjectionSchema = "document-rag-v3";

    public async Task ProjectAsync(
        Guid documentId,
        long requestedVersion,
        CancellationToken stopToken)
    {
        var document = await dbContext.Documents
            .AsNoTracking()
            .SingleAsync(x =&gt; x.Id == documentId, stopToken);

        if (document.Version != requestedVersion)
        {
            await publisher.MarkSupersededAsync(
                documentId,
                requestedVersion,
                stopToken);

            return;
        }

        if (document.IsDeleted)
        {
            await publisher.PublishDeletionAsync(
                documentId,
                requestedVersion,
                stopToken);

            return;
        }

        var texts = chunker.Split(document.Content);
        var chunks = new List&lt;DocumentChunk&gt;(texts.Count);

        for (var chunkNumber = 0; chunkNumber &lt; texts.Count; chunkNumber++)
        {
            var text = texts[chunkNumber];
            var vector = await embeddingGenerator.GenerateVectorAsync(
                text,
                cancellationToken: stopToken);

            chunks.Add(new DocumentChunk
            {
                Key = CreateChunkKey(
                    document.TenantId,
                    document.Id,
                    document.Version,
                    chunkNumber,
                    ProjectionSchema),
                TenantId = document.TenantId,
                DocumentId = document.Id,
                DocumentVersion = document.Version,
                ChunkNumber = chunkNumber,
                Text = text,
                ContentHash = Convert.ToHexString(
                    SHA256.HashData(Encoding.UTF8.GetBytes(text))),
                EmbeddingModel = EmbeddingModel,
                ProjectionSchema = ProjectionSchema,
                Vector = vector
            });
        }

        await vectorStore.UpsertAsync(chunks, stopToken);

        await publisher.TryPublishAsync(
            document.Id,
            document.Version,
            ProjectionSchema,
            stopToken);
    }

    private static string CreateChunkKey(
        Guid tenantId,
        Guid documentId,
        long documentVersion,
        int chunkNumber,
        string projectionSchema) =&gt;
        $"{tenantId:N}/{documentId:N}/{documentVersion}/{chunkNumber}/{projectionSchema}";
}
</code></pre>
<p><code>TryPublishAsync</code> must be conditional. It should publish version 12 only if the authoritative document is still at version 12 and the existing published version has not advanced beyond it. A transaction or conditional SQL update can enforce that rule. Reading the state, checking it in C# and saving without concurrency protection would allow an older worker to replace a newer publication. That final condition handles an easy to miss race. Version 12 can begin first, version 13 can finish first, and version 12 can then finish last. The vector records may arrive in either order. Publication must remain monotonic.</p>
<h2>Retrieval should distrust its own search results</h2>
<p>Versioned records prevent destructive overwrites, but old vectors can still be returned by similarity search. Physical cleanup is normally asynchronous, so correctness cannot depend on it completing immediately. Search should therefore have two stages. The vector store produces candidates. The application then loads current document and projection state for the returned document IDs, applies current authorisation, rejects stale or unpublished versions and only then constructs model context.</p>
<pre><code class="language-csharp">public sealed record VectorCandidate(
    Guid TenantId,
    Guid DocumentId,
    long DocumentVersion,
    string ProjectionSchema,
    string Text,
    double Score);

public sealed record GroundingChunk(
    Guid DocumentId,
    string Text,
    double Score);

public interface ICurrentUserAccess
{
    Task&lt;HashSet&lt;Guid&gt;&gt; GetReadableDocumentIdsAsync(
        IReadOnlyCollection&lt;Guid&gt; documentIds,
        CancellationToken stopToken);
}
</code></pre>
<pre><code class="language-csharp">public sealed class GroundingRetriever(
    IVectorSearch vectorSearch,
    DocumentsDbContext dbContext,
    ICurrentUserAccess currentUserAccess)
{
    public async Task&lt;IReadOnlyList&lt;GroundingChunk&gt;&gt; RetrieveAsync(
        Guid tenantId,
        string query,
        int requiredCount,
        CancellationToken stopToken)
    {
        var candidates = await vectorSearch.SearchAsync(
            tenantId,
            query,
            top: requiredCount * 5,
            stopToken);

        var documentIds = candidates
            .Select(x =&gt; x.DocumentId)
            .Distinct()
            .ToArray();

        var states = await dbContext.Documents
            .Where(x =&gt; documentIds.Contains(x.Id))
            .Join(
                dbContext.SearchProjectionStates,
                document =&gt; document.Id,
                projection =&gt; projection.DocumentId,
                (document, projection) =&gt; new
                {
                    document.Id,
                    document.TenantId,
                    document.Version,
                    document.IsDeleted,
                    projection.PublishedDocumentVersion,
                    projection.ProjectionSchema
                })
            .ToDictionaryAsync(x =&gt; x.Id, stopToken);

        var readableDocumentIds = await currentUserAccess
            .GetReadableDocumentIdsAsync(documentIds, stopToken);

        var accepted = new List&lt;GroundingChunk&gt;(requiredCount);

        foreach (var candidate in candidates.OrderByDescending(x =&gt; x.Score))
        {
            if (!states.TryGetValue(candidate.DocumentId, out var state))
            {
                continue;
            }

            if (state.TenantId != tenantId || state.IsDeleted)
            {
                continue;
            }

            if (state.Version != candidate.DocumentVersion ||
                state.PublishedDocumentVersion != candidate.DocumentVersion ||
                state.ProjectionSchema != candidate.ProjectionSchema)
            {
                continue;
            }

            if (!readableDocumentIds.Contains(candidate.DocumentId))
            {
                continue;
            }

            accepted.Add(new GroundingChunk(
                candidate.DocumentId,
                candidate.Text,
                candidate.Score));

            if (accepted.Count == requiredCount)
            {
                break;
            }
        }

        return accepted;
    }
}
</code></pre>
<p>The initial search applies the tenant filter inside the vector store. That reduces both leakage risk and wasted candidates, but it isn't the final authorisation decision. Fine grained access is checked using current application data before any text is placed in the prompt. Over fetching compensates for stale candidates that will be rejected. Five times the requested count is an example rather than a universal constant. A busy index with heavy churn may need iterative retrieval: request a page, validate it, then fetch more if too few current results survive.</p>
<p>This validation also changes how the system fails. A delayed index update can produce fewer results or a temporary "knowledge is still being prepared" response. It cannot silently feed a withdrawn document to the model simply because deletion cleanup is running late.</p>
<h2>Deletion and access revocation are correctness paths</h2>
<p>Teams often treat deletion from the vector store as storage maintenance. In a RAG system, deleted text can continue influencing generated answers. An access change can be even more urgent because the content still exists but the current caller is no longer entitled to see it. A source deletion should increment the document version and create projection work in the same commit. Current state validation will reject every earlier chunk as soon as that transaction completes, even while the physical vector deletion is pending. The cleanup worker can then remove all versions of the document and retain a tombstone or completion record for reconciliation.</p>
<p>Access revocation needs a similar immediate effect. If permissions are represented only as copied metadata in the vector index, the security boundary inherits indexing lag. Keeping current authorisation in the retrieval path closes that interval. Coarse, stable attributes such as tenant identity can still be indexed for efficient candidate filtering, while volatile user and role decisions remain authoritative elsewhere. There is a cost, retrieval now performs another read and possibly several authorisation checks. Batch those reads and cache only where invalidation semantics are acceptable. Saving a few milliseconds by trusting stale security metadata is a poor exchange when retrieved text is about to leave the deterministic part of the system and enter a prompt.</p>
<h2>Partial success is normal ingestion behaviour</h2>
<p>The <a href="https://learn.microsoft.com/en-us/dotnet/ai/conceptual/data-ingestion">.NET data-ingestion pipeline</a> explicitly represents partial success. Its <code>ProcessAsync</code> API returns results per document so the caller can decide whether to retry failures or stop. Production indexing code needs the same assumption at every layer. Text extraction can succeed while enrichment fails. Nine chunks can embed successfully while the tenth is rate limited. Every vector upsert can succeed while publication times out. Cleanup of the superseded version can fail after the new one becomes current.</p>
<p>These aren't equivalent outcomes. Before publication, a failed attempt can be repeated using the same deterministic keys. After publication, cleanup can proceed independently because read time validation already protects correctness. Permanent failures should remain visible in the work ledger with their document version and projection schema, rather than disappearing into logs after a fixed number of retries. A dead letter state is useful only when something owns it. Operators need enough data to decide whether to retry, skip, correct the source document or roll back a projection release. "Embedding failed" without the model, source version, chunk number and provider response category rarely supports that decision.</p>
<h2>Embedding changes are database migrations</h2>
<p>An embedding model maps text into a particular vector space. Changing the model can change the vector dimensions and will change the meaning of the coordinates even when the dimensions happen to match. Query vectors from the new model should not be compared with document vectors from the old model. Chunking changes have similar consequences. Altering token limits, overlap, heading rules, OCR behaviour or enrichment prompts changes the searchable records. A source document at version 18 may therefore have several valid historical projections, each produced by a different pipeline.</p>
<p>Treat those changes as projection schema migrations. Give the entire pipeline a version such as <code>document-rag-v3</code>, record the embedding model separately and build the replacement into a new collection or index. Continue serving queries from the current index while the new one is populated and evaluated. Switch a configuration pointer or search alias only when the replacement is complete enough to serve production traffic. This blue green approach costs additional storage and embedding calls during migration. In return, it avoids a long period in which one search collection contains incompatible vectors or an unpredictable mixture of old and new chunking behaviour. Rollback also becomes possible. If retrieval quality falls after the switch, route queries back to the earlier projection while investigating. Re-embedding the entire corpus again shouldn't be the rollback plan.</p>
<h2>Reconciliation is how the system discovers quiet failures</h2>
<p>A durable work record covers known updates, but production systems also need a way to prove that the projection still corresponds to the source. Bugs, manual data fixes, expired dead letters and operational mistakes can all bypass the path engineers expected. A reconciler compares authoritative documents with <code>SearchProjectionState</code>. A live document whose current version isn't published needs new work. A deleted document with remaining vector records needs cleanup. A projection built with a retired schema belongs to a migration backlog. A work item stuck in <code>Processing</code> beyond its lease needs to be reclaimed.</p>
<p>This should be routine background work rather than a disaster recovery script written during an incident. Because the index is a derived projection, rebuilding one document, one tenant or the whole corpus should be an ordinary supported operation. The most useful operational measurement is projection lag: the time between the authoritative commit and publication of its searchable version. Track its median and tail percentiles, not only an average. Also expose the number of current documents awaiting projection, permanently failed versions, rejected stale search candidates and orphaned vector records awaiting deletion.</p>
<p>Those signals reveal different faults. Growing lag suggests insufficient worker capacity or provider throttling. A sudden rise in rejected candidates suggests cleanup failure or a stuck projection version. A stable queue with a rising age suggests poison documents repeatedly taking the same worker slots.</p>
<h2>Decide what freshness the product promises</h2>
<p>Not every RAG feature requires the same consistency behaviour. An internal assistant over slowly changing engineering guidance may tolerate a few minutes of indexing delay. A system answering questions about active insurance terms, revoked access or current prices may not. Make that promise explicit. A strict read path accepts chunks only when the published projection version equals the current source version. During indexing it returns fewer results or tells the caller that the document is still being prepared. A relaxed path may allow the last published version for a bounded period, provided the document still exists and the caller remains authorised. The important part is that the choice belongs to the product and domain. It should not emerge accidentally from how quickly a queue happens to drain. For highly sensitive changes, an update can mark the document unavailable to retrieval until its new version is published. That sacrifices temporary availability for currentness. Other domains may continue serving the previous version and display its effective timestamp. Both policies can be valid when they are chosen deliberately and observable in production.</p>
<h2>Test the gaps between the successful lines</h2>
<p>A test that inserts one document, indexes it and retrieves it proves the happy path already shown by most quickstarts. The valuable tests stop the workflow between its durable state transitions. Stop the projector after the source commit but before the first vector write and confirm that pending work survives. Stop it after several chunk writes and confirm that none are accepted because the version remains unpublished. Stop it after every upsert but before publication and confirm that retrying writes the same keys. Run version 12 and version 13 concurrently, complete them in reverse order and confirm that version 12 cannot replace version 13.</p>
<p>Delete a document while an older projection attempt is running. Revoke access while its chunks remain in the index. Make physical cleanup fail for several hours. Retrieval should reject the content in every case. Migration tests should populate old and new projection schemas together, generate query embeddings with the corresponding model and verify that traffic never crosses the two vector spaces. Reconciliation tests should remove a work row or projection record deliberately and prove that the missing state is discovered and repaired. These tests are more revealing than asserting a particular cosine score from a tiny in-memory store. They exercise ownership, ordering and recovery, which are the properties production failures will challenge.</p>
<h2>The architecture behind reliable RAG</h2>
<p><code>Microsoft.Extensions.DataIngestion</code> can read, transform, chunk and enrich documents. <code>Microsoft.Extensions.AI</code> can generate embeddings. <code>Microsoft.Extensions.VectorData</code> can write records and search them through a common .NET abstraction. Together they provide a far better starting point than every application inventing that plumbing independently. Reliability still comes from the state surrounding those calls. The source record needs a version. Projection work needs to be committed with the source change. Chunk identities need to survive retries. Publication needs to be separate from storage and monotonic across concurrent attempts. Retrieval needs to verify freshness, deletion and authorisation. Reconciliation needs to prove that quiet failures haven't left the index behind.</p>
<p>Once those pieces exist, the vector store can fulfil its proper role, a fast, specialised and replaceable search projection. Without them, it becomes an unacknowledged second authority containing an unknown mixture of current, stale, partial and unauthorised data. The language model will answer from whichever chunks the application supplies. Keeping those chunks aligned with reality is a database consistency problem long before it becomes a prompt engineering problem.</p>
]]></content:encoded></item><item><title><![CDATA[When AsNoTracking Makes EF Core Slower]]></title><description><![CDATA[There is a piece of EF Core advice that appears in almost every performance discussion:

If the query is read-only, use AsNoTracking().

Its usually good advice. Tracking entities requires EF Core to ]]></description><link>https://fullstackcity.com/when-asnotracking-makes-ef-core-slower</link><guid isPermaLink="true">https://fullstackcity.com/when-asnotracking-makes-ef-core-slower</guid><category><![CDATA[Databases]]></category><category><![CDATA[entity framework]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[software development]]></category><category><![CDATA[software engineer]]></category><category><![CDATA[Microsoft]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Tue, 01 Sep 2026 12:57:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/965fab36-84ba-49fe-852b-7c6df9400303.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There is a piece of EF Core advice that appears in almost every performance discussion:</p>
<blockquote>
<p>If the query is read-only, use <code>AsNoTracking()</code>.</p>
</blockquote>
<p>Its usually good advice. Tracking entities requires EF Core to maintain information about every entity it materialises. It keeps references to those entities, records original values and performs identity resolution so that repeated references to the same database row resolve to the same CLR object. Remove tracking and some of that work disappears.</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .AsNoTracking()
    .Where(x =&gt; x.CustomerId == customerId)
    .ToListAsync(stopToken);
</code></pre>
<p>For many read only queries, this is exactly what you want. But turning off tracking changes more than whether <code>SaveChangesAsync()</code> notices modifications. It also changes how EF Core constructs the object graph returned by the query. In some workloads, removing tracking can result in EF Core creating considerably more objects than a tracking query would have created. And at that point, <code>AsNoTracking()</code> can become slower.</p>
<h2>Tracking does more than track changes</h2>
<p>Take an <code>Order</code> that belongs to a <code>Customer</code>.</p>
<pre><code class="language-csharp">public sealed class Order
{
    public Guid Id { get; init; }
    public Guid CustomerId { get; init; }
    public Customer Customer { get; init; } = null!;
    public decimal Total { get; init; }
}

public sealed class Customer
{
    public Guid Id { get; init; }
    public string Name { get; init; } = string.Empty;
}
</code></pre>
<p>Suppose one customer has 500 orders. Now query those orders together with their customer.</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .Include(x =&gt; x.Customer)
    .Where(x =&gt; x.CustomerId == customerId)
    .ToListAsync(stopToken);
</code></pre>
<p>At the SQL level, the customer data may appear repeatedly. Conceptually, the result resembles:</p>
<pre><code class="language-text">Order 1 | Customer 42 | Acme Ltd
Order 2 | Customer 42 | Acme Ltd
Order 3 | Customer 42 | Acme Ltd
Order 4 | Customer 42 | Acme Ltd
...
Order 500 | Customer 42 | Acme Ltd
</code></pre>
<p>There is one customer in the database. But its values appear throughout the result set. A tracking query performs identity resolution. When EF Core sees <code>Customer 42</code> again, it checks the change tracker and discovers that an entity with that key has already been materialised. It reuses the existing instance. Microsoft describes this behaviour explicitly: tracking queries return an already tracked entity instance when an entity with the same key has previously been encountered. So the resulting graph can contain:</p>
<pre><code class="language-text">500 Order objects
1 Customer object
</code></pre>
<p>Every order references the same customer instance.</p>
<pre><code class="language-text">Order 1 ─┐
Order 2 ─┤
Order 3 ─┼──&gt; Customer 42
Order 4 ─┤
...      │
Order 500┘
</code></pre>
<p>Now add <code>AsNoTracking()</code>.</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .AsNoTracking()
    .Include(x =&gt; x.Customer)
    .Where(x =&gt; x.CustomerId == customerId)
    .ToListAsync(stopToken);
</code></pre>
<p>EF Core no longer uses the context's change tracker to perform identity resolution for ordinary no tracking queries, as Microsoft’s documentation confirms. This changes the query’s cost profile even when the database performance, generated SQL and network round trip remain effectively unchanged. The difference appears during materialisation. Without identity resolution, EF Core may create multiple CLR objects for repeated references to the same entity. In sufficiently repetitive object graphs, the allocation cost removed by disabling change tracking can therefore reappear elsewhere as additional object allocation and garbage collection overhead.</p>
<h2>Identity resolution is the interesting part</h2>
<p>This distinction is easy to miss because change tracking and identity resolution normally arrive together. With a normal tracking query EF Core effectively maintains a map of entities it has already materialised.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Customer 42 -&gt; Customer instance A
Customer 93 -&gt; Customer instance B
Customer 107 -&gt; Customer instance C
</code></pre>
<p>When another row refers to customer <code>42</code>, EF Core can return the existing instance rather than constructing another <code>Customer</code>. That lookup isn’t free. EF Core must maintain internal data structures, compare entity keys and store tracking information. Microsoft specifically identifies the dictionary maintenance and key lookups required for identity resolution as sources of tracking overhead. However, those lookups can also prevent allocations. A query might return 10,000 rows that ultimately reference only 100 customers. Without identity resolution, the repeated customer data could produce thousands of separate CLR instances. With identity resolution, those references can converge on just 100 instances. This is why tracking cannot universally be described as slower. It exchanges the cost of identity lookups and tracking data structures for fewer object allocations when the result contains repeated entities.</p>
<h2>EF Core gives us a third option</h2>
<p>Fortunately, EF Core does not force us to choose only between full tracking and completely independent materialisation.</p>
<p>There is:</p>
<pre><code class="language-csharp">AsNoTrackingWithIdentityResolution()
</code></pre>
<p>For example:</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(x =&gt; x.Customer)
    .Where(x =&gt; x.CustomerId == customerId)
    .ToListAsync(stopToken);
</code></pre>
<p>The entities are still not attached to the context's normal change tracker. Changing them will not cause <code>SaveChangesAsync()</code> to persist those changes. But EF Core performs identity resolution while materialising the result. Microsoft implements this using a separate, temporary change tracker. Once enumeration has completed, that tracker is no longer required and can be garbage collected. That gives us three different behaviours. A normal tracking query gives us change tracking and identity resolution.</p>
<p><code>AsNoTracking()</code> gives us neither.</p>
<p><code>AsNoTrackingWithIdentityResolution()</code> gives us identity resolution without attaching the returned entities to the application's <code>DbContext</code>. For graph heavy read operations, this third behaviour can be extremely useful.</p>
<h2>Take a more realistic query</h2>
<p>Imagine an API returning orders together with their customer, account manager and products.</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .AsNoTracking()
    .Include(x =&gt; x.Customer)
    .Include(x =&gt; x.AccountManager)
    .Include(x =&gt; x.Items)
        .ThenInclude(x =&gt; x.Product)
    .Where(x =&gt; x.CreatedAt &gt;= from)
    .ToListAsync(stopToken);
</code></pre>
<p>Suppose the result contains 2,000 orders. Those orders might reference 150 customers, 20 account managers and 300 products. Many of those entities occur repeatedly throughout the relational result. That repetition can be substantial. You can easily end up with relationships conceptually resembling:</p>
<pre><code class="language-text">Order 1001 ─── Customer 17
Order 1002 ─── Customer 17
Order 1003 ─── Customer 17
Order 1004 ─── Customer 17

Order 1001 ─── Product 81
Order 1027 ─── Product 81
Order 1042 ─── Product 81
Order 1198 ─── Product 81
</code></pre>
<p>A query using identity resolution can recognise repeated entity identities, while a plain no tracking query has no equivalent context level identity map. As repetition within the result increases, the number of allocations becomes more significant because EF Core may materialise multiple CLR objects representing the same entity. Although creating objects in .NET is relatively cheap, repeatedly creating enormous numbers of unnecessary objects under load isn’t. The higher allocation rate can cause more frequent Gen 0 collections and increase memory pressure across the service. A negligible difference for an individual query can therefore become visible when the endpoint is handling hundreds of requests per second.</p>
<h2>Measure allocations, not just elapsed time</h2>
<p>This is where EF Core benchmarking often becomes misleading. Developers run the query once and look at the duration.</p>
<pre><code class="language-text">Tracking:       42 ms
No tracking:    39 ms
</code></pre>
<p>Then <code>AsNoTracking()</code> wins. But three milliseconds tells you very little by itself. For application level performance work, I would want to know what happened to allocations as well. BenchmarkDotNet makes that easy.</p>
<pre><code class="language-csharp">[MemoryDiagnoser]
public class OrderQueryBenchmarks
{
    private readonly DbContextOptions&lt;AppDbContext&gt; options;

    public OrderQueryBenchmarks()
    {
        options = new DbContextOptionsBuilder&lt;AppDbContext&gt;()
            .UseSqlServer(ConnectionString)
            .Options;
    }

    [Benchmark]
    public async Task Tracking()
    {
        await using var dbContext = new AppDbContext(options);

        _ = await dbContext.Orders
            .Include(x =&gt; x.Customer)
            .Include(x =&gt; x.Items)
                .ThenInclude(x =&gt; x.Product)
            .ToListAsync();
    }

    [Benchmark]
    public async Task NoTracking()
    {
        await using var dbContext = new AppDbContext(options);

        _ = await dbContext.Orders
            .AsNoTracking()
            .Include(x =&gt; x.Customer)
            .Include(x =&gt; x.Items)
                .ThenInclude(x =&gt; x.Product)
            .ToListAsync();
    }

    [Benchmark]
    public async Task NoTrackingWithIdentityResolution()
    {
        await using var dbContext = new AppDbContext(options);

        _ = await dbContext.Orders
            .AsNoTrackingWithIdentityResolution()
            .Include(x =&gt; x.Customer)
            .Include(x =&gt; x.Items)
                .ThenInclude(x =&gt; x.Product)
            .ToListAsync();
    }
}
</code></pre>
<p>Now the comparison becomes more interesting. You can inspect execution time, allocated bytes and garbage collection activity. More importantly, benchmark against realistic data. Testing ten orders owned by ten different customers tells you almost nothing about an endpoint where 50,000 rows repeatedly reference the same few hundred entities. Performance depends heavily on the shape of the result.</p>
<h2>There may be an even better answer</h2>
<p>There is another issue with the previous query. Why are we materialising all those entities at all? If the API only needs a response DTO, loading a complete entity graph may be unnecessary. Suppose the endpoint returns this:</p>
<pre><code class="language-csharp">public sealed record OrderSummary(
    Guid Id,
    string CustomerName,
    string AccountManagerName,
    decimal Total);
</code></pre>
<p>Instead of this:</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .AsNoTracking()
    .Include(x =&gt; x.Customer)
    .Include(x =&gt; x.AccountManager)
    .ToListAsync(stopToken);

return orders.Select(x =&gt;
    new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.AccountManager.Name,
        x.Total));
</code></pre>
<p>project directly in the query.</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .Where(x =&gt; x.CreatedAt &gt;= from)
    .Select(x =&gt; new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.AccountManager.Name,
        x.Total))
    .ToListAsync(stopToken);
</code></pre>
<p>Now EF Core does not need to materialise <code>Order</code>, <code>Customer</code> and <code>AccountManager</code> entities simply so you can immediately transform them into another object. The generated SQL can retrieve only the columns required by the projection. Microsoft's EF Core performance guidance specifically recommends projecting only the properties required by the caller rather than retrieving entire entities unnecessarily. For many read endpoints, this is a much bigger optimisation than deciding between <code>AsTracking()</code> and <code>AsNoTracking()</code>.</p>
<h2><code>AsNoTracking()</code> on a projection may tell you very little</h2>
<p>This also leads to code I regularly see:</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .AsNoTracking()
    .Where(x =&gt; x.CustomerId == customerId)
    .Select(x =&gt; new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.Total))
    .ToListAsync(stopToken);
</code></pre>
<p>The presence of <code>AsNoTracking()</code> gives the impression that an important optimisation has been applied. But if the projection contains no entity instances, there may be nothing meaningful for EF Core to track in the returned result anyway. Tracking behaviour becomes relevant when entity instances are present in the result. EF Core's documentation also notes that custom projections can still cause entities contained within those projections to be tracked.</p>
<p>For example:</p>
<pre><code class="language-csharp">var results = await dbContext.Orders
    .Select(x =&gt; new
    {
        Order = x,
        CustomerName = x.Customer.Name
    })
    .ToListAsync(stopToken);
</code></pre>
<p><code>Order</code> is still an entity. So tracking behaviour remains relevant.</p>
<p>Compare that with:</p>
<pre><code class="language-csharp">var results = await dbContext.Orders
    .Select(x =&gt; new
    {
        x.Id,
        x.CustomerId,
        CustomerName = x.Customer.Name,
        x.Total
    })
    .ToListAsync(stopToken);
</code></pre>
<p>Now the result contains scalar values rather than <code>Order</code> entities. Understanding the shape of the projection is more useful than mechanically adding <code>AsNoTracking()</code> to every query.</p>
<h2>Do not load no tracking entities just to attach them again</h2>
<p>Another questionable pattern is querying entities without tracking and then attaching them later.</p>
<pre><code class="language-csharp">var order = await dbContext.Orders
    .AsNoTracking()
    .SingleAsync(x =&gt; x.Id == orderId, stopToken);

order.MarkAsPaid(); 
dbContext.Attach(order);
await dbContext.SaveChangesAsync(stopToken);
</code></pre>
<p>The intention is usually performance. Tracking was avoided during the query, so surely the operation must be cheaper. Except the application immediately asks EF Core to begin tracking the entity again. You have removed information that EF Core normally collects during materialisation and then introduced another step to reconstruct state later. Microsoft explicitly advises against routinely performing a no tracking query and then attaching those entities back to the same context, describing the approach as slower and harder to get right than using a tracking query.</p>
<p>If the purpose of the query is to load an aggregate, modify it and call <code>SaveChangesAsync()</code>, ordinary tracking is often exactly the behaviour you want.</p>
<pre><code class="language-csharp">var order = await dbContext.Orders
    .SingleAsync(x =&gt; x.Id == orderId, stopToken);
order.MarkAsPaid();
await dbContext.SaveChangesAsync(stopToken);
</code></pre>
<p>There is no prize for having the largest number of <code>AsNoTracking()</code> calls in a codebase.</p>
<h2>Query shape dominates surprisingly quickly</h2>
<p>Another reason to avoid focusing on tracking first is that other query decisions can dwarf its cost. An unindexed predicate or a result containing 100,000 rows can dominate the entire request, while loading several collections through joins can produce an enormous relational result. An application can also introduce an N+1 pattern by enabling lazy loading or explicitly querying related data inside a loop, adding dozens or hundreds of database round trips. Lazy loading isn’t enabled by EF Core’s default configuration, but Microsoft warns that it makes accidental N+1 queries particularly easy to introduce.</p>
<p>Selecting every column from a wide table when an endpoint needs only four can also create unnecessary database, network and materialisation work. Microsoft’s EF Core performance documentation makes the broader point that database execution, network latency and round trips will usually dominate EF Core’s own runtime overhead. <a href="https://learn.microsoft.com/en-us/ef/core/performance/efficient-querying">Microsoft’s EF Core performance guidance</a></p>
<p>Changing:</p>
<pre><code class="language-csharp">AsTracking()
</code></pre>
<p>to:</p>
<pre><code class="language-csharp">AsNoTracking()
</code></pre>
<p>while ignoring a query performing a table scan is optimisation theatre.</p>
<h2>Choose tracking behaviour deliberately</h2>
<p>For an update operation, normal tracking is generally the natural choice.</p>
<pre><code class="language-csharp">var customer = await dbContext.Customers
    .SingleAsync(x =&gt; x.Id == customerId, stopToken);
customer.ChangeName(request.Name);
await dbContext.SaveChangesAsync(stopToken);
</code></pre>
<p>For a straightforward read only entity query with little duplication, <code>AsNoTracking()</code> is a sensible default.</p>
<pre><code class="language-csharp">var customers = await dbContext.Customers
    .AsNoTracking()
    .OrderBy(x =&gt; x.Name)
    .Take(100)
    .ToListAsync(stopToken);
</code></pre>
<p>For a read-only entity graph containing substantial repetition, test <code>AsNoTrackingWithIdentityResolution()</code>.</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(x =&gt; x.Customer)
    .Include(x =&gt; x.Items)
        .ThenInclude(x =&gt; x.Product)
    .ToListAsync(stopToken);
</code></pre>
<p>And for API read models, reports and query endpoints, consider whether entities need to be materialised in the first place.</p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .Where(x =&gt; x.CustomerId == customerId)
    .Select(x =&gt; new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.Total))
    .ToListAsync(stopToken);
</code></pre>
<p>That final option is often where I would start.</p>
<h2><code>AsNoTracking()</code> is a tool, not a rule</h2>
<p><code>AsNoTracking()</code> remains one of the simplest performance improvements available in EF Core. For read only queries that materialise independent entities, avoiding change tracking can reduce both processing and memory overhead. But the optimisation comes with different materialisation semantics.</p>
<p>Ordinary tracking gives EF Core an identity map. Repeated occurrences of the same database entity can resolve to the same CLR instance. Plain <code>AsNoTracking()</code> removes that behaviour. When a result contains substantial entity repetition, the number of objects EF Core has to construct can therefore increase. <code>AsNoTrackingWithIdentityResolution()</code> exists for precisely this middle ground, allowing identity resolution during materialisation without leaving the entities attached to the application's context. And in many read heavy applications, projection removes the argument almost completely by avoiding entity materialisation altogether.</p>
<p>So when reviewing a query like:</p>
<pre><code class="language-csharp">dbContext.Orders
    .AsNoTracking()
</code></pre>
<p>I wouldn't automatically assume that this change has made the query faster. I would first examine what the query returns, how many entities it materialises, how often those entities are repeated and whether the caller needs complete entities at all. I would then compare the memory allocated by each version before benchmarking the real query against realistic data.</p>
<p><code>AsNoTracking()</code> frequently produces the better result, but "frequently" should never be mistaken for "always".</p>
<ul>
<li><p><a href="https://learn.microsoft.com/en-us/ef/core/change-tracking/identity-resolution">Identity Resolution in EF Core</a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/ef/core/querying/tracking">Tracking vs. No-Tracking Queries</a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/ef/core/performance/efficient-querying">Efficient Querying in EF Core</a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.entityframeworkqueryableextensions.asnotrackingwithidentityresolution"><code>AsNoTrackingWithIdentityResolution</code></a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/ef/core/performance/">EF Core Performance</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Preventing Broken Object Level Authorisation in ASP.NET Core APIs]]></title><description><![CDATA[An authenticated user calls your API with a valid access token. The token contains the right role and the endpoint requires authorisation. Everything appears secure. The user then changes /claims/8d61]]></description><link>https://fullstackcity.com/preventing-broken-object-level-authorisation-in-asp-net-core-apis</link><guid isPermaLink="true">https://fullstackcity.com/preventing-broken-object-level-authorisation-in-asp-net-core-apis</guid><category><![CDATA[api security]]></category><category><![CDATA[#infosec]]></category><category><![CDATA[owasp]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[software engineer]]></category><category><![CDATA[software development]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sat, 18 Jul 2026 18:16:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/6d89e26e-76f8-44b6-b1e1-f5ef593e8eb9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>An authenticated user calls your API with a valid access token. The token contains the right role and the endpoint requires authorisation. Everything appears secure. The user then changes <code>/claims/8d61...</code> to <code>/claims/7a42...</code> and receives somebody else's insurance claim.</p>
<p>This is broken object-level authorisation, usually shortened to BOLA. It happens when an API confirms that a caller may use an endpoint but doesn't confirm that they may access the specific record requested. OWASP currently places it at API1 in its API Security Top 10. ASP.NET Core gives us solid authentication and policy-based authorisation features, but they still need to be applied at the correct level. In this article, we'll build a safer claims endpoint using Minimal APIs, EF Core and <a href="https://packages.nuget.org/packages/ClaimsFence">ClaimsFence</a>, a small claims based authorisation package for .NET. The insurance claims domain makes the naming slightly entertaining, but ClaimsFence is concerned with identity claims such as permissions and tenant IDs. It doesn't inspect or secure insurance claim records by itself.</p>
<h2>How BOLA appears in a .NET API</h2>
<p>Consider an API used by several insurance companies. Each company is a tenant, and every insurance claim belongs to one of those tenants. The endpoint below requires an authenticated user, loads a claim using the ID from the route and returns it:</p>
<pre><code class="language-csharp">app.MapGet("/claims/{claimId:guid}", async (
    Guid claimId,
    ClaimsDbContext db,
    CancellationToken stopToken) =&gt;
{
    var claim = await db.Claims
        .AsNoTracking()
        .SingleOrDefaultAsync(x =&gt; x.Id == claimId, stopToken);

    return claim is null
        ? Results.NotFound()
        : Results.Ok(claim);
})
.RequireAuthorization();
</code></pre>
<p><code>RequireAuthorization()</code> prevents anonymous access. It doesn't prove that the authenticated user belongs to the claim's tenant, owns the record or has been assigned to handle it. If an attacker obtains another valid identifier from a log, browser history, email or API response, they can request that object directly. Sequential integer IDs make discovery easier, but changing them to GUIDs doesn't fix the authorisation failure. OWASP explicitly treats integer, UUID and string identifiers as potential attack targets.</p>
<h2>Endpoint access and object access are separate decisions</h2>
<p>A useful authorisation design answers three questions:</p>
<ol>
<li><p>Is the caller authenticated?</p>
</li>
<li><p>Does the caller have permission to perform this operation?</p>
</li>
<li><p>May the caller perform it on this particular record?</p>
</li>
</ol>
<p>The first two decisions can often be made from trusted claims in the access token. The third usually depends on application data. For our claims API, a caller needs the <code>Claims.Read</code> permission, must be operating inside their own tenant and must either be assigned to the requested insurance claim or hold a broader <code>Claims.Read.All</code> permission.</p>
<p>That gives us two enforcement points:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/eb3ccec1-fd81-43de-a370-492a3c7413cc.png" alt="" style="display:block;margin:0 auto" />

<p>ClaimsFence handles the identity, permission and tenant route rules. EF Core scopes the database query to the records that identity is allowed to read.</p>
<h2>Using ClaimsFence at the endpoint boundary</h2>
<p>Install <a href="https://packages.nuget.org/packages/ClaimsFence">ClaimsFence from NuGet</a> into the API project:</p>
<pre><code class="language-bash">dotnet add package ClaimsFence
</code></pre>
<p>We can now express the endpoint level rules together instead of scattering <code>HasClaim</code> calls through handlers:</p>
<pre><code class="language-csharp">app.MapGet("/tenants/{tenantId:guid}/claims/{claimId:guid}", GetClaimAsync)
    .RequireClaimFence(rule =&gt; rule
        .RequiresAuthenticatedUser()
        .RequiresClaim("permission", "Claims.Read")
        .RequiresClaim("tenant", ClaimMatch.RouteValue("tenantId")));
</code></pre>
<p>This rule says that the caller must be authenticated, must have permission to read claims, and must have a <code>tenant</code> claim matching the <code>tenantId</code> route value. The route value came from the caller, so accepting it without comparison would be unsafe. Matching it against a tenant claim from a correctly validated token prevents a user from simply replacing one tenant ID with another. ClaimsFence is a natural fit here because this is still claims-based authorisation. The rule is concise, reusable and independently testable. It also keeps the endpoint metadata readable when more than one claim must be checked.</p>
<p>It isn't the full BOLA solution, though. Passing this rule only proves that the user can read claims within the requested tenant. It doesn't prove that they may read every insurance claim belonging to that tenant.</p>
<h2>Scope the database query to authorised records</h2>
<p>The handler should treat authorisation criteria as part of the query rather than loading an arbitrary row and hoping somebody remembers to check it afterwards.</p>
<pre><code class="language-csharp">using System.Security.Claims;
using Microsoft.EntityFrameworkCore;

static async Task&lt;IResult&gt; GetClaimAsync(
    Guid tenantId,
    Guid claimId,
    ClaimsPrincipal user,
    ClaimsDbContext db,
    CancellationToken stopToken)
{
    var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);

    if (string.IsNullOrWhiteSpace(userId))
    {
        return Results.Unauthorized();
    }

    var query = db.Claims
        .AsNoTracking()
        .Where(x =&gt; x.Id == claimId &amp;&amp; x.TenantId == tenantId);

    var canReadAll = user.HasClaim(
        "permission",
        "Claims.Read.All");

    if (!canReadAll)
    {
        query = query.Where(x =&gt; x.AssignedUserId == userId);
    }

    var claim = await query
        .Select(x =&gt; new ClaimResponse(
            x.Id,
            x.ClaimNumber,
            x.PolicyholderName,
            x.Status))
        .SingleOrDefaultAsync(stopToken);

    return claim is null
        ? Results.NotFound()
        : Results.Ok(claim);
}

public sealed record ClaimResponse(
    Guid Id,
    string ClaimNumber,
    string PolicyholderName,
    string Status);
</code></pre>
<p>The important part is the SQL predicate produced by the query. A normal handler can only retrieve a row when all applicable access conditions match:</p>
<pre><code class="language-sql">WHERE Id = @claimId
  AND TenantId = @tenantId
  AND AssignedUserId = @userId
</code></pre>
<p>A user asking for somebody else's claim receives no row. The application returns <code>404 Not Found</code>, which also avoids confirming that the identifier exists. Whether your API uses <code>403</code> or <code>404</code> consistently is a product decision, but returning <code>404</code> for inaccessible object IDs reduces information available for enumeration. Projecting directly to <code>ClaimResponse</code> also avoids returning internal or sensitive columns accidentally. Object level authorisation and property level authorisation are different concerns, and an endpoint can get the first right while still exposing too much data.</p>
<h2>Why checking after loading is weaker</h2>
<p>You could load the record by ID and then compare its tenant and assignment fields:</p>
<pre><code class="language-csharp">var claim = await db.Claims.FindAsync([claimId], stopToken);

if (claim?.TenantId != tenantId || claim.AssignedUserId != userId)
{
    return Results.NotFound();
}
</code></pre>
<p>This can be made correct, but the unrestricted entity has already crossed into application memory. It also creates a fragile pattern, every handler must remember every comparison, and a later refactor can easily return or process the record before the check. Putting stable ownership constraints into the query makes the permitted data set explicit and prevents the unauthorised row from being loaded in the first place.</p>
<h2>Add tenant isolation with an EF Core query filter</h2>
<p>For a multi tenant application, an EF Core global query filter provides another useful layer. The current tenant is supplied to the <code>DbContext</code>, and EF Core adds the tenant predicate whenever it queries an insurance claim:</p>
<pre><code class="language-csharp">public sealed class ClaimsDbContext(
    DbContextOptions&lt;ClaimsDbContext&gt; options,
    ITenantContext tenantContext)
    : DbContext(options)
{
    public DbSet&lt;InsuranceClaim&gt; Claims =&gt; Set&lt;InsuranceClaim&gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&lt;InsuranceClaim&gt;()
            .HasQueryFilter(
                "TenantFilter",
                claim =&gt; claim.TenantId == tenantContext.TenantId);
    }
}
</code></pre>
<p>Named query filters are available in EF Core 10. The filter reduces the chance of a developer accidentally issuing an unscoped query elsewhere in the application. It should still be treated as defence in depth. Code can deliberately disable filters, raw SQL can bypass them and background processes may run outside a normal request tenant. The application still needs a clear, trusted way to resolve the tenant, and privileged code that crosses tenant boundaries should be isolated and closely tested. Don't construct <code>ITenantContext</code> from the route value alone. The route identifies the requested tenant; the authenticated identity establishes which tenant the caller belongs to. ClaimsFence's route comparison is what joins those two pieces at the API boundary.</p>
<h2>When resource based authorisation is the better fit</h2>
<p>Some access rules can't be represented entirely by token claims or query predicates. A supervisor might be allowed to approve claims below a certain value, or access might depend on a team membership stored in the database. ASP.NET Core supports resource based authorisation through <code>IAuthorizationService</code>. The application loads an already tenant-scoped resource, then asks an authorisation handler whether the requested operation is permitted:</p>
<pre><code class="language-csharp">var result = await authorizationService.AuthorizeAsync(
    user,
    claim,
    ClaimOperations.Approve);

if (!result.Succeeded)
{
    return Results.Forbid();
}
</code></pre>
<p>Microsoft recommends this imperative approach when the decision depends on the resource because endpoint attributes and declarative policies run before the resource has been loaded. ClaimsFence and resource based authorisation therefore complement each other:</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Enforcement point</th>
</tr>
</thead>
<tbody><tr>
<td>Authentication</td>
<td>ASP.NET Core authentication</td>
</tr>
<tr>
<td>Permission claim</td>
<td>ClaimsFence rule</td>
</tr>
<tr>
<td>Tenant claim matches route</td>
<td>ClaimsFence rule</td>
</tr>
<tr>
<td>Row belongs to permitted tenant or user</td>
<td>Scoped EF Core query</td>
</tr>
<tr>
<td>Decision depends on loaded record state</td>
<td><code>IAuthorizationService</code> handler</td>
</tr>
<tr>
<td>Response contains only permitted fields</td>
<td>DTO projection</td>
</tr>
</tbody></table>
<p>Trying to make one mechanism solve every layer usually produces either an oversized token or business rules hidden inside endpoint plumbing.</p>
<h2>Test for identifier substitution</h2>
<p>Happy path tests aren't enough for object level authorisation. The key test is whether one valid user can substitute another valid object's ID.</p>
<pre><code class="language-csharp">[Fact]
public async Task GetClaim_ReturnsNotFound_WhenClaimBelongsToAnotherHandler()
{
    var client = factory.CreateClientFor(
        userId: "handler-17",
        tenantId: TenantIds.Fabrikam,
        permissions: ["Claims.Read"]);

    var response = await client.GetAsync(
        $"/tenants/{TenantIds.Fabrikam}/claims/{ClaimIds.AssignedToAnotherUser}");

    response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
</code></pre>
<p>Your integration tests should exercise the complete authorisation boundary. Verify anonymous access is rejected, users without <code>Claims.Read</code> are denied, and the tenant in the token must match the route. Within a valid tenant, confirm that users can’t access claims assigned to another handler or claims belonging to another tenant. Finally, prove that <code>Claims.Read.All</code> permits access to unassigned claims and that assigned users can retrieve their own claims. The same tests should be applied to reads, updates, downloads and deletes. It's common to protect a <code>GET</code> endpoint correctly while leaving an attachment download or status update exposed.</p>
<p>ClaimsFence rules can also be tested without running the entire API:</p>
<pre><code class="language-csharp">var rule = ClaimFence.Rule()
    .RequiresAuthenticatedUser()
    .RequiresClaim("permission", "Claims.Read");

var result = rule.Evaluate(user);

result.Succeeded.Should().BeFalse();
result.Failures.Should().NotBeEmpty();
</code></pre>
<p>That makes the identity level expectations easy to verify, while API integration tests prove that route matching and data scoping work together.</p>
<h2>Its easy to get things wrong</h2>
<p>GUIDs make identifiers harder to guess, which is useful, but IDs regularly leak through logs, URLs, exports and related API responses. Every ID must still be authorised. Two users can hold the same role while belonging to different tenants or owning different resources. A role establishes a broad capability, not access to an individual row. A tenant ID in a route, header or JSON body is request data. It must be matched against a trusted identity or server-side membership record before being used as an authorisation boundary. Another gotcha sees that the same object may be reachable through search, export, attachment, batch and administrative endpoints. Object level checks must apply to every path that accesses it. Even an authorised caller shouldn't automatically receive every property. Project the fields intended for that operation into a response contract.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/13ddc899-3f89-41d3-93bc-af26218f7e13.png" alt="" style="display:block;margin:0 auto" />

<p>The secure endpoint has several small controls working together. ASP.NET Core validates the token. ClaimsFence states that the user needs <code>Claims.Read</code> and that their tenant must match the route. EF Core restricts the query to records the user may access. Resource-based handlers cover decisions that depend on the loaded claim, and the response projection limits exposed fields. ClaimsFence belongs naturally in this design because it handles the claims-based portion cleanly without claiming to know who owns a database row. Keeping that boundary honest makes the package more credible and gives the article a stronger conclusion, claims can get a caller through the front door, but object level authorisation decides which records they may touch once inside.</p>
<ul>
<li><p><a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/">OWASP API1:2023: Broken Object Level Authorization</a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/aspnet/core/security/authorization/resource-based?view=aspnetcore-10.0">Microsoft: Resource-based authorisation in ASP.NET Core</a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/ef/core/querying/filters">Microsoft: Global query filters in EF Core</a></p>
</li>
<li><p><a href="https://packages.nuget.org/packages/ClaimsFence">ClaimsFence on NuGet</a></p>
</li>
<li><p><a href="https://github.com/kearns2000/claimsfence">ClaimsFence on GitHub</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Cutting AI Prompt Tokens by Turning Text into Images with .NET]]></title><description><![CDATA[Sending text to an AI model as an image sounds like it should cost more. The application has to render the text, encode a PNG and send a much larger payload over the network. Yet for the right kind of]]></description><link>https://fullstackcity.com/cutting-ai-prompt-tokens-by-turning-text-into-images-with-net</link><guid isPermaLink="true">https://fullstackcity.com/cutting-ai-prompt-tokens-by-turning-text-into-images-with-net</guid><category><![CDATA[AI]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[software engineer]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[System Architecture]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sat, 18 Jul 2026 11:41:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/319f98e6-e902-45ce-8da1-64862831bb20.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Sending text to an AI model as an image sounds like it should cost more. The application has to render the text, encode a PNG and send a much larger payload over the network. Yet for the right kind of prompt, the model may consume substantially fewer input tokens than it would if it received the same material as ordinary text.</p>
<p>Some published experiments have reported savings of up to 70% for particular models and workloads. That figure needs to be treated as a measured result rather than a general promise, but the underlying technique is real. A dense image can sometimes carry far more readable text per billed model token than the provider's text tokeniser can.</p>
<p>I first came across the practical version of this idea while watching <a href="https://www.youtube.com/watch?v=Bbt8cEyzsTk&amp;t=333s">ThePrimeagen discuss pxpipe</a>. I then read DeepSeek's paper on <a href="https://arxiv.org/abs/2510.18234">contexts optical compression</a>, which explores the idea more formally. That led me to build and publicly release <a href="https://github.com/kearns2000/PromptRaster">PromptRaster</a>, an open-source .NET library for selectively representing large text inputs as image context in applications built around <code>Microsoft.Extensions.AI</code>.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=Bbt8cEyzsTk&amp;t=333s">https://www.youtube.com/watch?v=Bbt8cEyzsTk&amp;t=333s</a></p>

<p><a href="https://www.nuget.org/packages/PromptRaster">PromptRaster</a> doesn't claim to have invented optical context compression. The broad idea already existed, and <a href="https://github.com/teamchong/pxpipe">pxpipe</a> had demonstrated a clever TypeScript implementation for AI coding workflows. PromptRaster takes that idea in a different direction, in process, policy controlled integration for .NET applications.</p>
<h2>Why text and images can have different token costs</h2>
<p>When text is sent normally, it passes through the model's text tokeniser. The tokeniser breaks it into units representing words, fragments of words, punctuation and whitespace. Source code, JSON, logs and repetitive structured content can produce a surprisingly large number of tokens because their symbols and formatting don't always pack as efficiently as ordinary text.</p>
<p>An image follows a different route. A multimodal model's vision encoder divides or resizes the image according to the provider's image processing rules and converts the result into visual tokens. Its cost is commonly driven by dimensions, detail level, tiling and model specific rules. It isn't necessarily proportional to every character visible in the image. That creates an opportunity. If thousands of characters can be rendered legibly into an image whose dimensions result in relatively few visual tokens, the model may receive a compressed visual representation of the same context.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/a81a83ba-7a7b-478b-bb6c-2d87622442bc.png" alt="" style="display:block;margin:0 auto" />

<p>This doesn't mean a PNG file is smaller than the original string. It will often be much larger in bytes. Network size, storage size and model token count are separate measurements. Optical context compression is concerned with the representation inside the model request, not conventional file compression.</p>
<h2>Think of the jumbled text memes</h2>
<p>There are memes in which the letters inside each word have been rearranged while the first and last letters remain in place. The wording is visibly wrong, but most people can still read the sentence at close to normal speed because the brain recognises shapes, context and likely patterns rather than carefully decoding every character in isolation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/dd7fcc74-304c-4618-9f3a-1b5e537202fa.jpg" alt="" style="display:block;margin:0 auto" />

<p>A multimodal model reading a dense page of text has a loose similarity to that experience. It doesn't need a separate language token for every visible fragment before it can use the material. Its vision system builds a compressed representation from pixels, shapes, layout and surrounding context, then the language model reasons over that representation. The analogy is useful, but it shouldn't be taken literally. A vision language model isn't reading in the same biological way as a person, and the image channel isn't a lossless text codec. The important connection is pattern recognition. Both a person reading jumbled text and a model reading rendered context can recover meaning from an imperfect representation because context helps to resolve what is seen.</p>
<p>That also explains the main limitation. We can understand the meaning of a jumbled sentence while misreading a particular letter. A model may understand an imaged log or design document while getting one digit, slash or identifier wrong. Optical compression can preserve meaning without preserving every character.</p>
<h2>What DeepSeek demonstrated</h2>
<p>DeepSeek's 2025 paper, <a href="https://deepseek.ai/blog/deepseek-ocr-context-compression"><em>DeepSeek-OCR: Contexts Optical Compression</em></a>, investigated whether visual input could act as an efficient compression medium for long text. Its DeepEncoder produces a restricted number of vision tokens from a document image, which a language decoder then uses to reconstruct or understand the content.</p>
<p>In its experiments, DeepSeek reported 97% OCR precision while the number of text tokens remained below ten times the number of vision tokens. At a compression ratio of 20, accuracy fell to roughly 60%. The paper describes token reductions in the range of 7 to 20 times across different historical context stages. Those are interesting research results, but they aren't evidence that every commercial multimodal API will make an application ten times cheaper. DeepSeek evaluated its own architecture, datasets and token allocations. An application calling Azure OpenAI, OpenAI, Anthropic or another provider is subject to that provider's vision encoding, model capability and pricing.</p>
<p>The wider lesson is more durable, text doesn't have to enter a language model only through a text tokeniser. A vision encoder provides another route, and for dense, approximate context that route can be more token-efficient.</p>
<h2>The loose idea already existed in TypeScript</h2>
<p><a href="https://pxpipe.dev/">pxpipe</a> applies this idea as a TypeScript proxy and library. It sits between an AI coding client and a supported model API, identifies bulky parts of the request, renders them as PNG pages and forwards the rewritten request to the multimodal model.</p>
<p>Its primary targets include system prompts, tool documentation, older conversation history, large tool results, logs and JSON. Current instructions and recent turns can remain as text. According to the project's published measurements, dense real-world content packed roughly 3.1 characters per image token compared with around one character per text token in the traffic it tested. It reports workload specific end-to-end savings of approximately 59% to 70% at the prices used for those tests.</p>
<p>Thats a smart use of an existing model capability. It also proves that the concept doesn't require a new foundation model. If an existing multimodal model can read dense screenshots reliably enough, request context can be rendered before it reaches the API.</p>
<p>However, a local proxy aimed at coding agents isn't always the right integration point for a production .NET system. An ASP.NET Core API, worker, Azure Function or document processing pipeline may already use dependency injection and <code>Microsoft.Extensions.AI</code>. Adding a transparent HTTP proxy can obscure which content has changed representation and make application-specific policy harder to enforce.</p>
<h2>How PromptRaster is different</h2>
<p><a href="https://www.nuget.org/packages/PromptRaster">PromptRaster</a> is a public, open-source .NET native visual context encoder released under the MIT licence. It runs inside the application and makes no outbound network calls itself. The application continues to communicate with its chosen provider through <code>IChatClient</code>, while PromptRaster decides whether explicitly selected content should remain text or become one or more PNG pages.</p>
<p>The distinction is control. PromptRaster doesn't automatically convert every large string in the request. A caller marks content as eligible using <code>RasterTextContent</code> or <code>AddRasterText</code>, and a rasterisation policy makes the final decision. Ordinary <code>TextContent</code> stays untouched.</p>
<p>This allows a request to keep its live instructions in text while moving bulky supporting information onto the image channel. A short instruction such as "summarise the background material and identify the main risks" needs exact interpretation, so it remains text. The long reference document can be considered for rasterisation because the model mainly needs its meaning.</p>
<p>The core renderer is provider neutral and uses SkiaSharp to generate deterministic PNG pages. Provider access remains behind <code>Microsoft.Extensions.AI</code>. PromptRaster can therefore sit in an application using Azure OpenAI, OpenAI or another multimodal provider without its core package taking a dependency on any one of them.</p>
<p>It also has conservative failure behaviour. Unsupported models, rejected content, rendering errors and uneconomical page density can fall back to the original text. Applications that require stricter handling can enable strict mode and receive an exception instead.</p>
<h2>Adding PromptRaster to a .NET application</h2>
<p>Install the core package and the <code>Microsoft.Extensions.AI</code> integration:</p>
<pre><code class="language-bash">dotnet add package PromptRaster
dotnet add package PromptRaster.MicrosoftExtensionsAI
</code></pre>
<p>The following example registers <a href="https://www.nuget.org/packages/PromptRaster">PromptRaster</a>, adds it to an existing <code>IChatClient</code> pipeline and marks only the background document as rasterisable:</p>
<pre><code class="language-csharp">using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using PromptRaster;
using PromptRaster.MicrosoftExtensionsAI;

var services = new ServiceCollection()
    .AddPromptRasterMicrosoftExtensionsAI(options =&gt;
    {
        options.MinimumTextLength = 4_000;
        options.FallbackToText = true;
    })
    .BuildServiceProvider();

var rasterizer = services.GetRequiredService&lt;IPromptRasterizer&gt;();

IChatClient client = providerClient
    .AsBuilder()
    .UsePromptRaster(rasterizer, options =&gt;
    {
        options.MinimumCharacterCount = 4_000;
        options.FallbackToText = true;
        options.Provider = AiProvider.AzureOpenAI;
    })
    .Build();

var message = new ChatMessage(
    ChatRole.User,
    "Summarise the attached background material.");

message.Contents.Add(
    new TextContent("Focus on risks and unresolved questions."));

message.AddRasterText(backgroundDocument);

ChatResponse response = await client.GetResponseAsync(
    [message],
    cancellationToken: stopToken);
</code></pre>
<p>The instruction stays as <code>TextContent</code>. If policy approves the background material, PromptRaster replaces that marked portion with one or more <code>DataContent</code> objects using the <code>image/png</code> media type. If it rejects the conversion, the original string is sent as text. This fits naturally into ASP.NET Core, worker services, Azure Functions and other hosts that already construct an <code>IChatClient</code> through dependency injection. No proxy process has to be started, routed or monitored.</p>
<h2>Policy matters more than rendering</h2>
<p>Turning a string into a PNG is the easy part. Choosing which strings may safely be turned into images is the important part. PromptRaster's default policy checks the text length, model profile, content classification, exact content heuristics, page limit and rendered density. Unknown models fall back to text unless an application supplies an appropriate profile. The application can replace <code>IRasterisationPolicy</code> or <code>IExactContentDetector</code> when its domain has stronger requirements.</p>
<p>Large documentation, descriptive schemas, historical logs and older supporting material can be reasonable candidates. Secrets, access tokens, hashes, file paths, exact financial figures and identifiers generally aren't. If one character changes the result, the value belongs in the text channel or should be obtained through a deterministic tool. Structured data also needs judgement. A large JSON document may rasterise efficiently when the task is to explain its broad structure, but it is a poor candidate when the model must reproduce exact property values. The same input can therefore be suitable for one task and unsafe for another.</p>
<h2>Where PromptRaster can help .NET systems</h2>
<p>A document processing application may attach a long policy manual as context while asking the model to classify an incoming document. A support system may include older case notes so the model can produce a summary. An engineering assistant may need a large block of logs to identify a likely cause rather than quote every line exactly. These workloads share two useful properties, the context is large, and semantic understanding matters more than byte-perfect recovery. They are also common in .NET systems that already call multimodal models through Azure OpenAI or another <code>IChatClient</code> implementation.</p>
<p>PromptRaster can cache rasterised pages through <code>IPromptRasterCache</code>, using stable keys for identical content and render settings. It also exposes structured logging and OpenTelemetry compatible activity and metric instrumentation without writing the prompt text or image bytes into logs. Those features are important when the technique moves from an experiment into an application request path.</p>
<h2>Why 70% must remain an "up to" figure</h2>
<p>The saving depends on several variables , the text tokeniser, image dimensions, density, provider vision token rules, model accuracy, caching and current prices. A dense page of code may perform very differently from loosely spaced prose. A provider can also change its image pricing or token accounting independently of its text pricing. Prompt caching complicates the comparison further. Repeated text may already be cheap when a provider serves cached input tokens at a discount. Converting that same stable block to an image could reduce the raw token count while producing a smaller financial benefit than expected. A fair benchmark must compare equivalent cache states.</p>
<p>Quality belongs in the calculation too. Saving 70% while subtly corrupting an account number isn't an optimisation. A useful evaluation measures token usage, actual cost, latency and task accuracy against the original text request. It should use the exact provider and model that the production application will use.</p>
<p>PromptRaster intentionally doesn't publish a universal savings number today. Its repository includes the benchmark methodology, while reproducible evaluation fixtures and model specific results remain on the roadmap. The pxpipe figures show what can be possible in a favourable workload. They shouldn't be relabelled as a PromptRaster guarantee.</p>
<h2>A second input channel for large context</h2>
<p>The most interesting part of this technique isn't that it turns text into a picture. It is that a multimodal model gives an application two ways to deliver language: directly through text tokens or indirectly through a visual representation. Text remains the right format for current instructions, exact values and anything security sensitive. Images can be useful for bulky background material where the model needs to recognise patterns and meaning. The jumbled text meme captures the intuition, perfect character-by-character recovery isn't always required to understand what a passage says.</p>
<p>pxpipe showed how effectively that idea could be applied to AI coding traffic in TypeScript. PromptRaster brings the technique into .NET applications through explicit content selection, dependency injection, policy, fallback behaviour, caching and <code>Microsoft.Extensions.AI</code> middleware.</p>
<p>There won't be a 70% saving on every request. For some prompts, there may be no saving at all. But when a .NET application repeatedly sends large, stable and semantically read only context to a capable multimodal model, optical context compression is now a practical option worth measuring.</p>
<ul>
<li><p><a href="https://github.com/kearns2000/PromptRaster">PromptRaster on GitHub</a></p>
</li>
<li><p><a href="https://www.nuget.org/packages/PromptRaster">PromptRaster on NuGet</a></p>
</li>
<li><p><a href="https://www.youtube.com/watch?v=Bbt8cEyzsTk&amp;t=333s">ThePrimeagen video discussing the approach</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/2510.18234">DeepSeek-OCR: Contexts Optical Compression</a></p>
</li>
<li><p><a href="https://github.com/teamchong/pxpipe">pxpipe on GitHub</a></p>
</li>
<li><p><a href="https://pxpipe.dev/">pxpipe documentation and benchmarks</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Building SSRF-Resistant HTTP Clients in .NET]]></title><description><![CDATA[Applications increasingly accept URLs from outside their own code. A webhook tester calls an endpoint supplied by a customer. An image service downloads a remote avatar. A document pipeline retrieves ]]></description><link>https://fullstackcity.com/building-ssrf-resistant-http-clients-in-net</link><guid isPermaLink="true">https://fullstackcity.com/building-ssrf-resistant-http-clients-in-net</guid><category><![CDATA[software developer]]></category><category><![CDATA[owasp]]></category><category><![CDATA[Application Security]]></category><category><![CDATA[#infosec]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Software Engineering]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Fri, 17 Jul 2026 23:21:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/2061a5bf-a924-4ab6-9479-de3937a8a067.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Applications increasingly accept URLs from outside their own code. A webhook tester calls an endpoint supplied by a customer. An image service downloads a remote avatar. A document pipeline retrieves an attachment from a link. The implementation often looks harmless:</p>
<pre><code class="language-csharp">app.MapPost("/fetch", async (FetchRequest request, HttpClient client) =&gt;
{
    var content = await client.GetStringAsync(request.Url);
    return Results.Text(content);
});
</code></pre>
<p>The problem is that the caller has gained partial control over a network client running inside your infrastructure. They may be able to make it call an internal API, probe private ports or reach a cloud metadata service that isn't exposed to the internet. This is server-side request forgery, usually shortened to SSRF.</p>
<p>Protecting this code takes more than checking that the value starts with <code>https://</code>. A useful defence needs to cover the URL, DNS resolution, the address used for the connection, redirects and the network environment around the application.</p>
<h2>What SSRF gives an attacker</h2>
<p>Imagine a public endpoint that creates a preview for a supplied URL. A legitimate request points it towards a public article. A malicious request instead uses an address such as <code>http://127.0.0.1</code>, a private RFC 1918 address or the link-local address used by a cloud metadata service. The target system sees the request as coming from the application, not from the original caller. That application may sit on a trusted network, have access to internal services or possess an identity that those services trust. Even when the response isn't returned, differences in status codes and timing can turn the feature into a network scanner. This is sometimes called blind SSRF.</p>
<p>The OWASP SSRF Prevention Cheat Sheet separates applications into two broad cases. If the application only needs to call known services, an allowlist is the strongest starting point. If users genuinely need to supply arbitrary public destinations, validation becomes more involved and should be backed by network controls.</p>
<h2>Start with the smallest possible destination policy</h2>
<p>Before writing an IP-address validator, decide whether the caller needs to submit a complete URL at all. If the destination is one of your own integrations, accept an identifier and look up the URL from trusted configuration:</p>
<pre><code class="language-csharp">public sealed record DeliveryRequest(string IntegrationId, string Payload);

public sealed class IntegrationRegistry(IConfiguration configuration)
{
    public Uri GetEndpoint(string integrationId) =&gt; integrationId switch
    {
        "accounts" =&gt; new Uri(configuration["Integrations:Accounts"]!),
        "claims" =&gt; new Uri(configuration["Integrations:Claims"]!),
        _ =&gt; throw new InvalidOperationException("Unknown integration.")
    };
}
</code></pre>
<p>This removes destination selection from untrusted input. Where a complete user-supplied URL is a genuine requirement, define a precise policy. A typical public-content fetcher may allow HTTPS only, port 443 only, no embedded credentials, no fragments and either a hostname allowlist or public IP addresses only.</p>
<p>Parsing must be done with <code>Uri</code>. String checks are easy to bypass with unusual URL forms, encoded characters and misleading hostnames.</p>
<pre><code class="language-csharp">public sealed class OutboundUrlPolicy
{
    private static readonly HashSet&lt;string&gt; AllowedHosts =
        new(StringComparer.OrdinalIgnoreCase)
        {
            "images.example.com",
            "documents.example.net"
        };

    public static Uri ParseAndValidate(string value)
    {
        if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
        {
            throw new InvalidOperationException("The URL is invalid.");
        }

        if (uri.Scheme != Uri.UriSchemeHttps)
        {
            throw new InvalidOperationException("Only HTTPS URLs are allowed.");
        }

        if (uri.Port != 443)
        {
            throw new InvalidOperationException("Only port 443 is allowed.");
        }

        if (!string.IsNullOrEmpty(uri.UserInfo))
        {
            throw new InvalidOperationException("Credentials aren't allowed in the URL.");
        }

        if (!string.IsNullOrEmpty(uri.Fragment))
        {
            throw new InvalidOperationException("URL fragments aren't allowed.");
        }

        if (!AllowedHosts.Contains(uri.IdnHost))
        {
            throw new InvalidOperationException("The destination isn't allowed.");
        }

        return uri;
    }
}
</code></pre>
<p>Compare complete, canonical hostnames. A check such as <code>host.EndsWith("example.com")</code> also accepts <code>notexample.com</code>. If subdomains are required, accept either the exact parent or a name ending in <code>.</code> followed by the parent. Avoid regular expressions when a direct comparison expresses the rule more clearly. An allowlisted hostname can still be misconfigured or compromised through DNS, so the connection address should also be checked.</p>
<h2>Why resolving the hostname before <code>HttpClient</code> isn't enough</h2>
<p>A common SSRF defence resolves the hostname, rejects private addresses and then gives the original URL to <code>HttpClient</code>:</p>
<pre><code class="language-csharp">var addresses = await Dns.GetHostAddressesAsync(uri.Host);
Validate(addresses);

return await httpClient.GetAsync(uri);
</code></pre>
<p>There are two separate DNS lookups here. Your validation code performs the first one. <code>HttpClient</code> may perform another when it opens the connection. An attacker who controls DNS can return a public address during validation and a private address for the connection. This is DNS rebinding, or more precisely a time-of-check/time-of-use problem. The validation must be tied to the address actually used by the socket. <code>SocketsHttpHandler.ConnectCallback</code> gives .NET applications a suitable interception point.</p>
<h2>Validate at connection time</h2>
<p>The following validator rejects address categories that an internet only fetcher shouldn't contact. It handles IPv4-mapped IPv6 addresses before applying the IPv4 rules.</p>
<pre><code class="language-csharp">using System.Net;
using System.Net.Sockets;

public static class PublicAddressPolicy
{
    public static bool IsAllowed(IPAddress address)
    {
        if (address.IsIPv4MappedToIPv6)
        {
            address = address.MapToIPv4();
        }

        if (IPAddress.IsLoopback(address) ||
            address.Equals(IPAddress.Any) ||
            address.Equals(IPAddress.IPv6Any) ||
            address.Equals(IPAddress.None) ||
            address.Equals(IPAddress.IPv6None))
        {
            return false;
        }

        var bytes = address.GetAddressBytes();

        if (address.AddressFamily == AddressFamily.InterNetwork)
        {
            return !IsPrivateOrSpecialIpv4(bytes);
        }

        if (address.AddressFamily == AddressFamily.InterNetworkV6)
        {
            return !address.IsIPv6LinkLocal &amp;&amp;
                   !address.IsIPv6Multicast &amp;&amp;
                   !IsUniqueLocalIpv6(bytes);
        }

        return false;
    }

    private static bool IsPrivateOrSpecialIpv4(byte[] address) =&gt;
        address[0] == 0 ||                                      // current network
        address[0] == 10 ||                                     // 10.0.0.0/8
        address[0] == 127 ||                                    // loopback
        address[0] == 169 &amp;&amp; address[1] == 254 ||                // link-local
        address[0] == 172 &amp;&amp; address[1] is &gt;= 16 and &lt;= 31 ||    // 172.16.0.0/12
        address[0] == 192 &amp;&amp; address[1] == 168 ||                // 192.168.0.0/16
        address[0] == 100 &amp;&amp; address[1] is &gt;= 64 and &lt;= 127 ||   // shared address space
        address[0] &gt;= 224;                                      // multicast/reserved

    private static bool IsUniqueLocalIpv6(byte[] address) =&gt;
        (address[0] &amp; 0xFE) == 0xFC;                             // fc00::/7
}
</code></pre>
<p>Address classification is easy to get subtly wrong. The example covers the ranges most relevant to SSRF, but a production internet-only policy should be tested against the complete set of special-purpose ranges relevant to the networks where it runs. A maintained IP/CIDR library or a centrally managed egress proxy can be preferable to duplicating this logic across applications.</p>
<p>We can now connect only after resolving and approving the destination address:</p>
<pre><code class="language-csharp">using System.Net;
using System.Net.Sockets;

public static class SafeSocketConnector
{
    public static async ValueTask&lt;Stream&gt; ConnectAsync(
        SocketsHttpConnectionContext context,
        CancellationToken stopToken)
    {
        var endpoint = context.DnsEndPoint;

        var addresses = await Dns.GetHostAddressesAsync(
            endpoint.Host,
            stopToken);

        var allowedAddresses = addresses
            .Where(PublicAddressPolicy.IsAllowed)
            .ToArray();

        if (allowedAddresses.Length != addresses.Length ||
            allowedAddresses.Length == 0)
        {
            throw new HttpRequestException(
                "The destination resolved to a blocked address.");
        }

        Exception? lastError = null;

        foreach (var address in allowedAddresses)
        {
            var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
            {
                NoDelay = true
            };

            try
            {
                await socket.ConnectAsync(
                    new IPEndPoint(address, endpoint.Port),
                    stopToken);

                return new NetworkStream(socket, ownsSocket: true);
            }
            catch (Exception error) when (error is SocketException or OperationCanceledException)
            {
                socket.Dispose();
                lastError = error;

                if (error is OperationCanceledException)
                {
                    throw;
                }
            }
        }

        throw new HttpRequestException(
            "A connection couldn't be established to the destination.",
            lastError);
    }
}
</code></pre>
<p>The strict <code>allowedAddresses.Length != addresses.Length</code> check rejects a hostname if any returned address is blocked. This avoids choosing a public answer while leaving a mixed public/private DNS configuration unnoticed. That strictness can expose broken DNS configurations, which is useful for an allowlisted integration. For a general public fetcher, define and document the behaviour deliberately.</p>
<h2>Register a dedicated client with <code>IHttpClientFactory</code></h2>
<p>SSRF-sensitive traffic should use its own named or typed client. This makes its handler policy difficult to bypass accidentally and keeps credentials intended for other services away from attacker-influenced requests.</p>
<pre><code class="language-csharp">builder.Services.AddHttpClient&lt;SafeContentClient&gt;(client =&gt;
{
    client.Timeout = TimeSpan.FromSeconds(10);
    client.DefaultRequestHeaders.UserAgent.ParseAdd("ContentFetcher/1.0");
})
.ConfigurePrimaryHttpMessageHandler(() =&gt; new SocketsHttpHandler
{
    AllowAutoRedirect = false,
    AutomaticDecompression = DecompressionMethods.None,
    ConnectTimeout = TimeSpan.FromSeconds(5),
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),
    ConnectCallback = SafeSocketConnector.ConnectAsync
});
</code></pre>
<p>The client should also cap how much data it reads. <code>ResponseHeadersRead</code> prevents <code>HttpClient</code> from buffering the entire response before your code can enforce a limit.</p>
<pre><code class="language-csharp">public sealed class SafeContentClient(HttpClient httpClient)
{
    private const int MaximumResponseBytes = 5 * 1024 * 1024;

    public async Task&lt;byte[]&gt; DownloadAsync(
        string untrustedUrl,
        CancellationToken stopToken)
    {
        var uri = OutboundUrlPolicy.ParseAndValidate(untrustedUrl);

        using var request = new HttpRequestMessage(HttpMethod.Get, uri);
        using var response = await httpClient.SendAsync(
            request,
            HttpCompletionOption.ResponseHeadersRead,
            stopToken);

        response.EnsureSuccessStatusCode();

        if (response.Content.Headers.ContentLength is &gt; MaximumResponseBytes)
        {
            throw new InvalidOperationException("The response is too large.");
        }

        await using var source = await response.Content.ReadAsStreamAsync(stopToken);
        await using var destination = new MemoryStream();

        var buffer = new byte[81920];
        var totalBytes = 0;

        while (true)
        {
            var bytesRead = await source.ReadAsync(buffer, stopToken);

            if (bytesRead == 0)
            {
                break;
            }

            totalBytes += bytesRead;

            if (totalBytes &gt; MaximumResponseBytes)
            {
                throw new InvalidOperationException("The response is too large.");
            }

            await destination.WriteAsync(buffer.AsMemory(0, bytesRead), stopToken);
        }

        return destination.ToArray();
    }
}
</code></pre>
<p>Checking <code>Content-Length</code> alone isn't sufficient because the header can be absent or dishonest. The streaming loop enforces the limit on the bytes actually read. Disabling automatic decompression also avoids silently expanding a small compressed response into a much larger body. If compressed content is a requirement, apply a limit after decompression as well.</p>
<h2>Redirects are new requests, not part of the old one</h2>
<p>Automatic redirects are dangerous in an SSRF-sensitive client. An allowed public URL can return a redirect to <code>https://127.0.0.1</code>, an internal hostname or a different port. When <code>AllowAutoRedirect</code> is enabled, the application may follow it before its own URL policy sees the new destination.</p>
<p>The simplest policy is to reject redirects. If the feature needs them, disable automatic redirects and handle a small number manually. Resolve relative <code>Location</code> values against the current URI, run the full URL policy again and send a new request. The connection callback must still validate the newly resolved address.</p>
<p>Be particularly careful with headers across redirects. Don't forward <code>Authorization</code>, cookies or other service credentials to a new host. In many fetcher-style applications, the safest client has no ambient credentials at all.</p>
<h2><code>IHttpClientFactory</code> helps, but it isn't an SSRF control by itself</h2>
<p><code>IHttpClientFactory</code> manages handler lifetimes and makes named or typed clients easy to configure. It doesn't decide whether a destination is trustworthy. Registering a client through the factory doesn't prevent calls to loopback, private networks or metadata endpoints unless its handler and surrounding network enforce that policy. Connection pooling also deserves attention. A pooled connection doesn't perform DNS resolution for every request because it reuses an existing socket. This isn't a bypass when the socket was validated as it was created, but it affects how quickly DNS changes take effect. <code>PooledConnectionLifetime</code> places a limit on reuse. Choose the value according to the destination's DNS behaviour and your operational requirements.</p>
<h2>Add network level enforcement</h2>
<p>Application validation is only one layer. OWASP recommends restricting the application's outbound network access so it can reach only the destinations it requires. In Azure, that may mean routing outbound traffic through Azure Firewall or another controlled egress component, applying network security rules and explicitly denying access to internal and link local ranges from the workload subnet. This layer protects you if a new code path creates a plain <code>HttpClient</code>, a parser misses an address form or a dependency makes an unexpected outbound request. It also provides a central place for logging and destination policy.</p>
<p>For a service that only calls several known APIs, the network allowlist can be narrow. A public URL fetching service is a different workload. Isolating it into a separate process or subnet with no route to internal systems limits what a successful bypass can reach. Cloud metadata endpoints deserve explicit attention. Azure services use platform specific mechanisms and headers to protect managed identity token endpoints, but application code shouldn't rely on those measures as its SSRF defence. Block link local access from untrusted fetch operations and use the supported identity SDK from trusted code.</p>
<h2>Test the policy as security code</h2>
<p>Unit tests should cover more than the obvious <code>127.0.0.1</code> case. Include IPv4 and IPv6 loopback, RFC 1918 addresses, link-local addresses, IPv4-mapped IPv6, unique-local IPv6, non-HTTPS schemes, unexpected ports, embedded credentials and misleading hostname suffixes.</p>
<pre><code class="language-csharp">public sealed class PublicAddressPolicyTests
{
    [Theory]
    [InlineData("127.0.0.1")]
    [InlineData("10.20.30.40")]
    [InlineData("169.254.169.254")]
    [InlineData("192.168.1.10")]
    [InlineData("::1")]
    [InlineData("fc00::1")]
    [InlineData("::ffff:127.0.0.1")]
    public void IsAllowed_BlocksNonPublicAddresses(string value)
    {
        var address = IPAddress.Parse(value);

        Assert.False(PublicAddressPolicy.IsAllowed(address));
    }

    [Theory]
    [InlineData("1.1.1.1")]
    [InlineData("8.8.8.8")]
    [InlineData("2606:4700:4700::1111")]
    public void IsAllowed_AcceptsPublicAddresses(string value)
    {
        var address = IPAddress.Parse(value);

        Assert.True(PublicAddressPolicy.IsAllowed(address));
    }
}
</code></pre>
<p>Integration tests should run against a controlled DNS server and HTTP service. Test a hostname that resolves to a blocked address, a mixed public/private response and a public endpoint that redirects to a blocked destination. These cases exercise the connection boundary that unit tests of the URL parser can't reach. Log blocked attempts using structured fields such as the normalised hostname, resolved address category and policy reason. Avoid logging embedded credentials or the full query string because URLs often contain sensitive tokens.</p>
<h2>A practical review checklist</h2>
<p>When reviewing an outbound HTTP feature, trace where the destination comes from and how much control the caller has. Prefer a trusted destination identifier over a supplied URL. If URLs are accepted, parse them with <code>Uri</code>, restrict the scheme and port, compare canonical hostnames and reject credentials. Then follow the request beyond validation. Confirm that every resolved address is acceptable at socket connection time, automatic redirects are disabled, credentials aren't attached to the client, response size and time are bounded, and outbound network policy blocks internal destinations. Finally, test the awkward address forms and redirect paths rather than only the normal request.</p>
<p><code>IHttpClientFactory</code> is a good place to package this behaviour into a dedicated client. The actual protection comes from treating destination selection as a security boundary and enforcing the same policy from URL parsing through to the socket and the surrounding network.</p>
<ul>
<li><p><a href="https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html">OWASP Server-Side Request Forgery Prevention Cheat Sheet</a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory">Microsoft: Use the <code>IHttpClientFactory</code></a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.net.http.socketshttphandler.connectcallback">Microsoft: <code>SocketsHttpHandler.ConnectCallback</code></a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines">Microsoft: Guidelines for using <code>HttpClient</code></a></p>
</li>
<li><p><a href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token">Microsoft: Managed identities for Azure resources</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[What If Your Pull Request Had a Blast Radius Score?]]></title><description><![CDATA[Some pull requests look harmless until they reach production. Then someone notices the change touched authentication, a shared response contract, an EF Core migration, and one production config value ]]></description><link>https://fullstackcity.com/what-if-your-pull-request-had-a-blast-radius-score</link><guid isPermaLink="true">https://fullstackcity.com/what-if-your-pull-request-had-a-blast-radius-score</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[software development]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[github-actions]]></category><category><![CDATA[Pull Requests]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Tue, 07 Jul 2026 07:04:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/1a8f95b5-478f-4160-b51a-fb7130601a64.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Some pull requests look harmless until they reach production. Then someone notices the change touched authentication, a shared response contract, an EF Core migration, and one production config value that doesn't exist in staging. That wasn't a small PR. That was a blast radius problem. We tend to review pull requests through the shape GitHub gives us. Files changed. Lines added. Lines removed. Comments. Checks. Approvals.</p>
<p>Those signals help, but they're incomplete. A one thousand line test only change may be tedious. A twelve line change to token validation may deserve every senior engineer in the room. A tiny update to <code>appsettings.Production.json</code> might be the whole incident.</p>
<p>So I started thinking about a small tool I wish existed in more .NET teams. What if every pull request had a <strong>Blast Radius Score</strong>? Not as a gate. Not as a replacement for review. More like a warning label that says, "look here first".</p>
<pre><code class="language-text">Blast Radius Score: 78 / 100
Risk level: High

Main signals:
- Public response contract changed
- EF Core migration added
- Production configuration changed
- Authorisation code touched
- No matching tests changed
</code></pre>
<p>That score doesn't need to be perfect to be useful. It just needs to make risk visible before the first reviewer opens the diff.</p>
<h2>PR size is a weak signal</h2>
<p>A lot of review conversations still start with size.</p>
<p>"This PR is only 80 lines."</p>
<p>"This PR is too big."</p>
<p>"Can you split this up?"</p>
<p>Size is important, but size alone can be misleading. Some large pull requests are mostly mechanical. Some tiny ones change the behaviour of the whole system. In a .NET application, the dangerous changes are often the ones that cross a boundary. A handler implementation change may stay local. A DTO change can leak into consumers. A migration can affect existing data. A config key can behave differently in every environment. A change to <code>AddAuthorization</code> can alter access rules across an entire API. Thats the kind of difference a blast radius score should surface.</p>
<p>The useful question is simple:</p>
<blockquote>
<p>How far could this change spread if it's wrong?</p>
</blockquote>
<p>That framing changes the review. Instead of asking someone to stare harder at a diff, the tool points them towards the parts of the change that deserve attention.</p>
<h2>What blast radius means in a .NET app</h2>
<p>In a .NET codebase, blast radius usually comes from boundaries and runtime behaviour. A private method inside one feature has a small radius. It might still contain a bug, but the damage is likely contained. A public API response has a wider radius. Other services, clients, tests, dashboards, and support scripts may depend on it. A database migration has a different kind of radius. It changes the shape of persisted state. That makes rollback harder, especially when production data starts moving through the new shape.</p>
<p>Configuration is another classic source of risk. A change can pass locally because local config is complete. It can pass in test because test has a fallback. Then production fails because a key was named differently in one deployment slot.</p>
<p>Security sensitive code deserves special weight. Authentication, authorisation, claims mapping, CORS, token validation, and secret handling can be affected by tiny changes. They shouldn't be reviewed with the same level of suspicion as a formatting fix. Runtime behaviour is important too. Retries, timeouts, background workers, queue consumers, caching, and <code>HttpClient</code> setup can change how a system behaves under pressure. Those changes may not look dramatic in code review, but they decide what happens at 2am when a dependency starts timing out.</p>
<p>A good blast radius tool should know about those areas.</p>
<h2>The first version should be simple on purpose</h2>
<p>The tempting version of this idea is too clever. You could imagine AI reading the whole pull request and writing a risk assessment. You could imagine deep semantic analysis. You could imagine a model trained on historic incidents.</p>
<p>I wouldn't start there.</p>
<p>The first useful version can be rule based. Look at the changed files. Classify them. Apply simple weights. Produce a short report. Make the output simple enough that people trust it. The tool could run as a .NET global tool, a GitHub Action, or both.</p>
<pre><code class="language-bash">dotnet tool install --global BlastGuard.Cli

blastguard analyse --base main --head feature/payment-change
</code></pre>
<p>The output might look like this:</p>
<pre><code class="language-text">Blast Radius Report

Score: 82 / 100
Risk: High

Detected risk areas:

Public contracts
- Modified CreatePaymentRequest
- Modified PaymentStatusResponse

Database
- Added EF Core migration
- Changed payment amount precision

Configuration
- Modified appsettings.Production.json
- Added Payments:ProviderTimeoutSeconds

Security-sensitive code
- Modified ClaimsPrincipalExtensions
- Modified authorisation policy registration

Test signal
- No matching test files changed under Payments.Tests

Suggested review focus:
- Confirm API consumers can handle the response change
- Confirm the migration is backwards compatible
- Confirm production config exists in all environments
- Add tests around authorisation and amount precision
</code></pre>
<p>That report is deliberately plain. The value comes from focus. It gives reviewers a starting point.</p>
<h2>A simple scoring pipeline</h2>
<p>The scoring flow can be simple enough to explain in one diagram.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/ce0387e6-56e5-4471-8e73-210fe85abec0.png" alt="" style="display:block;margin:0 auto" />

<p>At the centre is a change set.</p>
<pre><code class="language-csharp">public sealed record PullRequestChangeSet(
    string BaseRef,
    string HeadRef,
    IReadOnlyList&lt;ChangedFile&gt; Files);

public sealed record ChangedFile(
    string Path,
    ChangeKind ChangeKind,
    int Additions,
    int Deletions,
    string? Patch);

public enum ChangeKind
{
    Added,
    Modified,
    Deleted,
    Renamed
}
</code></pre>
<p>The first pass doesn't need to parse every line of C#. It can get useful signals from paths and filenames.</p>
<pre><code class="language-csharp">public enum ChangedFileType
{
    Unknown,
    Documentation,
    Test,
    ApplicationCode,
    PublicContract,
    DatabaseMigration,
    Configuration,
    ProductionConfiguration,
    SecuritySensitive,
    RuntimeBehaviour,
    Infrastructure
}
</code></pre>
<p>A classifier can start with conventions.</p>
<pre><code class="language-csharp">public sealed class ChangedFileClassifier
{
    public ChangedFileType Classify(string path)
    {
        if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Documentation;

        if (path.Contains(".Tests/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains(".Tests\\", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Test;

        if (path.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("\\Migrations\\", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.DatabaseMigration;

        if (path.EndsWith("appsettings.Production.json", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.ProductionConfiguration;

        if (path.EndsWith("appsettings.json", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Configuration;

        if (path.Contains("/Contracts/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/Dtos/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/Messages/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.PublicContract;

        if (path.Contains("/Auth/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/Security/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.SecuritySensitive;

        if (path.Contains("/Workers/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/HostedServices/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/HttpClients/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.RuntimeBehaviour;

        if (path.Contains("/Infrastructure/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Infrastructure;

        return ChangedFileType.ApplicationCode;
    }
}
</code></pre>
<p>This kind of code won't catch everything. That's fine. Early value beats perfect detection. The tool should also let teams configure their own rules, because every codebase has different risk areas.</p>
<pre><code class="language-json">{
  "riskAreas": [
    {
      "name": "Payments",
      "paths": ["src/Payments/**"],
      "points": 15
    },
    {
      "name": "Authentication",
      "paths": ["src/**/Auth/**", "src/**/Security/**"],
      "points": 25
    },
    {
      "name": "Public Contracts",
      "paths": ["src/**/Contracts/**", "src/**/Dtos/**"],
      "points": 20
    }
  ]
}
</code></pre>
<p>The default rules get you started. The config makes it fit your architecture.</p>
<h2>The scoring model</h2>
<p>I would keep the scoring model obvious. The tool adds points for detected risk signals. It subtracts points for safer signals. Then it clamps the result between 0 and 100.</p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Points</th>
</tr>
</thead>
<tbody><tr>
<td>Public API contract changed</td>
<td>+20</td>
</tr>
<tr>
<td>Message contract changed</td>
<td>+20</td>
</tr>
<tr>
<td>EF Core migration added</td>
<td>+20</td>
</tr>
<tr>
<td>Production config changed</td>
<td>+15</td>
</tr>
<tr>
<td>Authentication code touched</td>
<td>+25</td>
</tr>
<tr>
<td>Authorisation code touched</td>
<td>+25</td>
</tr>
<tr>
<td>Background worker changed</td>
<td>+15</td>
</tr>
<tr>
<td>Retry or timeout policy changed</td>
<td>+10</td>
</tr>
<tr>
<td>Multiple bounded areas touched</td>
<td>+5 each</td>
</tr>
<tr>
<td>No matching tests changed</td>
<td>+10</td>
</tr>
<tr>
<td>Large PR over 500 changed lines</td>
<td>+10</td>
</tr>
<tr>
<td>Large PR over 1,500 changed lines</td>
<td>+20</td>
</tr>
<tr>
<td>Only documentation changed</td>
<td>-30</td>
</tr>
<tr>
<td>Only tests changed</td>
<td>-20</td>
</tr>
</tbody></table>
<p>The exact numbers aren't sacred. They exist to start a conversation. A basic domain model:</p>
<pre><code class="language-csharp">public sealed record BlastRadiusScore(
    int Value,
    RiskLevel RiskLevel,
    IReadOnlyList&lt;RiskFinding&gt; Findings);

public sealed record RiskFinding(
    string Category,
    int Points,
    string Message,
    string? FilePath = null);

public enum RiskLevel
{
    Low,
    Medium,
    High,
    Critical
}
</code></pre>
<p>The scoring engine can be tiny.</p>
<pre><code class="language-csharp">public interface IBlastRadiusRule
{
    IEnumerable&lt;RiskFinding&gt; Analyse(PullRequestChangeSet changeSet);
}

public sealed class BlastRadiusScorer(IEnumerable&lt;IBlastRadiusRule&gt; rules)
{
    public BlastRadiusScore Score(PullRequestChangeSet changeSet)
    {
        var findings = rules
            .SelectMany(rule =&gt; rule.Analyse(changeSet))
            .ToList();

        var rawScore = findings.Sum(x =&gt; x.Points);
        var score = Math.Clamp(rawScore, 0, 100);

        return new BlastRadiusScore(
            Value: score,
            RiskLevel: ToRiskLevel(score),
            Findings: findings);
    }

    private static RiskLevel ToRiskLevel(int score) =&gt;
        score switch
        {
            &gt;= 75 =&gt; RiskLevel.Critical,
            &gt;= 50 =&gt; RiskLevel.High,
            &gt;= 25 =&gt; RiskLevel.Medium,
            _ =&gt; RiskLevel.Low
        };
}
</code></pre>
<p>The rules do the actual work.</p>
<pre><code class="language-csharp">public sealed class EfMigrationRule : IBlastRadiusRule
{
    public IEnumerable&lt;RiskFinding&gt; Analyse(PullRequestChangeSet changeSet)
    {
        foreach (var file in changeSet.Files)
        {
            var isMigration = file.Path.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase) ||
                              file.Path.Contains("\\Migrations\\", StringComparison.OrdinalIgnoreCase);

            if (!isMigration)
                continue;

            yield return new RiskFinding(
                Category: "Database",
                Points: 20,
                Message: "EF Core migration changed",
                FilePath: file.Path);
        }
    }
}
</code></pre>
<p>A production configuration rule is just as simple.</p>
<pre><code class="language-csharp">public sealed class ProductionConfigurationRule : IBlastRadiusRule
{
    public IEnumerable&lt;RiskFinding&gt; Analyse(PullRequestChangeSet changeSet)
    {
        foreach (var file in changeSet.Files)
        {
            if (!file.Path.EndsWith("appsettings.Production.json", StringComparison.OrdinalIgnoreCase))
                continue;

            yield return new RiskFinding(
                Category: "Configuration",
                Points: 15,
                Message: "Production configuration changed",
                FilePath: file.Path);
        }
    }
}
</code></pre>
<p>That already gives you something useful.</p>
<h2>Making it understand .NET better</h2>
<p>Path-based rules are a good start, but .NET gives us richer clues. A blast radius tool can look for common framework patterns. It doesn't need to understand the whole application. It just needs to recognise shapes that often carry risk. For Minimal APIs, route changes are important. A tool could inspect changed lines for calls like <code>MapGet</code>, <code>MapPost</code>, <code>MapPut</code>, and <code>MapDelete</code>. If a route changes, the report should mention it.</p>
<pre><code class="language-csharp">app.MapPost("/api/payments", async (
    CreatePaymentRequest request,
    CreatePaymentHandler handler,
    CancellationToken cancellationToken) =&gt;
{
    var result = await handler.Handle(request, cancellationToken);
    return result.ToHttpResult();
});
</code></pre>
<p>For EF Core, migrations are the obvious signal, but model configuration can be just as important. A change to <code>IEntityTypeConfiguration&lt;T&gt;</code> can alter column sizes, indexes, relationships, and delete behaviour.</p>
<pre><code class="language-csharp">public sealed class PaymentConfiguration : IEntityTypeConfiguration&lt;Payment&gt;
{
    public void Configure(EntityTypeBuilder&lt;Payment&gt; builder)
    {
        builder.Property(x =&gt; x.Amount)
            .HasPrecision(19, 4);
    }
}
</code></pre>
<p>A precision change like that might be correct. It still deserves a focused review because it affects persisted data and financial calculations. For options classes, the tool can flag config binding changes. These often look harmless, but they can break at runtime if the environment is missing a value.</p>
<pre><code class="language-csharp">builder.Services
    .AddOptions&lt;PaymentProviderOptions&gt;()
    .BindConfiguration("Payments:Provider")
    .ValidateDataAnnotations()
    .ValidateOnStart();
</code></pre>
<p>A nice rule would reward <code>ValidateOnStart</code>. If production config changes and options validation is missing, the score should rise. If the options are validated on startup, the score can be softened. For hosted services, queue consumers, and background workers, the tool should assume wider runtime impact. These areas often handle retries, concurrency, poison messages, and long running work.</p>
<pre><code class="language-csharp">public sealed class PaymentStatusWorker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessPendingPayments(stoppingToken);
        }
    }
}
</code></pre>
<p>Changes here can pass unit tests and still behave badly under load.</p>
<p>For outbound dependencies, the tool should care about <code>HttpClient</code>, retry policies, timeouts, and circuit breakers.</p>
<pre><code class="language-csharp">builder.Services
    .AddHttpClient&lt;PaymentProviderClient&gt;()
    .ConfigureHttpClient(client =&gt;
    {
        client.Timeout = TimeSpan.FromSeconds(10);
    });
</code></pre>
<p>A timeout change from 10 seconds to 90 seconds is small in the diff. It can be huge in production. For authentication and authorisation, the score should rise quickly. The changed line count barely matters here.</p>
<pre><code class="language-csharp">builder.Services.AddAuthorization(options =&gt;
{
    options.AddPolicy("CanApprovePayment", policy =&gt;
    {
        policy.RequireClaim("permission", "payments.approve");
    });
});
</code></pre>
<p>One altered claim name can lock people out or let the wrong people in.</p>
<h2>Test changes should affect the score</h2>
<p>A blast radius score should look at the risk and the evidence around that risk. If a PR changes a public contract and also updates contract tests, that is still a risk signal. It just feels better than changing the contract with no matching tests. The first version can use a rough convention.</p>
<p>If files under <code>src/Payments</code> changed, look for changed files under <code>tests/Payments.Tests</code>. If authentication code changed, look for tests with <code>Auth</code>, <code>Authorization</code>, or <code>Claims</code> in the path. If a migration changed, look for integration tests or migration tests.</p>
<pre><code class="language-csharp">public sealed class MissingMatchingTestsRule : IBlastRadiusRule
{
    public IEnumerable&lt;RiskFinding&gt; Analyse(PullRequestChangeSet changeSet)
    {
        var applicationAreas = changeSet.Files
            .Where(file =&gt; file.Path.StartsWith("src/", StringComparison.OrdinalIgnoreCase))
            .Select(file =&gt; GetTopLevelArea(file.Path))
            .Where(area =&gt; area is not null)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();

        var testPaths = changeSet.Files
            .Where(file =&gt; file.Path.Contains(".Tests", StringComparison.OrdinalIgnoreCase))
            .Select(file =&gt; file.Path)
            .ToList();

        foreach (var area in applicationAreas)
        {
            var hasMatchingTests = testPaths.Any(path =&gt;
                path.Contains(area!, StringComparison.OrdinalIgnoreCase));

            if (hasMatchingTests)
                continue;

            yield return new RiskFinding(
                Category: "Tests",
                Points: 10,
                Message: $"No matching test changes found for {area}");
        }
    }

    private static string? GetTopLevelArea(string path)
    {
        var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
        return parts.Length &gt;= 2 ? parts[1] : null;
    }
}
</code></pre>
<p>This is imperfect, but useful. It won't know whether the tests are good. It will spot when no related tests moved at all. That alone catches a lot of risky PRs.</p>
<h2>The PR comment</h2>
<p>Bad automation creates noise. Good automation helps the reviewer make a better decision. The PR comment should be short, specific, and calm. It shouldn't sound like a security scanner screaming about everything.</p>
<pre><code class="language-markdown">## Blast Radius Score: 76 / 100

Risk level: High

This PR touches areas that can affect runtime behaviour outside the changed files.

### Main risk signals

| Area | Finding | Points |
|---|---:|---:|
| API Contract | Response DTO changed | +20 |
| Database | EF Core migration added | +20 |
| Configuration | Production settings changed | +15 |
| Tests | No matching test changes found | +10 |
| Scope | 3 bounded areas touched | +15 |

### Suggested review focus

- Check whether API consumers are affected.
- Check whether the migration is backwards compatible.
- Confirm the new config value exists in each environment.
- Add or update tests around the changed contract.
</code></pre>
<p>The phrase "suggested review focus" is important. This shouldn't tell the reviewer what to think. It should tell them where to look.</p>
<h2>Running it in GitHub Actions</h2>
<p>The most useful place for this tool is probably the pull request itself. A GitHub Action could run on every PR, score the change, then publish a check summary or comment.</p>
<pre><code class="language-yaml">name: Blast Radius

on:
  pull_request:
    branches:
      - main

jobs:
  blast-radius:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install BlastGuard
        run: dotnet tool install --global BlastGuard.Cli

      - name: Analyse pull request
        run: |
          blastguard analyse \
            --base origin/${{ github.base_ref }} \
            --head HEAD \
            --format github
</code></pre>
<p>The first implementation doesn't need to block the PR. I would start with advisory comments only. Once the team trusts the signal, you can add optional thresholds. For example, a critical score could require an architecture review, a database review, or a second senior reviewer. That step should come later. If you make the tool a hard gate too early, people will treat it as an obstacle rather than a useful review aid.</p>
<h2>What different scores might look like</h2>
<p>A low score should be simple.</p>
<pre><code class="language-text">Score: 8 / 100
Risk: Low

Changed:
- README.md
- One unit test file

Review focus:
- Normal review only
</code></pre>
<p>A medium score should give context without being dramatic.</p>
<pre><code class="language-text">Score: 42 / 100
Risk: Medium

Changed:
- One feature handler
- One validator
- Matching tests

Review focus:
- Check validation behaviour
- Check handler branching
</code></pre>
<p>A high score should make the reviewer slow down.</p>
<pre><code class="language-text">Score: 71 / 100
Risk: High

Changed:
- API response contract
- Mapping logic
- EF Core migration
- No contract tests detected

Review focus:
- Check API compatibility
- Check migration safety
- Check missing tests
</code></pre>
<p>A critical score should be rare. If everything is critical, nothing is.</p>
<pre><code class="language-text">Score: 94 / 100
Risk: Critical

Changed:
- Auth policy registration
- Claims mapping
- Production config
- Shared package version
- No tests changed

Review focus:
- Confirm access rules
- Confirm production config
- Confirm downstream impact
- Consider splitting the PR
</code></pre>
<p>The tool earns trust by staying quiet on ordinary changes.</p>
<h2>Where this helps</h2>
<p>This kind of tool helps most when reviewers are busy. Reviewers don't always know the whole codebase. A backend engineer may not immediately know that a DTO is consumed by a frontend app. A new team member may not know that <code>ClaimsPrincipalExtensions</code> sits on the hot path for every secured endpoint. A senior engineer may be reviewing quickly between meetings.</p>
<p>The blast radius report gives them a map of likely risk. It also helps with PR descriptions. If the author says "small refactor" but the tool spots a migration and production config change, the conversation changes. The reviewer can ask for a better description, a rollout note, or extra tests. It helps team leads too. A dashboard of recent high risk PRs could reveal patterns. Maybe risky changes often arrive late in the sprint. Maybe migrations are regularly merged without tests. Maybe auth changes get reviewed by people outside the owning team.</p>
<p>Thats where the idea becomes more than a toy. It turns code review risk into something visible.</p>
<h2>Where it can go wrong</h2>
<p>The biggest danger is false confidence. A low score doesn't mean the change is safe. It means the tool didn't detect obvious risk signals. A bug in a private method can still be expensive. The second danger is noise. If the tool posts a dramatic comment on every PR, people will ignore it. The scoring needs to be conservative. It should prefer useful silence over constant warnings. The third danger is gaming. Once a score becomes a hard gate, people may split changes awkwardly or rename files to avoid rules. That is another reason to begin with advisory reports.</p>
<p>The fourth danger is pretending the score is objective. It isn't. It reflects the team's current understanding of risk. The rules should evolve as the codebase evolves. If a production incident came from a config flag, add a config rule. If a breaking change came from a message contract, add a message contract rule. If a slow outage came from retry behaviour, add a runtime behaviour rule.</p>
<p>The tool should learn from the team's scars.</p>
<h2>What I would add next</h2>
<p>After the first path based version, I would add a few deeper checks. OpenAPI diffing would be high value. If the API contract changes, compare generated OpenAPI output before and after the PR. That would catch changed routes, removed fields, altered response codes, and schema changes.</p>
<p>EF migration analysis would also be worth it. Not all migrations are equal. Adding a nullable column is different from dropping a table. A migration that changes precision is different from one that creates a new index.</p>
<p>Message contract detection would help distributed systems. If a record in <code>Contracts</code> or <code>Messages</code> changes, the tool could flag consumers and publishers. Options validation would be a useful .NET-specific rule. If new configuration is introduced, the tool could check whether the options class uses validation and <code>ValidateOnStart</code>.</p>
<p>Test mapping could become smarter over time. The first version can use folder conventions. Later versions could use project references, namespaces, or code coverage reports. None of this needs to happen on day one. The first version should be small enough to trust.</p>
<h2>A possible repo shape</h2>
<p>If I built this as a small .NET tool, I would keep the structure plain.</p>
<pre><code class="language-text">src/
  BlastGuard.Cli/
  BlastGuard.Core/
  BlastGuard.Git/
  BlastGuard.GitHub/

tests/
  BlastGuard.Core.Tests/
  BlastGuard.Cli.Tests/
</code></pre>
<p><code>BlastGuard.Core</code> owns the rules and scoring.</p>
<p><code>BlastGuard.Git</code> reads diffs locally.</p>
<p><code>BlastGuard.GitHub</code> formats comments and check summaries.</p>
<p><code>BlastGuard.Cli</code> wires it together.</p>
<p>The CLI could start with one command.</p>
<pre><code class="language-bash">blastguard analyse --base main --head HEAD --format markdown
</code></pre>
<p>Later, it could support JSON output for CI systems.</p>
<pre><code class="language-bash">blastguard analyse --base main --head HEAD --format json --output blast-radius.json
</code></pre>
<p>That keeps the tool useful outside GitHub too.</p>
<h2>The real value is better review behaviour</h2>
<p>The best version of this idea doesn't shame people for opening risky PRs. Sometimes a risky PR is necessary. A payment contract needs to change. A migration needs to ship. An auth policy needs fixing. The issue isn't that the PR has risk. The issue is when the risk is hidden.</p>
<p>A blast radius score makes the risk visible. It gives authors a chance to explain the change properly. It gives reviewers a better place to start. It gives teams a shared language for changes that deserve more care. Thats useful even if the score is rough. A pull request shouldn't be judged only by how many lines changed. It should be judged by how far the change can travel. And if a tiny PR can alter contracts, data, config, security, or runtime behaviour, it shouldn't be allowed to stroll through review pretending to be ordinary.</p>
<p><a href="https://github.com/marketplace/actions/blastguard-pr-blast-radius">Try it out in your Github actions here</a></p>
]]></content:encoded></item><item><title><![CDATA[Building a tiny IRC client in C#]]></title><description><![CDATA[IRC is old, plain, and surprisingly useful as a teaching tool. You open a TCP connection. You send text commands. The server sends text lines back. The connection stays open. If the server sends PING,]]></description><link>https://fullstackcity.com/building-a-tiny-irc-client-in-c</link><guid isPermaLink="true">https://fullstackcity.com/building-a-tiny-irc-client-in-c</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[C#]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[internet]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Wed, 24 Jun 2026 18:34:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/ff1a4575-42d0-4e5c-9a5f-eea6f828b235.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>IRC is old, plain, and surprisingly useful as a teaching tool. You open a TCP connection. You send text commands. The server sends text lines back. The connection stays open. If the server sends <code>PING</code>, you respond with <code>PONG</code>, or you get disconnected. That’s already a very different model from a normal web API. HTTP teaches request and response. IRC teaches connection lifetime. This post builds a tiny IRC client in C#. The goal isn’t to build a production IRC bot. The goal is to understand what sits underneath higher-level frameworks, sockets, streams, protocols, parsing, heartbeats, cancellation, and failure.</p>
<h2>What we’re building</h2>
<p>We’re going to build a small console app that connects to an IRC server, registers a nickname, joins a channel, reads messages, and responds to server heartbeats.</p>
<p>The core flow looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/303d3658-3ad1-4129-90ab-cb78af8cbcdc.png" alt="" style="display:block;margin:0 auto" />

<p>That’s enough to teach the interesting parts.</p>
<p>The application has a few small pieces. One part owns the connection. One part sends IRC commands. One part reads lines from the server. One parser turns raw IRC lines into a simple C# type.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/5c0b8a62-f4c8-4014-83d8-3be1ebf48518.png" alt="" style="display:block;margin:0 auto" />

<p>This is deliberately small. The code should stay close to the protocol, otherwise the useful lesson gets buried.</p>
<h2>IRC is just lines of text</h2>
<p>A basic IRC command is a line of text ending in CRLF, which means <code>\r\n</code>.For example, to set a nickname, the client sends this:</p>
<pre><code class="language-text">NICK tiny-csharp-bot
</code></pre>
<p>To provide user information, it sends this:</p>
<pre><code class="language-text">USER tiny-csharp-bot 0 * :Tiny CSharp Bot
</code></pre>
<p>To join a channel, it sends this:</p>
<pre><code class="language-text">JOIN #some-channel
</code></pre>
<p>The server sends lines back in a similar shape. Some are numeric replies. Some are messages from users. Some are server commands.</p>
<p>The first small but important detail is line endings. Network protocols are often fussy about tiny details. A command can look correct in your code and still fail because the server expected <code>\r\n</code>, not just <code>\n</code>.</p>
<p>That’s the kind of thing frameworks usually hide from you.</p>
<h2>Start with the connection</h2>
<p>Here’s a small IRC client class that connects over TCP and wraps the stream in TLS. Most public IRC networks expect TLS on port <code>6697</code>. Plain TCP on port <code>6667</code> still exists in some places, but using TLS is a better default even for a toy client.</p>
<pre><code class="language-csharp">using System.Net.Security;
using System.Net.Sockets;
using System.Text;

public sealed class IrcClient(string host, int port, string nick, string channel)
    : IAsyncDisposable
{
    private TcpClient? _tcpClient;
    private StreamReader? _reader;
    private StreamWriter? _writer;
    private SslStream? _sslStream;

    public async Task ConnectAsync(CancellationToken stopToken)
    {
        _tcpClient = new TcpClient();
        await _tcpClient.ConnectAsync(host, port, stopToken);
        _sslStream = new SslStream(_tcpClient.GetStream());
        await _sslStream.AuthenticateAsClientAsync(host);
        _reader = new StreamReader(_sslStream, Encoding.UTF8);
        _writer = new StreamWriter(_sslStream, Encoding.UTF8)
        {
            NewLine = "\r\n",
            AutoFlush = true
        };

        await SendAsync($"NICK {nick}");
        await SendAsync($"USER {nick} 0 * :Tiny CSharp Bot");
        await SendAsync($"JOIN {channel}");
    }

    public async Task RunAsync(CancellationToken stopToken)
    {
        if (_reader is null)
        {
            throw new InvalidOperationException("Client is not connected.");
        }

        while (!stopToken.IsCancellationRequested &amp;&amp;
               await _reader.ReadLineAsync() is { } line)
        {
            Console.WriteLine($"&lt; {line}");
            var message = IrcMessageParser.Parse(line);
            await HandleMessageAsync(message, stopToken);
        }
    }

    private async Task HandleMessageAsync(IrcMessage message, CancellationToken stopToken)
    {
        if (message.Command.Equals("PING", StringComparison.OrdinalIgnoreCase))
        {
            var token = message.Trailing ?? message.Parameters.FirstOrDefault();
            if (!string.IsNullOrWhiteSpace(token))
            {
                await SendAsync($"PONG :{token}");
            }
            return;
        }

        if (message.Command.Equals("PRIVMSG", StringComparison.OrdinalIgnoreCase))
        {
            var from = message.Prefix ?? "unknown";
            var target = message.Parameters.FirstOrDefault() ?? "unknown";
            var text = message.Trailing ?? string.Empty;
            Console.WriteLine($"{from} -&gt; {target}: {text}");
        }
    }

    private async Task SendAsync(string line)
    {
        if (_writer is null)
        {
            throw new InvalidOperationException("Client is not connected.");
        }

        Console.WriteLine($"&gt; {line}");
        await _writer.WriteLineAsync(line);
    }

    public async ValueTask DisposeAsync()
    {
        if (_writer is not null)
        {
            await _writer.DisposeAsync();
        }

        _reader?.Dispose();
        _sslStream?.Dispose();
        _tcpClient?.Dispose();
    }
}
</code></pre>
<p>There’s no ASP.NET Core here. No request object. No response object. No middleware. We’re just reading and writing lines over a stream. That’s the first useful lesson. A network connection is a stream. Your application decides what the bytes mean.</p>
<h2>The small console app</h2>
<p>The console app itself is boring, which is what we want.</p>
<pre><code class="language-csharp">using var cancellation = new CancellationTokenSource();

Console.CancelKeyPress += (_, eventArgs) =&gt;
{
    eventArgs.Cancel = true;
    cancellation.Cancel();
};

await using var client = new IrcClient(
    host: "irc.libera.chat",
    port: 6697,
    nick: "tiny-csharp-bot",
    channel: "#test-channel");

await client.ConnectAsync(cancellation.Token);
await client.RunAsync(cancellation.Token);
</code></pre>
<p>For a real run, you’d use your own nickname and a channel where testing is allowed. Don’t point a toy bot at a busy public channel and spam it while you debug. That’s a bad idea! The important part is the shape of the program. The client connects once, then keeps reading until cancellation or disconnection. That alone makes it feel different from a normal HTTP endpoint. There’s no single request to finish. The connection is the work.</p>
<h2>Parsing the message</h2>
<p>At first, you can get away with string checks. If the line starts with <code>PING</code>, send <code>PONG</code>. That works for the first version, but it doesn’t teach enough. So let’s parse the message into a small model. An IRC message can have a prefix, a command, some parameters, and trailing text. A normal line can look like this:</p>
<pre><code class="language-text">:nick!user@host PRIVMSG #channel :hello from IRC
</code></pre>
<p>The prefix is this part:</p>
<pre><code class="language-text">nick!user@host
</code></pre>
<p>The command is this:</p>
<pre><code class="language-text">PRIVMSG
</code></pre>
<p>The first parameter is this:</p>
<pre><code class="language-text">#channel
</code></pre>
<p>The trailing text is this:</p>
<pre><code class="language-text">hello from IRC
</code></pre>
<p>A small record is enough for this post.</p>
<pre><code class="language-csharp">public sealed record IrcMessage(
    string? Prefix,
    string Command,
    IReadOnlyList&lt;string&gt; Parameters,
    string? Trailing);
</code></pre>
<p>Now we need a parser.</p>
<pre><code class="language-csharp">public static class IrcMessageParser
{
    public static IrcMessage Parse(string line)
    {
        var remaining = line.AsSpan();
        string? prefix = null;
        string? trailing = null;

        if (remaining.StartsWith(":"))
        {
            var prefixEnd = remaining.IndexOf(' ');
            if (prefixEnd &lt; 0)
            {
                return new IrcMessage(
                    Prefix: line[1..],
                    Command: string.Empty,
                    Parameters: Array.Empty&lt;string&gt;(),
                    Trailing: null);
            }

            prefix = remaining[1..prefixEnd].ToString();
            remaining = remaining[(prefixEnd + 1)..];
        }

        var trailingStart = remaining.IndexOf(" :");
        if (trailingStart &gt;= 0)
        {
            trailing = remaining[(trailingStart + 2)..].ToString();
            remaining = remaining[..trailingStart];
        }

        var parts = remaining
            .ToString()
            .Split(' ', StringSplitOptions.RemoveEmptyEntries);

        if (parts.Length == 0)
        {
            return new IrcMessage(
                Prefix: prefix,
                Command: string.Empty,
                Parameters: Array.Empty&lt;string&gt;(),
                Trailing: trailing);
        }

        var command = parts[0];
        var parameters = parts.Skip(1).ToArray();

        return new IrcMessage(
            Prefix: prefix,
            Command: command,
            Parameters: parameters,
            Trailing: trailing);
    }
}
</code></pre>
<p>This parser is intentionally small. It’s good enough for basic messages, but it’s not a full IRC implementation. That’s part of the lesson. Parsing the happy path is easy. Protocol parsing gets harder when you account for malformed input, odd edge cases, length limits, encoding, missing parameters, unexpected commands, and network behaviour. A toy parser is useful because it shows the shape of the problem. A production parser has to survive everything else.</p>
<h2>Why PING and PONG matter</h2>
<p>The most important command in this tiny client is probably <code>PING</code>. The server sends a line like this:</p>
<pre><code class="language-text">PING :server-token
</code></pre>
<p>The client must respond with:</p>
<pre><code class="language-text">PONG :server-token
</code></pre>
<p>This is a heartbeat. The server is checking whether the client is still alive. If the client doesn’t answer, the server can close the connection. That’s not unique to IRC. Long running network systems often need some form of liveness check. WebSockets have ping and pong frames. Message brokers have heartbeats. Databases and caches have connection keep-alive behaviour. Distributed systems constantly need to decide whether something is still there or just silent.</p>
<p>The code looks tiny:</p>
<pre><code class="language-csharp">if (message.Command.Equals("PING", StringComparison.OrdinalIgnoreCase))
{
    var token = message.Trailing ?? message.Parameters.FirstOrDefault();

    if (!string.IsNullOrWhiteSpace(token))
    {
        await SendAsync($"PONG :{token}");
    }
}
</code></pre>
<p>But the idea is bigger than IRC. When the connection stays open, you need to prove you’re still alive.</p>
<h2>The connection is state</h2>
<p>With a web API, a lot of state is pushed to the edges. The client sends a request. The server handles it. The response goes back. Then the next request starts again. IRC doesn’t feel like that. Once the client connects, it has connection state. It has a nickname. It may have joined channels. It may need to know whether registration has completed. It needs to read messages in order. It needs to handle server heartbeats. It needs to notice when the connection drops.</p>
<p>The lifecycle looks more like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/8dbdf8cb-1b5b-4164-b830-c78844292342.png" alt="" style="display:block;margin:0 auto" />

<p>That’s a useful mental shift. A long-running connection is not just a transport. It’s a little state machine. Once you see that, other systems become easier to reason about. SignalR clients, WebSocket consumers, queue consumers, database listeners, streaming APIs, and pub/sub clients all have similar concerns. The protocol changes, but the shape is familiar.</p>
<h2>Reading from a stream is not the same as reading messages</h2>
<p>This version uses <code>StreamReader.ReadLineAsync()</code>, which makes IRC convenient because IRC is line-based. That’s a luxury. Many protocols are not line based. Some use length prefixed frames. Some use binary headers. Some use chunks. Some allow partial messages across multiple reads. TCP itself doesn’t preserve your application message boundaries. If you send three messages, the receiver may not read three messages. It may read half of the first one. It may read one and a half. It may read all three together. IRC avoids some of that pain because lines give us a simple boundary. Even then, the application still has to decide what to do with each line. That’s why protocol design spends so much time on framing. You need a reliable way to know where one message ends and the next begins. In IRC, the frame is a line ending. In other protocols, the frame could be a length prefix, a delimiter, a fixed-size header, or a binary structure. The high-level lesson is simple, the stream doesn’t know your protocol. Your parser does.</p>
<h2>What ASP.NET Core normally hides</h2>
<p>This little client is a good reminder of how much a framework does for you. ASP.NET Core accepts connections. Kestrel parses HTTP. It handles headers, request bodies, content length, chunking, TLS, timeouts, limits, logging integration, cancellation, response writing, connection reuse, and plenty of awkward edge cases you probably don’t want to reimplement. That doesn’t mean you need to know every internal detail before building APIs. You don’t.</p>
<p>But it does help to understand the shape of the work being done underneath. When a production issue involves timeouts, stuck connections, slow clients, buffering, streaming, cancellation, or malformed input, the abstraction starts to leak. Knowing what a socket, stream, parser, and heartbeat look like makes those issues less mysterious.</p>
<h2>Where this toy client breaks</h2>
<p>The client above is intentionally incomplete. It doesn’t handle nickname conflicts. If the nickname is already taken, the server will send an error and the client won’t recover. It doesn’t wait for registration to complete before joining a channel. Some servers may tolerate that. A stricter client should wait for the welcome response. It doesn’t reconnect after disconnection. Real long-running clients need retry logic, backoff, and a clean way to rebuild state after reconnecting. It doesn’t rate limit outbound messages. IRC servers can disconnect clients that send too much too quickly. It doesn’t model channel membership, user lists, server capabilities, authentication, or message tags.</p>
<p>It also has a tiny parser. That’s fine for learning, but not enough for hostile or unusual input. This is where a small project becomes a real piece of software. The first version is a connection and a loop. The production version is lifecycle management, error handling, protocol coverage, and observability. That jump is the point of the exercise.</p>
<h2>What I’d improve next</h2>
<p>The first improvement would be separating the read loop from the write path. Right now, the client reads a line, handles it, and writes if needed. For a toy client, that’s fine. For a more capable client, you’d probably have an outbound channel for messages. The rest of the application would write commands into that channel, and one dedicated writer would send them to the server.</p>
<p>The shape would look like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/9f332f09-ce0a-462f-8fae-4425c02106ef.png" alt="" style="display:block;margin:0 auto" />

<p>That design avoids multiple parts of the application writing to the stream at the same time. It also gives you a natural place for rate limiting, logging, retries, and shutdown behaviour.</p>
<p>The next improvement would be proper connection state. You’d model the client as disconnected, connecting, registering, connected, and disconnecting. That sounds like ceremony, but it gives you somewhere to put behaviour. Then I’d add reconnects with backoff. After that, I’d add structured logging and counters for received messages, sent messages, reconnects, parse failures, and heartbeat responses. By that point, the tiny IRC client has turned into something that looks a lot like any other long-running integration client.</p>
<h2>Why this is useful</h2>
<p>A tiny IRC client won’t make your normal web APIs faster. It will make some production behaviour easier to understand. When you’ve written a client like this, long-running connections feel less magical. You’ve seen the read loop. You’ve seen the heartbeat. You’ve seen protocol parsing. You’ve seen the difference between connecting and being ready. You’ve seen why cancellation and shutdown need thought. That knowledge carries over.</p>
<p>It helps when working with WebSockets. It helps when reading from queues. It helps when dealing with streaming APIs. It helps when a service has a background connection to some external system and occasionally gets stuck, disconnected, or out of sync.</p>
<p>You don’t need to build your own protocol stack every day. But it’s useful to know what one looks like. I like small low level projects because they make familiar abstractions feel earned. ASP.NET Core is easier to appreciate when you’ve manually read from a socket. SignalR makes more sense when you’ve handled a heartbeat yourself. A message consumer feels less mysterious when you’ve written a loop that reads, parses, handles, and keeps going.</p>
<p>Building a tiny IRC client in C# is not about IRC taking over the world again. It’s about remembering that networked applications are built on streams, protocols, state, timeouts, and failure. The frameworks are valuable because they hide most of that most of the time. But when production gets weird, it helps to know what’s underneath.</p>
<p><a href="https://innovation.world/irc-channels-for-engineering/">https://innovation.world/irc-channels-for-engineering/</a></p>
]]></content:encoded></item><item><title><![CDATA[The Most Dangerous Line in a .NET Background Worker]]></title><description><![CDATA[Every production system has a few lines of code that look too simple to question.In a .NET background worker, one of them is usually this:
while (true)
{
    await DoWorkAsync();
}

It looks ok. It lo]]></description><link>https://fullstackcity.com/the-most-dangerous-line-in-a-net-background-worker2</link><guid isPermaLink="true">https://fullstackcity.com/the-most-dangerous-line-in-a-net-background-worker2</guid><category><![CDATA[Programming Blogs]]></category><category><![CDATA[software engineer]]></category><category><![CDATA[software development]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[background]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sat, 20 Jun 2026 10:19:34 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/71bd63d1-75c6-4628-bd7c-8971d11e6be4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every production system has a few lines of code that look too simple to question.In a .NET background worker, one of them is usually this:</p>
<pre><code class="language-csharp">while (true)
{
    await DoWorkAsync();
}
</code></pre>
<p>It looks ok. It looks like the simplest possible worker. Keep running. Keep processing. Keep doing the job. That line is also how you build a worker that ignores shutdown, hammers failed dependencies, hides exceptions, duplicates work, burns CPU, blocks deployments, and turns a small downstream outage into a production incident. The dangerous part is not the loop itself. Long running workers need loops. The problem is what that loop says about the design. It says the worker owns time, retries, failure, cancellation, pacing, and recovery, but none of those things have been made explicit. Thats where the incident starts.</p>
<h2>Background workers are production code, not side code</h2>
<p>A lot of Developers treat background workers differently from APIs. API endpoints get validation, cancellation tokens, logging, metrics, timeouts, idempotency checks, and careful error handling. Workers often get a <code>while (true)</code> loop, a scoped service, and a vague hope that the hosted service will just keep running. Thats backwards!</p>
<p>A background worker is usually much closer to the dangerous part of the system than an API endpoint. Its often the code that charges a card, sends an email, processes a file, or moves a message to the next stage of a workflow. It may also be the thing calling external services in the background, retrying failed jobs, or changing state without a user watching the screen. That makes the worker easy to underestimate. When it fails, it doesnt always fail loudly. It can keep running quietly while doing the wrong thing again and again. When that code misbehaves, there may be no user sitting in front of the screen to notice. It can keep doing the wrong thing quietly.</p>
<p>Thats why a poor worker loop is so expensive. It doesnt fail once. It fails repeatedly.</p>
<h2>The naive worker</h2>
<p>This is the kind of code that shows up in plenty of real applications:</p>
<pre><code class="language-csharp">public sealed class PaymentWorker : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    public PaymentWorker(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        while (true)
        {
            using var scope = _serviceProvider.CreateScope();
            var processor = scope.ServiceProvider.GetRequiredService&lt;PaymentProcessor&gt;();

            await processor.ProcessPendingPaymentsAsync();
        }
    }
}
</code></pre>
<p>At first glance, this seems fine. The worker creates a scope, resolves the processor, and processes pending payments. But theres several problems hiding inside it. It ignores <code>stopToken</code>. It has no delay when there is no work. It has no pacing when there is too much work. It has no exception boundary. It has no timeout per operation. It has no clear retry behaviour. Theres no way to stop cleanly during deployment. It gives you no signal about whether the worker is healthy, stuck, or spinning. That one loop has quietly accepted responsibility for production behaviour it does not actually control.</p>
<h2>Ignoring cancellation breaks shutdown</h2>
<p>The first mistake is simple. The loop never observes cancellation. When the host is shutting down, .NET passes a cancellation token into <code>ExecuteAsync</code>. That token is the worker's signal to finish what it is doing and stop. If the worker ignores it, shutdown becomes a guess. That can cause slow deployments. It can leave work half processed. It can make Kubernetes, Azure App Service, containers, or Windows services terminate the process more aggressively because the app did not stop in time.</p>
<p>The fix starts with the loop condition:</p>
<pre><code class="language-csharp">protected override async Task ExecuteAsync(CancellationToken stopToken)
{
    while (!stopToken.IsCancellationRequested)
    {
        await DoWorkAsync(stopToken);
    }
}
</code></pre>
<p>Thats better, but it is still not enough. Passing the token into the work is the important part.</p>
<pre><code class="language-csharp">private static async Task DoWorkAsync(CancellationToken stopToken)
{
    await Task.Delay(TimeSpan.FromSeconds(1), stopToken);
}
</code></pre>
<p>If your worker calls a database, queue, HTTP API, blob store, or another service, the token should flow into those calls as well.</p>
<pre><code class="language-csharp">await dbContext.SaveChangesAsync(stopToken);
await httpClient.SendAsync(request, stopToken);
await queueClient.ReceiveMessagesAsync(cancellationToken: stopToken);
</code></pre>
<p>Cancellation is not decoration. It is how your worker cooperates with the host.</p>
<h2>The missing delay becomes a CPU bug</h2>
<p>The next mistake is the tight loop. If <code>ProcessPendingPaymentsAsync</code> finds no work and returns quickly, the worker immediately calls it again. Then again. Then again. That can turn an empty database table into constant polling. It can turn a quiet queue into unnecessary network traffic. It can turn a broken dependency into a retry storm.</p>
<p>A simple delay helps, but it needs to be cancellable:</p>
<pre><code class="language-csharp">protected override async Task ExecuteAsync(CancellationToken stopToken)
{
    while (!stopToken.IsCancellationRequested)
    {
        await ProcessNextBatchAsync(stopToken);
        await Task.Delay(TimeSpan.FromSeconds(5), stopToken);
    }
}
</code></pre>
<p>This is still basic, but it is already safer. The worker does not spin when there is no work, and the delay does not block shutdown. For more serious systems, the delay should usually depend on the outcome. If work was found, continue quickly. If no work was found, back off. If a dependency failed, back off more aggressively. The point is not to add a magic sleep. The point is to make pacing deliberate.</p>
<h2>Exceptions should not decide your architecture</h2>
<p>A background worker needs a clear exception boundary. Without one, an unhandled exception can stop the worker. Depending on your host options, that may stop the whole application or leave you with a dead background process while the web app still responds to health checks.</p>
<p>This version is too fragile:</p>
<pre><code class="language-csharp">protected override async Task ExecuteAsync(CancellationToken stopToken)
{
    while (!stopToken.IsCancellationRequested)
    {
        await ProcessNextBatchAsync(stopToken);
        await Task.Delay(TimeSpan.FromSeconds(5), stopToken);
    }
}
</code></pre>
<p>If <code>ProcessNextBatchAsync</code> throws once, your worker may be gone.A better worker makes failure explicit:</p>
<pre><code class="language-csharp">protected override async Task ExecuteAsync(CancellationToken stopToken)
{
    while (!stopToken.IsCancellationRequested)
    {
        try
        {
            await ProcessNextBatchAsync(stopToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stopToken);
        }
        catch (OperationCanceledException) when (stopToken.IsCancellationRequested)
        {
            break;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Payment worker failed while processing a batch.");

            await Task.Delay(TimeSpan.FromSeconds(30), stopToken);
        }
    }
}
</code></pre>
<p>The <code>OperationCanceledException</code> case matters. Cancellation is not the same thing as failure. You do not want noisy error logs every time the application shuts down cleanly. The general exception case also matters. You do not want one bad record, one timeout, or one transient network issue to permanently kill the worker. But this is not permission to swallow everything and move on. The worker should log the failure, emit metrics, back off, and make it visible. Silent recovery is how systems rot.</p>
<h2>Retrying the loop is not the same as retrying the operation</h2>
<p>A common worker bug is accidental retry behaviour. The code fails during processing. The loop catches the exception. The next iteration runs the same query again. The same item is picked up again. The same external call happens again. Sometimes thats fine. Often its not.</p>
<p>Imagine this flow:</p>
<pre><code class="language-csharp">await paymentGateway.ChargeAsync(payment, stopToken);
payment.MarkAsCharged();
await dbContext.SaveChangesAsync(stopToken);
</code></pre>
<p>If the gateway charge succeeds but <code>SaveChangesAsync</code> fails, the database still says the payment is pending. The next loop picks it up again. Now you may charge the customer twice. That is not a background worker problem in isolation. It is a workflow design problem. The worker only exposes it. The fix depends on the domain, but the principles are stable. Use idempotency keys when calling external providers. Store external operation IDs. Make state transitions explicit. Avoid selecting the same work item concurrently from multiple workers. Do not assume "retry the method" is safe just because the code is inside a loop. For a payment worker, the provider call should include an idempotency key based on a stable business operation:</p>
<pre><code class="language-csharp">var request = new ChargePaymentRequest
{
    PaymentId = payment.Id,
    Amount = payment.Amount,
    Currency = payment.Currency,
    IdempotencyKey = $"payment-charge-{payment.Id}"
};

var result = await paymentGateway.ChargeAsync(request, stopToken);
</code></pre>
<p>Then the local state should record what happened:</p>
<pre><code class="language-csharp">payment.MarkChargeSubmitted(result.ProviderReference);
await dbContext.SaveChangesAsync(stopToken);
</code></pre>
<p>The exact design will vary. The important part is accepting that the worker loop will retry. Your business operation has to survive that.</p>
<h2>Multiple workers make the bug worse</h2>
<p>A loop that works locally can fail badly when scaled out. On your machine, theres one worker. In production, there may be three app instances. During deployment, there may briefly be old and new instances running at the same time. If each instance runs the same worker, they may all select the same pending rows.</p>
<p>This code is suspicious:</p>
<pre><code class="language-csharp">var payments = await dbContext.Payments
    .Where(x =&gt; x.Status == PaymentStatus.Pending)
    .OrderBy(x =&gt; x.CreatedAt)
    .Take(50)
    .ToListAsync(stopToken);
</code></pre>
<p>It reads pending work, but it doesnt claim it. Two workers can read the same rows before either one saves a status change. Both think they own the work. A safer design needs an ownership step. In SQL Server, that often means moving work from <code>Pending</code> to <code>Processing</code> in a way that is atomic enough for your concurrency model. You may use row versioning, locking hints, a stored procedure, an outbox table, a queue, or a dedicated work-claim pattern. The details are less important than the rule, reading work is not the same as owning work. If your worker can run on more than one process, it needs a real claim strategy.</p>
<h2>Scoped services need scoped lifetimes</h2>
<p>Another worker smell is injecting a scoped service directly into a singleton hosted service. <code>BackgroundService</code> is registered as a singleton. A <code>DbContext</code> is scoped. Those lifetimes do not line up.</p>
<p>This is wrong:</p>
<pre><code class="language-csharp">public sealed class PaymentWorker(AppDbContext dbContext) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        while (!stopToken.IsCancellationRequested)
        {
            await dbContext.SaveChangesAsync(stopToken);
        }
    }
}
</code></pre>
<p>Use a scope per iteration or per batch:</p>
<pre><code class="language-csharp">public sealed class PaymentWorker(
    IServiceScopeFactory scopeFactory,
    ILogger&lt;PaymentWorker&gt; logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        while (!stopToken.IsCancellationRequested)
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();

                var processor = scope.ServiceProvider
                    .GetRequiredService&lt;PaymentProcessor&gt;();

                await processor.ProcessNextBatchAsync(stopToken);
                await Task.Delay(TimeSpan.FromSeconds(5), stopToken);
            }
            catch (OperationCanceledException) when (stopToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Payment worker failed.");
                await Task.Delay(TimeSpan.FromSeconds(30), stopToken);
            }
        }
    }
}
</code></pre>
<p>The scope gives each batch a clean set of scoped dependencies. Thats important for <code>DbContext</code>, unit of work boundaries, and services that hold request-level state.</p>
<h2>A safer worker shape</h2>
<p>A production worker does not need to be complicated. It needs to be honest about the behaviours it owns.</p>
<p>Here is a more sensible shape:</p>
<pre><code class="language-csharp">public sealed class PaymentWorker(
    IServiceScopeFactory scopeFactory,
    ILogger&lt;PaymentWorker&gt; logger)
    : BackgroundService
{
    private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(5);
    private static readonly TimeSpan FailureDelay = TimeSpan.FromSeconds(30);

    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        logger.LogInformation("Payment worker started.");

        while (!stopToken.IsCancellationRequested)
        {
            try
            {
                var processed = await ProcessBatchAsync(stopToken);

                if (processed == 0)
                {
                    await Task.Delay(IdleDelay, stopToken);
                }
            }
            catch (OperationCanceledException) when (stopToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Payment worker batch failed.");

                await Task.Delay(FailureDelay, stopToken);
            }
        }

        logger.LogInformation("Payment worker stopped.");
    }

    private async Task&lt;int&gt; ProcessBatchAsync(CancellationToken stopToken)
    {
        await using var scope = scopeFactory.CreateAsyncScope();

        var processor = scope.ServiceProvider
            .GetRequiredService&lt;PaymentProcessor&gt;();

        return await processor.ProcessNextBatchAsync(stopToken);
    }
}
</code></pre>
<p>This version is still small, but the behaviour is much easier to reason about. Cancellation now flows through the worker properly, idle periods don’t cause a tight loop, and failures are handled separately from shutdown. The worker also creates scoped dependencies in the right place and gives the processor a simple way to tell the loop whether any work was actually done. It still needs domain level safety around idempotency, ownership, retries, and state transitions. The loop cannot solve those alone. But it no longer makes everything worse by default.</p>
<h2>The worker should be observable</h2>
<p>A worker that only logs errors is hard to operate. You want to know whether it is alive, whether it is doing useful work, how long batches take, how many items it processes, how often it fails, and how old the oldest pending item is. That last one is especially useful. Queue length can lie. A queue with 100 items may be fine if they are fresh. A queue with 3 items may be a serious problem if the oldest one is 12 hours old. At a minimum, record batch duration, processed count, failure count, retry count, and work age. If the worker handles business-critical tasks, expose those numbers in dashboards and alerts.</p>
<p>A background worker should not be trusted just because the process is running. The process can be healthy while the worker is stuck. The worker can be running while the work is failing. Health checks need to reflect actual progress, not just application uptime.</p>
<h2>The line is dangerous because it hides decisions</h2>
<p><code>while (true)</code> is not evil.</p>
<p>The problem is that the loop often appears before the team has decided how the worker should behave under pressure. An idle queue, a failed database call, a half-successful external API request, or a poison message all need different handling. So does deployment shutdown. So does running more than one worker at the same time. Those decisions are easy to ignore when the code is just a loop. They’re much harder to ignore at 2am when the same job keeps running, failing, retrying, and touching production data. A good worker answers those questions in code. A bad worker answers them during the incident.</p>
<p>The most dangerous line in a .NET background worker is not dangerous because it is clever.</p>
<p>Its dangerous because it is ordinary.</p>
<pre><code class="language-csharp">while (true)
</code></pre>
<p>It slips through code review because everyone understands it. It works locally because there is no real load, no deployment pressure, no duplicate worker, no flaky dependency, and no awkward half-success from an external provider. Then production adds all of those things at once.</p>
<p>A background worker is not just a loop. It is a production workflow running without a user watching it. Treat it with the same seriousness you give your APIs, because when it fails, it may fail quietly, repeatedly, and expensively.</p>
]]></content:encoded></item><item><title><![CDATA[The Enum That Became a Production Incident]]></title><description><![CDATA[Enums are one of those C# features that feel too small to deserve much architectural attention. You use them for status. You use them for type. You use them for source, category, reason, mode, provide]]></description><link>https://fullstackcity.com/the-enum-that-became-a-production-incident</link><guid isPermaLink="true">https://fullstackcity.com/the-enum-that-became-a-production-incident</guid><category><![CDATA[enum]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[software development]]></category><category><![CDATA[C#]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Thu, 18 Jun 2026 18:46:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/2eb0dd6a-27cb-44f2-bc09-bbb67191d380.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Enums are one of those C# features that feel too small to deserve much architectural attention. You use them for status. You use them for type. You use them for source, category, reason, mode, provider, direction, level, permission, and every other value that seems to belong to a neat fixed list. Thats exactly why they are dangerous.</p>
<p>An enum is simple when the whole system lives inside one process, one deployment, one database schema, and one version of the code. Real systems rarely stay that clean. The moment an enum crosses a boundary, it stops being just a C# convenience. It becomes part of your contract. That contract can leak everywhere. Once that happens, changing the enum is no longer a small refactor. Its a compatibility decision.</p>
<p>This post is about the enum bugs that do not look serious in code review, but can quietly become production incidents.</p>
<h2>The simple enum that caused the damage</h2>
<p>Start with something ordinary.</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4
}
</code></pre>
<p>This looks fine. It is readable. It removes magic strings. It makes the domain easier to talk about. Then the system grows. The API accepts payments. SQL stores the status. A worker sends settlement files. Another worker sends notifications. A reporting job builds dashboards. A support tool allows manual updates. Another service consumes payment events. The enum is now everywhere.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/40d7044c-ef06-4a63-8d31-8ba1b7e89d03.png" alt="" style="display:block;margin:0 auto" />

<p>At this point, <code>PaymentStatus</code> is not just code. It is stored data. It is API data. It is message data. It is operational data. That changes the rules.</p>
<h2>C# enums are numbers underneath</h2>
<p>C# enum types are value types backed by an integral numeric type. If you dont specify the underlying type, the default is <code>int</code>.</p>
<p>That means this enum:</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4
}
</code></pre>
<p>is also a set of numbers. That seems obvious, but it leads to a nasty mistake. Developers often treat enums as if the type system guarantees the value must be one of the named members. It doesnt.</p>
<p>You can do this:</p>
<pre><code class="language-csharp">var status = (PaymentStatus)999;

Console.WriteLine(status);  
</code></pre>
<p>The compiler allows it. The runtime allows it. Your enum variable can hold a numeric value that has no named member. That alone surprises people. Now imagine <code>999</code> comes from JSON, a database row, a queue message, or a bad import. If the code assumes every enum value is named, the bug can travel a long way before anyone notices.</p>
<h2>Parsing can accept numeric values</h2>
<p>This is one of the easiest places to get caught. You might write this at an API boundary:</p>
<pre><code class="language-csharp">if (!Enum.TryParse&lt;PaymentStatus&gt;(value, ignoreCase: true, out var status))
{
    return Results.BadRequest("Invalid payment status.");
}
</code></pre>
<p>It looks reasonable.</p>
<p>The problem is that <code>Enum.TryParse</code> converts the string representation of either the name or the numeric value of enum constants. In practice, that means a string like <code>"Settled"</code> can parse, but so can <code>"3"</code>. More importantly, <code>"999"</code> can also parse into <code>(PaymentStatus)999</code>.</p>
<p>So this can happen:</p>
<pre><code class="language-csharp">Enum.TryParse&lt;PaymentStatus&gt;("999", out var status);

Console.WriteLine(status);  
</code></pre>
<p><code>TryParse</code> tells you the text was convertible to the enum type. It does not prove the value is one of the named values you intended to allow. If you need that check, you need a second step.</p>
<pre><code class="language-csharp">public static bool TryParseDefinedPaymentStatus(
    string? value,
    out PaymentStatus status)
{
    status = default;

    if (string.IsNullOrWhiteSpace(value))
    {
        return false;
    }

    if (!Enum.TryParse(value, ignoreCase: true, out status))
    {
        return false;
    }

    return Enum.IsDefined(status);
}
</code></pre>
<p>Thats better for normal enums. Its not automatically right for <code>[Flags]</code> enums, because a valid combined value may not be defined as a single named enum member. Flags need separate handling. More on that later.</p>
<h2>Default enum values are often accidental states</h2>
<p>Now look at this DTO.</p>
<pre><code class="language-csharp">public sealed record CreatePaymentRequest(
    decimal Amount,
    string Currency,
    PaymentStatus Status);
</code></pre>
<p>What is the default value of <code>PaymentStatus</code>?</p>
<p>Its <code>0</code>.</p>
<p>But the enum starts at <code>1</code>.</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4
}
</code></pre>
<p>That means <code>default(PaymentStatus)</code> is not a named value.</p>
<p>A missing value can become <code>0</code>.</p>
<p>If your code does not handle that clearly, you get a ghost status. It exists in memory. It can be written to the database. It may appear in logs as <code>0</code>. It may not appear in dashboards because nobody expected it.</p>
<p>The safer pattern is usually to make the zero value explicit.</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Unknown = 0,
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4
}
</code></pre>
<p>This does not magically fix the design. It does make the accidental state visible.</p>
<p>Now you can reject it at the boundary.</p>
<pre><code class="language-csharp">if (request.Status is PaymentStatus.Unknown)
{
    return Results.BadRequest("Payment status is required.");
}
</code></pre>
<p>Or you can allow it internally only where it has a clear meaning.</p>
<p>The key point is simple: never let <code>0</code> become an unnamed accident.</p>
<h2>JSON can turn enum design into API design</h2>
<p>Enums become more dangerous once they appear in JSON. You have two broad choices. You can serialise them as numbers:</p>
<pre><code class="language-json">{
  "status": 3
}
</code></pre>
<p>Or as strings:</p>
<pre><code class="language-json">{
  "status": "settled"
}
</code></pre>
<p>Numbers are compact, but they are a poor public contract. They force every consumer to know your internal numeric mapping. They also make logs, traces, payload captures, and support tickets harder to read. Strings are easier to read and safer across systems, but they still need versioning discipline. If you rename <code>Authorised</code> to <code>Authorized</code>, you have changed the wire contract unless you handle the old value. With <code>System.Text.Json</code>, <code>JsonStringEnumConverter&lt;TEnum&gt;</code> can convert enum values to and from strings.</p>
<pre><code class="language-csharp">builder.Services.ConfigureHttpJsonOptions(options =&gt;
{
    options.SerializerOptions.Converters.Add(
        new JsonStringEnumConverter&lt;PaymentStatus&gt;(
            namingPolicy: JsonNamingPolicy.CamelCase,
            allowIntegerValues: false));
});
</code></pre>
<p>The <code>allowIntegerValues: false</code> part is important. The default constructor allows integer values. That can mean your API accepts <code>"status": 999</code> even when you intended to expose string enum names only. For public APIs, I would usually reject integer enum values unless there is a specific compatibility reason to allow them.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/4716801d-e487-49b4-9462-f562b61cb796.png" alt="" style="display:block;margin:0 auto" />

<p>The detail here is not about being precious over JSON style. It is about avoiding accidental contracts. Once clients start sending <code>3</code>, they depend on <code>3</code> meaning the same thing forever.</p>
<h2>Changing enum order can corrupt meaning</h2>
<p>This is the classic enum mistake.</p>
<p>A developer starts with this:</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Created,
    Authorised,
    Settled,
    Failed
}
</code></pre>
<p>The implicit values are:</p>
<pre><code class="language-csharp">Created = 0
Authorised = 1
Settled = 2
Failed = 3
</code></pre>
<p>Later, someone adds a new value in the middle.</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Created,
    Validating,
    Authorised,
    Settled,
    Failed
}
</code></pre>
<p>Now the values are:</p>
<pre><code class="language-csharp">Created = 0
Validating = 1
Authorised = 2
Settled = 3
Failed = 4
</code></pre>
<p>If the enum is only used inside the same version of the same process, that is not usually a problem. If the numeric values are stored in SQL, sent over queues, written into JSON, exported to data lakes, or consumed by another service, you may have changed the meaning of historical data.</p>
<p>Yesterday, <code>2</code> meant <code>Settled</code>.</p>
<p>Today, <code>2</code> means <code>Authorised</code>.</p>
<p>That is not a refactor. That is data corruption by redeployment.</p>
<p>Always assign explicit values to enums that cross a boundary.</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Unknown = 0,
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4,
    Validating = 5
}
</code></pre>
<p>New values go at the end unless you are deliberately reserving ranges. You do not need to love this style. You only need to remember that the number may outlive the code that created it.</p>
<h2>Old services do not know your new enum value</h2>
<p>This is where enum bugs become distributed system bugs.</p>
<p>Imagine you add <code>Reversed</code>.</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Unknown = 0,
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4,
    Reversed = 5
}
</code></pre>
<p>The API deploys first and starts publishing events.</p>
<pre><code class="language-json">{
  "paymentId": "pay_123",
  "status": "reversed"
}
</code></pre>
<p>But the notification worker is still running the old code.</p>
<p>It only knows:</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Unknown = 0,
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4
}
</code></pre>
<p>What happens?</p>
<p>That depends on how the worker deserialises, validates, and handles unknown values. It might fail the message. It might dead letter it. It might map it to <code>Unknown</code>. It might fall into a default branch. It might treat it as a status that should never happen.</p>
<p>The dangerous version is this:</p>
<pre><code class="language-csharp">var message = JsonSerializer.Deserialize&lt;PaymentEvent&gt;(json);

var template = message.Status switch
{
    PaymentStatus.Created =&gt; "payment-created",
    PaymentStatus.Authorised =&gt; "payment-authorised",
    PaymentStatus.Settled =&gt; "payment-settled",
    PaymentStatus.Failed =&gt; "payment-failed",
    _ =&gt; "payment-created"
};
</code></pre>
<p>That fallback looks harmless. Its not. A reversed payment could send a created-payment notification.</p>
<p>Thats how a small enum change becomes a customer-facing incident.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/7edbae40-a046-4a56-8f4b-18bc16a794da.png" alt="" style="display:block;margin:0 auto" />

<p>The fix is not always to reject unknown values. Sometimes rejecting is right. Sometimes preserving and ignoring is right. Sometimes mapping to <code>Unknown</code> is right.</p>
<p>The real fix is to make that decision explicit.</p>
<pre><code class="language-csharp">var template = message.Status switch
{
    PaymentStatus.Created =&gt; "payment-created",
    PaymentStatus.Authorised =&gt; "payment-authorised",
    PaymentStatus.Settled =&gt; "payment-settled",
    PaymentStatus.Failed =&gt; "payment-failed",
    PaymentStatus.Reversed =&gt; "payment-reversed",
    PaymentStatus.Unknown =&gt; throw new InvalidOperationException(
        "Cannot send notification for unknown payment status."),
    _ =&gt; throw new InvalidOperationException(
        $"Unsupported payment status: {message.Status}")
};
</code></pre>
<p>Do not quietly guess when the system sees a value it does not understand.</p>
<h2>Databases make enum mistakes permanent</h2>
<p>Storing enum values as integers is common.</p>
<pre><code class="language-csharp">public sealed class Payment
{
    public Guid Id { get; set; }
    public PaymentStatus Status { get; set; }
}
</code></pre>
<p>With EF Core, enum values are commonly mapped to their underlying numeric values by convention. EF Core also supports value converters, including converting enum values to strings in the database. Numeric storage has benefits. It is compact. It is stable if you never change existing numeric assignments. It is easy to index. String storage has different benefits. It is readable. It avoids meaning drift when enum member order changes. It makes manual investigation easier. It can also make external reporting clearer.</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&lt;Payment&gt;()
        .Property(x =&gt; x.Status)
        .HasConversion&lt;string&gt;();
}
</code></pre>
<p>This stores values such as <code>"Settled"</code> rather than <code>3</code>.</p>
<p>That is not automatically better in every system. Renaming enum members becomes a data migration problem. Case changes can matter depending on collation and converter behaviour. Strings take more space. Reporting systems may still want normalised reference data. The point isnt "always store enums as strings". The point is that storage is part of the contract.</p>
<p>You need to choose intentionally.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/dbe29b24-d26a-467c-a729-03fb2dbe4b58.png" alt="" style="display:block;margin:0 auto" />

<p>For short-lived internal technical states, an int enum can be fine. For business statuses that appear in support tools, reports, integrations, and audit trails, I would seriously consider a string column or a lookup table.</p>
<h2>Switch expressions can hide unsafe defaults</h2>
<p>Switch expressions are clean.</p>
<pre><code class="language-csharp">public static bool CanSettle(PaymentStatus status)
{
    return status switch
    {
        PaymentStatus.Authorised =&gt; true,
        PaymentStatus.Created =&gt; false,
        PaymentStatus.Settled =&gt; false,
        PaymentStatus.Failed =&gt; false,
        _ =&gt; false
    };
}
</code></pre>
<p>That <code>_ =&gt; false</code> looks safe. Sometimes it is. But it also hides unknown values. If the business wants unknown statuses to stop processing loudly, this fallback does the opposite. It quietly suppresses the issue. For operational code, I often prefer this:</p>
<pre><code class="language-csharp">public static bool CanSettle(PaymentStatus status)
{
    return status switch
    {
        PaymentStatus.Authorised =&gt; true,
        PaymentStatus.Created =&gt; false,
        PaymentStatus.Settled =&gt; false,
        PaymentStatus.Failed =&gt; false,
        PaymentStatus.Unknown =&gt; false,
        _ =&gt; throw new ArgumentOutOfRangeException(
            nameof(status),
            status,
            "Unsupported payment status.")
    };
}
</code></pre>
<p>That may feel noisy, but it turns unknown values into visible failures. You can then decide where to catch them. Maybe the API returns a 400. Maybe the queue message goes to a dead-letter queue. Maybe the worker logs a structured error and skips the record. What you should not do is accidentally let unknown values take a random business path.</p>
<h2>Enum values are not workflow rules</h2>
<p>Payment statuses are tempting to model as a single enum.</p>
<pre><code class="language-csharp">public enum PaymentStatus
{
    Unknown = 0,
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4,
    Reversed = 5
}
</code></pre>
<p>That describes possible states. It doesnt describe valid transitions.</p>
<p>Can <code>Created</code> go straight to <code>Settled</code>?</p>
<p>Can <code>Failed</code> become <code>Authorised</code>?</p>
<p>Can <code>Settled</code> become <code>Failed</code>?</p>
<p>Can <code>Reversed</code> become <code>Settled</code> again?</p>
<p>The enum cannot answer those questions. If you scatter transition rules across handlers, controllers, validators, and workers, you eventually get contradictory behaviour.</p>
<p>A state transition should be explicit.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/213f5ff8-fcde-4465-a965-8b654420d7ed.png" alt="" style="display:block;margin:0 auto" />

<p>The code can be simple.</p>
<pre><code class="language-csharp">public static class PaymentStatusTransitions
{
    private static readonly IReadOnlyDictionary&lt;PaymentStatus, PaymentStatus[]&gt; Allowed =
        new Dictionary&lt;PaymentStatus, PaymentStatus[]&gt;
        {
            [PaymentStatus.Created] =
            [
                PaymentStatus.Authorised,
                PaymentStatus.Failed
            ],
            [PaymentStatus.Authorised] =
            [
                PaymentStatus.Settled,
                PaymentStatus.Failed
            ],
            [PaymentStatus.Settled] =
            [
                PaymentStatus.Reversed
            ],
            [PaymentStatus.Failed] = [],
            [PaymentStatus.Reversed] = []
        };

    public static bool CanMoveTo(
        PaymentStatus current,
        PaymentStatus next)
    {
        return Allowed.TryGetValue(current, out var allowed)
            &amp;&amp; allowed.Contains(next);
    }
}
</code></pre>
<p>For a simple workflow, that is enough.</p>
<p>For a serious financial workflow, I would usually go further. The transition would have a command, actor, timestamp, reason, correlation ID, idempotency key, and audit entry. The enum is only the state label. It is not the workflow engine.</p>
<h2>Flags enums are a different kind of sharp edge</h2>
<p>Flags enums are useful for combinations.</p>
<pre><code class="language-csharp">[Flags]
public enum UserPermission
{
    None = 0,
    Read = 1,
    Write = 2,
    Approve = 4,
    Admin = 8
}
</code></pre>
<p>This allows:</p>
<pre><code class="language-csharp">var permissions = UserPermission.Read | UserPermission.Write;
</code></pre>
<p>That is fine.</p>
<p>The problem starts when flags are treated like normal enums.</p>
<pre><code class="language-csharp">Enum.IsDefined(UserPermission.Read | UserPermission.Write); 
</code></pre>
<p>The combination is valid as a bit pattern, but it may not be a named enum member.</p>
<p>You also need to reject impossible bits.</p>
<pre><code class="language-csharp">public static bool HasOnlyDefinedFlags(UserPermission value)
{
    const UserPermission all =
        UserPermission.Read |
        UserPermission.Write |
        UserPermission.Approve |
        UserPermission.Admin;

    return (value &amp; ~all) == 0;
}
</code></pre>
<p>Without that kind of check, <code>(UserPermission)1024</code> can exist. It can be stored. It can travel through your system. It can fail in some places and be ignored in others. Flags are not bad. They are just not a good fit for everything. They work well for low-level permissions or options where combinations are truly independent. They work badly for business states where combinations have meaning, ordering, lifecycle, or audit requirements. If a payment is <code>Created | Settled</code>, that is not a clever enum. That is a broken model.</p>
<h2>OpenAPI can make your enum contract official</h2>
<p>Once your enum appears in an API schema, clients start generating code from it. ASP.NET Core OpenAPI support can describe enum values in generated OpenAPI metadata. If an enum is represented as a string in JSON, the schema can expose those string values. That is useful, but it also makes the enum part of the client contract. A frontend may generate TypeScript types. A partner may generate a Java client. A mobile app may bake the values into a release that will stay in the wild for months. That means adding a value is not always harmless. From the server side, adding <code>Reversed</code> feels backward compatible. Existing values still work. The database still works. Your new tests pass. From the client side, the generated enum may not know <code>reversed</code>. Some clients will fail deserialisation. Some will map it to unknown. Some will crash when a switch has no default handling. Some will render a blank label.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/c50e0dfc-d32b-43dc-90c3-eae66216d232.png" alt="" style="display:block;margin:0 auto" />

<p>This is why API enums deserve versioning thought. For public APIs, consider whether the field should be an enum at all. Sometimes a string with documented known values and clear unknown handling is more honest. Sometimes you expose both a machine code and a display label. Sometimes the status deserves its own resource. The more external the contract, the less casual the enum should be.</p>
<h2>A better way to use enums</h2>
<p>Enums are still useful. The goal is not to ban them. The goal is to stop treating them like harmless local details when they are actually system contracts. For internal code, Im comfortable with enums when the value is genuinely closed, local, and owned by the same deployment. For external contracts, stored data, workflow state, or integration values, I want more discipline.</p>
<p>Here is the version I would rather see.</p>
<pre><code class="language-csharp">[JsonConverter(typeof(JsonStringEnumConverter&lt;PaymentStatus&gt;))]
public enum PaymentStatus
{
    Unknown = 0,
    Created = 1,
    Authorised = 2,
    Settled = 3,
    Failed = 4,
    Reversed = 5
}
</code></pre>
<p>Then I would configure JSON input to reject numeric enum values.</p>
<pre><code class="language-csharp">builder.Services.ConfigureHttpJsonOptions(options =&gt;
{
    options.SerializerOptions.Converters.Add(
        new JsonStringEnumConverter&lt;PaymentStatus&gt;(
            JsonNamingPolicy.CamelCase,
            allowIntegerValues: false));
});
</code></pre>
<p>I would validate incoming values at the boundary.</p>
<pre><code class="language-csharp">public static IResult ValidateStatus(PaymentStatus status)
{
    if (status is PaymentStatus.Unknown)
    {
        return Results.BadRequest("Payment status is required.");
    }

    if (!Enum.IsDefined(status))
    {
        return Results.BadRequest("Unsupported payment status.");
    }

    return Results.Ok();
}
</code></pre>
<p>I would avoid silent switch fallbacks in business logic.</p>
<pre><code class="language-csharp">public static string ToProviderCode(PaymentStatus status)
{
    return status switch
    {
        PaymentStatus.Created =&gt; "CREATED",
        PaymentStatus.Authorised =&gt; "AUTHORISED",
        PaymentStatus.Settled =&gt; "SETTLED",
        PaymentStatus.Failed =&gt; "FAILED",
        PaymentStatus.Reversed =&gt; "REVERSED",
        PaymentStatus.Unknown =&gt; throw new InvalidOperationException(
            "Unknown status cannot be sent to provider."),
        _ =&gt; throw new ArgumentOutOfRangeException(
            nameof(status),
            status,
            "Unsupported payment status.")
    };
}
</code></pre>
<p>I would put workflow transitions in one place.</p>
<pre><code class="language-csharp">public sealed class PaymentStatusPolicy
{
    public bool CanTransition(
        PaymentStatus current,
        PaymentStatus next)
    {
        return (current, next) switch
        {
            (PaymentStatus.Created, PaymentStatus.Authorised) =&gt; true,
            (PaymentStatus.Created, PaymentStatus.Failed) =&gt; true,
            (PaymentStatus.Authorised, PaymentStatus.Settled) =&gt; true,
            (PaymentStatus.Authorised, PaymentStatus.Failed) =&gt; true,
            (PaymentStatus.Settled, PaymentStatus.Reversed) =&gt; true,
            _ =&gt; false
        };
    }
}
</code></pre>
<p>And I would treat storage as a long-term contract.</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Payment&gt;()
    .Property(x =&gt; x.Status)
    .HasConversion&lt;string&gt;()
    .HasMaxLength(32);
</code></pre>
<p>That is not much code. Its just code that admits what the enum has become.</p>
<h2>When an enum should become something else</h2>
<p>The hard part is knowing when the enum has outgrown itself. An enum is usually fine when the values are few, stable, local, and not user managed. It starts to smell when values need display names, translations, sort order, effective dates, audit history, permissions, lifecycle rules, tenant specific behaviour, external mappings, or frequent additions without deployment.</p>
<p>At that point, you may want a lookup table.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/7e124689-049f-4f41-bdc9-9cd0586eac35.png" alt="" style="display:block;margin:0 auto" />

<p>Or you may want a richer domain type.</p>
<pre><code class="language-csharp">public sealed record PaymentStatusCode
{
    public static readonly PaymentStatusCode Created = new("created");
    public static readonly PaymentStatusCode Authorised = new("authorised");
    public static readonly PaymentStatusCode Settled = new("settled");
    public static readonly PaymentStatusCode Failed = new("failed");
    public static readonly PaymentStatusCode Reversed = new("reversed");

    public string Value { get; }
    private PaymentStatusCode(string value)
    {
        Value = value;
    }

    public static bool TryCreate(
        string? value,
        out PaymentStatusCode? status)
    {
        status = value?.Trim().ToLowerInvariant() switch
        {
            "created" =&gt; Created,
            "authorised" =&gt; Authorised,
            "settled" =&gt; Settled,
            "failed" =&gt; Failed,
            "reversed" =&gt; Reversed,
            _ =&gt; null
        };

        return status is not null;
    }

    public override string ToString() =&gt; Value;
}
</code></pre>
<p>Thats more ceremony than an enum, so dont reach for it automatically. But for values that live outside your process, ceremony can be cheaper than ambiguity.</p>
<h2>The production lesson</h2>
<p>The production bug is rarely "someone used an enum". The bug is usually one of these........</p>
<p><strong>The enum had implicit numeric values and those numbers escaped.</strong></p>
<p><strong>A new enum value reached an old service.</strong></p>
<p><strong>A fallback branch guessed instead of failing.</strong></p>
<p><strong>A default</strong> <code>0</code> <strong>value became a real state.</strong></p>
<p><strong>A database stored numbers whose meaning changed later.</strong></p>
<p><strong>A public API exposed internal enum names.</strong></p>
<p><strong>A flags enum allowed impossible combinations.</strong></p>
<p><strong>A workflow used an enum as if it also contained the transition rules.</strong></p>
<p>The fix is not complicated. It is mostly discipline. Name the zero value. Assign explicit numbers. Reject numeric JSON values for public contracts. Validate parsed values. Be careful with <code>[Flags]</code>. Avoid silent switch defaults. Keep transitions explicit. Treat stored enum values as data, not implementation details.</p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/enum">Microsoft Learn - Enumeration types, C# referencees,</a></p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/enums">Microsoft Learn - C# language specification,</a></p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.enum.tryparse">Microsoft Learn - Enum.TryParse</a>Parse</p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.enum.isdefined">Microsoft Learn - Enum.IsDefined</a>IsDefined</p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonstringenumconverter-1">Microsoft Learn - JsonStringEnumConverter</a></p>
<p><a href="https://learn.microsoft.com/en-us/ef/core/modeling/value-conversions">Microsoft Learn - EF Core value conversions</a>alue conversions</p>
<p><a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/include-metadata">Microsoft Learn - ASP.NET Core OpenAPI metadata for enum schemas</a>oft Learn - ASP.NET Core OpenAPI metadata for enum schemas</p>
]]></content:encoded></item><item><title><![CDATA[The C# Code That Looks Synchronous But Isn’t]]></title><description><![CDATA[Some of the most confusing C# bugs do not come from complicated syntax. They come from code that looks like it runs now, but actually runs later. Thats where IEnumerable<T>, LINQ, yield return, IQuery]]></description><link>https://fullstackcity.com/the-c-code-that-looks-synchronous-but-isn-t</link><guid isPermaLink="true">https://fullstackcity.com/the-c-code-that-looks-synchronous-but-isn-t</guid><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Wed, 17 Jun 2026 18:33:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/3acb5a74-d344-4241-9d6e-35b402a2f105.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Some of the most confusing C# bugs do not come from complicated syntax. They come from code that looks like it runs now, but actually runs later. Thats where <code>IEnumerable&lt;T&gt;</code>, <code>LINQ</code>, <code>yield return</code>, <code>IQueryable&lt;T&gt;</code>, async streams, and <code>Lazy&lt;T&gt;</code> all become dangerous in the same way. They separate the place where code is described from the place where code is executed. In small examples, that feels elegant. In production code, it can mean duplicate HTTP calls, unexpected SQL, hidden latency, disposed context errors, and side effects that happen twice.</p>
<p>This is not about avoiding those features. Theyre good features. The problem starts when a codebase stops being honest about when work actually happens.</p>
<h2>A LINQ query is often just a recipe</h2>
<p>This code looks like it filters the list:</p>
<pre><code class="language-csharp">var activeUsers = users
    .Where(user =&gt;
    {
        Console.WriteLine($"Checking {user.Id}");
        return user.IsActive;
    });

Console.WriteLine("Query created");
</code></pre>
<p>The <code>Where</code> has not checked anything yet. It has built a sequence. The work starts when something enumerates that sequence.</p>
<pre><code class="language-csharp">foreach (var user in activeUsers)
{
    Console.WriteLine(user.Id);
}
</code></pre>
<p>That distinction sounds small, but it changes how you read the code. The <code>Where</code> line is not the execution point. The <code>foreach</code> is. A call to <code>ToList()</code>, <code>ToArray()</code>, <code>Count()</code>, <code>First()</code>, <code>Single()</code>, <code>Any()</code>, or <code>foreach</code> can be the real trigger.</p>
<p>It gets worse when the sequence is enumerated twice.</p>
<pre><code class="language-csharp">var activeUsers = users.Where(user =&gt;
{
    Console.WriteLine($"Checking {user.Id}");
    return user.IsActive;
});

var firstCount = activeUsers.Count();
var secondCount = activeUsers.Count();
</code></pre>
<p>That predicate runs twice. If the predicate only checks memory, you waste CPU. If it logs, mutates state, calls a service, or touches a database through another abstraction, you have a real bug hiding behind innocent syntax. The practical rule is simple, keep side effects out of LINQ queries. A LINQ query should describe data transformation. Once it starts sending emails, writing logs, mutating objects, or making network calls, the timing becomes too easy to misread.</p>
<h2><code>yield return</code> turns a method into a suspended workflow</h2>
<p>Iterator methods are another place where C# reads more eagerly than it behaves.</p>
<pre><code class="language-csharp">static IEnumerable&lt;int&gt; GetNumbers()
{
    Console.WriteLine("Starting");
    yield return 1;
    Console.WriteLine("Continuing");
    yield return 2;
}
</code></pre>
<p>Calling the method does not print <code>Starting</code>.</p>
<pre><code class="language-csharp">var numbers = GetNumbers();
Console.WriteLine("Method called");
</code></pre>
<p>The body begins when you enumerate it.</p>
<pre><code class="language-csharp">foreach (var number in numbers)
{
    Console.WriteLine(number);
}
</code></pre>
<p>The method runs until it reaches the first <code>yield return</code>. Then it pauses. On the next iteration, it resumes from where it left off. Thats the important point. This isnt a normal method that returns a completed collection. It is a resumable state machine generated by the compiler. Thats great when you want streaming behaviour. It avoids building a whole collection up front. It also means <code>try</code>, <code>finally</code>, open files, database readers, and other lifetime-sensitive code need more care. The resource may stay alive for as long as the sequence is being enumerated, not just for the duration of the original method call.</p>
<p>This is why returning <code>IEnumerable&lt;T&gt;</code> from a method can be unclear. Sometimes it means "here is an in-memory collection". Sometimes it means "here is a deferred pipeline that will execute later". The type alone does not tell you enough.</p>
<h2><code>IQueryable&lt;T&gt;</code> changes who owns the code</h2>
<p><code>IQueryable&lt;T&gt;</code> is where things get stranger.</p>
<pre><code class="language-csharp">var query = db.Orders
    .Where(order =&gt; order.Total &gt; 100)
    .Select(order =&gt; new
    {
        order.Id,
        order.Total
    });
</code></pre>
<p>This looks like ordinary C#. With Entity Framework Core, it is closer to a query description. The expression tree is handed to a provider, and that provider decides how to execute it. For a relational provider, that usually means translating as much as possible to SQL. Thats powerful, but it means your C# expression is not always executed by the CLR. Some of it may become SQL. Some of it may become parameters. Some of it may be rejected because the provider cannot translate it.</p>
<p>This line looks harmless:</p>
<pre><code class="language-csharp">var query = db.Orders
    .Where(order =&gt; IsLargeOrder(order.Total));
</code></pre>
<p>If <code>IsLargeOrder</code> is your own C# method, EF Core cannot automatically translate that method body into SQL. In modern EF Core, unsupported client evaluation inside a filter causes a runtime exception instead of silently pulling everything into memory. Thats a good failure, but it still surprises people because the code compiled fine.</p>
<p>A projection is different:</p>
<pre><code class="language-csharp">var query = db.Orders
    .Select(order =&gt; new
    {
        order.Id,
        Label = BuildLabel(order.Total)
    });
</code></pre>
<p>EF Core allows client evaluation in the top-level projection. It can fetch the required data from the database, then run <code>BuildLabel</code> in your process. That can be exactly what you want. It can also hide work at the end of a query that looks fully database-backed.</p>
<p>The dangerous boundary is <code>AsEnumerable()</code>.</p>
<pre><code class="language-csharp">var results = db.Orders
    .Where(order =&gt; order.Total &gt; 100)
    .AsEnumerable()
    .Where(order =&gt; IsLargeOrder(order.Total))
    .ToList();
</code></pre>
<p>Everything before <code>AsEnumerable()</code> is still provider backed. Everything after it is LINQ to Objects. You have crossed from database query building into in-process enumeration. That can be a valid choice when the result set is known to be small. It is a production issue when someone accidentally moves the boundary too early.</p>
<h2>Async streams make loops look local</h2>
<p><code>await foreach</code> is one of those features that reads beautifully.</p>
<pre><code class="language-csharp">await foreach (var message in ReadMessagesAsync(stopToken))
{
    await ProcessAsync(message, stopToken);
}
</code></pre>
<p>The code looks like a normal loop with an <code>await</code>. The runtime behaviour is closer to pulling values from an asynchronous source one at a time. Each iteration may involve I/O. The next value may require a network call, a database read, a queue receive, or a timer.</p>
<p>An async iterator can also hide work behind <code>yield return</code>.</p>
<pre><code class="language-csharp">static async IAsyncEnumerable&lt;Order&gt; ReadOrdersAsync(
    [System.Runtime.CompilerServices.EnumeratorCancellation]
    CancellationToken stopToken = default)
{
    var page = 1;

    while (!stopToken.IsCancellationRequested)
    {
        var orders = await GetOrdersPageAsync(page, stopToken);
        if (orders.Count == 0)
        {
            yield break;
        }

        foreach (var order in orders)
        {
            yield return order;
        }

        page++;
    }
}
</code></pre>
<p>That method does not fetch all orders when it is called. The fetching happens as the consumer asks for the next item. That is the right shape for streaming. It also means cancellation, error handling, retries, logging, and resource cleanup need to be designed around the enumeration, not just the method call. The name should say what is happening. <code>GetOrdersAsync</code> could mean "return all orders", <code>StreamOrdersAsync</code> is clearer when the method returns <code>IAsyncEnumerable&lt;Order&gt;</code> and work continues during enumeration.</p>
<h2>LINQ over async is a trap</h2>
<p>This one bites a lot of good developers.</p>
<pre><code class="language-csharp">var tasks = userIds.Select(id =&gt; GetUserAsync(id));
var users = await Task.WhenAll(tasks);
</code></pre>
<p>That can be fine, but <code>Select</code> is still lazy. The calls to <code>GetUserAsync</code> happen when the sequence is enumerated by <code>Task.WhenAll</code>. If you enumerate <code>tasks</code> twice, you can create two sets of tasks and call the remote service twice.</p>
<pre><code class="language-csharp">var tasks = userIds.Select(id =&gt; GetUserAsync(id));

var first = await Task.WhenAll(tasks);
var second = await Task.WhenAll(tasks);
</code></pre>
<p>That second line does not await the same work again. It creates new work. If <code>GetUserAsync</code> calls an API, you just made the API calls twice.</p>
<p>Materialise the tasks when the intent is "start this batch now".</p>
<pre><code class="language-csharp">var tasks = userIds
    .Select(id =&gt; GetUserAsync(id))
    .ToArray();

var users = await Task.WhenAll(tasks);
</code></pre>
<p>Now you have a stable set of tasks. You can pass it around without accidentally rebuilding the sequence. This is one of those places where <code>ToArray()</code> is not just a performance choice. It documents the execution boundary.</p>
<h2><code>Lazy&lt;T&gt;</code> hides the expensive moment</h2>
<p><code>Lazy&lt;T&gt;</code> is another version of the same idea. The object exists, but the value does not.</p>
<pre><code class="language-csharp">private readonly Lazy&lt;ReportCache&gt; _reportCache =
    new(() =&gt; LoadReportCache());
</code></pre>
<p>Nothing calls <code>LoadReportCache()</code> until somebody asks for <code>_reportCache.Value</code>.</p>
<pre><code class="language-csharp">var cache = _reportCache.Value;
</code></pre>
<p>That access can now become the slow line in your request path. It might read a file, hit a database, compile a regex, hydrate a large object graph, or throw an exception. The field declaration looked cheap. The first <code>.Value</code> access pays the bill.</p>
<p>Thats not a reason to avoid <code>Lazy&lt;T&gt;</code>. Its a reason to be deliberate. If the first request after deployment should not pay the initialisation cost, warm it explicitly. If the value can fail to initialise, treat <code>.Value</code> as a meaningful execution point rather than a property access you skim past during review.</p>
<h2>Materialisation is a design decision</h2>
<p>A lot of C# code gets clearer when you make execution boundaries obvious.</p>
<pre><code class="language-csharp">var eligibleOrders = await db.Orders
    .Where(order =&gt; order.Total &gt; 100)
    .OrderByDescending(order =&gt; order.CreatedAt)
    .Take(100)
    .ToListAsync(stopToken);
</code></pre>
<p>That <code>ToListAsync</code> line says, the database query happens here, and after this point we are working with memory.</p>
<p>Then the next stage can be ordinary C#.</p>
<pre><code class="language-csharp">var summaries = eligibleOrders
    .Select(order =&gt; BuildSummary(order))
    .ToList();
</code></pre>
<p>Theres nothing clever about this. Thats the point. You can look at the code and see where the database work ends, where in-process work begins, and where the collection is materialised.</p>
<p>The alternative is a chain that mixes provider backed query code, local helper methods, async work, and deferred enumeration. It may compile. It may even pass tests with small data. Under production load, it becomes hard to tell which line is responsible for the cost.</p>
<h2>How I’d review this in real code</h2>
<p>When I see <code>IEnumerable&lt;T&gt;</code>, I ask whether it represents a finished collection or a deferred pipeline. When I see <code>IQueryable&lt;T&gt;</code>, I ask where the SQL boundary is. When I see <code>Select(id =&gt; SomeAsyncCall(id))</code>, I check whether the sequence is materialised before it is reused. When I see <code>yield return</code>, I look for resource lifetime issues. When I see <code>Lazy&lt;T&gt;.Value</code>, I treat it as a method call that may be expensive. The fixes are usually simple. Use <code>ToList()</code>, <code>ToArray()</code>, <code>ToListAsync()</code>, or <code>AsEnumerable()</code> deliberately. Name streaming methods as streaming methods. Do not return <code>IQueryable&lt;T&gt;</code> across too many layers unless your architecture really wants query composition to leak that far. Avoid side effects inside LINQ. Keep database expressions translatable until the point where you intentionally switch to memory.</p>
<p>The deeper lesson is that C# has several features that delay execution. They are designed that way. The runtime is not tricking you. The compiler is not broken. The problem is reading deferred code as if it were immediate code. Extreme C# is not always about raw speed. Sometimes it is about knowing which line actually runs.</p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/standard/linq/deferred-execution-lazy-evaluation">Microsoft Learn - Deferred execution and lazy evaluation</a></p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/yield">Microsoft Learn - yield statement</a></p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.linq.iqueryable-1">Microsoft Learn - IQueryable Interface</a></p>
<p><a href="https://learn.microsoft.com/en-us/ef/core/querying/client-eval">Microsoft Learn - EF Core Client vs Server Evaluation</a>tion</p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/generate-consume-asynchronous-stream">Microsoft Learn - Generate and consume async streams</a>te and consume async streams</p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-scenarios">Microsoft Learn - Asynchronous programming scenarios</a>soft Learn - Asynchronous programming scenarios</p>
<p><a href="https://learn.microsoft.com/en-us/dotnet/framework/performance/lazy-initialization">Microsoft Learn - Lazy Initialization</a></p>
]]></content:encoded></item><item><title><![CDATA[How to DDoS your own .NET app]]></title><description><![CDATA[Most people think about DDoS as something hostile. Someone floods your public API, your infrastructure starts to bend, and the conversation moves towards rate limiting, WAF rules, autoscaling, caching]]></description><link>https://fullstackcity.com/how-to-ddos-your-own-net-app</link><guid isPermaLink="true">https://fullstackcity.com/how-to-ddos-your-own-net-app</guid><category><![CDATA[ddos]]></category><category><![CDATA[software security]]></category><category><![CDATA[software development]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[Programming Tips]]></category><category><![CDATA[C#]]></category><category><![CDATA[dotnet]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sat, 13 Jun 2026 17:51:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/908b89e2-efc8-4577-b26c-4721651279fa.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most people think about DDoS as something hostile. Someone floods your public API, your infrastructure starts to bend, and the conversation moves towards rate limiting, WAF rules, autoscaling, caching, and traffic filtering. That version is real, but it is not the only version. A lot of production systems get overloaded by their own code.</p>
<p>No attacker. No botnet. No suspicious traffic pattern from the outside. Just a set of reasonable engineering decisions that combine badly under load. A retry policy that looked sensible in development. A background worker that pulls too quickly. A health check that calls real dependencies every few seconds. A startup routine that warms every cache across every pod at the same time. A request path that fans out to five downstream services and assumes they will all keep up. Individually, these choices can look fine. Together, they can create the same look as an attack.</p>
<h2>The app becomes its own traffic multiplier</h2>
<p>The simplest way to accidentally DDoS your own app is to make one request turn into many. A request comes into your API. The endpoint calls a profile service, a pricing service, a permissions service, a feature flag service, and a database. Each one is fast enough during normal traffic, so nobody worries too much. Then traffic doubles. The incoming traffic doubles, but the outbound traffic does not just feel like it doubles. Every request now carries a fan-out cost. If one downstream dependency starts to slow down, the request duration increases. Longer requests stay in flight for longer. More work piles up. Thread pool pressure increases. Connection pools stay busy. Retries start. The system begins producing extra load while already struggling with the original load. This is how self-inflicted overload often starts. The problem is rarely one bad line of code. It is usually a multiplier hidden inside a perfectly normal request path.</p>
<p>A common version:</p>
<pre><code class="language-csharp">app.MapGet("/dashboard/{userId:guid}", async (
    Guid userId,
    IUserClient users,
    IOrdersClient orders,
    IFeatureClient features,
    IRecommendationClient recommendations,
    CancellationToken stopToken) =&gt;
{
    var userTask = users.GetUser(userId, stopToken);
    var ordersTask = orders.GetRecentOrders(userId, stopToken);
    var featuresTask = features.GetEnabledFeatures(userId, stopToken);
    var recommendationsTask = recommendations.GetRecommendations(userId, stopToken);

    await Task.WhenAll(userTask, ordersTask, featuresTask, recommendationsTask);

    return Results.Ok(new DashboardResponse(
        await userTask,
        await ordersTask,
        await featuresTask,
        await recommendationsTask));
});
</code></pre>
<p>At a glance, this looks good. The calls are independent. <code>Task.WhenAll</code> reduces latency. The endpoint avoids blocking. The hidden question is what happens at scale. One thousand incoming requests are now four thousand outbound calls. If each outbound call has retries, the real number can be much higher. If every instance does the same thing at the same time, the downstream services feel the multiplied traffic before your own API does. Parallelism is useful, but unbounded parallelism is one of the easiest ways to turn normal load into a traffic storm.</p>
<h2>Retry policies can make an outage worse</h2>
<p>Retries are one of those things that feel responsible. A transient error happens. You retry. The user never sees the failure. The system becomes more resilient. Thats the happy path. The failure path is more interesting. Imagine a downstream API is slowing down because it is overloaded. Your .NET service receives a timeout. Polly retries. Other requests do the same thing. Every app instance sends more calls to a dependency that is already unable to deal with the original call volume.</p>
<p>The retry policy was added to improve reliability. Under pressure, it increases traffic.</p>
<p>This is the kind of code that can cause pain:</p>
<pre><code class="language-csharp">builder.Services
    .AddHttpClient&lt;IPaymentClient, PaymentClient&gt;()
    .AddStandardResilienceHandler();
</code></pre>
<p>The newer resilience APIs in .NET are useful, and the standard handler is a good starting point. The bigger issue is that teams often add resilience as a checkbox rather than thinking through the behaviour. What gets retried? How many times? Is there jitter? Is there a timeout per try and an overall timeout? What happens when the dependency is already failing? Does the caller have a retry budget, or can every request keep adding more work?</p>
<p>A more deliberate setup might cap the damage:</p>
<pre><code class="language-csharp">builder.Services
    .AddHttpClient&lt;IPaymentClient, PaymentClient&gt;(client =&gt;
    {
        client.Timeout = TimeSpan.FromSeconds(3);
    })
    .AddResilienceHandler("payments", pipeline =&gt;
    {
        pipeline.AddTimeout(TimeSpan.FromSeconds(2));
        pipeline.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 2,
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = true,
            Delay = TimeSpan.FromMilliseconds(200)
        });

        pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            MinimumThroughput = 20,
            SamplingDuration = TimeSpan.FromSeconds(30),
            BreakDuration = TimeSpan.FromSeconds(15)
        });
    });
</code></pre>
<p>The exact numbers are less important than the thinking.</p>
<p>Retries should be treated as extra traffic. Every retry has a cost. Every timeout keeps work alive for longer. Every failed dependency needs space to recover. A good retry policy reduces user visible failures during short blips. A bad one turns a slow dependency into a shared incident.</p>
<h2>Background workers can attack your database</h2>
<p>Background processing is another easy place to create accidental overload. The API stays responsive because it drops work onto a queue. That is good. The queue absorbs spikes. Also good. Then workers start pulling as fast as possible. The database becomes the real victim.</p>
<p>This usually starts with code that feels clean:</p>
<pre><code class="language-csharp">public sealed class ImportWorker(
    Channel&lt;ImportJob&gt; channel,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        await foreach (var job in channel.Reader.ReadAllAsync(stopToken))
        {
            _ = ProcessJob(job, stopToken);
        }
    }

    private async Task ProcessJob(ImportJob job, CancellationToken stopToken)
    {
        using var scope = scopeFactory.CreateScope();

        var handler = scope.ServiceProvider.GetRequiredService&lt;IImportHandler&gt;();

        await handler.Handle(job, stopToken);
    }
}
</code></pre>
<p>The worker reads jobs and starts processing them. The problem is that nothing controls concurrency. If the channel fills up, the worker can create a large amount of simultaneous work. Each job might open database connections, make HTTP calls, allocate memory, write logs, and publish events. The queue protected the API, but the worker moved the overload somewhere else.</p>
<p>A safer worker makes concurrency explicit:</p>
<pre><code class="language-csharp">public sealed class ImportWorker(
    Channel&lt;ImportJob&gt; channel,
    IServiceScopeFactory scopeFactory,
    ILogger&lt;ImportWorker&gt; logger) : BackgroundService
{
    private const int MaxConcurrency = 8;
    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        using var semaphore = new SemaphoreSlim(MaxConcurrency);

        var running = new List&lt;Task&gt;();
        await foreach (var job in channel.Reader.ReadAllAsync(stopToken))
        {
            await semaphore.WaitAsync(stopToken);
            var task = ProcessJobSafely(job, semaphore, stopToken);
            running.Add(task);
            running.RemoveAll(t =&gt; t.IsCompleted);
        }

        await Task.WhenAll(running);
    }

    private async Task ProcessJobSafely(
        ImportJob job,
        SemaphoreSlim semaphore,
        CancellationToken stopToken)
    {
        try
        {
            using var scope = scopeFactory.CreateScope();
            var handler = scope.ServiceProvider.GetRequiredService&lt;IImportHandler&gt;();
            await handler.Handle(job, stopToken);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to process import job {JobId}", job.Id);
        }
        finally
        {
            semaphore.Release();
        }
    }
}
</code></pre>
<p>This still processes work in parallel, but it gives the system a pressure valve. For serious workloads, you probably want more than a semaphore. You may need bounded channels, batch sizes, queue depth metrics, etc. The key point is simple. A background worker should have a speed limit. If it can pull faster than the rest of the system can safely process, it can become the thing that takes production down.</p>
<h2>Health checks can become load tests</h2>
<p>Health checks are supposed to make systems safer. They tell Kubernetes, Azure App Service, load balancers, and deployment platforms whether an instance is alive and ready for traffic. The dangerous version is the health check that does too much. It checks SQL. Then Redis. Then blob storage. Then a message broker. Then three internal APIs. Then Key Vault. Then maybe it runs a small query to prove the database is really working. That can feel thorough. Now multiply it. If you have 30 pods and something calls the readiness endpoint every few seconds, your health check is no longer just a health check. It is recurring production traffic. If the health check hits dependencies during an incident, it adds load at exactly the wrong time. Theres a better split. A liveness check should usually prove the process is alive. A readiness check should prove the app is ready to receive traffic. Deep dependency checks are useful, but they should be handled carefully, cached briefly, or moved into diagnostics that humans and monitoring systems can query deliberately.</p>
<p>This is the kind of health check that can get expensive:</p>
<pre><code class="language-csharp">builder.Services
    .AddHealthChecks()
    .AddSqlServer(connectionString)
    .AddRedis(redisConnection)
    .AddUrlGroup(new Uri("https://pricing.internal/health"))
    .AddUrlGroup(new Uri("https://users.internal/health"))
    .AddUrlGroup(new Uri("https://payments.internal/health"));
</code></pre>
<p>There are cases where dependency checks are useful. The mistake is pretending they are free. For high-scale systems, health checks should be simple, cheap, and predictable. They should not become a hidden load generator.</p>
<h2>Cache stampedes are internal traffic spikes</h2>
<p>Caching can save a system, but it can also create sharp traffic spikes. A popular cache key expires. Every request misses at the same time. Every app instance tries to rebuild the same value. The database or downstream API receives a sudden burst of identical work. This is a cache stampede. It often appears as a strange production pattern. Everything is fine, then latency jumps every few minutes. Database CPU spikes. Logs show repeated calls for the same data. Then the system settles again. The cache was added to reduce load. The expiry pattern created bursts.</p>
<p>The risky version looks like this:</p>
<pre><code class="language-csharp">public async Task&lt;ProductSummary&gt; GetSummary(Guid productId, CancellationToken stopToken)
{
    var cacheKey = $"product-summary:{productId}";
    var cached = await cache.GetStringAsync(cacheKey, stopToken);

    if (cached is not null)
    {
        return JsonSerializer.Deserialize&lt;ProductSummary&gt;(cached)!;
    }

    var summary = await database.LoadProductSummary(productId, stopToken);

    await cache.SetStringAsync(
        cacheKey,
        JsonSerializer.Serialize(summary),
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        },
        stopToken);

    return summary;
}
</code></pre>
<p>This works until many requests miss together. A better design prevents every caller from rebuilding the same value at the same time. Depending on the system, that might mean per-key locking, stale-while-revalidate, randomised expiry, early refresh, or single-flight loading.</p>
<p>Even a simple randomised expiry helps avoid every key expiring on the same boundary:</p>
<pre><code class="language-csharp">var expiry = TimeSpan.FromMinutes(5)
    .Add(TimeSpan.FromSeconds(Random.Shared.Next(0, 60)));

await cache.SetStringAsync(
    cacheKey,
    JsonSerializer.Serialize(summary),
    new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = expiry
    },
    stopToken);
</code></pre>
<p>That does not solve every cache problem, but it removes one common source of synchronised load. Caching should flatten demand. If it creates sharp bursts, it can become part of the overload story.</p>
<h2>Startup code can create deploy-time incidents</h2>
<p>One of the easiest ways to overload your own system is during deployment. A new version rolls out. Multiple instances start. Each instance loads configuration, warms caches, fetches secrets, preloads reference data, validates external services, runs startup checks, opens connections, and maybe applies migrations.</p>
<p>That might be fine with one instance. With 40 instances, it can become a deploy-time traffic spike. This is especially painful when deployments happen during an already busy period. The app is under normal production load, then every new replica starts doing the same expensive startup work at the same time.</p>
<p>Startup work feels safe because it happens before traffic. In reality, it still consumes shared dependencies. Database migrations are the classic example. Running migrations automatically on startup can look convenient, especially early in a project. Then the system grows, the deployment model changes, and every instance suddenly has code capable of touching schema state on boot.</p>
<p>Cache warming has the same problem. It sounds responsible to warm everything before accepting traffic. It can also mean every pod hits the database at once to load data that only one pod really needed to prepare. A safer approach is to keep startup light. Do the minimum needed for the process to start. Move expensive one-time work into deployment jobs. Make cache warming gradual or lazy. Use readiness checks to control when instances receive traffic, but do not turn readiness into a full dependency test suite. The goal is not to make startup empty. The goal is to stop every replica from behaving like it owns the whole platform.</p>
<h2>Logs and metrics can add to the blast radius</h2>
<p>Observability helps you understand production, but it also has a runtime cost. During an incident, systems often log more. More errors mean more exception logs. More retries mean more warning logs. More failed dependency calls mean more telemetry. More telemetry means more CPU, memory, network traffic, and ingestion pressure. Its very easy to build a system where failure generates more work than success.</p>
<p>This can be especially bad with high cardinality logging. User IDs, request IDs, order IDs, payload fragments, dynamic labels, and exception details can all be useful. They can also make log storage and metric backends expensive and noisy. The app may survive the original issue, then start struggling because it is trying to describe the issue in too much detail. You still need logs. You still need metrics. You still need traces. But production telemetry needs limits. Sampling, log levels, metric cardinality, payload size, and sink behaviour all deserve attention. Console logging in containers can also become surprisingly expensive when volume gets high. Observability should help you recover. It should not become another source of pressure.</p>
<h2>The pattern is almost always missing limits</h2>
<p>The common theme across all of this is not bad engineering. Retries are useful. Parallelism is useful. Queues are useful. Health checks are useful. Caching is useful. Observability is useful. The danger comes from useful patterns without limits. A retry policy needs a budget. Fan-out needs bounded concurrency. Workers need backpressure. Health checks need to be cheap. Most accidental DDoS patterns come from code that assumes the rest of the system can always keep up. Production teaches you where that assumption fails.</p>
<h2>What I would watch in a real .NET system</h2>
<p>For a .NET API, I would start by watching the shape of work rather than only the raw request count. How many outbound HTTP calls does one inbound request create? How many database queries? How many retries? Then I would look for traffic multipliers. A single request turning into ten downstream calls. A single failure turning into three retries. A single queue message turning into twenty writes. A single deployment causing every instance to warm the same data. That is where the risk usually hides. The fix is rarely one magic library. It is usually a set of simple limits placed in the right parts of the system.</p>
<h2>The awkward truth</h2>
<p>You do not need a hostile actor to create a DDoS-shaped incident. A normal deploy can do it. A retry policy can do it. A background worker can do it. Thats what makes these incidents frustrating. The code usually looks sensible in isolation. The real question is what happens when every instance, every request, every retry, and every worker does the sensible thing at the same time. Thats where many .NET systems get caught.</p>
]]></content:encoded></item><item><title><![CDATA[The New Copilot .NET SDK Changes More Than Autocomplete]]></title><description><![CDATA[GitHub Copilot started as something most developers experienced inside the editor. You wrote a method. Copilot suggested the next few lines. Sometimes it was useful. Sometimes it was noisy. Either way]]></description><link>https://fullstackcity.com/the-new-copilot-net-sdk-changes-more-than-autocomplete-for-devs</link><guid isPermaLink="true">https://fullstackcity.com/the-new-copilot-net-sdk-changes-more-than-autocomplete-for-devs</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[software development]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[AI]]></category><category><![CDATA[copilot]]></category><category><![CDATA[github copilot]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Thu, 11 Jun 2026 19:22:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/f3a1eac5-eb52-44d0-8b2a-4a9150ec642b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>GitHub Copilot started as something most developers experienced inside the editor. You wrote a method. Copilot suggested the next few lines. Sometimes it was useful. Sometimes it was noisy. Either way, the shape of the tool was obvious. It helped you write code while you were already writing code. The new GitHub Copilot SDK changes that.</p>
<p>With the SDK, Copilot becomes something you can call from your own .NET applications, services and developer tools. It gives you programmatic access to the same kind of agent runtime behind Copilot CLI. That means planning, tool invocation, file edits, streaming responses and multi-turn sessions are no longer locked inside the IDE experience. Thats the interesting part.</p>
<p>For .NET developers, this is less about another AI wrapper package and more about where AI-assisted development is heading. Copilot is moving from an editor feature to an embeddable agent capability.</p>
<h2>The old Copilot model was narrow</h2>
<p>The original Copilot workflow was simple. You opened Visual Studio or VS Code. You asked for help. Copilot answered in that environment. It could explain code, suggest tests, generate snippets, and help you work through local problems. That workflow still has value, but it has a natural limit. A lot of useful engineering work does not start with a developer typing inside a file. It starts with a ticket, a failing build, a dependency update, etc.</p>
<p>Those workflows cut across files, commands, repositories, logs, documentation, package versions and team rules. A chat box inside the IDE can help, but the real work often needs orchestration. Thats where the SDK is different. It lets you build the harness around Copilot yourself.</p>
<h2>What the SDK actually gives you</h2>
<p>At a high level, the GitHub Copilot SDK lets a .NET application talk to Copilot as an agent runtime. That sounds abstract, so make it practical.</p>
<p>You can install the package into a .NET project.</p>
<pre><code class="language-bash">dotnet add package GitHub.Copilot.SDK
</code></pre>
<p>If you want to use it through Microsoft Agent Framework, you can also add the Copilot integration package.</p>
<pre><code class="language-bash">dotnet add package Microsoft.Agents.AI.GitHub.Copilot --prerelease
</code></pre>
<p>Then you can wrap Copilot as an agent and call it through the same abstraction you would use for other agents.</p>
<pre><code class="language-csharp">using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;

await using CopilotClient copilotClient = new();

await copilotClient.StartAsync();

AIAgent agent = copilotClient.AsAIAgent();

var result = await agent.RunAsync(
    "Review this repository structure and suggest where a new payment reconciliation feature should live.");

Console.WriteLine(result);
</code></pre>
<p>That small example is not the important thing. The important thing is the boundary. Copilot can now sit behind your own workflow. You decide what context it receives. You decide what tools it can use. You decide when the agent runs. You decide whether the output becomes a draft, a pull request, a report, a build comment, a migration note, or a support action. Thats a very different programming model from asking a chat window to help you manually.</p>
<h2>The Microsoft Agent Framework angle</h2>
<p>The Copilot SDK becomes more interesting when you put it beside Microsoft Agent Framework. MAF gives .NET developers a common way to build agents, define tools, stream responses, manage sessions, compose workflows and connect different providers. Copilot can now be one of those providers. That means you can use Copilot for coding oriented work, but still compose it with other agents.</p>
<p>One agent might inspect a codebase. Another might query Azure AI Foundry. Another might talk to an internal system. Another might summarise the final recommendation for a developer or architect. The value is not that every problem becomes a multi-agent system. Most systems do not need that. The value is that Copilot becomes a replaceable part of a larger pipeline instead of a separate tool bolted onto the side. For .NET teams already thinking about internal developer platforms, this is where things get cool. You could build a dependency upgrade assistant that scans a solution, checks package changes, runs tests, and drafts a pull request. You could build a migration assistant that understands your company’s preferred patterns and explains what must change before a .NET Framework service moves to modern .NET. How about a review assistant that checks whether a pull request follows your vertical slice structure, your API conventions, your logging rules and your architecture boundaries.</p>
<p>The difference is that these assistants do not need to live only inside chat. They can become part of the engineering system.</p>
<h2>This is not just another chat API</h2>
<p>A normal LLM API gives you prompt in, text out. You can build useful systems with that, but the orchestration is yours. You need to manage planning, tool access, file changes, sessions, permission boundaries, streaming and retries yourself. The Copilot SDK gives you access to an agent runtime that already understands developer workflows. It is built around code, files, shell commands, tool calls and iterative work. That does not remove the need for engineering discipline. In some ways, it increases it. Once an agent can touch files, run commands, fetch URLs or call tools, you are no longer playing with autocomplete. You are giving software the ability to act inside a development workflow. That means you need boundaries.</p>
<p>You need to control what it can read. You need to control what it can write. You need to decide when a human must approve a change. You need logs. You need audit trails. You need repeatable prompts and deterministic enough workflows. You need to think about secrets, build agents, local machines, containers and repository permissions. The SDK makes powerful workflows possible, but it also makes weak engineering practices more dangerous.</p>
<h2>Where this fits in a real .NET team</h2>
<p>The first useful use case is probably internal developer tooling. Most teams have repeated engineering work that is too specific for a general product feature but too common to ignore. A senior engineer reviews the same mistakes in pull requests. An architect explains the same layering rule every few weeks. A DevOps lead chases the same missing pipeline configuration. A team lead asks for the same test coverage gaps.</p>
<p>These are good candidates for a Copilot-powered internal agent. Not because the agent should own the final decision, but because it can do the first pass. Imagine an ASP.NET Core API repo where every feature should follow a vertical slice structure.</p>
<p>You could expose a simple internal command:</p>
<pre><code class="language-text">/review-architecture src/Payments.Features/CreatePayment
</code></pre>
<p>Behind that command, a .NET service could call a Copilot agent with your architecture rules, inspect the files, compare the implementation against your patterns and produce a review note.</p>
<p>The output might look like this:</p>
<pre><code class="language-text">The handler is doing validation, persistence and external provider mapping in one place.

Suggested split:
- keep request validation in the validator
- move provider mapping into a small mapper
- keep idempotency check close to the command handler
- add an integration test for duplicate payment references
</code></pre>
<p>You still review it. You still decide. The gain is that the simple first pass happens consistently. Thats the practical use case.</p>
<h2>Why .NET developers should pay attention</h2>
<p>.NET teams often adopt AI tooling more cautiously than frontend heavy teams. Thats not a bad thing. Enterprise .NET systems usually carry long lived code, security rules, regulated data, internal dependencies, old services, mixed hosting models and a lot of business behaviour hidden in boring code.</p>
<p>An AI tool that only writes snippets is useful, but limited. An AI tool that can be embedded into controlled .NET workflows is more serious. It means AI can start showing up in places like build systems, migration tools, code review automation, developer portals, internal CLIs, architecture checks, documentation generation and support tooling. That doesnt mean every company should rush to give agents write access to production repositories. Please dont do that!</p>
<p>Start smaller.</p>
<p>Use the SDK for read only analysis first. Let it inspect a solution and produce a report. Let it draft a migration plan. Let it explain test gaps. Let it generate a review checklist. Let it suggest refactoring steps without applying them. Once the team trusts the workflow, you can add controlled write operations behind explicit approval. Thats the sensible adoption path.</p>
<h2>The security model needs to be simple</h2>
<p>Any agent that can run commands or edit files needs a simple security model.</p>
<p>Simple is good here.</p>
<p>Run it in a container or dev container. Give it the smallest workspace it needs. Avoid broad access to secrets. Prefer short lived credentials. Log what it did. Store the prompt and output when the workflow affects a pull request or build result. Make destructive operations explicit. Keep humans in the approval path for code changes. This is especially important when the agent becomes part of CI or internal tooling. A developer asking Copilot a question in an IDE is one risk profile. A service account running an agent across repositories is another. A workflow that can create branches, edit files and run shell commands needs proper security from day one.</p>
<p>The SDK does not remove that responsibility. It makes it easier to build the useful part, so the engineering team has fewer excuses to ignore the safety part.</p>
<h2>What this means for Copilot itself</h2>
<p>The bigger shift is that Copilot is becoming infrastructure. That sounds dramatic, but it is the direction of travel. First, Copilot helped inside the editor. Then it moved into chat, terminals, pull requests and coding agents. Now the SDK lets developers embed the agent runtime into their own tools. That changes the role of Copilot in the development process. It becomes less like a feature you open and more like a capability your systems can call.</p>
<p>For .NET developers, this lines up with how enterprise software is usually built. We like services. We like abstractions. We like pipelines. We like controlled integration points. We like being able to wrap something behind our own rules. The SDK gives us that shape.</p>
<h2>The catch</h2>
<p>There is still a catch. The SDK can make agentic developer tooling easier, but it will not make poor engineering decisions disappear. If your repository is badly structured, the agent will struggle. If your architecture rules live only in someone’s head, the agent cannot reliably enforce them. If your tests are flaky, the agent will waste time. If your prompts are vague, the output will be vague. If you give it too much permission too early, you will eventually regret it.</p>
<p>The teams that get value from this will be the teams that treat it like engineering infrastructure. They will define clear workflows. They will version prompts. They will keep tool permissions tight. They will measure results. They will start with developer assistance before pushing into automation. They will make the agent explain its changes. The teams that treat it like magic will get inconsistent demos and noisy pull requests.</p>
<h2>The real opportunity</h2>
<p>The real opportunity is not replacing developers. Its compressing the repetitive engineering work around development. That work takes time. Its also the work senior developers often do repeatedly for other people. The Copilot SDK gives .NET teams a way to turn some of that repeated judgement into internal tooling. Not final judgement. Not blind automation. But a controlled first pass that saves time and raises the baseline. Thats why this release is worth paying attention to. The SDK is about moving Copilot from the developer’s editor into the developer platform.</p>
<p><a href="https://github.blog/changelog/2026-06-02-copilot-sdk-is-now-generally-available/">GitHub Changelog - Copilot SDK is now generally available</a></p>
<p><a href="https://github.com/github/copilot-sdk">GitHub Copilot SDK repository</a></p>
<p><a href="https://devblogs.microsoft.com/agent-framework/build-ai-agents-with-github-copilot-sdk-and-microsoft-agent-framework/">Microsoft Agent Framework - Build AI Agents with GitHub Copilot SDK and Microsoft Agent Framework</a></p>
<p><a href="https://devblogs.microsoft.com/agent-framework/microsoft-agent-framework-at-build-2026-announce/">Microsoft Agent Framework at Build 2026</a></p>
<p><a href="https://learn.microsoft.com/en-us/agent-framework/agents/providers/github-copilot">Microsoft Learn - GitHub Copilot Agents provider</a></p>
<p><a href="https://www.nuget.org/packages/GitHub.Copilot.SDK">NuGet - GitHub.Copilot.SDK</a></p>
]]></content:encoded></item><item><title><![CDATA[Claude Opus 4.8 for .NET Developers ]]></title><description><![CDATA[Anthropic has released Claude Opus 4.8, and the headline is easy to understand. It is the stronger Opus model. Better coding. Better agentic work. Better long-context behaviour. Better judgement. More]]></description><link>https://fullstackcity.com/claude-opus-4-8-for-net-developers</link><guid isPermaLink="true">https://fullstackcity.com/claude-opus-4-8-for-net-developers</guid><category><![CDATA[AI]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[software development]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[#anthropic]]></category><category><![CDATA[claude]]></category><category><![CDATA[claude-code]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sat, 30 May 2026 16:10:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/2bac1e10-3fb4-49fa-9dde-1cf39319536b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Anthropic has released Claude Opus 4.8, and the headline is easy to understand. It is the stronger Opus model. Better coding. Better agentic work. Better long-context behaviour. Better judgement. More willingness to flag uncertainty instead of pretending everything is fine. Thats useful, but for .NET developers, the better question is not whether Opus 4.8 is impressive, its where does it actually belong in a production .NET system? Because this is not the model you should blindly put behind every AI feature. Opus 4.8 is a premium model. It is built for harder work, not cheap high-volume text generation. If you use it for every summary, every classification, every small chatbot response and every background task, you may get good answers, but you may also get a bill you did not need. The real decision is not, should we use Opus 4.8? The real decision is, which parts of the system are difficult enough to justify Opus 4.8? Thats where the architecture conversation starts.</p>
<h2>What Opus 4.8 is</h2>
<p>Claude Opus 4.8 is Anthropic’s latest Opus-class model. Anthropic describes it as its most capable generally available model at launch. It is aimed at complex reasoning, long-horizon agentic coding, high-autonomy work, tool-heavy workflows and professional knowledge tasks.</p>
<p>The API model ID is:</p>
<pre><code class="language-text">claude-opus-4-8
</code></pre>
<p>Thats the value developers need to care about. Anthropic’s newer model IDs are dateless. That can look like an alias, but it is not. From the Claude 4.6 generation onwards, IDs such as <code>claude-opus-4-8</code> identify a fixed model snapshot. Anthropic does not silently update that model ID to new weights later. If a new version arrives, it gets a new model ID. Thats good for production systems. You dont want your model behaviour changing under the same ID without you knowing. AI systems are hard enough to test already. At least with a pinned model ID, you know what version your code is targeting.</p>
<h2>What is actually new</h2>
<p>Opus 4.8 builds on Opus 4.7. The main improvements are around long-running work, coding, agentic tasks, tool use and honesty. Anthropic says Opus 4.8 is more likely to flag uncertainty, less likely to make unsupported claims, and around four times less likely than Opus 4.7 to let flaws in its own generated code pass without comment. That last point is interesting for developers. One of the frustrating parts of AI-assisted coding is not that the model makes mistakes. All models make mistakes. The problem is when the model sounds confident while being wrong. A model that catches more of its own mistakes, asks better questions and pushes back on weak assumptions is more useful than a model that simply produces more code. That doesnt mean you trust it blindly. It means the collaboration shape is getting better.</p>
<h2>The API details developers should notice</h2>
<p>The model ID is <code>claude-opus-4-8</code>. Opus 4.8 supports a 1M token context window by default on the Claude API, Amazon Bedrock and Google Cloud Vertex AI. On Microsoft Foundry, the documented context window is 200k. The maximum output is 128k tokens. Thats a lot of context. But do not treat a massive context window as an excuse to dump your whole system into every request. Big context is useful for codebase exploration, document analysis and long-running agentic tasks. It is not a replacement for good retrieval, clear prompts or small task boundaries. Opus 4.8 also uses adaptive thinking. That means the model can decide when a task needs extra reasoning and when it can answer directly. In the API, adaptive thinking is the supported thinking mode. Older extended thinking budgets are not supported on Opus 4.7 and later. The <code>effort</code> default is <code>high</code>. Thats a sensible default for a premium model, but it is also something teams need to understand. More effort can mean better output, but it can also mean more token use. For production workloads, effort is not just a quality setting. It is also a cost and latency setting.</p>
<h2>Fast mode</h2>
<p>Opus 4.8 also has fast mode as a research preview on the Claude API. Fast mode is designed for higher output speed. Anthropic says it can give up to 2.5x higher output tokens per second from the same model, but at premium pricing.</p>
<p>Base pricing for Opus 4.8 regular usage is unchanged from Opus 4.7:</p>
<pre><code class="language-text">$5 per million input tokens
$25 per million output tokens
</code></pre>
<p>Fast mode is priced higher:</p>
<pre><code class="language-text">$10 per million input tokens
$50 per million output tokens
</code></pre>
<p>That is a clear trade-off.</p>
<p>Use fast mode where latency is worth paying for. Do not turn it on everywhere because it sounds better. For internal analysis jobs, background review tasks or offline migration planning, regular mode may be fine. For interactive coding tools, live assistants or time-sensitive workflows, fast mode may make more sense.</p>
<h2>The prompt caching change</h2>
<p>Opus 4.8 lowers the minimum cacheable prompt length to 1,024 tokens. Thats a practical improvement. Prompt caching is useful when you repeatedly send the same large instruction block, schema, policy, code context or tool description. If your application has stable context at the front of the prompt and variable user input later, caching can reduce repeated input cost. This is especially relevant for agentic systems. A coding agent may reuse the same repository rules, architecture guidance, coding standards and tool descriptions across many turns. A document assistant may reuse the same extraction schema. A support assistant may reuse the same policy material. Caching does not make bad prompts good. It simply makes repeated stable prompt content cheaper. You still need to design the prompt properly.</p>
<h2>Mid-conversation system messages</h2>
<p>One of the more interesting API changes is support for system messages inside the messages array after a user turn, subject to placement rules. In plain English, this means a developer can update Claude’s instructions mid-task without having to restate the whole original system prompt. That is useful for long-running workflows. An agent may start with one set of instructions, discover new constraints, receive updated permissions, hit a new token budget, or move into a different phase of the task. Being able to add updated system guidance later can keep the conversation cleaner and preserve prompt cache hits.</p>
<p>For .NET developers building agents or workflow tools, this is more relevant than it first sounds. Long-running AI tasks are not just one prompt and one answer. They have phases. The system may need to say, now you are allowed to inspect files, now you are not allowed to modify code, now only propose a plan, now apply the patch, now run checks, now summarise. Mid-conversation system messages make that style easier to model.</p>
<h2>What about sampling parameters?</h2>
<p>This is one of the details that can catch people out. Opus 4.8 does not support setting <code>temperature</code>, <code>top_p</code> or <code>top_k</code> to non-default values. The API returns a 400 error if you try to use non-default sampling parameters. That is inherited from Opus 4.7. If your current abstraction assumes every model supports temperature, you need to adjust it. Do not build your application contract around parameters the model does not support. Use prompting and task design instead. For example, if you want a stricter answer, tell the model to return a specific format. If you want a review, give it a checklist. If you want less creativity, reduce the degrees of freedom in the prompt and validate the output. Do not assume temperature is the right control.</p>
<h2>Using Opus 4.8 from .NET</h2>
<p><a href="https://dotnetdigest.com/building-net-applications-with-the-claude-ai-c-sdk">Anthropic now has an official C# SDK</a> through the <code>Anthropic</code> NuGet package. Thats the package I would use for direct Claude API work in .NET.</p>
<pre><code class="language-bash">dotnet add package Anthropic
</code></pre>
<h2>Where I would use Opus 4.8</h2>
<p>I would use Opus 4.8 for high-value work. Code review assistance is a good fit. Architecture review is a good fit. Pull request risk analysis is a good fit. Large refactoring planning is a good fit. Complex document comparison is a good fit. Multi-step agentic workflows are a good fit. Deep reasoning over long context is a good fit. I would also consider it for tasks where the model needs to challenge assumptions. Thats one of the more useful claims around Opus 4.8. Anthropic is not just saying it writes better answers. It is saying it is more likely to flag uncertainty and less likely to make unsupported claims. In software work, that behaviour can be more useful than raw generation. A model that says, I need more context before making this change, is often more valuable than a model that confidently makes the wrong change.</p>
<h2>Where I would not use it</h2>
<p>I wouldnt put Opus 4.8 behind every small AI feature by default. Simple classification does not usually need it. Basic text rewriting probably does not need it. Low-risk summaries may not need it. High-volume support message drafting may not need it. Simple extraction from well-structured input may not need it. Use a cheaper model where the task is simple and the risk is low. This is not about being cheap for the sake of it. It is about matching the model to the job. A good AI architecture should route work based on difficulty, risk, latency and cost.</p>
<p>You might use a smaller model first, then escalate to Opus 4.8 when the request is complex, ambiguous, high-value or needs deeper reasoning. Thats a better default than one model for everything.</p>
<h2>Dont confuse model quality with system safety</h2>
<p>Opus 4.8 may be better at catching uncertainty and tool use mistakes, but that does not make the whole system safe. The application still needs controls. If the model is reviewing code, do not let it merge code. If the model is analysing payments, do not let it release payments. If the model is reviewing permissions, do not let it grant permissions, you get the idea.</p>
<p>A better model lowers some friction. It doesnt remove engineering responsibility.</p>
<h2>Opus 4.8 and Microsoft.Extensions.AI</h2>
<p>The Anthropic C# SDK also supports <code>IChatClient</code> integration from <code>Microsoft.Extensions.AI.Abstractions</code>. That is useful if you are already building AI features behind common .NET abstractions. There are two sensible approaches. Use the Anthropic SDK directly when you need Claude-specific API features. <a href="https://dotnetdigest.com/microsoft-extensions-ai-explained-the-new-abstraction-layer-for-net-ai-apps">Use <code>IChatClient</code> when you want the application-facing code to stay provider-neutral.</a></p>
<p>That is the same boundary I would use with OpenAI, Azure OpenAI or local models. Keep provider-specific setup in infrastructure. Keep your application services focused on the use case. For example, a document review service should not care whether the underlying model is Claude, GPT or something else. It should care about the result it needs, the validation it applies and the business rules around that result.</p>
<p>The provider is important.</p>
<p>It just should not leak everywhere.</p>
<h2>What about Claude Code?</h2>
<p>Opus 4.8 is also interesting because of Claude Code. Anthropic says Opus 4.8 improves long-horizon agentic coding and tool use. It also launched dynamic workflows in research preview for Claude Code, allowing Claude to plan work and run large numbers of parallel subagents in a session for bigger codebase tasks.</p>
<p>Thats not the same thing as adding Opus 4.8 to your ASP.NET Core API. Claude Code is a developer tool. The Claude API is an application integration surface. They are related, but they are not the same product shape. For .NET teams, I would separate the two conversations. Use Claude Code to help developers explore, refactor, test and understand code. Use the Claude API when your application needs AI behaviour at runtime. <a href="https://dotnetdigest.com/securing-ai-features-in-asp-net-core">Do not blur those two without thinking about security</a>, permissions and audit trails.</p>
<h2>What I would watch during migration</h2>
<p>If you are moving from Opus 4.7 to Opus 4.8, I would not just change the model ID and ship. I would test the prompts that matter. Pay attention to tool calling. Anthropic says Opus 4.8 improves tool triggering, but any tool-calling behaviour change can affect workflows. Pay attention to adaptive thinking and effort settings. Pay attention to token usage. Pay attention to prompts that relied on temperature or older thinking settings. Pay attention to structured output quality. Also test refusal handling. Opus 4.8 has documented refusal stop details. Your application should not treat every refusal as a generic failure. Some refusals should produce a user-friendly message. Some should route to support. Some should be logged for review. Some should trigger a safer fallback. That is application behaviour, not model behaviour.</p>
<h2>The real decision</h2>
<p>So should a .NET team use Claude Opus 4.8? Yes, but not everywhere. Use it where deeper reasoning, long context, coding judgement, tool use and reliability justify the cost. Keep it away from simple, high-volume tasks unless there is a clear reason. Put model selection behind your own routing policy. Use the official Anthropic C# SDK when you need direct Claude API access. Use Microsoft.Extensions.AI when you want a cleaner provider-neutral boundary. Most importantly, do not treat the model as the architecture. Opus 4.8 is a stronger model. That is useful. But the production system still needs boring engineering around it. Good boundaries. Sensible routing. Cancellation. Retries. Rate limits. Cost controls. Structured outputs. Validation. Telemetry. Human review for risky actions. Thats how you make it useful in a real .NET system.</p>
<h2>What should you actually do?</h2>
<p>If I were adding Opus 4.8 to a .NET application, I would start small. I would pick one high-value workflow, such as code review assistance, architecture review, long document analysis or complex support investigation. I would wrap the model call behind an application service. I would use the official <code>Anthropic</code> NuGet package. I would keep the model ID in configuration. I would log token use, latency and failures. I would validate the output. I would avoid automatic write actions until the workflow had been tested properly. Then I would compare it against a cheaper model. If Opus 4.8 gives a clear improvement on the hard cases, keep it for those cases. If a smaller model performs well enough on simple cases, route those there.</p>
<p>That is the practical answer. Dont just use it because its the newest model. Use it where the work is difficult enough to deserve it.</p>
<p><a href="https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-8">https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-8</a>de.com/docs/en/about-claude/models/whats-new-claude-4-8</p>
<p><a href="https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions">https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions</a></p>
<p><a href="https://platform.claude.com/docs/en/api/sdks/csharp">https://platform.claude.com/docs/en/api/sdks/csharp</a></p>
<p><a href="https://www.nuget.org/packages/Anthropic">https://www.nuget.org/packages/Anthropic</a>ropic</p>
]]></content:encoded></item><item><title><![CDATA[DDD and Vertical Slice Architecture Are Friends, Not Rivals]]></title><description><![CDATA[A lot of Developers talk about Domain Driven Design and Vertical Slice Architecture as if they are competing choices. They're really not. DDD helps you model the business. Vertical Slice Architecture ]]></description><link>https://fullstackcity.com/ddd-and-vertical-slice-architecture-are-friends-not-rivals</link><guid isPermaLink="true">https://fullstackcity.com/ddd-and-vertical-slice-architecture-are-friends-not-rivals</guid><category><![CDATA[dotnet]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[software development]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Programming Tips]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[C#]]></category><category><![CDATA[#Domain-Driven-Design]]></category><category><![CDATA[DDD]]></category><category><![CDATA[Vertical Slice Architecture]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sat, 09 May 2026 12:27:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/6d162801-d8dc-46be-8e78-15d4868ca43e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A lot of Developers talk about Domain Driven Design and Vertical Slice Architecture as if they are competing choices. They're really not. DDD helps you model the business. Vertical Slice Architecture helps you organise the application around behaviour. One is about understanding and protecting the domain. The other is about shaping the application so business operations are easy to find, change, test, and deploy.</p>
<p>The confusion usually starts when people compare them as folder structures. They look at a traditional layered architecture with <code>Domain</code>, <code>Application</code>, <code>Infrastructure</code>, and <code>API</code>, then they look at vertical slices grouped by feature, and assume they must choose one.</p>
<p>That is the wrong comparison. DDD is not a folder layout. Vertical Slice Architecture is not a domain model. They solve different problems, and a serious system can use both, In fact, they work better together.</p>
<h2>The false choice</h2>
<p>Traditional layered architecture usually starts with technical separation. Controllers go in one place. Services go in another. Repositories go somewhere else. Validators, DTOs, mappings, and persistence models all get their own homes.</p>
<p>That can work, but it often creates a poor development experience. To change one business capability, you jump across several folders or projects. The code is technically separated, but the feature itself is scattered. Vertical Slice Architecture flips that around. It says a business operation should mostly live together. If the user reserves cinema seats, the endpoint, command, validator, handler, response, and tests should sit close to each other.</p>
<p>Thats useful, but it doesn't automatically give you a good domain model.</p>
<p>A vertical slice can still be procedural. It can still be a transaction script with all the business rules buried in a handler. It can still mutate EF entities directly. It can still turn every business operation into a thin CRUD wrapper. This is where DDD comes in.</p>
<p>Vertical slices give your use cases a home. DDD gives your business rules a home.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/bb202cd1-e50e-4e1c-a8be-fb9d33701d3a.png" alt="" style="display:block;margin:0 auto" />

<p>The handler should coordinate the work. The domain model should make the business decision.</p>
<p>That distinction sounds small. It is not. It is the difference between a system that merely stores data and a system that protects business meaning.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=caxS7806es0">https://www.youtube.com/watch?v=caxS7806es0</a></p>

<h2>Vertical slices organise behaviour</h2>
<p>A vertical slice should represent a useful business action. Not a table. Not a repository. Not a generic service. A business action.</p>
<p><code>ReserveSeats</code> is better than <code>BookingService.Update</code>.</p>
<p><code>ConfirmBookingPayment</code> is better than <code>PatchBookingStatus</code>.</p>
<p><code>CancelReservation</code> is better than <code>SetReservationInactive</code>.</p>
<p>The name of the slice should tell you what the system is doing from the business point of view. This is one of the places where DDD and Vertical Slice Architecture naturally support each other. DDD pushes you towards business language. Vertical Slice Architecture gives that language a clear place in the codebase.</p>
<p>A slice for reserving cinema seats might look like this.</p>
<pre><code class="language-text">Features/
  SeatReservations/
    ReserveSeats/
      ReserveSeatsEndpoint.cs
      ReserveSeatsCommand.cs
      ReserveSeatsHandler.cs
      ReserveSeatsResponse.cs
      ReserveSeatsValidator.cs
      ReserveSeatsTests.cs
</code></pre>
<p>That structure is not the domain model. It is the application boundary around one use case.</p>
<p>Inside that slice, the handler should load the aggregate, call domain behaviour, persist the result, and return a response. It should not become the place where every rule lives.</p>
<h2>The handler is not the domain</h2>
<p>This is the common failure.</p>
<p>A team adopts vertical slices. The folders look clean. The endpoints are small. The feature boundaries look sensible. Then every handler grows into a wall of conditional logic.</p>
<p>The code starts like this.</p>
<pre><code class="language-csharp">internal sealed class ReserveSeatsHandler(CinemaDbContext db)
    : ICommandHandler&lt;ReserveSeatsCommand, ReserveSeatsResponse&gt;
{
    public async Task&lt;Result&lt;ReserveSeatsResponse&gt;&gt; Handle(
        ReserveSeatsCommand command,
        CancellationToken stopToken)
    {
        var screening = await db.Screenings
            .Include(x =&gt; x.Reservations)
            .SingleOrDefaultAsync(x =&gt; x.Id == command.ScreeningId, stopToken);

        if (screening is null)
        {
            return Error.NotFound(
                "Screening.NotFound",
                "The requested screening could not be found.");
        }

        if (screening.StartsAt &lt;= DateTimeOffset.UtcNow)
        {
            return Error.Conflict(
                "Screening.AlreadyStarted",
                "Seats cannot be reserved after the screening has started.");
        }

        var requestedSeats = command.SeatNumbers.ToHashSet(StringComparer.OrdinalIgnoreCase);

        var alreadyReserved = screening.Reservations
            .Where(x =&gt; requestedSeats.Contains(x.SeatNumber))
            .Where(x =&gt; x.ExpiresAt &gt; DateTimeOffset.UtcNow)
            .Select(x =&gt; x.SeatNumber)
            .ToArray();

        if (alreadyReserved.Length &gt; 0)
        {
            return Error.Conflict(
                "Seats.AlreadyReserved",
                "One or more selected seats are no longer available.");
        }

        if (requestedSeats.Count &gt; 6)
        {
            return Error.Validation(
                "Seats.TooManySelected",
                "A customer can reserve a maximum of six seats.");
        }

        var reservation = new SeatReservation
        {
            Id = Guid.NewGuid(),
            ScreeningId = screening.Id,
            CustomerId = command.CustomerId,
            SeatNumbers = requestedSeats.ToArray(),
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10),
            Status = "Held"
        };

        db.SeatReservations.Add(reservation);

        await db.SaveChangesAsync(stopToken);

        return new ReserveSeatsResponse(
            reservation.Id,
            reservation.ExpiresAt);
    }
}
</code></pre>
<p>This is vertical slice code, but it is not strong domain code.</p>
<p>The slice has become the model. The handler knows when a screening can accept reservations. It knows the seat limit. It knows how seat holds expire. It knows what <code>Held</code> means. It knows which existing reservations block a new one. It knows how long the reservation lasts.</p>
<p>That works for a while. Then another feature needs the same rules. A mobile endpoint needs them. A kiosk needs them. An admin flow needs them. A background job needs to rebuild reservations after a payment provider outage. Each one copies part of the logic, slightly differently.</p>
<p>That is how business rules leak.</p>
<h2>Put business behaviour in the model</h2>
<p>A better version keeps the slice focused on orchestration and pushes business decisions into the aggregate. The handler should not decide whether seats can be reserved. The aggregate should decide that.</p>
<pre><code class="language-csharp">internal sealed class Screening
{
    private readonly List&lt;SeatReservation&gt; _reservations = [];

    private Screening()
    {
    }

    public Screening(
        ScreeningId id,
        DateTimeOffset startsAt,
        Auditorium auditorium)
    {
        Id = id;
        StartsAt = startsAt;
        Auditorium = auditorium;
    }

    public ScreeningId Id { get; private set; } = null!;

    public DateTimeOffset StartsAt { get; private set; }

    public Auditorium Auditorium { get; private set; } = null!;

    public IReadOnlyCollection&lt;SeatReservation&gt; Reservations =&gt; _reservations.AsReadOnly();

    public Result&lt;SeatReservation&gt; ReserveSeats(
        CustomerId customerId,
        IReadOnlyCollection&lt;SeatNumber&gt; requestedSeats,
        DateTimeOffset now)
    {
        if (StartsAt &lt;= now)
        {
            return ScreeningErrors.AlreadyStarted(Id);
        }

        if (requestedSeats.Count &gt; 6)
        {
            return ScreeningErrors.TooManySeatsSelected(Id);
        }

        if (!Auditorium.ContainsAll(requestedSeats))
        {
            return ScreeningErrors.InvalidSeatSelection(Id);
        }

        var unavailableSeats = _reservations
            .Where(x =&gt; x.IsActiveAt(now))
            .SelectMany(x =&gt; x.Seats)
            .Intersect(requestedSeats)
            .ToArray();

        if (unavailableSeats.Length &gt; 0)
        {
            return ScreeningErrors.SeatsAlreadyReserved(Id, unavailableSeats);
        }

        var reservation = SeatReservation.Hold(
            Id,
            customerId,
            requestedSeats,
            now.AddMinutes(10));

        _reservations.Add(reservation);

        Raise(new SeatsReserved(
            Id.Value,
            reservation.Id.Value,
            customerId.Value,
            requestedSeats.Select(x =&gt; x.Value).ToArray()));

        return reservation;
    }
}
</code></pre>
<p>Now the handler changes shape.</p>
<pre><code class="language-csharp">internal sealed class ReserveSeatsHandler(CinemaDbContext db, TimeProvider clock)
    : ICommandHandler&lt;ReserveSeatsCommand, ReserveSeatsResponse&gt;
{
    public async Task&lt;Result&lt;ReserveSeatsResponse&gt;&gt; Handle(
        ReserveSeatsCommand command,
        CancellationToken stopToken)
    {
        var screening = await db.Screenings
            .Include(x =&gt; x.Reservations)
            .SingleOrDefaultAsync(x =&gt; x.Id == new ScreeningId(command.ScreeningId), stopToken);

        if (screening is null)
        {
            return Error.NotFound(
                "Screening.NotFound",
                "The requested screening could not be found.");
        }

        var requestedSeatsResult = SeatNumber.CreateMany(command.SeatNumbers);

        if (requestedSeatsResult.IsFailure)
        {
            return requestedSeatsResult.Error;
        }

        var reserveResult = screening.ReserveSeats(
            new CustomerId(command.CustomerId),
            requestedSeatsResult.Value,
            clock.GetUtcNow());

        if (reserveResult.IsFailure)
        {
            return reserveResult.Error;
        }

        await db.SaveChangesAsync(stopToken);

        return new ReserveSeatsResponse(
            reserveResult.Value.Id.Value,
            reserveResult.Value.ExpiresAt);
    }
}
</code></pre>
<p>This is still a vertical slice. The endpoint, command, handler, response, validator, and tests can still live together. But the rules that define seat reservation live in the domain model.</p>
<p>Thats the balance.</p>
<p>The slice owns the use case. The aggregate owns the business behaviour.</p>
<h2>DDD gives slices a spine</h2>
<p>Without DDD, vertical slices can become isolated scripts. Each slice does its job, but the domain has no centre of gravity. Business rules spread sideways across handlers.</p>
<p>DDD gives those slices a spine.</p>
<p>The domain model becomes the place where important concepts live. Aggregates protect consistency. Value objects protect meaning. Domain events describe important facts. Policies make complex decisions explicit. Errors use business language instead of technical failure messages.</p>
<p>The vertical slice still matters because it keeps the application flow readable. You can open one folder and understand how a business operation enters the system, what it validates, what it loads, what it changes, and what it returns.</p>
<p>But the slice should not become a dumping ground. A good slice should answer this question: what does this use case do?</p>
<p>A good domain model should answer this question: what is allowed to happen?</p>
<p>Those are different questions.</p>
<h2>Aggregates still have a place in vertical slices</h2>
<p>You might think Vertical Slice Architecture removes the need for aggregates because each feature is already isolated. Thats a mistake. A slice is not a consistency boundary. It is an application boundary.</p>
<p>An aggregate is a consistency boundary. It protects rules that must be true after a transaction completes.</p>
<p>For a cinema system, the <code>Screening</code> aggregate may protect seat availability, reservation expiry, reservation limits, auditorium rules, and whether the screening has already started. A <code>Booking</code> aggregate may protect payment confirmation, refund rules, ticket issuing, and cancellation rules.</p>
<p>The slice should not manually enforce those invariants. It should ask the aggregate to perform the operation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/df412a94-4903-486f-bae0-c776a146a9a7.png" alt="" style="display:block;margin:0 auto" />

<p>This is where vertical slices and DDD fit neatly together. Each slice can use the aggregate in a focused way. The aggregate remains the authority for its own rules.</p>
<h2>Value objects reduce primitive obsession</h2>
<p>Vertical slices often expose primitive values at the edge of the system. That is normal. HTTP requests, JSON bodies, route values, and query strings usually enter the application as strings, numbers, and booleans.</p>
<p>The problem starts when those primitives flow all the way into the domain.</p>
<p>A string called <code>SeatNumber</code> is not always just a string. It may need to match the cinema layout. It may need to preserve row and seat semantics. It may need to reject values that look valid but do not exist in the room.</p>
<p>A <code>Guid</code> called <code>ScreeningId</code> is not just a random identifier. It identifies a scheduled showing of a film in a specific auditorium at a specific time. Treating it as a naked primitive everywhere weakens the model.</p>
<p>Value objects give those ideas a name.</p>
<pre><code class="language-csharp">internal sealed record SeatNumber
{
    private SeatNumber(string value)
    {
        Value = value;
    }

    public string Value { get; }

    public static Result&lt;SeatNumber&gt; Create(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return Error.Validation(
                "SeatNumber.Empty",
                "Seat number is required.");
        }

        var normalised = value.Trim().ToUpperInvariant();

        if (!Regex.IsMatch(normalised, "^[A-Z][1-9][0-9]?$"))
        {
            return Error.Validation(
                "SeatNumber.Invalid",
                "Seat number must use a valid format such as A1 or C12.");
        }

        return new SeatNumber(normalised);
    }

    public static Result&lt;IReadOnlyCollection&lt;SeatNumber&gt;&gt; CreateMany(
        IReadOnlyCollection&lt;string&gt; values)
    {
        var seats = new List&lt;SeatNumber&gt;();

        foreach (var value in values)
        {
            var result = Create(value);

            if (result.IsFailure)
            {
                return result.Error;
            }

            seats.Add(result.Value);
        }

        return seats;
    }
}
</code></pre>
<p>The vertical slice can still accept <code>string[] seatNumbers</code> from the request. But the domain should not treat those strings as casual text. Once they cross into the model, they should become domain concepts.</p>
<p>That is one of the simplest ways to improve a vertical-slice codebase. Keep primitives at the boundary. Convert them into meaningful types before they reach business behaviour.</p>
<h2>Application validation and domain rules are different</h2>
<p>Vertical slices often have validators. Thats good, but validators should not replace domain rules.</p>
<p>A command validator can check whether a request is structurally valid. It can check whether required fields are present, whether an array is empty, or whether a route value has the right shape. The domain model should protect business truth.</p>
<p>A validator can say, “At least one seat must be selected.”</p>
<p>The aggregate should say, “These seats cannot be reserved because they are already held by another active reservation.”</p>
<p>Those rules live at different levels.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/b0d9b56e-9c1d-48f0-bd3f-f8da48c21c5f.png" alt="" style="display:block;margin:0 auto" />

<p>When teams mix these up, the validator becomes a second domain model. That creates weak design because validators usually sit outside the domain and are easy to bypass.</p>
<p>The domain should protect itself even when a different entry point calls it.</p>
<p>That might be an API endpoint today. Tomorrow it might be a kiosk flow, a mobile app, a queue consumer, a scheduled cleanup job, a support tool, or a data repair process.</p>
<p>The model should not depend on the current slice being the only caller.</p>
<h2>Domain events fit naturally inside slices</h2>
<p>Domain events also fit well with vertical slices, as long as you keep the distinction clear.</p>
<p>A domain event describes something meaningful that happened inside the model. It should use business language. It should not be designed around a message broker, storage queue, or external contract.</p>
<p><code>SeatsReserved</code> is a domain event. It says something happened in the business.</p>
<p>The slice can persist the aggregate and let an outbox, dispatcher, or post-commit pipeline handle the event later. The important thing is that the domain event comes from the model, not from the handler guessing what changed.</p>
<pre><code class="language-csharp">internal sealed record SeatsReserved(
    Guid ScreeningId,
    Guid ReservationId,
    Guid CustomerId,
    string[] SeatNumbers) : IDomainEvent;
</code></pre>
<p>Inside the aggregate, the event is raised when the business operation succeeds.</p>
<pre><code class="language-csharp">public Result&lt;SeatReservation&gt; ReserveSeats(
    CustomerId customerId,
    IReadOnlyCollection&lt;SeatNumber&gt; requestedSeats,
    DateTimeOffset now)
{
    if (StartsAt &lt;= now)
    {
        return ScreeningErrors.AlreadyStarted(Id);
    }

    if (requestedSeats.Count &gt; 6)
    {
        return ScreeningErrors.TooManySeatsSelected(Id);
    }

    if (!Auditorium.ContainsAll(requestedSeats))
    {
        return ScreeningErrors.InvalidSeatSelection(Id);
    }

    var unavailableSeats = _reservations
        .Where(x =&gt; x.IsActiveAt(now))
        .SelectMany(x =&gt; x.Seats)
        .Intersect(requestedSeats)
        .ToArray();

    if (unavailableSeats.Length &gt; 0)
    {
        return ScreeningErrors.SeatsAlreadyReserved(Id, unavailableSeats);
    }

    var reservation = SeatReservation.Hold(
        Id,
        customerId,
        requestedSeats,
        now.AddMinutes(10));

    _reservations.Add(reservation);

    Raise(new SeatsReserved(
        Id.Value,
        reservation.Id.Value,
        customerId.Value,
        requestedSeats.Select(x =&gt; x.Value).ToArray()));

    return reservation;
}
</code></pre>
<p>The vertical slice does not need to know how to describe that event. It only needs to complete the use case. That keeps the business language inside the domain and the orchestration inside the slice.</p>
<h2>The folder structure is less important than the dependency direction</h2>
<p>People get very religious about folders.</p>
<p>You can put domain types in a shared domain folder. You can put feature-specific domain types near the slice. You can group by module. You can use projects. You can use folders. None of that matters as much as dependency direction and business ownership.</p>
<p>The application slice can depend on the domain. The domain should not depend on the application slice.</p>
<p>The domain should not know about endpoints, DTOs, EF Core queries, HTTP, queues, JSON, or request models.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/6bf2139b-e05a-456f-8081-e3e790e722f2.png" alt="" style="display:block;margin:0 auto" />

<p>That simple rule prevents a lot of damage.</p>
<p>Once the domain starts depending on application concerns, it becomes harder to test, harder to reuse, and harder to reason about. The model stops being a model and becomes a reflection of the current delivery mechanism.</p>
<h2>EF Core should persist the model, not design it</h2>
<p>EF Core is a good tool, but it should not become the architect of your domain.</p>
<p>The model should express business rules first. Persistence mapping should support that model. That usually means some extra mapping work, but the tradeoff is worth it when the domain has real behaviour.</p>
<p>For example, <code>Screening</code> can expose reservations as a read-only collection while EF Core maps the backing field.</p>
<pre><code class="language-csharp">internal sealed class ScreeningConfiguration : IEntityTypeConfiguration&lt;Screening&gt;
{
    public void Configure(EntityTypeBuilder&lt;Screening&gt; builder)
    {
        builder.HasKey(x =&gt; x.Id);

        builder.Property(x =&gt; x.Id)
            .HasConversion(
                id =&gt; id.Value,
                value =&gt; new ScreeningId(value));

        builder.Property(x =&gt; x.StartsAt);

        builder.HasMany&lt;SeatReservation&gt;("_reservations")
            .WithOne()
            .HasForeignKey(x =&gt; x.ScreeningId);

        builder.Navigation("_reservations")
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}
</code></pre>
<p>This kind of mapping keeps the domain model honest. External code cannot casually mutate the reservation list, but EF Core can still load and persist it.</p>
<p>That is the right relationship. The domain owns behaviour. EF Core handles storage.</p>
<h2>Vertical slices make DDD easier to adopt gradually</h2>
<p>One of the best things about Vertical Slice Architecture is that it lets you adopt DDD gradually. You dont need to stop everything and design a perfect domain model upfront. You can improve one use case at a time.</p>
<p>Start with a handler that contains too much business logic. Move one rule into the aggregate. Extract one value object. Rename one command to reflect business intent. Replace one generic update operation with a meaningful method. Add one domain event where a real business fact occurs.</p>
<p>Thats how real systems improve.</p>
<p>DDD fails when you turn it into a ceremony. You spend weeks arguing about repositories, factories, aggregates, and bounded contexts before improving the code. Vertical slices help avoid that because you keep the work close to a business outcome.</p>
<p>Pick a use case. Model it better. Move on.</p>
<h2>Where this goes wrong</h2>
<p>The first mistake is treating every vertical slice as an excuse to duplicate domain logic. That gives you local simplicity and global chaos. The code feels clean inside each folder, but the system becomes inconsistent over time.</p>
<p>The second mistake is creating a domain model that does not do anything. If your entities only have getters and setters, and all decisions happen in handlers, you do not really have a domain model. You have persistence objects.</p>
<p>The third mistake is over-modelling everything. Not every feature needs a rich aggregate, value objects, policies, and domain events. Some slices are simple queries. Some operations are basic administration tasks. Some parts of the system are CRUD, and pretending otherwise only adds noise.</p>
<p>The fourth mistake is letting EF Core dictate the model. EF Core should persist the model, not flatten it into a set of public setters and navigation properties because that is easier to map.</p>
<p>The fifth mistake is confusing module boundaries with aggregate boundaries. A module can contain many slices and several aggregates. A slice can use one aggregate, multiple read models, and infrastructure services. These concepts overlap, but they are not the same thing.</p>
<h2>A practical way to think about it</h2>
<p>Use vertical slices to organise use cases.</p>
<p>Use DDD to model business behaviour.</p>
<p>Use aggregates to protect consistency.</p>
<p>Use value objects to give meaning to important values.</p>
<p>Use domain events to describe facts that happened.</p>
<p>Use handlers to coordinate, not to own the business.</p>
<p>When a request comes in, the slice handles the application flow. When a business decision needs to be made, the domain model makes it. The result is code that is easier to navigate and harder to corrupt.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=8Z5IAkWcnIw&amp;list=PLzYkqgWkHPKDpXETRRsFv2F9ht6XdAF3v">https://www.youtube.com/watch?v=8Z5IAkWcnIw&amp;list=PLzYkqgWkHPKDpXETRRsFv2F9ht6XdAF3v</a></p>

<p>DDD and Vertical Slice Architecture are not rivals. They're complementary tools. Vertical slices stop your features from being scattered across technical layers. DDD stops your business rules from being scattered across procedural handlers. Together, they give you a structure that works with the way software actually changes. Features change because the business changes. Rules change because the business changes. Language changes because the business changes. Your architecture should make those changes easier to understand, safer to implement, and harder to get subtly wrong.</p>
]]></content:encoded></item><item><title><![CDATA[Denormalisation for Performance in C#]]></title><description><![CDATA[How to unlock real performance gains without turning your data model into a mess
Most engineers start in the same place. You normalise the schema, remove duplication, keep each fact in one place, and ]]></description><link>https://fullstackcity.com/denormalisation-for-performance-in-c</link><guid isPermaLink="true">https://fullstackcity.com/denormalisation-for-performance-in-c</guid><category><![CDATA[denormalization]]></category><category><![CDATA[software development]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[performance]]></category><category><![CDATA[C#]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[Microsoft]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Thu, 02 Apr 2026 20:49:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/49a783a7-1216-433f-b8d9-35aa00e82c9f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>How to unlock real performance gains without turning your data model into a mess</p>
<p>Most engineers start in the same place. You normalise the schema, remove duplication, keep each fact in one place, and rely on joins to reconstruct the answer when the application needs it. That is still the right default for a transactional system. The problem is that many people quietly stretch that rule too far. They end up assuming that a data model which is logically clean must also be operationally fast. In production, that falls apart quickly.</p>
<p>The real cost of a heavily normalised model is not usually visible in one query. It shows up in repetition. The same joins run over and over. The same aggregates are recalculated on every request. The same object graph is rebuilt for every page, every API call, every dashboard tile, and every export. The database becomes a reconstruction engine. The application becomes a shaping engine. Both work hard, not because the business needs new information, but because the model forces them to keep rebuilding information the system already knows.</p>
<h2>This is where denormalisation comes in.</h2>
<p>Denormalisation is a deliberate decision to move work away from the read path and into the write path, or into a background projection step, because doing that once is cheaper than doing the same work thousands of times on demand. In a modern C# system, especially one built with <a href="http://ASP.NET">ASP.NET</a> Core, EF Core, background workers, queues, Redis, and event driven processing, that trade can transform performance.</p>
<p>The gains are not abstract. You see them in lower latency, lower database CPU, fewer allocations, more stable p95 and p99 response times, better concurrency, and less fragile query behaviour. You also get a cleaner separation between the source of truth and the shape that the application actually needs at the edge.</p>
<p>This is important mainly in systems with heavy read traffic, complex dashboards, queue screens, search pages, configuration endpoints, reporting APIs, and integration surfaces that repeatedly ask for the same shaped view of the data. If you treat every one of those reads as a fresh act of discovery, the system wastes time. If you precompute and persist the shape once, the request becomes a cheap lookup.</p>
<p>The key idea is simple. Normalisation optimises storage and correctness. Denormalisation optimises access. Mature systems usually need both.</p>
<h2>The hidden cost of clean relational models</h2>
<p>A normalised schema protects integrity. That is its job. It makes writes understandable and it keeps the domain tidy. The trouble starts when the application’s hot paths are read heavy and shape heavy.</p>
<p>Imagine a common business screen. You need to show a case list with case number, customer name, policy type, current stage, outstanding balance, number of open actions, last correspondence date, assigned handler, SLA status, and a search summary. In a fully normalised design, those values may come from six or seven tables, plus a few aggregate queries, plus a handful of rules in application code. Nothing about that is inherently wrong. The problem is frequency. If that same shape is requested constantly, the system is paying the cost of reconstruction on every request.</p>
<p>The request path starts to look like this.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/97c37459-20b3-4baa-8375-849e487bdda9.png" alt="" style="display:block;margin:0 auto" />

<p>That path is often acceptable in development. Small data volumes hide the cost. A local database hides the latency. The ORM hides the SQL. Then production arrives, concurrency rises, the dataset grows, and the endpoint that felt harmless becomes one of the hottest parts of the estate.</p>
<p>The first thing people usually try is index tuning. That helps. Then they add projections in LINQ. That helps a bit more. Then they introduce caching. That can help a lot, but it often hides the problem rather than fixing it. A cache miss still falls back to the same expensive reconstruction path. Once the underlying shape is wrong for the read pattern, you are tuning around the problem rather than changing it.</p>
<p>That is why denormalisation is so powerful. It does not ask how to run the same expensive query a little faster. It asks whether the query should exist in that form at all.</p>
<h2>What denormalisation really means in a C# system</h2>
<p>In practice, denormalisation in a C# system usually takes one of a few forms.</p>
<p>You store precomputed aggregates directly on a parent row, such as current balance, open item count, or last activity date.</p>
<p>You build a dedicated read model that already matches a page or API response.</p>
<p>You snapshot descriptive values, such as broker name or product name, onto a transactional record so you do not join to reference tables on every read.</p>
<p>You persist flags and classifications, such as IsUrgent, RiskBand, or HasOpenTasks, instead of recalculating them repeatedly.</p>
<p>You compile expensive response payloads into a cached or persisted format, often as JSON, so the request path can serve them directly.</p>
<p>Those are all forms of the same idea. You take work that would otherwise happen every time the application reads data and you pay for it once when the data changes.</p>
<p>That changes the economics of the system.</p>
<p>If a value changes once an hour but is read ten thousand times in that hour, it is usually madness to compute it ten thousand times. Store it. Keep it fresh. Read it cheaply.</p>
<h2>Where the performance gains come from</h2>
<p>The gains from denormalisation are easy to hand wave, but the useful part is knowing where they show up.</p>
<p>The first gain is query simplification. A query that previously needed multiple joins, aggregates, and conditional expressions can become a simple index seek against a flat row. That cuts database CPU, logical reads, memory pressure, and plan complexity.</p>
<p>The second gain is lower application overhead. Even if the database work is acceptable, materialising nested EF Core graphs and then reshaping them into DTOs still costs CPU and memory in the application. A flat read model avoids much of that.</p>
<p>The third gain is better tail performance. Complex queries are far more likely to show unstable p95 and p99 latencies, especially under concurrency or when parameter values vary. Simple denormalised queries are usually more predictable.</p>
<p>The fourth gain is improved cache behaviour. A denormalised row or payload already matches the response shape, so a cache miss is not painful. You do not have to rebuild the world before you can refill the cache.</p>
<p>The fifth gain is fewer cross service dependencies. If you snapshot small pieces of descriptive data, one service no longer needs to ask another service the same question on every request. That is often a bigger win than any SQL optimisation.</p>
<p>The sixth gain is better scaling behaviour. Once each request does less work, every instance can serve more traffic with more stable latency. Horizontal scaling starts to work properly because the units of work are cheaper and more predictable.</p>
<p>The right way to picture this is as two separate paths.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/f70d48a5-96e3-4499-a273-4d18b21d9f58.png" alt="" style="display:block;margin:0 auto" />

<p>The write path gets slightly heavier. The read path becomes dramatically cheaper. In read heavy systems that is exactly the trade you want.</p>
<h3>Technique one, precomputed aggregates</h3>
<p>This is the most obvious denormalisation technique and still one of the most effective. If an aggregate is read often and changes comparatively rarely, store it.</p>
<p>Think about balances, counts, totals, last updated timestamps, most recent activity, most recent payment date, open task count, total claim value, or number of outstanding documents. Engineers often recalculate these on every request because the database can do it. That does not mean it should.</p>
<p>A typical normalised query might look like this.</p>
<pre><code class="language-csharp">public sealed class AccountSummaryService
{
    private readonly FinanceDbContext _db;

    public AccountSummaryService(FinanceDbContext db)
    {
        _db = db;
    }

    public async Task&lt;AccountSummaryDto?&gt; GetAsync(Guid accountId, CancellationToken stopToken)
    {
        return await _db.Accounts
            .Where(x =&gt; x.Id == accountId)
            .Select(x =&gt; new AccountSummaryDto
            {
                AccountId = x.Id,
                CustomerName = x.Customer.Name,
                CurrentBalance = x.Transactions.Sum(t =&gt; t.Amount),
                OpenInvoiceCount = x.Invoices.Count(i =&gt; !i.IsPaid),
                LastPaymentUtc = x.Payments
                    .OrderByDescending(p =&gt; p.PaidAtUtc)
                    .Select(p =&gt; (DateTime?)p.PaidAtUtc)
                    .FirstOrDefault()
            })
            .SingleOrDefaultAsync(stopToken);
    }
}
</code></pre>
<p>This is tidy. It is also doing real work every time the endpoint is called. The sum is recomputed. The count is recomputed. The payment ordering is revisited. The joins are rebuilt. If that account page is busy, you are paying that cost repeatedly for no gain in truth.</p>
<p>A denormalised design moves those values onto the account row or onto a dedicated account summary table.</p>
<pre><code class="language-csharp">public sealed class Account
{
    public Guid Id { get; set; }
    public Guid CustomerId { get; set; }
    public decimal CurrentBalance { get; set; }
    public int OpenInvoiceCount { get; set; }
    public DateTime? LastPaymentUtc { get; set; }
}
</code></pre>
<p>The read path becomes much simpler.</p>
<pre><code class="language-csharp">public sealed class AccountSummaryService
{
    private readonly FinanceDbContext _db;

    public AccountSummaryService(FinanceDbContext db)
    {
        _db = db;
    }

    public async Task&lt;AccountSummaryDto?&gt; GetAsync(Guid accountId, CancellationToken stopToken)
    {
        return await _db.Accounts
            .AsNoTracking()
            .Where(x =&gt; x.Id == accountId)
            .Select(x =&gt; new AccountSummaryDto
            {
                AccountId = x.Id,
                CustomerName = x.Customer.Name,
                CurrentBalance = x.CurrentBalance,
                OpenInvoiceCount = x.OpenInvoiceCount,
                LastPaymentUtc = x.LastPaymentUtc
            })
            .SingleOrDefaultAsync(stopToken);
    }
}
</code></pre>
<p>The gain here is not subtle. You have shifted work from every read to only the writes that actually change the values.</p>
<p>There are two good ways to maintain these fields. If the value is part of a hard business invariant, update it in the same transaction as the canonical write. If the value is mainly for display or read optimisation, project it asynchronously through an outbox driven worker.</p>
<p>Here is a synchronous example.</p>
<pre><code class="language-csharp">public sealed class PaymentService
{
    private readonly FinanceDbContext _db;

    public PaymentService(FinanceDbContext db)
    {
        _db = db;
    }

    public async Task RecordPaymentAsync(Guid accountId, decimal amount, DateTime paidAtUtc, CancellationToken stopToken)
    {
        var account = await _db.Accounts.SingleAsync(x =&gt; x.Id == accountId, stopToken);

        _db.Payments.Add(new Payment
        {
            Id = Guid.NewGuid(),
            AccountId = accountId,
            Amount = amount,
            PaidAtUtc = paidAtUtc
        });

        account.CurrentBalance -= amount;
        account.LastPaymentUtc = paidAtUtc;

        await _db.SaveChangesAsync(stopToken);
    }
}
</code></pre>
<p>That looks almost boring, which is exactly the point. Good denormalisation is often simple. It gives you a cheap read path because the system has already done the work.</p>
<h3>Technique two, dedicated read models</h3>
<p>This is where denormalisation starts to move from a tactical optimisation into a strategic design choice. A read model is a table or document that exists purely because a specific screen, endpoint, or integration needs data in a specific shape.</p>
<p>Suppose you have an underwriting queue page. The page needs submission number, insured name, broker name, product class, status, risk rating, attachment count, assigned underwriter, created date, and an urgency flag. In a fully normalised schema those values may be scattered across several tables and some of them may need to be computed. You can absolutely query them on demand. You will just keep paying for it.</p>
<p>A denormalised read model lets you store exactly what the queue needs.</p>
<pre><code class="language-csharp">public sealed class SubmissionReviewQueueItem
{
    public Guid SubmissionId { get; set; }
    public string SubmissionNumber { get; set; } = string.Empty;
    public string InsuredName { get; set; } = string.Empty;
    public string BrokerName { get; set; } = string.Empty;
    public string ProductClass { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
    public string RiskRating { get; set; } = string.Empty;
    public int AttachmentCount { get; set; }
    public string AssignedUnderwriterName { get; set; } = string.Empty;
    public bool IsUrgent { get; set; }
    public DateTime CreatedUtc { get; set; }
}
</code></pre>
<p>The query then becomes a simple paged lookup.</p>
<pre><code class="language-csharp">public sealed class ReviewQueueService
{
    private readonly UnderwritingDbContext _db;

    public ReviewQueueService(UnderwritingDbContext db)
    {
        _db = db;
    }

    public async Task&lt;IReadOnlyList&lt;ReviewQueueItemDto&gt;&gt; GetPageAsync(int page, int pageSize, CancellationToken stopToken)
    {
        return await _db.SubmissionReviewQueueItems
            .AsNoTracking()
            .OrderByDescending(x =&gt; x.IsUrgent)
            .ThenBy(x =&gt; x.CreatedUtc)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(x =&gt; new ReviewQueueItemDto
            {
                SubmissionId = x.SubmissionId,
                SubmissionNumber = x.SubmissionNumber,
                InsuredName = x.InsuredName,
                BrokerName = x.BrokerName,
                ProductClass = x.ProductClass,
                Status = x.Status,
                RiskRating = x.RiskRating,
                AttachmentCount = x.AttachmentCount,
                AssignedUnderwriterName = x.AssignedUnderwriterName,
                IsUrgent = x.IsUrgent,
                CreatedUtc = x.CreatedUtc
            })
            .ToListAsync(stopToken);
    }
}
</code></pre>
<p>This is the kind of change that can take an endpoint from unpredictable and expensive to stable and cheap. It also changes how you index. Instead of trying to satisfy a messy query against the whole domain model, you can build indexes specifically for the queue.</p>
<p>The strongest way to maintain a read model is with a projection pipeline. The domain write commits. An outbox message is recorded in the same transaction. A background worker consumes the outbox and updates the read model. The read path never has to rebuild the queue shape on demand.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/d1ae1e75-0588-4091-ad8e-eb48a861dbdb.png" alt="" style="display:block;margin:0 auto" />

<p>That pattern gives you reliability, observability, and a clean failure model. If a projection fails, you can retry it. If you need to rebuild, you can replay events or recalculate from the source of truth.</p>
<h3>Technique three, snapshot descriptive data</h3>
<p>A huge amount of hidden query cost comes from descriptive joins. Broker name. Product name. Region name. Handler display name. Organisation name. Status description. These are often joined into hot queries simply because they are stored elsewhere. That keeps the schema pure. It also keeps the read path unnecessarily busy.</p>
<p>Snapshotting descriptive values means copying them at the point where they matter. That often improves performance, and in many domains it also improves auditability because it preserves the value as it was when the transaction occurred.</p>
<p>Here is a simple example.</p>
<pre><code class="language-csharp">public sealed class Submission
{
    public Guid Id { get; set; }
    public Guid BrokerId { get; set; }
    public string BrokerNameSnapshot { get; set; } = string.Empty;
    public string ProductNameSnapshot { get; set; } = string.Empty;
    public string InsuredName { get; set; } = string.Empty;
    public DateTime CreatedUtc { get; set; }
}
</code></pre>
<p>When you create the submission, you take the snapshot.</p>
<pre><code class="language-csharp">public sealed class SubmissionService
{
    private readonly UnderwritingDbContext _db;

    public SubmissionService(UnderwritingDbContext db)
    {
        _db = db;
    }

    public async Task&lt;Guid&gt; CreateAsync(CreateSubmissionCommand command, CancellationToken stopToken)
    {
        var broker = await _db.Brokers.SingleAsync(x =&gt; x.Id == command.BrokerId, stopToken);
        var product = await _db.Products.SingleAsync(x =&gt; x.Id == command.ProductId, stopToken);

        var submission = new Submission
        {
            Id = Guid.NewGuid(),
            BrokerId = broker.Id,
            BrokerNameSnapshot = broker.DisplayName,
            ProductNameSnapshot = product.Name,
            InsuredName = command.InsuredName,
            CreatedUtc = DateTime.UtcNow
        };

        _db.Submissions.Add(submission);
        await _db.SaveChangesAsync(stopToken);

        return submission.Id;
    }
}
</code></pre>
<p>Now every queue, dashboard, export, and search result that needs the broker or product name can read it directly from the submission or from a projection row built from it. That removes joins from the hot path and makes results historically accurate. If the broker later changes their display name, older submissions do not silently rewrite history.</p>
<p>This is one of the most underused denormalisation techniques because people dismiss it as duplication. In reality it is often one of the cleanest improvements you can make.</p>
<h3>Technique four, persist flags and classifications</h3>
<p>A lot of expensive read logic is not about fetching data at all. It is about classifying it. Is this item urgent. Is this account over limit. Is this submission nearing SLA breach. Does this customer require manual review. Is this case ready for escalation. Those rules often combine dates, counts, statuses, and related rows. If they sit on the hot read path, they get recalculated constantly.</p>
<p>If the answer is needed often, persist it.</p>
<pre><code class="language-csharp">public sealed class SubmissionReviewQueueItem
{
    public Guid SubmissionId { get; set; }
    public bool IsUrgent { get; set; }
    public string RiskBand { get; set; } = string.Empty;
    public DateTime? EscalationDueUtc { get; set; }
}
</code></pre>
<p>The projector decides the values once.</p>
<pre><code class="language-csharp">public static class SubmissionClassification
{
    public static bool CalculateUrgency(DateTime createdUtc, string status, int attachmentCount)
    {
        if (status == "Completed")
        {
            return false;
        }

        if (attachmentCount == 0)
        {
            return true;
        }

        return DateTime.UtcNow - createdUtc &gt; TimeSpan.FromHours(24);
    }

    public static string CalculateRiskBand(decimal score)
    {
        if (score &gt;= 80m)
        {
            return "High";
        }

        if (score &gt;= 50m)
        {
            return "Medium";
        }

        return "Low";
    }
}
</code></pre>
<p>Once you store these values, the database can index them directly. That is the real shift. Instead of asking the engine to compute urgency on every candidate row, you let it seek on IsUrgent or RiskBand. That changes both performance and plan quality.</p>
<h3>Technique five, flattened search columns</h3>
<p>Search is a classic source of accidental complexity. Users want one box. They expect it to match case number, broker name, customer name, postcode, product, maybe even a phone number or note. A normalised model turns that into a wide OR condition with several joins, or pushes the team into adding a separate search engine earlier than they really need one.</p>
<p>A useful middle ground is to denormalise search into a dedicated row with flattened searchable fields.</p>
<pre><code class="language-csharp">public sealed class SubmissionSearchRow
{
    public Guid SubmissionId { get; set; }
    public string SubmissionNumber { get; set; } = string.Empty;
    public string BrokerName { get; set; } = string.Empty;
    public string InsuredName { get; set; } = string.Empty;
    public string Postcode { get; set; } = string.Empty;
    public string ProductName { get; set; } = string.Empty;
    public string SearchText { get; set; } = string.Empty;
    public DateTime CreatedUtc { get; set; }
}
</code></pre>
<p>A projector builds the flattened text.</p>
<pre><code class="language-csharp">public sealed class SubmissionSearchProjector
{
    private readonly UnderwritingDbContext _db;

    public SubmissionSearchProjector(UnderwritingDbContext db)
    {
        _db = db;
    }

    public async Task RebuildAsync(Guid submissionId, CancellationToken stopToken)
    {
        var source = await _db.Submissions
            .Where(x =&gt; x.Id == submissionId)
            .Select(x =&gt; new
            {
                x.Id,
                x.SubmissionNumber,
                x.BrokerNameSnapshot,
                x.InsuredName,
                x.Postcode,
                x.ProductNameSnapshot,
                x.CreatedUtc
            })
            .SingleAsync(stopToken);

        var searchText = string.Join(' ',
            source.SubmissionNumber,
            source.BrokerNameSnapshot,
            source.InsuredName,
            source.Postcode,
            source.ProductNameSnapshot)
            .ToLowerInvariant();

        var row = await _db.SubmissionSearchRows.FindAsync(new object[] { submissionId }, stopToken);

        if (row is null)
        {
            row = new SubmissionSearchRow { SubmissionId = submissionId };
            _db.SubmissionSearchRows.Add(row);
        }

        row.SubmissionNumber = source.SubmissionNumber;
        row.BrokerName = source.BrokerNameSnapshot;
        row.InsuredName = source.InsuredName;
        row.Postcode = source.Postcode;
        row.ProductName = source.ProductNameSnapshot;
        row.CreatedUtc = source.CreatedUtc;
        row.SearchText = searchText;

        await _db.SaveChangesAsync(stopToken);
    }
}
</code></pre>
<p>This is not a replacement for a true search platform in every case. It is a practical step that often solves internal search needs very well and removes painful joins from the request path.</p>
<h3>Technique six, compiled JSON payloads</h3>
<p>Some read paths are expensive not because the data is hard to query, but because the response is expensive to build. Configuration payloads, product catalogues, pricing rules, feature flag definitions, and reference datasets often fall into this category. The source data may be split across multiple tables and the application may have to turn it into a nested object model before serialising it.</p>
<p>If the payload changes relatively rarely and is read heavily, compile it once and store the result.</p>
<pre><code class="language-csharp">public sealed class CompiledProductConfig
{
    public Guid ProductId { get; set; }
    public string Version { get; set; } = string.Empty;
    public string JsonPayload { get; set; } = string.Empty;
    public DateTime CompiledUtc { get; set; }
}
</code></pre>
<p>A compiler service generates the payload after changes.</p>
<pre><code class="language-csharp">public sealed class ProductConfigCompiler
{
    private readonly ProductDbContext _db;

    public ProductConfigCompiler(ProductDbContext db)
    {
        _db = db;
    }

    public async Task CompileAsync(Guid productId, CancellationToken stopToken)
    {
        var product = await _db.Products
            .Where(x =&gt; x.Id == productId)
            .Select(x =&gt; new
            {
                x.Id,
                x.Name,
                Rules = x.Rules
                    .OrderBy(r =&gt; r.Priority)
                    .Select(r =&gt; new
                    {
                        r.Key,
                        r.Operator,
                        r.Value
                    })
                    .ToList()
            })
            .SingleAsync(stopToken);

        var payload = JsonSerializer.Serialize(product);

        var row = await _db.CompiledProductConfigs.FindAsync(new object[] { productId }, stopToken);

        if (row is null)
        {
            row = new CompiledProductConfig { ProductId = productId };
            _db.CompiledProductConfigs.Add(row);
        }

        row.Version = Guid.NewGuid().ToString("N");
        row.JsonPayload = payload;
        row.CompiledUtc = DateTime.UtcNow;

        await _db.SaveChangesAsync(stopToken);
    }
}
</code></pre>
<p>The request path then becomes almost trivial.</p>
<pre><code class="language-csharp">public sealed class ProductConfigService
{
    private readonly ProductDbContext _db;

    public ProductConfigService(ProductDbContext db)
    {
        _db = db;
    }

    public async Task&lt;string?&gt; GetCompiledJsonAsync(Guid productId, CancellationToken stopToken)
    {
        return await _db.CompiledProductConfigs
            .AsNoTracking()
            .Where(x =&gt; x.ProductId == productId)
            .Select(x =&gt; x.JsonPayload)
            .SingleOrDefaultAsync(stopToken);
    }
}
</code></pre>
<p>This is denormalisation at the payload level. It is blunt, and when used in the right place it is extremely effective. You remove query assembly and serialisation work from the hot path entirely.</p>
<h2>How to implement denormalisation cleanly in .NET</h2>
<p>The biggest risk with denormalisation is not duplication. It is ambiguity. If nobody can tell which model is canonical, which values are derived, how freshness works, and how to repair drift, the design will decay.</p>
<p>A clean .NET implementation usually has four parts.</p>
<p>You keep the canonical write model explicit. This is the model the business truly owns.</p>
<p>You emit an outbox event or domain event when the source data changes.</p>
<p>You project that event into one or more read models in a background process.</p>
<p>You make the read endpoints talk to the read models directly, without trying to reconstruct the domain again.</p>
<p>A simple hosted service can handle the projection side. In larger systems that may be a separate worker or Azure Function. The important part is not the hosting model. The important part is that the projection is idempotent and observable.</p>
<pre><code class="language-csharp">public sealed class SubmissionProjectionWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger&lt;SubmissionProjectionWorker&gt; _logger;

    public SubmissionProjectionWorker(
        IServiceScopeFactory scopeFactory,
        ILogger&lt;SubmissionProjectionWorker&gt; logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        while (!stopToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService&lt;UnderwritingDbContext&gt;();
            var projector = scope.ServiceProvider.GetRequiredService&lt;SubmissionProjector&gt;();

            var batch = await db.OutboxMessages
                .Where(x =&gt; x.ProcessedUtc == null &amp;&amp; x.Type == "SubmissionChanged")
                .OrderBy(x =&gt; x.OccurredUtc)
                .Take(100)
                .ToListAsync(stopToken);

            if (batch.Count == 0)
            {
                await Task.Delay(TimeSpan.FromSeconds(1), stopToken);
                continue;
            }

            foreach (var message in batch)
            {
                try
                {
                    await projector.ProjectAsync(message.AggregateId, stopToken);
                    message.ProcessedUtc = DateTime.UtcNow;
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Failed to project submission {SubmissionId}", message.AggregateId);
                }
            }

            await db.SaveChangesAsync(stopToken);
        }
    }
}
</code></pre>
<p>That worker does not need to be clever. It needs to be reliable. Projection code should be deterministic, repeatable, and easy to rebuild.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/4f56f637-5158-43d7-8a9c-9bd79cb37c35.png" alt="" style="display:block;margin:0 auto" />

<p>This pattern works because it gives you separation. Writes preserve truth. Projections shape data for speed. Reads stay lean.</p>
<p>Measuring the gains properly</p>
<p>If you denormalise without measuring, you are guessing. Sometimes the guess is right, but expert engineering means proving it.</p>
<p>Do not just measure average response time. That hides a lot. You want median, p95, and p99. You want database CPU and logical reads. You want application allocations. You want throughput under concurrency. You want to know whether you made writes slightly heavier and whether that matters.</p>
<p>The pattern to look for is simple. If the old design has acceptable median latency but poor p95 and p99, denormalisation usually helps a lot because it simplifies the work and stabilises the plan. If the old design burns database CPU and application allocations on every request, denormalisation usually helps there too. In C# terms, you should benchmark at three levels. Measure the database query cost. Measure the endpoint under realistic concurrency. Measure the shaping cost in process if serialisation or object mapping is part of the problem. A single stopwatch around an API call is not enough. Its common to see a read endpoint go from well over one hundred milliseconds to below twenty once it moves from reconstruction to direct lookup. More importantly, the tail often tightens dramatically. The endpoint stops having bad days.</p>
<p>That is a stronger win than a modest median improvement because production pain lives in the tail.</p>
<p>The trade offs you must own</p>
<p>Denormalisation works because it changes where the work happens. That means the cost does not disappear. It moves.</p>
<p>Writes may become heavier because you now update projections or summary fields.</p>
<p>You may accept eventual consistency if projections run asynchronously.</p>
<p>You add more moving parts, especially if you use outbox processing and background workers.</p>
<p>You need rebuild and reconciliation tooling because projections can drift if there is a bug.</p>
<p>None of those are reasons to avoid denormalisation. They are reasons to design it properly.</p>
<p>The important discipline is to be explicit. Name read models as read models. Keep projection logic out of the domain core where possible. Decide which values must be transactionally current and which values can lag slightly. Build replays or rebuild jobs so you can recover from bad logic. Make it obvious to every engineer which table tells the truth and which table exists for speed.</p>
<p>If you fail to do that, denormalisation becomes accidental duplication. That is where teams get burned.</p>
<h2>When not to denormalise</h2>
<p>Do not denormalise because a query feels ugly. Ugly code is not always expensive code.</p>
<p>Do not denormalise values you cannot clearly derive and refresh.</p>
<p>Do not duplicate fields with no owner and no repair story.</p>
<p>Do not turn projections into hidden sources of truth.</p>
<p>Do not assume eventual consistency is always harmless. A stale dashboard count is one thing. A stale available credit decision is another.</p>
<p>Do not denormalise everything. Most systems only need it in a few hot places. If you apply it everywhere, you increase complexity without improving the parts that matter.</p>
<p>The expert judgement is knowing where the hot paths really are and shaping only those.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/2f94efd7-d7b0-49e8-b7a5-ad64e0c48c07.png" alt="" style="display:block;margin:0 auto" />

<p>Denormalisation is one of the few performance techniques that changes the shape of the problem instead of merely tuning around it. Indexes, cache layers, and ORM tweaks all matter, but they mostly help you execute the same work more efficiently. Denormalisation asks a better question. Should the system be doing this work on every read at all.</p>
<p>In many serious C# systems, the honest answer is no.</p>
<p>If the application already knows a balance, a count, a risk band, a queue shape, a search row, or a compiled payload, and if that value is read far more often than it changes, storing it in the form the read path needs is not a compromise. It is good engineering.</p>
<p>The strongest systems keep their transactional core clean and truthful. Then they build denormalised shapes around that core for speed. They measure the gains. They own the trade offs. They keep projections rebuildable. They keep the source of truth clear. That is how you get fast systems without losing control of the design. If you want real performance gains from denormalisation, do not think of it as breaking the rules. Think of it as moving work to the cheapest place in the system. When you do that deliberately, your database stops reconstructing the obvious, your APIs stop carrying unnecessary weight, and your read path starts behaving like it was designed for production rather than for a whiteboard.</p>
]]></content:encoded></item><item><title><![CDATA[API Payload Compression in ASP.NET Core]]></title><description><![CDATA[People talk about payload compression as if it were a single checkbox in Program.cs. Turn on gzip, maybe add Brotli, and move on. That approach is no good for a production system that serves high-volu]]></description><link>https://fullstackcity.com/api-payload-compression-in-asp-net-core</link><guid isPermaLink="true">https://fullstackcity.com/api-payload-compression-in-asp-net-core</guid><category><![CDATA[Microsoft]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Sat, 28 Mar 2026 16:21:18 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/74c42aa2-c8c7-4805-b3aa-ab56f77af5a7.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>People talk about payload compression as if it were a single checkbox in <code>Program.cs</code>. Turn on gzip, maybe add Brotli, and move on. That approach is no good for a production system that serves high-volume JSON, handles uploads, runs behind a proxy, and needs predictable latency under load.</p>
<p>In real systems, compression is a transport concern with application-level consequences. It affects bandwidth, CPU, latency, caching behaviour, security posture, and even how you shape your contracts. ASP.NET Core gives you built-in middleware for response compression and request decompression, but the framework does not make the architectural decisions for you. You still need to decide what to compress, where to compress it, when to reject it, and how to avoid using compression as a bandage for bad API design. The current ASP.NET Core guidance is still clear on the fundamentals, use compression to reduce payload size, prefer server or proxy compression where available, and use the built-in middleware when Kestrel or HTTP.sys are serving the app directly because they do not provide built-in compression themselves.</p>
<p>The first mistake Developers make is thinking compression is mainly about speed. It is really about trade-offs. Compression reduces bytes on the wire, which often improves responsiveness, especially for JSON and other text-heavy payloads. At the same time, it costs CPU to compress and decompress data. That trade-off is usually favourable for medium and large JSON responses over public networks, but not always favourable for tiny payloads or already-compressed binary content. That is why serious API design starts with payload shape first and compression second. If your endpoint returns bloated documents with duplicated fields, unnecessary nesting, and data the caller never asked for, compression will help, but only after you already lost the bigger battle. Microsoft’s guidance frames compression as a way to reduce response size and improve responsiveness, not as a replacement for lean responses.</p>
<p>A useful mental model is to separate outbound compression from inbound decompression. Outbound compression is the default case. Your API produces JSON, problem details, text, CSV, or other compressible formats, and the client advertises supported encodings through Accept-Encoding. The response compression middleware examines the request and response, selects a provider such as Brotli or gzip, and writes the compressed payload if the response type is eligible. Inbound decompression is different. There, the client sends a compressed request body and marks it with Content-Encoding, and the request decompression middleware unwraps it before model binding or request body reading happens. ASP.NET Core supports both directions, but they solve different problems and they should not be enabled with the same level of enthusiasm. Response compression is broadly useful. Request decompression is useful only when clients are actually sending large compressed payloads, typically large JSON, text, or similar upload bodies.</p>
<p>In practice, the best default for a modern ASP.NET Core API is straightforward. Compress responses that are actually compressible. Prefer Brotli when the client supports it. Fall back to gzip for compatibility. Leave already-compressed formats alone. If you are running behind IIS, Apache, or Nginx, prefer server-based compression because Microsoft explicitly notes that server modules generally outperform the ASP.NET Core middleware. If you are serving directly from Kestrel or HTTP.sys, use the middleware because those servers do not currently offer built-in compression support.</p>
<p>The second mistake Developers make is compressing everything indiscriminately. Compression is not magic. It works best on text-heavy formats because they contain repeating structure. JSON is the classic win because property names, quotes, punctuation, and repeated values compress well. XML, HTML, CSS, JavaScript, CSV, plain text, and problem details are all strong candidates. JPEG, PNG, MP4, ZIP, and many other binary formats are not. Recompressing data that is already compressed often gives you negligible size reduction and unnecessary CPU overhead. This is exactly why the ASP.NET Core response compression middleware is configured around MIME types. You tell it what content types are eligible instead of asking it to blindly compress whatever leaves the process.</p>
<p>Here is a production-friendly baseline for a .NET API using minimal APIs. It enables response compression, explicitly adds Brotli and gzip, includes JSON-related MIME types, and sets both providers to Fastest because API latency usually matters more than squeezing out the very last percentage point of compression ratio.</p>
<pre><code class="language-csharp">
using Microsoft.AspNetCore.RequestDecompression;
using Microsoft.AspNetCore.ResponseCompression;
using System.IO.Compression;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddResponseCompression(options =&gt;
{
    options.EnableForHttps = true;

    options.Providers.Add&lt;BrotliCompressionProvider&gt;();
    options.Providers.Add&lt;GzipCompressionProvider&gt;();

    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
    {
        "application/json",
        "application/problem+json",
        "text/plain",
        "text/csv"
    });
});

builder.Services.Configure&lt;BrotliCompressionProviderOptions&gt;(options =&gt;
{
    options.Level = CompressionLevel.Fastest;
});

builder.Services.Configure&lt;GzipCompressionProviderOptions&gt;(options =&gt;
{
    options.Level = CompressionLevel.Fastest;
});

builder.Services.AddRequestDecompression();

var app = builder.Build();

app.UseRequestDecompression();
app.UseResponseCompression();

app.MapGet("/api/orders/{id:int}", (int id) =&gt;
{
    var response = new
    {
        Id = id,
        Customer = "ACME Insurance",
        Lines = Enumerable.Range(1, 250).Select(i =&gt; new
        {
            LineNumber = i,
            Sku = $"SKU-{i:0000}",
            Quantity = i % 5 + 1,
            Price = 49.99m + i
        })
    };

    return Results.Json(response);
});

app.Run();
</code></pre>
<p>This gives you the right starting point, but serious systems usually need more discipline than a baseline setup. One example is compression level. Many developers instinctively choose Optimal, assuming it must be better because the name sounds better. That is too simplistic. In APIs, especially low-latency APIs, Fastest is often the better operational choice because it cuts CPU cost and still captures most of the size reduction on JSON. Optimal can make sense for larger batch-style responses or download scenarios where throughput matters more than raw request latency. The right answer is not theoretical. Benchmark it with your own payloads and concurrency profile.</p>
<p>A useful way to think about the pipeline is this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/b5d88a82-1de0-4a8b-889f-35aee4effaaf.png" alt="" style="display:block;margin:0 auto" />

<p>Another place where Devlopers get sloppy is HTTPS compression. ASP.NET Core exposes EnableForHttps, and the documented default is false. Microsoft also warns that enabling compression for HTTPS responses containing remotely manipulable content may expose security problems. That warning exists because compression can become part of a side-channel when attacker-controlled input and secret-bearing content share the same compressed response. In normal internal or line-of-business APIs, many people still enable HTTPS compression because the benefits are real and the attack surface may be limited, but that decision should be deliberate. If you reflect attacker-supplied content into a response that also carries secrets, tokens, or sensitive dynamic values, do not just enable HTTPS compression and forget about it. Understand what is actually in those responses.</p>
<p>Request decompression deserves even more caution. The feature is real and useful, but it is not something to switch on simply because the middleware exists. The request decompression middleware automatically inspects Content-Encoding and decompresses supported request bodies, which saves you from writing custom request-body handling code. That part is good. The hard part is operational safety. Inbound compressed payloads shift CPU work onto your servers and can amplify resource consumption if abused. If you accept large compressed uploads, you should pair that with request size limits, timeout controls, careful endpoint scoping, and monitoring. The middleware also needs to run before anything reads the body, otherwise you are too late.</p>
<p>A targeted inbound example looks like this:</p>
<pre><code class="language-csharp">app.UseRequestDecompression();

app.MapPost("/api/import/products", async (HttpContext httpContext) =&gt;
{
    httpContext.Features.Get&lt;IHttpMaxRequestBodySizeFeature&gt;()?.DisableMaxRequestBodySize();

    using var reader = new StreamReader(httpContext.Request.Body);
    var json = await reader.ReadToEndAsync();

    return Results.Ok(new
    {
        Message = "Compressed request accepted",
        Characters = json.Length
    });
});

app.Run();
</code></pre>
<p>That sample shows the mechanics, but the operational point matters more than the syntax. You should not enable inbound decompression across every endpoint unless the endpoints really need it. A typical CRUD API that accepts small POST and PUT bodies gets little benefit from compressed requests. A bulk import endpoint that accepts a multi-megabyte JSON document might benefit a lot.</p>
<p>You should also think about where compression belongs in a broader deployment. If you are behind Nginx or IIS, server-side compression at the edge is often the better place for outbound response compression because it takes work off the app and can be tuned centrally. Microsoft’s guidance says exactly that, noting that the performance of the ASP.NET Core middleware probably will not match dedicated server modules. That does not make middleware wrong. It just means you should not ignore the reverse proxy when you have one. If you already terminate traffic behind a capable gateway, that is often the best place to handle compression consistently.</p>
<p>Caching behaviour is another area where compression changes system behaviour more than people expect. Once you serve multiple encoded versions of the same representation, the cache key must vary by encoding. That is why compressed responses are tied to Accept-Encoding, and why intermediaries need to treat the compressed and uncompressed versions as distinct representations. If you run API caching, CDN caching, or reverse-proxy caching, compression is no longer just a transport tweak. It becomes part of representation management. That matters even more if you also use ETags. In a well-behaved system, you need consistency in how representations are generated and validated, especially if compression is handled at the proxy layer instead of the app layer.</p>
<p>Another point is that compression interacts with streaming. If your endpoint sends buffered JSON in one shot, compression is easy. If you are sending data progressively, such as large streamed responses, NDJSON, SSE-style traffic, or anything latency-sensitive where flushing behavior matters, compression may introduce buffering or delivery characteristics that work against the protocol. In those cases, the question is not just "can I compress this?" but "does compression preserve the delivery behaviour I actually want?" For real-time or progressive-delivery endpoints, the right answer is often endpoint-specific rather than global.</p>
<p>The security and resilience side should not be ignored either. Kestrel exposes minimum request and response data rate limits, and the documented defaults are 240 bytes per second with a 5 second grace period. That matters because slow clients, large request bodies, and decompression work can combine into unpleasant failure modes if you do not have sane guardrails. Kestrel also supports request header timeouts and request body size limits, and those settings should be part of your overall posture when you accept uploads or large bodies, compressed or otherwise. Compression is not an isolated tuning knob. It sits inside your wider transport hardening model.</p>
<p>Here is a fuller example with Kestrel limits and explicit compression setup:</p>
<pre><code class="language-csharp">using Microsoft.AspNetCore.ResponseCompression;
using System.IO.Compression;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =&gt;
{
    options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
    options.Limits.MinRequestBodyDataRate = new(bytesPerSecond: 240, gracePeriod: TimeSpan.FromSeconds(5));
    options.Limits.MinResponseDataRate = new(bytesPerSecond: 240, gracePeriod: TimeSpan.FromSeconds(5));
    options.Limits.MaxRequestBodySize = 20 * 1024 * 1024; // 20 MB
});

builder.Services.AddResponseCompression(options =&gt;
{
    options.EnableForHttps = true;
    options.Providers.Add&lt;BrotliCompressionProvider&gt;();
    options.Providers.Add&lt;GzipCompressionProvider&gt;();

    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
    {
        "application/json",
        "application/problem+json"
    });
});

builder.Services.Configure&lt;BrotliCompressionProviderOptions&gt;(options =&gt;
{
    options.Level = CompressionLevel.Fastest;
});

builder.Services.Configure&lt;GzipCompressionProviderOptions&gt;(options =&gt;
{
    options.Level = CompressionLevel.Fastest;
});

var app = builder.Build();

app.UseResponseCompression();

app.MapGet("/api/report", () =&gt;
{
    var report = Enumerable.Range(1, 10_000).Select(i =&gt; new
    {
        Id = i,
        Name = $"Item {i}",
        Status = i % 3 == 0 ? "Pending" : "Complete",
        Timestamp = DateTime.UtcNow.AddMinutes(-i)
    });

    return Results.Json(report);
});

app.Run();
</code></pre>
<p>That is the kind of configuration that belongs in a serious service. It does not just say "turn compression on." It defines the transport assumptions that go with it.</p>
<p>There is also a design lesson here for internal APIs and service-to-service calls. Developers sometimes assume compression matters only for internet-facing traffic. That is not always true. In cloud environments, especially across regions, VNets, or heavily loaded east-west traffic paths, payload size still matters. Compressing large JSON documents between services can reduce network cost and improve throughput. The catch is that the CPU trade-off now happens on your own estate at scale. If a service is already CPU-bound, compression can make it worse. If the network is the bottleneck, compression can help a lot. Again, this is why you benchmark real workloads instead of arguing from instinct.</p>
<p>If you want a clean set of rules that hold up in practice, they are these. Shape payloads properly first. Compress text-heavy responses by default. Prefer Brotli with gzip fallback. Leave already-compressed binaries alone. Use request decompression only for endpoints that genuinely need it. Prefer edge or proxy compression when your hosting stack supports it well. Treat HTTPS compression as a conscious security decision, not a default checkbox. Add limits and timeouts when you accept large request bodies. Measure the CPU and latency profile under realistic traffic before you call the job done. Those rules are not glamorous, but they are what separate a neat code sample from a production-grade API.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/b4a6317d-794c-47cd-b7bf-7f04de3d4702.png" alt="" style="display:block;margin:0 auto" />

<p>The big point is simple. Payload compression in ASP.NET Core is not a trick. It is part of transport engineering. When you treat it that way, the implementation becomes clearer. You stop asking whether you should "turn on gzip" and start asking the questions that actually matter: where should compression happen, which representations benefit, what security caveats apply, what limits protect the server, and whether your payloads deserved to be that large in the first place.</p>
<p>That's what serious API payload compression looks like in modern .NET. It's not complicated, but it does require intent.</p>
<p><a href="https://learn.microsoft.com/en-us/aspnet/core/performance/response-compression?view=aspnetcore-10.0&amp;utm_source=chatgpt.com">https://learn.microsoft.com/en-us/aspnet/core/performance/response-compression?view=aspnetcore-10.0&amp;utm_source=chatgpt.com</a></p>
<p><a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/request-decompression?view=aspnetcore-10.0&amp;utm_source=chatgpt.com">https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/request-decompression?view=aspnetcore-10.0&amp;utm_source=chatgpt.com</a></p>
]]></content:encoded></item><item><title><![CDATA[Patterns for Resilience and Integration at Scale]]></title><description><![CDATA[Modern distributed systems rarely fail because the core business logic is too hard. They fail because the edges are messy. One service is slow, another is flaky, a third is legacy, a fourth is owned b]]></description><link>https://fullstackcity.com/patterns-for-resilience-and-integration-at-scale</link><guid isPermaLink="true">https://fullstackcity.com/patterns-for-resilience-and-integration-at-scale</guid><category><![CDATA[software development]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[design patterns]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[C#]]></category><category><![CDATA[serverless]]></category><category><![CDATA[distributed system]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Tue, 17 Mar 2026 18:19:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/0b5a8e18-c4a7-488f-bf3c-e8a28b2c9c58.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Modern distributed systems rarely fail because the core business logic is too hard. They fail because the edges are messy. One service is slow, another is flaky, a third is legacy, a fourth is owned by another team, and a fifth needs a human to click an approval link before anything can continue. The logic inside your own codebase might be clean and deterministic, but the moment a workflow starts crossing boundaries, certainty disappears. That is where resilience stops being a nice architectural word and starts becoming the difference between a system that degrades gracefully and one that creates operational chaos.</p>
<p>This is the part many Developers underestimate. They can design a clean domain model, expose a tidy API, and even get the happy path flowing nicely in development. Then integration begins. Payments have retry semantics you do not control. Fraud services throttle under burst load. ERP platforms respond eventually, but only after translating your request into formats that feel like they were invented in another decade. Humans approve things late, suppliers call back twice, webhooks arrive out of order, and support teams need answers while the workflow is still in flight. None of those problems are unusual. They are the normal operating environment of serious enterprise systems.</p>
<p>That reality creates what is best described as an integration tax. Every dependency adds latency, risk, state mismatch, and behavioural quirks. Every new handoff expands the number of ways a process can stall or become inconsistent. This tax cannot be avoided. If your system has to interact with payment providers, CRM tools, ERP platforms, shipping carriers, external risk engines, old databases, partner APIs, or human approvers, then complexity is already part of the deal. The real question is whether that complexity is handled intentionally or left to leak through the architecture.</p>
<p>The good news is that the same failure shapes show up again and again. Systems struggle with overload, duplicate work, half-completed transactions, tight coupling, invisible state, and awkward coexistence between new platforms and old ones. Once you see those patterns clearly, the architecture becomes much easier to reason about. Durable orchestration platforms such as Azure Durable Functions are especially useful here because they provide a strong set of building blocks for stateful workflows, retries, timers, external events, and long-running coordination. But the bigger lesson is not tied to one platform. The patterns in this article apply whether you are orchestrating with Durable Functions, Temporal, Step Functions, Camunda, MassTransit sagas, or even a carefully designed internal workflow engine.</p>
<p>This article takes those recurring resilience and integration problems and turns them into a practical operating model. We will look at circuit breakers, idempotency, compensation, event-driven handoffs, workflow status, hybrid architecture, and resilience-first design. The goal is not to repeat a chapter from a book. The goal is to turn those ideas into a standalone guide for engineers and architects building systems that need to survive the real world.</p>
<h2>Why Integration Gets Harder at Scale</h2>
<p>A single integration in a low-volume system is often manageable with little more than an HTTP client, a timeout, and a retry policy. That is why many systems look fine during the first release. The real trouble appears later, once transaction volume grows, external dependencies multiply, and the business starts relying on workflows that stretch across multiple bounded contexts.</p>
<p>At that point, latency stops being an isolated technical concern and starts shaping business outcomes. A fraud service that takes three seconds instead of two might not sound catastrophic, but if that call sits in the middle of a checkout flow, the extra second now becomes customer friction. Multiply that by retries, duplicate callbacks, rate limiting, and a few downstream dependencies, and what looked like a simple workflow becomes a slow-motion queueing problem. Enterprise systems rarely collapse in one dramatic moment. More often, they drown gradually in coordination overhead.</p>
<p>Another issue is failure diversity. Internal services often fail in relatively predictable ways because the same teams own the deployment model, monitoring stack, and operational practices. External systems are different. One dependency might fail fast with clear error codes. Another might hang without responding. Another might accept the request but finish it later. Another might partially succeed and provide no clean rollback. Legacy platforms are especially problematic because they often expose interfaces that were never designed for modern reliability expectations, yet still sit on the critical path of important business processes.</p>
<p>Human interaction adds another layer of uncertainty. Approvals, escalations, document review, manual intervention, and exception handling all introduce variable time windows that cannot be compressed by throwing more CPU at the problem. A workflow might be technically healthy but still paused for six hours waiting on someone in a different department. If the system does not model that state explicitly, operators end up guessing whether it is broken or simply waiting.</p>
<p>This is why mature integration architecture is less about making every dependency perfect and more about building a workflow that can absorb imperfect behaviour. You are not designing for a world where all systems are reliable. You are designing for a world where some systems are slow, some are inconsistent, some are overloaded, and some are still useful enough that the business cannot function without them.</p>
<h2>The Core Idea: Resilience Is a Workflow Concern</h2>
<p>Developers think about resilience at the level of individual service calls. They add retries to HTTP clients, configure exponential backoff, maybe wrap a few dependencies in a circuit breaker, and consider the job largely done. That helps, but it is not enough. In distributed systems, resilience is rarely just a call-level concern. It is a workflow concern.</p>
<p>A payment retry is not just a payment retry. It is part of a broader transaction that may also reserve inventory, create an order record, notify a customer, update a loyalty profile, and send data into finance systems. A supplier callback is not just an inbound event. It affects which timer should be cancelled, what status should be shown to support, and whether the workflow can proceed to the next stage. A human approval is not just a pause in processing. It changes how you monitor state, set expectations, and decide when intervention is needed.</p>
<p>This is where orchestration platforms earn their keep. They provide a durable memory of the workflow so that retries, waiting, state transitions, and external signals are modelled as first-class behaviour instead of being spread across controller methods, background jobs, and database flags. That durable state is not just useful for implementation. It also creates a place where resilience patterns can be applied consistently.</p>
<p>The rest of this article focuses on those patterns.</p>
<h2>Pattern 1: Circuit Breakers Prevent a Bad Dependency from Taking the Workflow Down with It</h2>
<p>One of the most common mistakes in integration-heavy systems is treating every failure as a reason to retry harder. That instinct is understandable. Retries solve a lot of transient faults, especially network blips, brief throttling, and short-lived platform issues. The problem is that retries are not free. When a downstream service is genuinely unhealthy, repeated retries can amplify the damage by increasing traffic against a struggling dependency and consuming resources in your own system while little useful work gets done.</p>
<p>That is why circuit breakers matter. A circuit breaker watches failure behaviour over time. If failures cross a threshold, the breaker opens and temporarily blocks new requests to the dependency. Rather than continuing to hammer a service that is already in trouble, the workflow fails fast or routes into a fallback path. After a cooldown period, the breaker can move into a half-open state and allow limited traffic to test whether the downstream system has recovered.</p>
<p>In a long-running workflow, this pattern is especially valuable because it prevents an unhealthy external service from dragging large volumes of orchestration instances into pointless retry loops. Imagine an order pipeline that calls an external fraud scoring API before taking payment. If that provider is returning 500 errors for ten minutes, the wrong response is to let every new order attempt the call repeatedly until the orchestration backlog expands and customer-facing latency spikes. A better response is to trip the breaker, fail new attempts quickly with a clear status, and alert operators that the fraud dependency is down.</p>
<p>A simplified view looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/e2a5f789-862d-4b26-ac02-10e6fe10a706.png" alt="" style="display:block;margin:0 auto" />

<p>In Durable Functions, one practical implementation is to use a Durable Entity to hold breaker state for a dependency. The entity can track consecutive failures, the time the breaker opened, and whether a call is allowed. Each orchestration or activity checks the entity before making the dependency call. That gives you a central, durable place to enforce the breaker rather than leaving each workflow instance to make its own isolated decision.</p>
<p>A stripped-back example might look like this in C#:</p>
<pre><code class="language-csharp">public record CircuitBreakerState(
    int ConsecutiveFailures,
    DateTime? OpenedAtUtc,
    bool IsOpen);

public class FraudServiceBreakerEntity
{
        public CircuitBreakerState State { get; set; } = new(0, null, false);
        public bool CanExecute(DateTime nowUtc)
        {
            if (!State.IsOpen)
                return true; 

            var cooldown = TimeSpan.FromMinutes(2);

            return State.OpenedAtUtc is { } openedAt &amp;&amp; nowUtc - openedAt &gt;= cooldown;

         }

        public void RecordSuccess()
        {
            State = new CircuitBreakerState(0, null, false);
        }

        public void RecordFailure(DateTime nowUtc)
        {
            var failures = State.ConsecutiveFailures + 1;
            if (failures &gt;= 5)
            {
                State = new CircuitBreakerState(failures, nowUtc, true);
                return;
            }

        State = new CircuitBreakerState(failures, State.OpenedAtUtc, false);

    }

}
</code></pre>
<p>The important part is not the code. It is the operational behaviour. Once the breaker opens, you stop turning a bad dependency into a system-wide slowdown. You make the failure explicit, measurable, and bounded.</p>
<p>That said, circuit breakers are not magic. They must be tuned carefully. Thresholds that are too aggressive can block useful traffic. Cooldowns that are too long can delay recovery. Breakers also need observability. If the team cannot see when they open, why they opened, and how often they are being exercised, they become another hidden state machine nobody trusts during an incident.</p>
<h2>Pattern 2: Idempotency Turns Retries from a Risk into a Safety Net</h2>
<p>If you work on distributed systems long enough, you stop asking whether duplicate requests will happen and start asking where they will happen first. Retries from clients, retries from orchestrators, webhook replays, queue redelivery, supplier callbacks, double clicks from users, and timeouts that hide already-completed work all create duplicate execution paths. If your system is not designed for that, it will eventually perform the same side effect twice.</p>
<p>That is where idempotency becomes non-negotiable. An idempotent operation can be executed multiple times with the same logical input and still produce the same final outcome. This does not mean every call is naturally idempotent. It means the system is built so that repeated attempts are recognised and handled safely.</p>
<p>Payment flows are the classic example. If a payment service receives the same charge request twice because the first response timed out, the customer must not be charged twice. The standard approach is to send an idempotency key, often the order ID or payment request ID, with the outbound call. The payment provider stores the first result for that key and returns the same outcome for later retries instead of executing a second charge.</p>
<p>But idempotency belongs far beyond payments. ERP submission endpoints should reject duplicate order registration for the same business reference. Customer reward updates should not apply points twice. Shipping requests should not create duplicate consignments. Inventory allocation should not reserve the same units repeatedly because a callback was delivered more than once.</p>
<p>Here is the shape of the idea:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/b03cac35-d1a2-4284-9b38-85afe34e63ed.png" alt="" style="display:block;margin:0 auto" />

<p>In your own services, the idempotency mechanism often comes down to a durable write model. You persist a unique business operation key before or alongside the side effect, and later requests with the same key return the stored outcome. Sometimes that means a dedicated idempotency table. Sometimes it means a natural domain guard such as a unique constraint on an external reference. Sometimes it means tracking processed event IDs in an entity or aggregate.</p>
<p>A simple service-side pattern in C# could look like this:</p>
<pre><code class="language-csharp">public sealed class ProcessedRequest
{
    public string RequestId { get; init; } = default!;
    public string ResultJson { get; init; } = default!;
    public DateTime ProcessedAtUtc { get; init; }
}

public async Task&lt;PaymentResult&gt; ChargeAsync(
    string requestId, decimal amount, cancellationToken stopToken)
{
    var existing = await db.ProcessedRequests
 .SingleOrDefaultAsync(x =&gt; x.RequestId == requestId, stopToken);

    if (existing is not null)
        return JsonSerializer.Deserialize&lt;PaymentResult&gt;(existing.ResultJson)!;

    var result = await gateway.ChargeAsync(amount, stopToken);

    db.ProcessedRequests.Add(new ProcessedRequest
    {
        RequestId = requestId,
        ResultJson = JsonSerializer.Serialize(result),
        ProcessedAtUtc = DateTime.UtcNow
    });

    await db.SaveChangesAsync(stopToken);
    return result;
}
</code></pre>
<p>The hard part is deciding the correct scope of the idempotency key. If it is too broad, distinct operations can accidentally collapse into one. If it is too narrow, duplicates slip through. Good idempotency design requires a clear understanding of the business operation, not just the transport request.</p>
<p>It is also worth being blunt about this: retries without idempotency are reckless. They create the appearance of resilience while quietly shifting the cost onto customers, finance teams, and support operations. Once you understand that, idempotency stops feeling like a technical detail and starts feeling like table stakes.</p>
<h2>Pattern 3: Compensation Is How You Survive Without Distributed Transactions</h2>
<p>Enterprise workflows almost always cross boundaries where a single atomic transaction is impossible. You might charge a card in one system, reserve inventory in another, create a shipment in a third, and register the order in an ERP platform that still thinks SOAP is modern. No transaction coordinator is going to make all of that commit or roll back as one neat unit. Even if it could, you probably would not want the coupling and latency that came with it.</p>
<p>So what happens when part of the workflow succeeds and a later step fails? That is where compensation comes in. Compensation is the deliberate reversal of already-completed actions so that the broader workflow returns to a consistent business state.</p>
<p>Suppose a checkout flow successfully charges the customer, then later fails to allocate stock. Without compensation, the system has taken money for an order it cannot fulfil. That is not a mere technical defect. It is a business failure. The workflow needs a compensating action, such as issuing a refund, releasing provisional customer benefits, and notifying operations if manual review is required.</p>
<p>The same applies in other domains. If a claims workflow opens a financial reserve and later discovers a validation failure, the reserve may need reversing. If an onboarding workflow provisions downstream access and then fails a compliance check, those accounts may need disabling. If a shipping request is accepted and the ERP later rejects the order, logistics and customer communication may both need corrective action.</p>
<p>A compensation flow often looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/de090a5f-df4b-4851-af82-77c70acb9e8e.png" alt="" style="display:block;margin:0 auto" />

<p>Compensation is frequently misunderstood as just calling an undo API. Sometimes that is possible, but often it is not. Real compensations can be asynchronous, partial, or manual. A refund might take time. A shipment might be cancellable only before handoff to the carrier. A legacy platform might support reversal only through an overnight batch. That means compensation needs its own design, status tracking, and operational visibility.</p>
<p>In orchestrated systems, a common pattern is to record which forward steps have completed, then execute compensations in reverse order if the workflow later fails. Durable Functions makes this practical because orchestration state can keep track of what has happened so far.</p>
<p>A simplified orchestration sketch might look like this:</p>
<pre><code class="language-csharp">[Function(nameof(ProcessOrderOrchestrator))]

public static async Task Run(
 [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var order = context.GetInput&lt;OrderRequest&gt;();
    var completedSteps = new List&lt;string&gt;();

    try
    {
        await context.CallActivityAsync(nameof(ReserveInventoryActivity), order);

        completedSteps.Add("inventory");
        await context.CallActivityAsync(nameof(ChargePaymentActivity), order);

        completedSteps.Add("payment");
        await context.CallActivityAsync(nameof(RegisterOrderInErpActivity), order);

        completedSteps.Add("erp");
    }
    catch (Exception)
    {
        if (completedSteps.Contains("payment"))
        {
            await context.CallActivityAsync(nameof(RefundPaymentActivity), order);
        }

        if (completedSteps.Contains("inventory"))
        {
            await context.CallActivityAsync(nameof(ReleaseInventoryActivity), order);
        }

        await context.CallActivityAsync(nameof(RaiseOpsAlertActivity), order.OrderId);

        throw;
     }
}
</code></pre>
<p>This is deliberately simple, but it makes the main point. Compensation is not an optional extra you add later. It is part of the workflow contract. If a business process can leave the world half-changed, then it also needs a defined path to recover from that condition.</p>
<p>There is another important truth here. Compensation is rarely perfect. You should not promise exact rollback semantics where the domain does not support them. Some workflows are better described as eventually corrected rather than fully undone. That is fine, provided the state transitions are explicit and visible. False certainty is more dangerous than honest eventual consistency.</p>
<h2>Pattern 4: Event-Driven Integration Reduces Coupling and Preserves Flow</h2>
<p>One of the easiest ways to make orchestration brittle is to let the central workflow call every downstream system directly. It feels simple at first because all the logic is in one place. The orchestrator confirms the order, then calls the ERP, then calls analytics, then calls CRM, then calls some downstream fulfilment component, then maybe calls a notification service. The problem is that each of those direct calls adds latency and dependency pressure to the core flow.</p>
<p>A better option in many cases is to separate the business milestone from the downstream reactions. Once the workflow reaches a meaningful state, such as order confirmed, claim submitted, policy approved, or onboarding completed, it can publish an event. Other systems subscribe independently and handle their own processing. That removes non-essential side effects from the critical path and reduces direct coupling between the orchestrator and every consumer.</p>
<p>Here is the contrast:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/ddb9c2ba-be57-460e-8b21-d166eb4c20e2.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/73830a2d-a58c-431c-90b4-836b8f2bddee.png" alt="" style="display:block;margin:0 auto" />

<p>This shift matters for several reasons. First, it shortens the synchronous path of the core workflow. Second, it allows new consumers to be added later without modifying the orchestrator. Third, it isolates failure. If analytics is down, that should not usually block order confirmation. If CRM processing is delayed, the business milestone may still be valid.</p>
<p>That does not mean direct calls disappear entirely. Some steps remain essential to the transaction outcome and must stay in the workflow. Payment authorisation is usually not optional. Inventory reservation is often not optional. But secondary reactions are usually better handled as event subscribers.</p>
<p>In Azure, this might mean an orchestration step publishes an <code>OrderConfirmed</code> event into Event Grid or a queue topic after core invariants are satisfied. Separate Functions then react to that event and perform ERP synchronisation, customer communications, and reporting updates. In other stacks, the same pattern could use Kafka, RabbitMQ, SNS/SQS, NATS, or any eventing platform with durable delivery.</p>
<p>A typical event contract should be boring and explicit. That is a good thing. It might include a business ID, event type, timestamp, correlation ID, schema version, and only the data consumers genuinely need. Resist the urge to publish an anemic dump of internal objects. Events are integration contracts, not convenient serialisation shortcuts.</p>
<p>A simple event model could look like this:</p>
<pre><code class="language-csharp">public sealed record OrderConfirmedEvent(
    string OrderId,
    string CustomerId,
    decimal Total,
    DateTime ConfirmedAtUtc,
    string CorrelationId,
    int SchemaVersion);
</code></pre>
<p>There is a trade-off, of course. Event-driven systems push you toward eventual consistency. Consumers may process at different times. Delivery may be at least once, not exactly once. That takes us right back to idempotency and observability. Event-driven integration works well when paired with those patterns, not when treated as a shortcut that somehow removes the need for them.</p>
<h2>Pattern 5: Custom Status and Observability Keep Workflows from Becoming Black Boxes</h2>
<p>Many operational incidents are not caused by the workflow being broken. They are caused by nobody being able to tell what the workflow is doing. A long-running integration process can be perfectly healthy while waiting on a supplier response, a human approval, or an overnight ERP batch. Without good status signals, support teams often interpret waiting as failure and failure as waiting. That confusion creates noise, escalations, and manual work that should never have existed.</p>
<p>The fix is simple in principle and often neglected in practice. Long-running workflows need explicit, queryable status. Not vague technical state, but business-meaningful status. A fraud check should not just be running. It should be <code>FraudCheckPending</code> or <code>FraudCheckFailed</code>. An ERP handoff should be <code>ErpSubmissionPending</code>, <code>ErpRegistered</code>, or <code>ErpRejected</code>. A supplier callback stage should be <code>AwaitingSupplierApproval</code>. A manual review should be <code>PendingHumanDecision</code>.</p>
<p>Durable Functions supports custom orchestration status, which is a powerful way to surface this information directly from the workflow runtime. But the same idea applies on any platform. You need a state model that answers the basic operational question: where is this process now, and why is it there?</p>
<p>A practical lifecycle might look like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/48055546-64cf-4c6b-91e7-ba6c317f1c9c.png" alt="" style="display:block;margin:0 auto" />

<p>In code, that might be as straightforward as setting custom status at each meaningful stage:</p>
<pre><code class="language-csharp">context.SetCustomStatus(new
    {
        orderId = order.OrderId,
        stage = "FraudCheckPending",
        updatedAtUtc = context.CurrentUtcDateTime
    });
</code></pre>
<p>That single line is more valuable than many teams realise. Once status is queryable, you can power dashboards, operator portals, support tooling, and incident triage without reverse engineering workflow behaviour from logs.</p>
<p>Observability also needs more than status labels. Correlation IDs must flow through the entire chain, from inbound request to orchestration instance to activity calls to outbound dependency calls and published events. Logs need consistent structured fields. Metrics should cover latency, retries, breaker state, queue depth, timeout counts, compensation frequency, and downstream failure rates. Tracing should allow engineers to follow a transaction through multiple services without playing archaeology across disconnected log stores.</p>
<p>Here is the ugly truth. If your workflow depends on several systems and you do not have proper correlation and state visibility, you do not have an operable architecture. You have a hope-based architecture.</p>
<h2>Pattern 6: Hybrid Integration Accepts Reality Instead of Demanding a Rewrite</h2>
<p>A lot of technical content on serverless and orchestration quietly assumes the organisation has the freedom to build a clean greenfield system. That is rarely how enterprise work actually looks. Most teams are not replacing everything. They are inserting modern capability into an environment where a mixture of old and new already exists.</p>
<p>That is why hybrid integration matters. Serverless does not have to replace the ERP. It can orchestrate around it. Durable workflows do not have to own every business rule. They can coordinate specialised services that already exist. Modern data stores can support fast projections and reporting while a different platform remains the canonical system of record. New cloud-native capabilities can coexist with legacy systems provided the architectural boundaries are clear.</p>
<p>A realistic enterprise shape often looks something like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/75e0a41e-3352-4be8-8197-0c3274204f5e.png" alt="" style="display:block;margin:0 auto" />

<p>This hybrid model is often the most pragmatic route to value. The orchestration layer becomes the coordinator of the business process. Compliance-sensitive payment logic can remain in a dedicated service. A legacy ERP can continue as the source of truth for certain financial or operational records. Cloud-native projections can power responsive read models and dashboards without forcing the organisation to migrate everything at once.</p>
<p>That also means architects need discipline around ownership. The orchestration engine should coordinate process state, not become the dumping ground for every piece of business logic in the company. The ERP should retain the responsibilities it is still good at, not be called for every trivial lookup. Projection stores should serve read performance and user experience, not quietly evolve into shadow systems with ambiguous truth boundaries.</p>
<p>The big win in hybrid architecture is incremental progress. You do not need a grand rewrite to improve resilience, observability, and flow control. You can wrap brittle integrations with better orchestration. You can isolate long-running handoffs. You can publish cleaner events. You can add compensations and workflow visibility around systems that were never built with those ideas in mind.</p>
<p>That is usually how real transformation succeeds, not through replacement fantasies but through carefully chosen seams.</p>
<h2>Pattern 7: Resilience by Design Means Assuming the System Will Be Incomplete, Slow, and Wrong Sometimes</h2>
<p>The strongest systems are not the ones that assume everything will go right. They are the ones that assume at least some parts will go wrong and still define how the workflow should behave. That mindset is what resilience by design really means.</p>
<p>It means assuming partial failure is normal. A dependency might succeed after a retry, fail permanently, or accept work and complete later. A callback might arrive twice. A timer might expire before a human responds. An event consumer might process late. An external system might hold the canonical answer even though your local projection says otherwise. These are not edge cases. They are part of the design space.</p>
<p>Resilience by design also means being honest about consistency. Many distributed workflows are eventually consistent, and pretending otherwise helps nobody. The real architectural task is to define where temporary inconsistency is acceptable, how it is reconciled, and what the user or operator sees while it exists. Good systems make the transition states explicit instead of hiding them behind vague processing messages.</p>
<p>It also means measuring the behaviour that matters. You should know which dependencies are slowest, which steps retry most often, which compensations are frequent, how long workflows remain in waiting states, and which manual interventions are recurring. Teams that do not measure this tend to rediscover the same operational pain every quarter and act surprised each time.</p>
<p>Finally, resilience by design means accepting that supportability is part of architecture. A workflow is not finished when it compiles and passes tests. It is finished when operators can understand it, support teams can explain it, incidents can be triaged quickly, and business stakeholders can trust that failures are bounded and recoverable.</p>
<h2>A Concrete End-to-End Example</h2>
<p>Let us pull these patterns together in a single scenario. Imagine a large B2B order workflow. An order enters the system through an API. The orchestration starts and immediately assigns a correlation ID that follows the transaction everywhere. The workflow sets its status to <code>Received</code>. It then checks whether the fraud provider breaker is open. If it is, the workflow fails fast with a visible dependency-unavailable status rather than quietly piling into retries.</p>
<p>If the breaker allows execution, the workflow sends a fraud request with a request ID that can be used for deduplication if the provider supports it. Once fraud is approved, payment is attempted with an idempotency key derived from the order ID. That ensures retries cannot double-charge the customer. After payment succeeds, the workflow publishes an <code>OrderConfirmed</code> event so downstream analytics and CRM updates can proceed independently instead of extending the critical path.</p>
<p>Next, the workflow submits the order to a legacy ERP. The ERP is slow and sometimes responds asynchronously, so the orchestration switches status to <code>ErpSubmissionPending</code> and waits for either an external callback or a timeout. If the callback arrives with success, the workflow completes. If the ERP rejects the order, the orchestration enters <code>CompensationInProgress</code>, triggers a refund, releases any provisional inventory state, raises an operational alert, and finally moves the order into a failed terminal state with a reason that support can actually understand.</p>
<p>That end-to-end shape looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/91a411c9-8198-48eb-b360-7880110071d5.png" alt="" style="display:block;margin:0 auto" />

<p>Nothing in that flow is exotic. That is exactly the point. Most resilient architectures are not built from obscure theory. They are built from boring patterns applied consistently and early enough that the system does not rot under growth.</p>
<h2>What Developers Usually Get Wrong</h2>
<p>The first common mistake is over-centralising the orchestration. Developers discover a workflow engine and start putting every rule, integration, and transformation into the orchestrator itself. That turns the orchestrator into a giant god-process that becomes hard to change and impossible to reason about. The workflow should coordinate. It should not absorb every responsibility.</p>
<p>The second mistake is believing retries are a resilience strategy on their own. They are not. Retries without idempotency, compensation, status visibility, and bounded dependency behaviour are just a way of failing repeatedly.</p>
<p>The third mistake is underestimating operational visibility. Teams often spend far more time designing the happy path than designing the support path. Then the first real incident happens and nobody can answer the obvious questions. Which stage is this order at. Did payment happen already. Has ERP seen it. Is this waiting for a callback or stuck in a retry loop. Those questions should not require an engineer to grep logs across five systems.</p>
<p>The fourth mistake is assuming greenfield purity is required before improvement is possible. It is not. Some of the best resilience gains come from putting orchestration, status modelling, idempotency, and compensations around existing systems rather than replacing them.</p>
<p>The fifth mistake is treating eventual consistency as a flaw to be hidden instead of a reality to be designed for. Users and operators can cope with transition states if those states are honest and understandable. What they cannot cope with is silent ambiguity.</p>
<h2>How to Apply These Patterns in Practice</h2>
<p>If you are building or modernising an integration-heavy workflow, start by identifying the true business milestones rather than the raw API calls. Ask where side effects happen, which ones must be synchronous, which ones can be event-driven, and which ones need compensation if a later step fails. That alone will usually reveal whether your current workflow is too tightly coupled.</p>
<p>Then look at duplicate execution risk. Anywhere you have retries, redelivery, callbacks, or human re-submission, you need a defined idempotency strategy. Be precise about the operation key and where the result is recorded. Vague assurances that the provider should handle duplicates are not enough. Next, inspect dependency behaviour. Which integrations deserve a circuit breaker. Which ones should fail fast. Which ones should shift into async wait mode with timers and external events. Which ones are important enough to stay on the critical path and which ones should react to events later.</p>
<p>After that, design your status model. Not your log messages, your status model. What are the meaningful states of the workflow from an operator and business perspective. How are those states exposed. Where do correlation IDs flow. What metrics would tell you this process is degrading before customers notice.</p>
<p>Finally, decide how the new workflow lives alongside existing systems. Be explicit about what remains the source of truth, what becomes a projection, and what the orchestrator does and does not own. Hybrid architecture becomes dangerous only when ownership is vague.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/5a099285-acef-4216-ab5d-6e5fe508412d.png" alt="" style="display:block;margin:0 auto" />

<p>Resilience at scale is not about making distributed systems behave like a single local transaction. That fantasy does not survive contact with real dependencies, real organisations, or real time. The job is to build workflows that remain understandable and recoverable when the surrounding systems behave imperfectly.</p>
<p>That is why these patterns are useful. Circuit breakers keep one bad dependency from turning into systemic slowdown. Idempotency makes retries safe. Compensation gives workflows a path back from partial success. Event-driven integration reduces unnecessary coupling. Custom status and observability make the process operable. Hybrid architecture accepts the systems you actually have. Resilience by design ties all of it together into a mindset rather than a patchwork of technical tricks.</p>
<p>Once you start thinking this way, integration architecture changes. You stop asking how to make the happy path pass one more test and start asking how the workflow behaves when the world around it is late, duplicated, unavailable, or inconsistent. That is the right question. It is also the one that separates systems that merely work from systems that keep working.</p>
]]></content:encoded></item><item><title><![CDATA[Communicating Between Modules in a Modular Monolith]]></title><description><![CDATA[Why Developers Get This Wrong
Most engineers learning modular monoliths fall into two traps. The first group collapses boundaries by sharing DbContexts, repositories, and entities across modules. The ]]></description><link>https://fullstackcity.com/communicating-between-modules-in-a-modular-monolith</link><guid isPermaLink="true">https://fullstackcity.com/communicating-between-modules-in-a-modular-monolith</guid><category><![CDATA[Modular Monolith]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[C#]]></category><category><![CDATA[Microsoft]]></category><category><![CDATA[.NET]]></category><dc:creator><![CDATA[Patrick Kearns]]></dc:creator><pubDate>Thu, 12 Mar 2026 21:19:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/d439dff6-4c67-4e67-aeec-e75a35230890.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Why Developers Get This Wrong</h2>
<p>Most engineers learning modular monoliths fall into two traps. The first group collapses boundaries by sharing DbContexts, repositories, and entities across modules. The second group overcompensates by enforcing microservice-style communication within the monolith, introducing HTTP calls or message buses between modules. Both patterns undermine the purpose of a modular monolith. The correct approach maintains module isolation while enabling fast in-process communication through contracts and events. Vertical Slice architecture alters how we structure these modules.</p>
<h2>The Architecture We Are Targeting</h2>
<p>We are building a modular monolith with:</p>
<ul>
<li><p>Vertical Slice architecture</p>
</li>
<li><p>CQRS</p>
</li>
<li><p>Minimal APIs</p>
</li>
<li><p>Separate module databases</p>
</li>
<li><p>No HTTP between modules</p>
</li>
<li><p>No message bus inside the process</p>
</li>
</ul>
<p>Example modules:</p>
<ul>
<li><p>Users</p>
</li>
<li><p>Claims</p>
</li>
</ul>
<p>Each module owns its data and exposes capabilities, not services.</p>
<p>Instead of layers like Application, Domain, Infrastructure, the module is organised by features.</p>
<pre><code class="language-plaintext">src
 ├ Users
 │   ├ Contracts
 │   │   └ UserQueries.cs
 │   │
 │   ├ CreateUser
 │   │   ├ Endpoint.cs
 │   │   ├ Command.cs
 │   │   ├ Handler.cs
 │   │   └ Validator.cs
 │   │
 │   ├ GetUser
 │   │   ├ Query.cs
 │   │   └ Handler.cs
 │   │
 │   └ DeleteUser
 │       ├ Command.cs
 │       └ Handler.cs
 │
 └ Claims
     ├ Contracts
     │   └ ClaimQueries.cs
     │
     ├ CreateClaim
     │   ├ Endpoint.cs
     │   ├ Command.cs
     │   └ Handler.cs
     │
     └ ApproveClaim
         ├ Command.cs
         └ Handler.cs
</code></pre>
<p>Each folder is a slice.</p>
<p>The slice contains everything needed for that use case.</p>
<h2>Dependency Direction Between Modules</h2>
<p>Modules reference contracts only, never implementation slices.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/9627390f-a9d4-4c86-8841-ac040fdf142e.png" alt="" style="display:block;margin:0 auto" />

<p>This rule is the cornerstone that keeps a modular monolith genuinely modular instead of slowly degrading into a tangled codebase. The Claims module is allowed to reference Users.Contracts because contracts represent the public capability surface of the Users module. They define what the Users module is willing to expose to the rest of the system in a controlled, stable way. Contracts typically contain simple request objects, response DTOs, and interfaces that describe operations such as queries or commands. Importantly, they contain no business logic, persistence concerns, or internal implementation details. By depending only on this contract layer, the Claims module interacts with the Users module in the same way an external client would, through clearly defined capabilities rather than through direct knowledge of how the module works internally. What the Claims module must never do is reference internal slices like Users.CreateUser, Users.GetUser, or Users.DeleteUser, because those folders represent implementation details of individual vertical slices inside the Users module. Allowing other modules to depend on those slices would immediately couple them to internal design decisions, meaning a simple refactor of a handler or slice could ripple across the entire system. The same rule applies even more strongly to infrastructure concerns such as UsersDbContext. Once another module starts using a different module’s DbContext, it is effectively reaching directly into that module’s database and bypassing its business rules, validations, and invariants. That instantly destroys the boundary between modules and turns the architecture into a shared persistence layer disguised as modules. By restricting dependencies strictly to Users.Contracts, the Users module remains free to reorganise its slices, change its database schema, refactor its handlers, or even split into a separate service later without breaking other modules. The Claims module only knows what operations are available, not how they are implemented, which is exactly the level of coupling a modular monolith is designed to enforce.The Three Communication Mechanisms</p>
<p>Vertical slice modular monoliths usually communicate through:</p>
<ol>
<li><p>Query contracts</p>
</li>
<li><p>Command contracts</p>
</li>
<li><p>Domain events</p>
</li>
</ol>
<p>These are not HTTP calls and not service bus messages. They are simple in-process calls.</p>
<h2>Pattern 1 - Query Contracts</h2>
<p>Suppose the CreateClaim slice needs to verify that the user associated with the claim actually exists and is allowed to submit a claim. At first glance it may seem natural for the Claims module to simply query the Users table directly, especially since everything runs inside the same application and the Users database is technically accessible. However, doing so would immediately violate the boundary between modules because the Claims module would now be coupled to the Users module’s persistence model and database schema. Any change to the Users table structure, indexes, or entity model could silently break the Claims module, and worse, the Claims module would be bypassing any business rules or invariants that the Users module is responsible for enforcing. In a modular monolith, each module owns its data and must be the only component allowed to access that data directly. Instead of reading the Users database, the Claims module should request the information it needs through a query contract exposed by the Users module. This contract defines a simple, explicit capability such as "retrieve a summary of a user by ID." The Claims module then calls that query through an interface defined in Users.Contracts, allowing the Users module to remain the sole authority over how user data is stored, retrieved, and validated. The Claims module gets exactly the information it needs to perform its operation, while the internal implementation of the Users module remains completely hidden behind the contract boundary.</p>
<h3>Users.Contracts</h3>
<pre><code class="language-csharp">public record GetUserSummaryQuery(Guid UserId);

public record UserSummaryDto(
    Guid Id,
    string Email,
    bool IsActive);

public interface IUserQueries
{
    Task&lt;UserSummaryDto?&gt; GetUserSummary(
        GetUserSummaryQuery query,
        CancellationToken stopToken);
}
</code></pre>
<p>The contract lives inside Users.Contracts.</p>
<p>No EF. No implementation.</p>
<h2>Implementing the Query Slice</h2>
<p>Inside the Users module.</p>
<pre><code class="language-plaintext">Users/GetUserSummary
</code></pre>
<pre><code class="language-csharp">internal sealed class Handler : IUserQueries
{
    private readonly UsersDbContext db;

    public Handler(UsersDbContext db)
    {
        this.db = db;
    }

    public async Task&lt;UserSummaryDto?&gt; GetUserSummary(
        GetUserSummaryQuery query,
        CancellationToken stopToken)
    {
        return await db.Users
            .Where(x =&gt; x.Id == query.UserId)
            .Select(x =&gt; new UserSummaryDto(
                x.Id,
                x.Email,
                x.IsActive))
            .FirstOrDefaultAsync(stopToken);
    }
}
</code></pre>
<p>Register the slice handler.</p>
<pre><code class="language-csharp">services.AddScoped&lt;IUserQueries, Handler&gt;();
</code></pre>
<hr />
<h2>Using the Query in a Claims Slice</h2>
<p>Inside the CreateClaim slice.</p>
<pre><code class="language-csharp">public sealed class Handler
{
    private readonly IUserQueries users;
    private readonly ClaimsDbContext db;

    public Handler(
        IUserQueries users,
        ClaimsDbContext db)
    {
        this.users = users;
        this.db = db;
    }

    public async Task&lt;Guid&gt; Handle(
        Command cmd,
        CancellationToken stopToken)
    {
        var user = await users.GetUserSummary(
            new GetUserSummaryQuery(cmd.UserId),
            stopToken);

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

        var claim = Claim.Create(cmd.UserId);

        db.Claims.Add(claim);
        await db.SaveChangesAsync(stopToken);

        return claim.Id;
    }
}
</code></pre>
<p>The call remains fully in-process.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/6959c079-7736-4711-81be-3afa92b251a9.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Pattern 2 - Command Contracts</h2>
<p>Sometimes a module does not just need to read information from another module, it needs that module to actually perform some work on its behalf. A good example is when a claim is approved in the Claims module and the user should be notified that their claim has been accepted. It might be tempting for the Claims module to send an email directly or call some notification service itself, but that would be a mistake because notification behaviour belongs to the Users module’s responsibility. The Claims module should not need to know whether notifications are sent via email, SMS, push notification, or some future system that has not even been introduced yet. If Claims implements that logic, it becomes tightly coupled to infrastructure details that are outside its domain. Instead, the Claims module should simply express the intent of the action by issuing a command to the Users module through a contract. That command represents a capability such as "notify this user with this message." The Users module then decides how that notification is handled internally. By structuring the interaction this way, the Claims module remains focused purely on claims-related business logic while the Users module retains full ownership of notification behaviour and the infrastructure required to deliver it. This keeps responsibilities clearly separated and prevents implementation details from leaking across module boundaries.</p>
<h3>Users.Contracts</h3>
<pre><code class="language-csharp">public record NotifyUserCommand(
    Guid UserId,
    string Message);

public interface IUserCommands
{
    Task NotifyUser(
        NotifyUserCommand command,
        CancellationToken stopToken);
}
</code></pre>
<hr />
<h2>Users Slice Implementation</h2>
<pre><code class="language-plaintext">Users/NotifyUser
</code></pre>
<pre><code class="language-csharp">internal sealed class Handler : IUserCommands
{
    public Task NotifyUser(
        NotifyUserCommand command,
        CancellationToken stopToken)
    {
        // send email, SMS etc
        return Task.CompletedTask;
    }
}
</code></pre>
<hr />
<h2>Claims Slice Triggering the Command</h2>
<pre><code class="language-plaintext">Claims/ApproveClaim
</code></pre>
<pre><code class="language-csharp">public sealed class Handler
{
    private readonly IUserCommands users;

    public Handler(IUserCommands users)
    {
        this.users = users;
    }

    public async Task Handle(
        Command cmd,
        CancellationToken stopToken)
    {
        await users.NotifyUser(
            new NotifyUserCommand(
                cmd.UserId,
                "Claim approved"),
            stopToken);
    }
}
</code></pre>
<p>Again, no HTTP, no message broker.</p>
<hr />
<h2>Pattern 3 - Domain Events</h2>
<p>Commands are appropriate when one module explicitly knows that another module must perform a specific action. In those cases the calling module intentionally invokes a capability exposed by the other module through a contract. Events serve a different purpose. Events are used when a module should not know which other modules might care about something that has happened. Instead of directing another module to do something, the module simply announces that a significant domain event occurred. A good example is user deletion. When the Users module deletes a user, it should not contain logic that checks whether the Claims module exists or whether it needs to clean up claims data. That would tightly couple the Users module to the rest of the system and force it to understand responsibilities that belong to other domains. Instead, the Users module publishes a UserDeleted event indicating that the user has been removed. Other modules that care about that event can react independently. The Claims module might close open claims for that user, an auditing module might archive historical data, and a reporting module might update statistics. None of those reactions are the Users module’s responsibility. By publishing an event rather than issuing direct commands, the Users module remains completely unaware of which modules subscribe to that event, preserving loose coupling and allowing new behavior to be added later without modifying the Users module itself.</p>
<pre><code class="language-csharp">public record UserDeletedEvent(Guid UserId);
</code></pre>
<hr />
<h2>Event Flow</h2>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/8cc028e9-33e1-4ee4-9b69-eaa963d1ebe0.png" alt="" style="display:block;margin:0 auto" />

<p>Users publishes:</p>
<pre><code class="language-csharp">await dispatcher.Publish(
    new UserDeletedEvent(userId),
    stopToken);
</code></pre>
<p>Claims reacts:</p>
<pre><code class="language-csharp">public sealed class Handler
{
    private readonly ClaimsDbContext db;

    public async Task Handle(
        UserDeletedEvent evt,
        CancellationToken stopToken)
    {
        var claims = await db.Claims
            .Where(x =&gt; x.UserId == evt.UserId)
            .ToListAsync(stopToken);

        foreach (var claim in claims)
        {
            claim.MarkUserDeleted();
        }

        await db.SaveChangesAsync(stopToken);
    }
}
</code></pre>
<p>Users never references Claims.</p>
<h2>In-Process Event Dispatcher</h2>
<p>Because everything runs in one process, the dispatcher is trivial.</p>
<pre><code class="language-csharp">public class EventDispatcher
{
    private readonly IServiceProvider services;

    public EventDispatcher(IServiceProvider services)
    {
        this.services = services;
    }

    public async Task Publish&lt;T&gt;(
        T domainEvent,
        CancellationToken stopToken)
    {
        var handlers =
            services.GetServices&lt;IEventHandler&lt;T&gt;&gt;();

        foreach (var handler in handlers)
        {
            await handler.Handle(domainEvent, stopToken);
        }
    }
}
</code></pre>
<hr />
<h2>Minimal API Integration</h2>
<p>Endpoints live inside the slice.</p>
<p>Example:</p>
<pre><code class="language-plaintext">Claims/CreateClaim/Endpoint.cs
</code></pre>
<pre><code class="language-csharp">app.MapPost("/claims",
    async (
        Command cmd,
        Handler handler,
        CancellationToken stopToken) =&gt;
{
    var id = await handler.Handle(cmd, stopToken);
    return Results.Ok(id);
});
</code></pre>
<p>The endpoint talks only to its slice handler.</p>
<h2>Why This Works</h2>
<p>This architecture preserves:</p>
<ul>
<li><p>strict module boundaries</p>
</li>
<li><p>independent databases</p>
</li>
<li><p>vertical slice isolation</p>
</li>
<li><p>extremely fast in-process calls</p>
</li>
</ul>
<p>The system remains loosely coupled because modules depend only on contracts.</p>
<p>Yet communication remains extremely simple.</p>
<h2>The Performance Advantage</h2>
<p>In-process contract calls are dramatically faster than external communication.</p>
<table>
<thead>
<tr>
<th>Communication</th>
<th>Typical latency</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP</td>
<td>3–15 ms</td>
</tr>
<tr>
<td>Message bus</td>
<td>10–100 ms</td>
</tr>
<tr>
<td>In-process contract</td>
<td>&lt;0.1 ms</td>
</tr>
</tbody></table>
<p>For high-throughput systems, that difference matters.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/82637f69-6083-49c0-a46d-bbef48182deb.png" alt="" style="display:block;margin:0 auto" />

<p>A modular monolith using Vertical Slice + CQRS + Minimal APIs should not resemble either a layered monolith or a microservice system.</p>
<p>Slices contain the behaviour. Modules own the data. Contracts define the boundaries.</p>
<p>Queries read across modules. Commands trigger behaviour. Domain events propagate changes.</p>
<p>The result is an architecture that is simple, fast, and strongly modular without introducing the complexity of distributed systems.</p>
]]></content:encoded></item></channel></rss>