<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:a10="http://www.w3.org/2005/Atom" version="2.0">
  <channel xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <title>ABP.IO Stories</title>
    <link>https://abp.io/community/articles</link>
    <description>A hub for ABP Framework, .NET, and software development. Access articles, tutorials, news, and contribute to the ABP community.</description>
    <lastBuildDate>Tue, 10 Nov 2026 13:12:26 Z</lastBuildDate>
    <generator>Community - ABP.IO</generator>
    <image>
      <url>https://abp.io/assets/favicon.ico/favicon-32x32.png</url>
      <title>ABP.IO Stories</title>
      <link>https://abp.io/community/articles</link>
    </image>
    <a10:link rel="self" type="application/rss+xml" title="self" href="https://abp.io/community/rss?member=okankoca" />
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/benchmarking-.net-10-vs-.net-11-with-abp-framework-load-testing-and-performance-results-t113nefe</guid>
      <link>https://abp.io/community/posts/benchmarking-.net-10-vs-.net-11-with-abp-framework-load-testing-and-performance-results-t113nefe</link>
      <a10:author>
        <a10:name>okankoca</a10:name>
        <a10:uri>https://abp.io/community/members/okankoca</a10:uri>
      </a10:author>
      <category>abp-framework</category>
      <category>dotnet</category>
      <category>abp</category>
      <title>Benchmarking .NET 10 vs .NET 11 with ABP Framework: Load Testing and Performance Results</title>
      <description>How does upgrading from ABP 10 (.NET 10) to ABP 11 (.NET 11) impact real-world performance? Across 27 repeated k6 load-test runs, the upgraded stack delivered 9.4% lower HTTP p95 latency, 4.4% lower CPU usage, and a 44 MB smaller managed GC heap.</description>
      <pubDate>Mon, 09 Nov 2026 11:40:05 Z</pubDate>
      <a10:updated>2026-11-10T12:47:13Z</a10:updated>
      <content:encoded><![CDATA[<h1>Benchmarking .NET 10 vs .NET 11 with ABP Framework: Load Testing and Performance Results</h1>
<p>How does an ABP application perform after upgrading from <strong>ABP 10 on .NET 10</strong> to <strong>ABP 11 on .NET 11</strong>?</p>
<p>To investigate, I load-tested two <strong>template-based ABP Framework MVC applications</strong>: <strong>ABP 10.7.1 on .NET 10.0.13</strong> (stable baseline) and <strong>ABP 11.0.0-rc.1 on .NET 11.0.0-rc.3</strong> (preview upgrade). Both exercised the same authentication, identity, role, permission, tenant, settings, and localization operations against PostgreSQL. I also ran the unchanged ABP 10 application on the .NET 11 runtime as a <strong>supporting control</strong>, not as a third framework version under comparison.</p>
<p>The <strong>main comparison is ABP 10 + .NET 10 versus ABP 11 + .NET 11</strong>, measured by <strong>response time, throughput, errors, CPU, and memory</strong> under the same workload. The additional runtime-control run helps interpret the observed difference, but does not change the two applications being compared.</p>
<h2>Results at a glance</h2>
<p>Upgrading from <strong>ABP 10 on .NET 10</strong> to <strong>ABP 11 on .NET 11</strong> lowered response times and resource use at the same load. The numbers below come from <strong>27 measured runs</strong> (three configurations, three load levels, three repetitions each) and show the highest routinely tested load: <strong>6 it/s</strong>, or six complete scenario iterations per second, which is about <strong>479 HTTP requests per second</strong>.</p>
<ul>
<li><strong>9.4% lower HTTP p95 latency:</strong> <strong>38.3 ms → 34.7 ms</strong>.</li>
<li><strong>5 percentage points lower normalized web-process CPU:</strong> <strong>55% → 50%</strong>.</li>
<li><strong>44 MB lower reported managed GC heap size:</strong> <strong>355 MB → 311 MB</strong>.</li>
<li><strong>21 MB lower process working set:</strong> <strong>759 MB → 738 MB</strong>.</li>
<li><strong>The same throughput:</strong> <strong>478.1 → 478.8 HTTP requests per second</strong>.</li>
</ul>
<p>These results come from one controlled environment and workload, using <strong>.NET 11.0.0-rc.3 and ABP 11.0.0-rc.1</strong>. They are observations, not guaranteed improvements for all applications. In particular, <strong>a lower p95 at a fixed offered load does not prove a higher maximum throughput</strong>.</p>
<h2>1. Application setup and comparison design</h2>
<p>The applications were created from the same ABP MVC/Razor Pages template with <strong>Entity Framework Core, PostgreSQL, and LeptonX Lite</strong>. The upgraded project was based on a copy of the stable application to limit template drift. The initial database contained the same schema and seeded application data; the test harness added 1,000 dedicated user accounts.</p>
<p>| Configuration | Application and target framework | Selected .NET runtime | Purpose |
|---|---|---|---|
| <code>abp10</code> | ABP <strong>10.7.1</strong>, <code>net10.0</code> | <strong>10.0.13</strong> | Stable baseline |
| <code>abp10-rt11</code> | <strong>Same published ABP 10 binary</strong>, <code>net10.0</code> | <strong>11.0.0-rc.3</strong> | Runtime-control configuration |
| <code>abp11</code> | ABP <strong>11.0.0-rc.1</strong>, <code>net11.0</code> | <strong>11.0.0-rc.3</strong> | Upgraded preview stack |</p>
<p>Throughout the charts and tables, <code>abp10</code> is the <strong>ABP 10 + .NET 10 baseline</strong> and <code>abp11</code> is the <strong>ABP 11 + .NET 11 upgrade</strong>. The additional <code>abp10-rt11</code> series is a <strong>supporting runtime-control measurement</strong>.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-11-09-benchmarking-dotnet10-vs-dotnet11/assets/01-three-target-design.png" alt="Three-target benchmark design" /></p>
<p>The middle configuration uses the exact same compiled .NET 10 application with a different runtime. The <strong>primary comparison</strong> is between <code>abp10</code> and <code>abp11</code>: the stable ABP 10/.NET 10 stack versus the preview ABP 11/.NET 11 stack. As a secondary check, <code>abp10-rt11</code> shows what happens when the <strong>same ABP 10 application binary</strong> runs on the newer runtime. Moving from <code>abp10-rt11</code> to <code>abp11</code> upgrades not only ABP itself, but also the target framework (<code>net11.0</code>), EF Core (<strong>10.0.11</strong> to <strong>11.0.0-rc.3</strong>), the Npgsql EF Core provider, and the theme packages.</p>
<h2>2. The application workflow under test</h2>
<p>I used <strong>Grafana k6</strong> to replay a recorded HTTP workflow through the built-in pages and features of a template-based ABP application. <strong>k6 Studio</strong> captured the initial browser traffic; the scenario was then adapted to manage dynamic authentication tokens, cookies, and entity identifiers reliably under load. The tool choice matters mainly because the same workflow needed to run consistently against every configuration.</p>
<h3>Making the recorded scenario replayable</h3>
<p>Simply replaying the recorded requests was not enough: replaying recorded tokens caused anti-forgery failures, while redirects could disguise a failed login as a successful <code>200 OK</code> response. Concurrency introduced another problem when multiple users attempted to create entities with the same recorded names.</p>
<p>The final scenario addressed these issues by:</p>
<ul>
<li>Extracting fresh hidden <code>__RequestVerificationToken</code> values for form submissions.</li>
<li>Reading the <code>XSRF-TOKEN</code> cookie and using it in ABP AJAX requests.</li>
<li>Correlating newly created user, role, and tenant IDs and their <code>ConcurrencyStamp</code> values.</li>
<li>Assigning each virtual user its own account and giving newly created entities unique identifiers.</li>
<li>Checking response status <strong>and</strong>, where applicable, returned data or redirect targets.</li>
</ul>
<p>The journey covers nine functional groups:</p>
<p>| Functional group | Representative operations |
|---|---|
| Login | Authenticate and establish a cookie-based session |
| Home | Render the home page and request ABP dynamic scripts |
| Users | List, filter, create, edit, assign permissions, delete |
| Roles | Create/edit roles, manage permissions, delete |
| Tenants | Create/edit tenants, manage features, delete |
| Settings | Load settings views and update email settings |
| Profile | Read and update profile details |
| Language | Switch between English and Turkish |
| Logout | Terminate the session |</p>
<p>The original recorded journey contained approximately <strong>92 HTTP requests</strong>, but <strong>not every measured iteration repeats all 92</strong>. Virtual users retain their sessions and normally log out and back in every ten iterations; measured throughput therefore averages roughly <strong>80 requests per iteration</strong>. The script also fetches three ABP dynamic script endpoints per page in parallel, while omitting static assets.</p>
<p>This is a <strong>stateful HTTP workflow</strong>, not a full browser-rendering or frontend paint-time benchmark. It exercises the template's principal built-in features rather than custom enterprise business logic.</p>
<h2>3. Making the measurements comparable</h2>
<p>The test started <strong>2, 4, or 6 scenario iterations per second (<code>it/s</code>)</strong>. In k6, this is called a <strong>constant-arrival-rate</strong> workload: the tool aims to start new iterations at a fixed pace, rather than maintaining a fixed number of virtual users. Each iteration contains multiple HTTP requests, so <strong>6 it/s is not 6 HTTP requests per second</strong>. The number of concurrently active virtual users can vary with server response time. Actual completed iterations, dropped iterations, and failures must therefore be checked.</p>
<p>Each measured run used the same procedure:</p>
<ul>
<li><strong>120 seconds of warm-up</strong>, a <strong>10-second grace interval</strong>, and <strong>300 seconds of measurement</strong>.</li>
<li><strong>Three repetitions</strong> per configuration and load level, with alternating target order.</li>
<li>Release-published apps in <strong>Production</strong> mode with reduced logging noise.</li>
<li>A clean PostgreSQL database recreated from a seeded template before each run, after preparing query-planner statistics with <code>VACUUM ANALYZE</code>.</li>
<li>Web-process affinity to logical CPUs <strong>0–7</strong> (four P-cores / eight hardware threads), and k6 affinity to CPUs <strong>16–23</strong> (E-cores).</li>
<li><code>dotnet-counters</code> and Docker statistics for server-side resource measurements.</li>
</ul>
<p>The workstation had an <strong>Intel Core i7-14650HX</strong>, <strong>31.7 GB RAM</strong>, Windows 11, and <strong>PostgreSQL 18.6</strong> running under Docker Desktop/WSL2. The database was not independently pinned to a dedicated core set; affinity reduced contention but did not make the experiment equivalent to isolated servers.</p>
<p>For the reported results, each value is the <strong>median of three separately measured runs</strong> for that application and load level. The full comparison comprises 27 measured runs. Medians help limit the influence of individual noisy measurements, but they do <strong>not</strong> establish statistical confidence intervals.</p>
<h2>4. Benchmark results</h2>
<h3>4.1 Response time and throughput</h3>
<p>The table summarizes the repeated measurements at three load levels. <strong>Arrival rate (<code>it/s</code>)</strong> is the configured rate of complete scenario iterations per second, while <strong>HTTP req/s</strong> is the traffic actually handled by the server. Every HTTP request in this measurement set was reported as successful by k6’s HTTP failure metric; application-level checks are a separate consideration.</p>
<p>| Arrival rate | Configuration | HTTP req/s | Avg (ms) | p95 (ms) | p99 (ms) | HTTP errors |
|---|---|---:|---:|---:|---:|---:|
| <strong>2 it/s</strong> | <code>abp10</code> | 160.8 | 10.8 | 34.0 | 78.7 | 0.00% |
| | <code>abp10-rt11</code> | 160.5 | 10.6 | 33.0 | 77.1 | 0.00% |
| | <code>abp11</code> | 160.5 | 10.7 | 33.7 | 80.0 | 0.00% |
| <strong>4 it/s</strong> | <code>abp10</code> | 321.1 | 10.0 | 30.2 | 77.8 | 0.00% |
| | <code>abp10-rt11</code> | 321.4 | 9.4 | 27.8 | 77.0 | 0.00% |
| | <code>abp11</code> | 321.1 | 9.6 | 28.8 | 77.2 | 0.00% |
| <strong>6 it/s</strong> | <code>abp10</code> | 478.1 | 12.8 | 38.3 | 102.1 | 0.00% |
| | <code>abp10-rt11</code> | 479.1 | 11.7 | 34.5 | 97.0 | 0.00% |
| | <code>abp11</code> | 478.8 | 11.6 | <strong>34.7</strong> | <strong>96.7</strong> | 0.00% |</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-11-09-benchmarking-dotnet10-vs-dotnet11/assets/02-http-p95-by-load.png" alt="HTTP p95 latency at different offered-load levels" /></p>
<p>At light load, the two ABP versions performed similarly. As the load increased, <strong>ABP 11 on .NET 11 generally showed lower p95 response times</strong> than ABP 10 on .NET 10. At the highest repeated load, the upgraded application had <strong>9.4% lower p95 latency</strong> while handling essentially the same number of HTTP requests per second.</p>
<h3>4.2 Performance and resource usage</h3>
<p>These measurements were taken at the <strong>highest repeated load</strong> (<code>6 it/s</code>, six scenario iterations started per second):</p>
<p>| Metric | <code>abp10</code> | <code>abp10-rt11</code> | <code>abp11</code> |
|---|---:|---:|---:|
| Web-process CPU | 55% | 50% | 50% |
| Process working set | 759 MB | 739 MB | 738 MB |
| Reported managed GC heap | 355 MB | 321 MB | 311 MB |
| HTTP p95 | 38.3 ms | 34.5 ms | 34.7 ms |</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-11-09-benchmarking-dotnet10-vs-dotnet11/assets/03-performance-resource-usage.png" alt="Comparison of response time, CPU, and memory usage" /></p>
<p>The CPU percentages are <strong>normalized to the web process's eight available logical processors</strong>, not the entire machine. Thus <strong>55% to 50%</strong> is a drop of <strong>5 percentage points</strong>. Similarly, the GC number is a <strong>sampled managed-heap-size counter</strong>: a 44 MB difference in that counter is not the same as 44 MB fewer allocations, and it is not the total process memory footprint.</p>
<h3>4.3 Performance across application features</h3>
<p>Looking at the application's individual features, <strong>ABP 11 on .NET 11 showed lower p95 response times in eight of the nine groups</strong>, including user, role, and tenant management; only the settings group stayed flat (16.0 ms to 16.2 ms). Tenant management, user operations, and login had the highest request latencies overall.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-11-09-benchmarking-dotnet10-vs-dotnet11/assets/04-application-feature-performance.png" alt="HTTP p95 latency by functional group" /></p>
<p>The chart compares <strong>HTTP-request p95 values within each feature group</strong>, rather than the time needed to complete an entire multi-request workflow.</p>
<h3>4.4 Higher-load observations</h3>
<p>In a separate, repeated higher-load test at <strong>7 it/s</strong> (seven scenario iterations per second, approximately <strong>557 HTTP requests per second</strong>), p95 latency remained close: <strong>39.5 ms</strong> for ABP 10 + .NET 10, <strong>40.2 ms</strong> for ABP 11 + .NET 11, and <strong>40.3 ms</strong> for the supporting ABP 10 + .NET 11 runtime control. At <strong>8 it/s</strong>, an exploratory test showed sharply higher latency and dropped iterations; because that load level was not tested repeatedly, it was <strong>not used to rank maximum throughput</strong>.</p>
<h2>5. What the upgrade to ABP 11 gives</h2>
<p>Upgrading from <strong>ABP 10.7.1 on .NET 10</strong> to <strong>ABP 11.0.0-rc.1 on .NET 11</strong> gave <strong>lower p95 response times at the same throughput</strong>. At the highest repeated load, p95 fell from <strong>38.3 ms</strong> to <strong>34.7 ms</strong>, and CPU and memory use were lower as well. At light load (2 it/s) the two stacks performed similarly.</p>
<p>The runtime-control test shows where the gain comes from: the <strong>unchanged ABP 10 binary on .NET 11</strong> reached a similar <strong>34.5 ms p95</strong>. The gain therefore appears to come with the move to .NET 11, and the ABP 11 preview packages <strong>did not add a substantial performance penalty in this workload</strong>.</p>
<h2>6. Scope and limitations</h2>
<p>The results apply to a <strong>template-based ABP MVC application</strong>, not to all production systems. The test used preview versions of .NET 11 and ABP 11, ran on a single workstation, and measured HTTP requests rather than browser rendering. Each condition was repeated three times, and the reported values are medians, not cross-environment guarantees. The preview EF Core PostgreSQL provider also showed a package-compatibility warning in this setup.</p>
<p>The repository includes benchmark summary tables, while the PowerShell runner generates per-run k6 summaries and telemetry files locally when the tests are executed.</p>
<h2>7. Reproducing the experiment</h2>
<p>All benchmark application source code, k6 load-testing scenarios, PowerShell runners, and published result summaries are available in the <a href="https://github.com/abpframework/abp/tree/dev/test/AbpNet10vsNet11PerfTest">AbpNet10vsNet11PerfTest test suite</a> of the ABP repository. <strong>The commands below refer to that benchmark implementation</strong>, rather than being stand-alone commands. With its source code available, the required .NET SDKs/runtimes, Docker, k6, Node.js, and <code>dotnet-counters</code> installed, local OpenIddict certificate settings configured, and test credentials provided, an example run is:</p>
<pre><code class="language-powershell">docker compose up -d
./scripts/publish.ps1

./scripts/db.ps1 -Target abp10 -Action seed
./scripts/db.ps1 -Target abp11 -Action seed

$env:BENCH_PASSWORD = '&lt;seeded_admin_password&gt;'

./scripts/run-bench.ps1 `
    -Mode fixed `
    -Rates 2,4,6 `
    -Targets abp10,abp10-rt11,abp11 `
    -Repeats 3 `
    -Batch article-comparison

node k6/tools/summarize.mjs results/article-comparison report/results-article-comparison.md
</code></pre>
<p>The <code>-Batch</code> value is only a folder name for organizing output, not a distinct benchmark methodology. The runner resets the database, starts the selected web process with the configured affinity, collects telemetry, invokes k6, and aggregates the results. <strong>Recording and replay are separate steps</strong>: k6 Studio is used to capture the initial traffic; the correlated, parameterized k6 scenario is what performs the actual concurrent benchmark.</p>
<h2>Conclusion</h2>
<p>Upgrading to <strong>ABP 11.0.0-rc.1 on .NET 11</strong> lowered <strong>p95 latency by 9.4%</strong> at the highest repeated load (38.3 ms → 34.7 ms), with lower CPU and memory use at the same throughput. Eight of the nine tested feature groups were faster as well.</p>
<p>The unchanged ABP 10 application also improved on the .NET 11 runtime (34.5 ms p95), so the gain comes with the move to .NET 11 and the ABP 11 preview upgrade added no substantial penalty. These findings apply to the tested applications and preview dependencies, not to every ABP deployment.</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a24d248-92d0-6051-f3bb-7ec1c641ec32" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a24d248-92d0-6051-f3bb-7ec1c641ec32" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/system.text.json-in-.net-11-naming-policies-union-types-and-ndjson-streaming-ul2reiuf</guid>
      <link>https://abp.io/community/posts/system.text.json-in-.net-11-naming-policies-union-types-and-ndjson-streaming-ul2reiuf</link>
      <a10:author>
        <a10:name>okankoca</a10:name>
        <a10:uri>https://abp.io/community/members/okankoca</a10:uri>
      </a10:author>
      <category>openapi</category>
      <category>dotnet</category>
      <category>asp.net-core</category>
      <category>csharp</category>
      <category>.net</category>
      <title>System.Text.Json in .NET 11: Naming Policies, Union Types, and NDJSON Streaming</title>
      <description>Explore the key System.Text.Json improvements in .NET 11, including flexible naming policies, C# union type serialization, and NDJSON streaming. This article demonstrates these features with runnable ASP.NET Core examples and explains their impact on API contracts, frontend clients, OpenAPI, streaming, performance, and production adoption.</description>
      <pubDate>Mon, 21 Sep 2026 12:48:24 Z</pubDate>
      <a10:updated>2026-11-10T12:53:36Z</a10:updated>
      <content:encoded><![CDATA[<p><code>System.Text.Json</code> is getting several useful improvements in .NET 11 that go beyond simple serialization.</p>
<p>Three changes are particularly relevant for API developers:</p>
<ul>
<li>more flexible naming-policy customization,</li>
<li>serialization support for C# union types,</li>
<li>and NDJSON output for asynchronous streams.</li>
</ul>
<p>At first glance, these may look like unrelated serializer features. In practice, they affect three important parts of an API contract:</p>
<pre><code class="language-text">Naming policies → property names
Union types     → possible value shapes
NDJSON          → how values are delivered
</code></pre>
<p>That has direct consequences for ASP.NET Core APIs, TypeScript frontends, generated clients, large-result endpoints, and AI streaming scenarios.</p>
<p>In this article, we'll look at each feature, build a small API around them, and discuss when NDJSON is a better fit than a conventional JSON array.</p>
<p>.NET 11 includes other <code>System.Text.Json</code> work as well, but features such as <code>GetTypeInfo&lt;T&gt;</code>, type-level <code>JsonIgnore</code>, and closed-hierarchy inference are outside the scope of this article except where they clarify the union guidance.</p>
<hr />
<h2>.NET 11 Status First</h2>
<p>At the time of writing, .NET 11 is at <strong>Release Candidate 1</strong>. Microsoft released <code>.NET 11.0.0-rc.1</code> on September 8, 2026, and the .NET release metadata lists the channel in the <strong>Go-Live</strong> support phase.</p>
<p>An important RC1 change is that <strong>C# 15 is now the default language version for projects targeting .NET 11</strong>. Union types were stabilized for C# 15 in RC1, so a <code>net11.0</code> project no longer needs:</p>
<pre><code class="language-xml">&lt;LangVersion&gt;preview&lt;/LangVersion&gt;
</code></pre>
<p>This matters because some .NET 11 library documentation still contains older wording that calls C# unions a preview feature. For RC1 language status, the <a href="https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/rc1/csharp.md">C# in .NET 11 RC1 release notes</a> are the more specific source.</p>
<p>RC1 is still pre-GA, however. Before a final production rollout, re-check the .NET 11 release notes, known issues, and serializer behavior against the final SDK.</p>
<hr />
<h2>Naming Policies Become More Flexible</h2>
<p><code>System.Text.Json</code> already supports built-in naming conventions such as camelCase, snake_case, and kebab-case.</p>
<p>.NET 11 adds:</p>
<pre><code class="language-csharp">JsonNamingPolicy.PascalCase
</code></pre>
<p>and introduces <code>JsonNamingPolicyAttribute</code>, which allows a naming policy to be applied to an individual property or field.</p>
<p>Per-member name overrides were already possible with <code>[JsonPropertyName(&quot;someName&quot;)]</code>. The .NET 11 addition is different: instead of hard-coding one literal JSON name, a member can opt into a naming <strong>policy</strong>, so the transformation remains policy-driven.</p>
<p>For example:</p>
<pre><code class="language-csharp">using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.PascalCase
};

var response = new EventResponse
{
    EventName = &quot;UserRegistered&quot;,
    CreatedAtUtc =
        new DateTimeOffset(2026, 9, 18, 8, 30, 0, TimeSpan.Zero)
};

Console.WriteLine(
    JsonSerializer.Serialize(response, options));

public sealed class EventResponse
{
    [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)]
    public string EventName { get; init; } = &quot;&quot;;

    public DateTimeOffset CreatedAtUtc { get; init; }
}
</code></pre>
<p>Actual compact output:</p>
<pre><code class="language-json">{&quot;eventName&quot;:&quot;UserRegistered&quot;,&quot;CreatedAtUtc&quot;:&quot;2026-09-18T08:30:00+00:00&quot;}
</code></pre>
<p>The serializer uses PascalCase globally, but <code>EventName</code> is explicitly kept in camelCase.</p>
<h3>Why does this matter for APIs?</h3>
<p>Because JSON naming is part of the wire contract.</p>
<p>These responses contain the same data:</p>
<pre><code class="language-json">{&quot;userId&quot;:12}
</code></pre>
<pre><code class="language-json">{&quot;UserId&quot;:12}
</code></pre>
<p>but they are not necessarily compatible from a client's perspective.</p>
<p>A TypeScript application might have:</p>
<pre><code class="language-ts">interface User {
    userId: number;
}
</code></pre>
<p>Changing the server to return <code>UserId</code> can break client mappings, generated SDKs, runtime validation, or tests without changing the underlying C# property at all.</p>
<p>So naming-policy changes should be treated as contract changes.</p>
<p>In ASP.NET Core, a global policy can be configured like this:</p>
<pre><code class="language-csharp">builder.Services.ConfigureHttpJsonOptions(options =&gt;
{
    options.SerializerOptions.PropertyNamingPolicy =
        JsonNamingPolicy.PascalCase;
});
</code></pre>
<p>The point of .NET 11 is not that PascalCase is suddenly preferable for web APIs. The useful part is having finer control when an API needs exceptions to its global convention.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-21-system-text-json-in-net11-naming-policies-union-types-ndjson-streaming/api_contract_flow.png" alt="C# model to JSON contract and frontend client flow" /></p>
<p><em>Figure: An illustrative contract-flow example showing how a server-side naming decision becomes part of the JSON and frontend contract. The runnable sample below intentionally uses PascalCase globally to demonstrate the new .NET 11 policy.</em></p>
<hr />
<h2>C# Union Types Meet System.Text.Json</h2>
<p>Another important .NET 11 improvement is serialization support for C# union types.</p>
<p>A union represents a value that can be one of a fixed set of cases.</p>
<p>For example:</p>
<pre><code class="language-csharp">public record OrderCreated(
    int OrderId,
    string Status);

public record ValidationFailure(
    string ErrorCode,
    string Message);

public union CreateOrderResult(
    OrderCreated,
    ValidationFailure);
</code></pre>
<p>A <code>CreateOrderResult</code> can now contain either:</p>
<pre><code class="language-csharp">CreateOrderResult result =
    new OrderCreated(125, &quot;created&quot;);
</code></pre>
<p>or:</p>
<pre><code class="language-csharp">CreateOrderResult result =
    new ValidationFailure(
        &quot;INVALID_QUANTITY&quot;,
        &quot;Quantity must be greater than zero.&quot;);
</code></pre>
<p>Pattern matching can then handle the known cases explicitly:</p>
<pre><code class="language-csharp">string message = result switch
{
    OrderCreated order =&gt;
        $&quot;Order {order.OrderId} created.&quot;,

    ValidationFailure failure =&gt;
        failure.Message
};
</code></pre>
<p>The compiler knows which cases belong to the union instead of treating the value as an arbitrary <code>object</code>.</p>
<hr />
<h3>Serializing a Union</h3>
<p>.NET 11's <code>System.Text.Json</code> recognizes C# unions through the new <code>JsonTypeInfoKind.Union</code> contract kind and can serialize the active case directly. The feature works with both reflection-based serialization and source generation.</p>
<p>For example:</p>
<pre><code class="language-csharp">CreateOrderResult result =
    new OrderCreated(125, &quot;created&quot;);

string json =
    JsonSerializer.Serialize(result);

Console.WriteLine(json);
</code></pre>
<p>Actual compact output:</p>
<pre><code class="language-json">{&quot;OrderId&quot;:125,&quot;Status&quot;:&quot;created&quot;}
</code></pre>
<p>The validation case produces:</p>
<pre><code class="language-json">{&quot;ErrorCode&quot;:&quot;INVALID_QUANTITY&quot;,&quot;Message&quot;:&quot;Quantity must be greater than zero.&quot;}
</code></pre>
<p>There is no artificial union wrapper or discriminator in the JSON. The active case becomes the actual representation.</p>
<p>That maps to a TypeScript union using the same wire-property casing:</p>
<pre><code class="language-ts">type CreateOrderResult =
    | {
        OrderId: number;
        Status: string;
      }
    | {
        ErrorCode: string;
        Message: string;
      };
</code></pre>
<p>Because the serialized union has no discriminator, the client narrows by shape, for example:</p>
<pre><code class="language-ts">function handle(result: CreateOrderResult) {
    if (&quot;OrderId&quot; in result) {
        console.log(result.Status);
    } else {
        console.error(result.Message);
    }
}
</code></pre>
<p>That is valid TypeScript, but an explicit discriminator can be easier to evolve when you control a brand-new polymorphic object contract.</p>
<hr />
<h3>A Note on Union Deserialization</h3>
<p>Serialization and deserialization are not identical concerns. When a union contains two object-shaped cases, <code>System.Text.Json</code> may not be able to determine the active case from the JSON token alone.</p>
<p>For structurally distinct objects, .NET 11 provides <code>JsonUnionTypeStructuralClassifier</code>:</p>
<pre><code class="language-csharp">[JsonUnion(
    TypeClassifier =
        typeof(JsonUnionTypeStructuralClassifier))]
public union CreateOrderResult(
    OrderCreated,
    ValidationFailure);
</code></pre>
<p>In this sample, <code>OrderCreated</code> and <code>ValidationFailure</code> expose different root-level properties, so the classifier can distinguish them. The behavior is verified in the <strong>Running and Testing the Sample</strong> section below. If cases are still ambiguous, an explicit custom classifier may be required.</p>
<hr />
<h3>OpenAPI and Generated Clients</h3>
<p>ASP.NET Core's OpenAPI support represents a C# union as an <code>anyOf</code> schema with one entry per case type.</p>
<p>Conceptually:</p>
<pre><code class="language-yaml">CreateOrderResult:
  anyOf:
    - $ref: &quot;#/components/schemas/OrderCreated&quot;
    - $ref: &quot;#/components/schemas/ValidationFailure&quot;
</code></pre>
<p>A client generator can then potentially produce:</p>
<pre><code class="language-ts">type CreateOrderResult =
    OrderCreated | ValidationFailure;
</code></pre>
<p>That is much better than exposing the response as an untyped <code>object</code>, but generated clients should still be tested because generators can differ in how they model <code>anyOf</code>. Also remember that union serialization itself does not add a discriminator, so generated frontend code may still need shape-based narrowing.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-21-system-text-json-in-net11-naming-policies-union-types-ndjson-streaming/anyof_union_openapi.png" alt="Scalar OpenAPI view of the CreateOrderResult union" /></p>
<p><em>Figure: Scalar represents <code>CreateOrderResult</code> as an OpenAPI <code>anyOf</code> schema with <code>OrderCreated</code> and <code>ValidationFailure</code> alternatives.</em></p>
<hr />
<h2>Putting Everything Together in ASP.NET Core</h2>
<p>The following Minimal API demonstrates the naming-policy configuration, a union response, OpenAPI/Scalar setup, and an NDJSON streaming endpoint.</p>
<h3>Project file</h3>
<pre><code class="language-xml">&lt;Project Sdk=&quot;Microsoft.NET.Sdk.Web&quot;&gt;

  &lt;PropertyGroup&gt;
    &lt;TargetFramework&gt;net11.0&lt;/TargetFramework&gt;
    &lt;Nullable&gt;enable&lt;/Nullable&gt;
    &lt;ImplicitUsings&gt;enable&lt;/ImplicitUsings&gt;
  &lt;/PropertyGroup&gt;

  &lt;ItemGroup&gt;
    &lt;PackageReference
        Include=&quot;Microsoft.AspNetCore.OpenApi&quot;
        Version=&quot;11.0.0-rc.1.26425.128&quot; /&gt;
    &lt;PackageReference
        Include=&quot;Scalar.AspNetCore&quot;
        Version=&quot;2.17.4&quot; /&gt;
  &lt;/ItemGroup&gt;

&lt;/Project&gt;
</code></pre>
<h3><code>Program.cs</code></h3>
<pre><code class="language-csharp">using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Options;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

builder.Services.ConfigureHttpJsonOptions(options =&gt;
{
    options.SerializerOptions.PropertyNamingPolicy =
        JsonNamingPolicy.PascalCase;
});

var app = builder.Build();

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

app.MapGet(&quot;/api/contracts/naming&quot;, () =&gt;
    new NamingResponse
    {
        EventName = &quot;UserRegistered&quot;,
        CreatedAtUtc = DateTimeOffset.UtcNow
    });

app.MapPost(&quot;/api/orders&quot;, (
    CreateOrderRequest request) =&gt;
{
    CreateOrderResult result =
        request.Quantity &lt;= 0
            ? new ValidationFailure(
                &quot;INVALID_QUANTITY&quot;,
                &quot;Quantity must be greater than zero.&quot;)
            : new OrderCreated(
                125,
                &quot;created&quot;);

    return TypedResults.Ok(result);
});

app.MapGet(&quot;/api/events/stream&quot;, async (
    HttpContext context,
    IOptions&lt;JsonOptions&gt; jsonOptions,
    CancellationToken cancellationToken) =&gt;
{
    context.Response.ContentType =
        &quot;application/x-ndjson; charset=utf-8&quot;;

    await JsonSerializer.SerializeAsyncEnumerable(
        context.Response.BodyWriter,
        GenerateEvents(cancellationToken),
        topLevelValues: true,
        options: jsonOptions.Value.SerializerOptions,
        cancellationToken: cancellationToken);
});

app.Run();

static async IAsyncEnumerable&lt;StreamEvent&gt; GenerateEvents(
    [EnumeratorCancellation]
    CancellationToken cancellationToken)
{
    for (var i = 1; i &lt;= 5; i++)
    {
        await Task.Delay(1000, cancellationToken);

        yield return new StreamEvent(
            i,
            $&quot;Chunk {i}&quot;,
            DateTimeOffset.UtcNow);
    }
}

public sealed class NamingResponse
{
    [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)]
    public string EventName { get; init; } = &quot;&quot;;

    public DateTimeOffset CreatedAtUtc { get; init; }
}

public record CreateOrderRequest(
    int ProductId,
    int Quantity);

public record OrderCreated(
    int OrderId,
    string Status);

public record ValidationFailure(
    string ErrorCode,
    string Message);

public union CreateOrderResult(
    OrderCreated,
    ValidationFailure);

public record StreamEvent(
    int Id,
    string Text,
    DateTimeOffset Timestamp);
</code></pre>
<p>A successful request:</p>
<pre><code class="language-http">POST /api/orders
Content-Type: application/json

{&quot;ProductId&quot;:17,&quot;Quantity&quot;:2}
</code></pre>
<p>returns:</p>
<pre><code class="language-json">{&quot;OrderId&quot;:125,&quot;Status&quot;:&quot;created&quot;}
</code></pre>
<p>while an invalid quantity produces the other union shape:</p>
<pre><code class="language-json">{&quot;ErrorCode&quot;:&quot;INVALID_QUANTITY&quot;,&quot;Message&quot;:&quot;Quantity must be greater than zero.&quot;}
</code></pre>
<p>This sample deliberately returns both domain outcomes under HTTP <code>200 OK</code> so that one endpoint can demonstrate a single union response and the generated <code>anyOf</code> schema. That is a serializer/OpenAPI demonstration, not a general HTTP error-handling recommendation. In a production API, validation failures are commonly mapped to an appropriate <code>4xx</code> response.</p>
<hr />
<h2>NDJSON: Streaming JSON Records</h2>
<p>The third major improvement is around asynchronous JSON output.</p>
<p>The read side of this model already existed before .NET 11: <code>JsonSerializer.DeserializeAsyncEnumerable(..., topLevelValues: true)</code> can consume a sequence of whitespace-separated top-level JSON values (available in .NET 9). .NET 11 completes the story on the <strong>write</strong> side by adding top-level-value output to <code>SerializeAsyncEnumerable</code> and direct <code>PipeWriter</code> support.</p>
<p>A conventional JSON array looks like this:</p>
<pre><code class="language-json">[{&quot;Id&quot;:1,&quot;Text&quot;:&quot;Chunk 1&quot;},{&quot;Id&quot;:2,&quot;Text&quot;:&quot;Chunk 2&quot;},{&quot;Id&quot;:3,&quot;Text&quot;:&quot;Chunk 3&quot;}]
</code></pre>
<p>NDJSON — Newline Delimited JSON — looks like this:</p>
<pre><code class="language-text">{&quot;Id&quot;:1,&quot;Text&quot;:&quot;Chunk 1&quot;}
{&quot;Id&quot;:2,&quot;Text&quot;:&quot;Chunk 2&quot;}
{&quot;Id&quot;:3,&quot;Text&quot;:&quot;Chunk 3&quot;}
</code></pre>
<p>Each line is an independent JSON value.</p>
<p>.NET 11 extends <code>JsonSerializer.SerializeAsyncEnumerable</code> with two useful capabilities:</p>
<ul>
<li>writing directly to a <code>PipeWriter</code>,</li>
<li>and a <code>topLevelValues</code> option for NDJSON-style output.</li>
</ul>
<p>The key call from the sample is:</p>
<pre><code class="language-csharp">await JsonSerializer.SerializeAsyncEnumerable(
    context.Response.BodyWriter,
    GenerateEvents(cancellationToken),
    topLevelValues: true,
    options: jsonOptions.Value.SerializerOptions,
    cancellationToken: cancellationToken);
</code></pre>
<p>A .NET client can consume the same record-oriented format with:</p>
<pre><code class="language-csharp">await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable&lt;StreamEvent&gt;(
    responseStream,
    topLevelValues: true))
{
    Console.WriteLine(item);
}
</code></pre>
<p>When relying on line-based framing, verify any serializer formatting changes you make, especially indentation, against the actual wire output expected by your clients.</p>
<p>On Windows, run:</p>
<pre><code class="language-powershell">curl.exe -N http://localhost:5050/api/events/stream
</code></pre>
<p>The wire output is similar to:</p>
<pre><code class="language-text">{&quot;Id&quot;:1,&quot;Text&quot;:&quot;Chunk 1&quot;,&quot;Timestamp&quot;:&quot;...&quot;}
{&quot;Id&quot;:2,&quot;Text&quot;:&quot;Chunk 2&quot;,&quot;Timestamp&quot;:&quot;...&quot;}
{&quot;Id&quot;:3,&quot;Text&quot;:&quot;Chunk 3&quot;,&quot;Timestamp&quot;:&quot;...&quot;}
</code></pre>
<p><img
src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-21-system-text-json-in-net11-naming-policies-union-types-ndjson-streaming/ndjson_screenshot.png"
alt="NDJSON runtime response shown in Scalar"
width="620"
/></p>
<p><em>Figure: Scalar's Try It view shows the runtime response returned with <code>application/x-ndjson</code>. Scalar may pretty-print the values for display; the terminal capture below shows the actual newline-delimited wire format.</em></p>
<p><img
src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-21-system-text-json-in-net11-naming-policies-union-types-ndjson-streaming/ndjson_stream.png"
alt="NDJSON records arriving incrementally in Windows Terminal"
width="720"
/></p>
<p><em>Figure: Streaming the NDJSON endpoint with <code>curl.exe -N</code>. Each JSON record arrives independently as it becomes available.</em></p>
<hr />
<h2>JSON Array vs. NDJSON</h2>
<p>A normal JSON array is still the better default when a response is conceptually one document. NDJSON becomes more interesting when individual records are useful before the operation has completely finished.</p>
<p>One important distinction is that <strong>streaming is not unique to NDJSON</strong>. ASP.NET Core has been able to serialize <code>IAsyncEnumerable&lt;T&gt;</code> with <code>System.Text.Json</code> without first buffering the full sequence since .NET 6. The server-side memory benefit comes mainly from streaming the source and avoiding materialization such as <code>ToListAsync()</code>. NDJSON's main difference is its record-oriented framing, which lets clients parse and process complete records incrementally.</p>
<p>| Scenario | JSON array | NDJSON |
|---|---|---|
| Small CRUD or paginated response | Natural fit | Usually unnecessary |
| Client wants <code>response.json()</code> | Natural fit | Poor fit |
| Large streamed result | Can also stream with <code>IAsyncEnumerable&lt;T&gt;</code> | Easy per-record framing and parsing |
| Log/event stream | Possible, but awkward to frame incrementally | Natural fit |
| Incremental search results | Possible | Natural fit |
| ETL/data pipeline | Works | Often convenient |
| Browser AI stream | Possible | Good generic JSON-record format; SSE may be more convenient |
| One atomic document required | Natural fit | Poor fit |</p>
<p>A useful rule of thumb is:</p>
<blockquote>
<p><strong>If the response is conceptually one document, use normal JSON. If it is a sequence of independent records that should be processed as they arrive, NDJSON may be the better model.</strong></p>
</blockquote>
<hr />
<h2>Consuming NDJSON from the Frontend</h2>
<p>There is one important frontend consequence.</p>
<p>This will not work:</p>
<pre><code class="language-ts">const response = await fetch(&quot;/api/events/stream&quot;);
const data = await response.json();
</code></pre>
<p>NDJSON is not one complete JSON document.</p>
<p>Instead, the browser needs to consume the response body as a stream:</p>
<pre><code class="language-ts">type StreamEvent = {
    Id: number;
    Text: string;
    Timestamp: string;
};

async function consumeEvents() {
    const response =
        await fetch(&quot;/api/events/stream&quot;);

    if (!response.ok || !response.body) {
        throw new Error(
            `Streaming request failed: ${response.status}`
        );
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    let buffer = &quot;&quot;;

    while (true) {
        const { value, done } =
            await reader.read();

        if (done) {
            break;
        }

        buffer += decoder.decode(
            value,
            { stream: true });

        const lines = buffer.split(&quot;\n&quot;);
        buffer = lines.pop() ?? &quot;&quot;;

        for (const line of lines) {
            if (!line.trim()) {
                continue;
            }

            const item =
                JSON.parse(line) as StreamEvent;

            console.log(item);
        }
    }

    buffer += decoder.decode();

    if (buffer.trim()) {
        const item =
            JSON.parse(buffer) as StreamEvent;

        console.log(item);
    }
}
</code></pre>
<p>The buffer is important because network chunks do not necessarily align with NDJSON records.</p>
<p>For example, the browser might receive:</p>
<pre><code class="language-text">{&quot;Id&quot;:1,&quot;Text&quot;:&quot;Chu
</code></pre>
<p>and then:</p>
<pre><code class="language-text">nk 1&quot;}\n{&quot;Id&quot;:2,
</code></pre>
<p>So each <code>reader.read()</code> result cannot safely be passed directly to <code>JSON.parse()</code>.</p>
<hr />
<h2>Large Results and AI Streaming</h2>
<p>Large-result scenarios are where the distinction between <strong>streaming</strong> and <strong>format</strong> becomes especially important.</p>
<p>Consider:</p>
<pre><code class="language-csharp">var rows =
    await db.Transactions
        .ToListAsync();

return Results.Ok(rows);
</code></pre>
<p>If the query returns one million rows, the application materializes the result before serialization.</p>
<p>A streaming pipeline instead looks like this:</p>
<pre><code class="language-text">Database
   │
   ▼
IAsyncEnumerable
   │
   ▼
Serializer
   │
   ▼
Network
</code></pre>
<p>The important point is that the <strong>data source must stream too</strong>. Calling:</p>
<pre><code class="language-csharp">var rows = await query.ToListAsync();
</code></pre>
<p>before writing either a JSON array or NDJSON removes much of the server-side memory benefit.</p>
<p>NDJSON is useful here because the client can parse each complete record independently. A streamed JSON array can also avoid full server-side buffering, but many clients still treat the array as one document and wait for the full payload before calling a normal JSON parser.</p>
<p>There is also an operational trade-off: a truly streamed EF Core query can keep its data reader, database connection, and related upstream resources active while a slow client is still consuming the response. For very large exports, measure this behavior under realistic client speeds and consider whether paging or a background export job is a better fit.</p>
<hr />
<h3>AI Streaming</h3>
<p>AI responses are another natural streaming scenario.</p>
<p>An AI agent may produce more than just text:</p>
<pre><code class="language-text">TextDelta
ToolStarted
ToolResult
Citation
Completed
Error
</code></pre>
<p>An application-level event envelope could look like this:</p>
<pre><code class="language-text">{&quot;type&quot;:&quot;text&quot;,&quot;delta&quot;:&quot;Hello &quot;}
{&quot;type&quot;:&quot;text&quot;,&quot;delta&quot;:&quot;world&quot;}
{&quot;type&quot;:&quot;tool-started&quot;,&quot;name&quot;:&quot;search&quot;}
{&quot;type&quot;:&quot;tool-result&quot;,&quot;count&quot;:4}
{&quot;type&quot;:&quot;completed&quot;,&quot;finishReason&quot;:&quot;stop&quot;}
</code></pre>
<p>The <code>type</code> field above is <strong>application-defined</strong>. C# union serialization does not automatically add a discriminator.</p>
<p>Union types and NDJSON can still complement each other:</p>
<blockquote>
<p><strong>Union types can describe which event shapes are valid in code.</strong></p>
</blockquote>
<blockquote>
<p><strong>NDJSON can describe how independent event records are delivered over time.</strong></p>
</blockquote>
<p>If the JSON contract itself needs an explicit discriminator, model that discriminator deliberately rather than assuming it will appear because the server-side type is a union.</p>
<p>For browser-first server-to-client streaming, SSE may also be worth considering depending on the client requirements.</p>
<hr />
<h2>Error Handling Is Different Once Streaming Starts</h2>
<p>Suppose a server has already sent:</p>
<pre><code class="language-text">HTTP 200 OK

{&quot;Id&quot;:1}
{&quot;Id&quot;:2}
</code></pre>
<p>and then the database fails.</p>
<p>The HTTP response has already started, so the server cannot replace the response with:</p>
<pre><code class="language-http">500 Internal Server Error
</code></pre>
<p>A streaming API therefore needs an explicit strategy for mid-stream errors.</p>
<p>For an event-oriented protocol, that might be:</p>
<pre><code class="language-text">{&quot;type&quot;:&quot;data&quot;,&quot;value&quot;:{&quot;id&quot;:1}}
{&quot;type&quot;:&quot;error&quot;,&quot;code&quot;:&quot;DB_FAILURE&quot;}
</code></pre>
<p>It can also be useful to define a final completion event:</p>
<pre><code class="language-text">{&quot;type&quot;:&quot;data&quot;,&quot;value&quot;:{&quot;id&quot;:1}}
{&quot;type&quot;:&quot;completed&quot;}
</code></pre>
<p>If the connection closes without <code>completed</code>, the client can treat the stream as interrupted rather than successfully finished.</p>
<p>These <code>type</code> fields are part of the application's streaming protocol; they are not inserted automatically by union serialization.</p>
<hr />
<h2>Performance and Cancellation</h2>
<p>NDJSON does not automatically mean “faster”, and it does not automatically use less server memory than a streamed JSON array.</p>
<p>The main benefits of NDJSON are usually:</p>
<ul>
<li>incremental client parsing,</li>
<li>simple record boundaries,</li>
<li>and the ability to process already-completed records before the full response finishes.</li>
</ul>
<p>Memory behavior depends more on whether the producer and serializer stream or buffer the data. Every record still has serialization and parsing overhead, and flushing extremely small records can reduce throughput.</p>
<p>The response may also be buffered by infrastructure such as ASP.NET Core middleware, compression, reverse proxies, CDNs, or the client itself.</p>
<blockquote>
<p><strong>NDJSON defines the format. Streaming, flushing, and buffering define the transport behavior.</strong></p>
</blockquote>
<p>That distinction should be tested in a production-like environment.</p>
<p>Cancellation matters too.</p>
<p>When a browser closes the connection or a user presses <strong>Stop generating</strong>, the request cancellation token should ideally propagate through the entire pipeline:</p>
<pre><code class="language-text">Client disconnect
      │
      ▼
RequestAborted
      │
      ▼
IAsyncEnumerable
      │
      ├── DB query stops
      ├── AI request stops
      └── downstream calls stop
</code></pre>
<p>This can be especially important for AI workloads where unnecessary generation has a real cost.</p>
<hr />
<h2>Compatibility Considerations</h2>
<p>All three features can change a public API contract. Renaming <code>userId</code> to <code>UserId</code> may break generated clients or frontend mappings, and widening a value from one JSON shape to several possible union shapes changes what consumers must handle. Because union serialization is discriminator-free by default, clients may also need shape-based narrowing unless the API defines its own discriminator.</p>
<p>Switching an endpoint from <code>application/json</code> to <code>application/x-ndjson</code> is an even more visible change because clients can no longer rely on <code>response.json()</code> and must consume the body incrementally. For existing APIs, prefer additive changes over silent contract replacements. Keeping <code>GET /api/orders</code> for the conventional JSON response and introducing <code>GET /api/orders/stream</code> for NDJSON, for example, makes the migration explicit and allows existing consumers to continue working unchanged.</p>
<hr />
<h2>RC1 and Production-Readiness Notes</h2>
<p>These features do not all carry the same kind of production risk.</p>
<h3>Naming policies</h3>
<p>The naming APIs are straightforward, but the wire contract is compatibility-sensitive. Treat casing changes exactly like other public-schema changes and contract-test them.</p>
<h3>NDJSON</h3>
<p>NDJSON itself is established, but production behavior depends on the complete delivery path: client parsing, buffering, compression, proxy behavior, cancellation, mid-stream errors, load testing, and observability.</p>
<h3>C# unions</h3>
<p>C# union syntax is <strong>stable in C# 15 as of .NET 11 RC1</strong> and does not require <code>LangVersion=preview</code> for <code>net11.0</code>.</p>
<p>The more important production consideration is deserialization classification. Object-shaped cases can require structural or custom classification. Structural classification has a scanning cost and couples classification to property shape, so contract evolution should be tested carefully.</p>
<p>There is also a documentation inconsistency at the time of writing: the .NET 11 libraries page still contains older wording that calls unions a preview language feature, while the RC1 C# release notes explicitly state that unions were stabilized. For RC1 language status, use the RC1 C# release notes.</p>
<p>.NET 11 RC1 is a Go-Live release, but it is not yet GA. Re-check the final release notes and known issues before the final production rollout.</p>
<hr />
<h2>Running and Testing the Sample</h2>
<p>The screenshots and runtime examples in this article were produced with the locally verified <strong>.NET SDK <code>11.0.100-rc.1.26425.128</code></strong>.</p>
<p>The project uses <code>Microsoft.AspNetCore.OpenApi</code> <code>11.0.0-rc.1.26425.128</code> and <code>Scalar.AspNetCore</code> <code>2.17.4</code>.</p>
<p>To reproduce the setup:</p>
<pre><code class="language-powershell">dotnet new web -n Json11Demo
cd Json11Demo

dotnet add package Microsoft.AspNetCore.OpenApi --version 11.0.0-rc.1.26425.128
dotnet add package Scalar.AspNetCore --version 2.17.4
</code></pre>
<p>Replace the generated project file and <code>Program.cs</code> with the versions shown above, then verify the installed SDK and build the project:</p>
<pre><code class="language-powershell">dotnet --version
dotnet build
dotnet run --urls http://localhost:5050
</code></pre>
<p>The local <code>dotnet --version</code> output was <code>11.0.100-rc.1.26425.128</code>, matching the SDK build listed in the .NET 11 RC1 release notes. The project built successfully and the API was then started on <code>http://localhost:5050</code>.</p>
<p>The successful order request used during testing was:</p>
<pre><code class="language-powershell">Invoke-RestMethod `
  -Uri &quot;http://localhost:5050/api/orders&quot; `
  -Method Post `
  -ContentType &quot;application/json&quot; `
  -Body '{&quot;ProductId&quot;:17,&quot;Quantity&quot;:2}'
</code></pre>
<p>PowerShell displayed:</p>
<pre><code class="language-text">OrderId Status
------- ------
125     created
</code></pre>
<p>The validation branch was also tested with <code>Quantity</code> set to <code>0</code>:</p>
<pre><code class="language-powershell">Invoke-RestMethod `
  -Uri &quot;http://localhost:5050/api/orders&quot; `
  -Method Post `
  -ContentType &quot;application/json&quot; `
  -Body '{&quot;ProductId&quot;:17,&quot;Quantity&quot;:0}'
</code></pre>
<p>PowerShell displayed:</p>
<pre><code class="language-text">ErrorCode        Message
---------        -------
INVALID_QUANTITY Quantity must be greater than zero.
</code></pre>
<p>The NDJSON endpoint was tested with:</p>
<pre><code class="language-powershell">curl.exe -N http://localhost:5050/api/events/stream
</code></pre>
<p>and produced records incrementally:</p>
<pre><code class="language-text">{&quot;Id&quot;:1,&quot;Text&quot;:&quot;Chunk 1&quot;,&quot;Timestamp&quot;:&quot;...&quot;}
{&quot;Id&quot;:2,&quot;Text&quot;:&quot;Chunk 2&quot;,&quot;Timestamp&quot;:&quot;...&quot;}
{&quot;Id&quot;:3,&quot;Text&quot;:&quot;Chunk 3&quot;,&quot;Timestamp&quot;:&quot;...&quot;}
</code></pre>
<p>The generated OpenAPI document was also inspected in Scalar at:</p>
<pre><code class="language-text">http://localhost:5050/scalar/v1
</code></pre>
<p>where <code>CreateOrderResult</code> appeared as an <code>anyOf</code> choice between <code>OrderCreated</code> and <code>ValidationFailure</code>.</p>
<p>A separate <code>net11.0</code> console check was used to verify union deserialization. The same JSON object was deserialized first into an object-object union without a classifier, and then into the same union shape with <code>JsonUnionTypeStructuralClassifier</code>.</p>
<p>Input:</p>
<pre><code class="language-json">{&quot;OrderId&quot;:125,&quot;Status&quot;:&quot;created&quot;}
</code></pre>
<p>Observed output:</p>
<pre><code class="language-text">Default: JsonException: JSON value type 'Object' is ambiguous for union type 'DefaultResult' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
Structural: OrderCreated
</code></pre>
<p>This confirms the distinction discussed earlier: serialization of the active case is straightforward, but deserializing two object-shaped cases requires classification. For these structurally distinct cases, <code>JsonUnionTypeStructuralClassifier</code> successfully selected <code>OrderCreated</code>.</p>
<hr />
<h2>Final Thoughts</h2>
<p>The most useful way to look at these <code>System.Text.Json</code> improvements is not as three unrelated serializer features.</p>
<p>They affect three different parts of an API contract:</p>
<pre><code class="language-text">Naming policy → name
Union type    → shape
NDJSON        → delivery
</code></pre>
<p>For normal CRUD and paginated endpoints, conventional JSON remains the simpler choice.</p>
<p>For large or long-running responses, the first architectural decision is whether the source and serializer should stream at all. NDJSON then becomes useful when the client benefits from independent, line-delimited records that can be parsed as they arrive.</p>
<p>Union types make fixed alternative shapes explicit in C#, while the naming-policy improvements provide more control over compatibility-sensitive JSON property names. The important production caveat is that serialization and deserialization are not the same problem: writing the active union case is simple, while reading ambiguous shapes may require classification.</p>
<p>Together, these .NET 11 changes give API developers more explicit control over how contracts are named, shaped, and delivered.</p>
<hr />
<h2>Adoption Checklist</h2>
<p>Before adopting these features:</p>
<ul>
<li>[ ] Treat JSON property casing as part of the public contract.</li>
<li>[ ] Contract-test naming-policy changes and per-member overrides.</li>
<li>[ ] Keep frontend types aligned with the actual wire casing.</li>
<li>[ ] Inspect generated OpenAPI <code>anyOf</code> schemas for union responses.</li>
<li>[ ] Test union deserialization separately from serialization.</li>
<li>[ ] Add structural or custom classification when union cases are ambiguous.</li>
<li>[ ] Consider a discriminator-based closed hierarchy for new related object contracts you fully control.</li>
<li>[ ] Use NDJSON when independent records should be processed incrementally.</li>
<li>[ ] Remember that streamed JSON arrays can also avoid full server-side buffering.</li>
<li>[ ] Make sure the underlying data source streams instead of materializing with <code>ToListAsync()</code>.</li>
<li>[ ] Test slow-client behavior and upstream resource lifetime for large streamed queries.</li>
<li>[ ] Use an incremental frontend parser instead of <code>response.json()</code> for NDJSON.</li>
<li>[ ] Handle records that span multiple network chunks and flush any final decoder buffer.</li>
<li>[ ] Propagate request cancellation to downstream work.</li>
<li>[ ] Define mid-stream error and completion semantics.</li>
<li>[ ] Test proxy, compression, flushing, and buffering behavior.</li>
<li>[ ] Compare NDJSON with SSE for browser-focused AI streaming.</li>
<li>[ ] Re-check .NET 11 release notes and known issues before moving from RC to GA.</li>
</ul>
<h2>References</h2>
<ul>
<li><a href="https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/rc1/11.0.0-rc.1.md">.NET 11.0.0 RC1 release notes</a></li>
<li><a href="https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/rc1/csharp.md">C# in .NET 11 RC1 release notes</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries">What's new in .NET libraries for .NET 11</a></li>
<li><a href="https://devblogs.microsoft.com/dotnet/unions-and-closed-hierarchies-in-aspnetcore/">Use C# unions and closed hierarchies in ASP.NET Core</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview">System.Text.Json overview</a></li>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/aspnetcore-openapi">Generate OpenAPI documents in ASP.NET Core</a></li>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/6/iasyncenumerable-not-buffered-by-mvc">MVC doesn't buffer IAsyncEnumerable types when using System.Text.Json</a></li>
<li><a href="https://learn.microsoft.com/en-us/ef/core/performance/efficient-querying">Efficient querying in EF Core — buffering and streaming</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a23d62f-8594-0942-7648-2c3d8fc44d04" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a23d62f-8594-0942-7648-2c3d8fc44d04" medium="image" />
    </item>
  </channel>
</rss>