﻿<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>endjin.com</title><atom:link href="https://endjin.com/rss.xml" rel="self" type="application/rss+xml" />
    <link>https://endjin.com</link>
    <description>endjin is a UK-based Technology Consultancy specialising in Data, Analytics &amp; AI, and Cloud Native App Dev on Microsoft Fabric, Databricks &amp; Azure. We help small teams achieve big things.</description>
    <copyright>Endjin Limited</copyright>
    <docs>http://www.rssboard.org/rss-specification</docs>
    <generator>Vellum Static Site Generator</generator>
    <image>
      <link>https://endjin.com</link>
      <title>endjin.com</title>
      <url>https://res.cloudinary.com/endjin/image/upload/v1775912583/assets/images/logo/endjin-logo-square.png</url>
    </image>
    <language>en</language>
    <lastBuildDate>Mon, 20 Jul 2026 05:30:00 GMT</lastBuildDate>
    <pubDate>Mon, 20 Jul 2026 05:30:00 GMT</pubDate>
    <ttl>60</ttl>
    <item>
      <title>OpenAPI Code Generation with Corvus: Callbacks, Webhooks and Links</title>
      <description>Generate typed webhook receivers and senders from OpenAPI specs with Corvus - callback server stubs for receiving notifications, callback clients for sending them, runtime expression resolution, and linked operations for hypermedia-style API navigation.</description>
      <link>https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks</link>
      <guid isPermaLink="true">https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks</guid>
      <pubDate>Mon, 20 Jul 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>OpenAPI</category>
      <category>code-generation</category>
      <category>webhooks</category>
      <category>callbacks</category>
      <category>event-driven</category>
      <category>runtime-expressions</category>
      <category>system.text.json</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/openapi-code-generation-with-corvus-part-06.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-authentication">previous post</a> we looked at authentication patterns.</p>
<p>So far in this series, we've focused on the classic HTTP request/response pattern: a client sends a request, a server returns a response. But modern APIs increasingly need to push notifications <em>back</em> to their clients. They need to tell you when an order ships, when a payment succeeds, or when a long-running operation completes. OpenAPI models this with webhooks, callbacks, and links.</p>
<h2 id="the-notification-symmetry-problem">The notification symmetry problem</h2>
<p>When your API server sends a webhook to a subscriber, something interesting happens to the client/server roles. Your API server becomes a <em>client</em> (it sends an HTTP request to the subscriber's URL). The subscriber's application becomes a <em>server</em> (it exposes an endpoint to receive the notification).</p>
<p>This role reversal creates two code generation needs from the same spec:</p>
<table>
<thead>
<tr>
<th>You are building…</th>
<th>You need…</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>The subscriber app</td>
<td>A server to receive the webhooks</td>
<td><code>openapi-callback-server</code></td>
</tr>
<tr>
<td>The API server</td>
<td>A client to send the webhooks</td>
<td><code>openapi-callback-client</code></td>
</tr>
</tbody>
</table>
<p>Both sides benefit from the same typed validation and schema enforcement that the regular <code>openapi-client</code> and <code>openapi-server</code> commands provide. The subscriber gets schema-validated payloads with typed handler interfaces. The API server gets a typed client that validates notifications before sending them.</p>
<h2 id="generating-a-callback-server-receiving-webhooks">Generating a callback server (receiving webhooks)</h2>
<p>When your application subscribes to a service's events, you need endpoints to receive the callbacks. The <code>openapi-callback-server</code> command generates them:</p>
<pre><code class="language-bash">corvusjson openapi-callback-server petstore.json \
    --rootNamespace MyApp.WebhookReceiver \
    --outputPath ./Generated/WebhookReceiver
</code></pre>
<p>This produces the same structure you've seen from <code>openapi-server</code> - handler interfaces, endpoint registration, params and result types - but for the callback and webhook operations rather than the main API paths.</p>
<p>Wire it up exactly as you would a regular generated server:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using MyApp.WebhookReceiver;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

WebhookHandler handler = new();
app.MapApiEndpoints(handler);

app.Run();
</code></pre>
<p>Your handler implements the generated interface. Each webhook or callback operation gets its own method with typed parameters:</p>
<pre><code class="language-csharp">internal sealed class WebhookHandler : IApiCallbacksHandler, IApiWebhooksHandler
{
    public ValueTask&lt;OnEventCallbackResult&gt; HandleOnEventCallbackAsync(
        OnEventCallbackParams parameters,
        JsonWorkspace workspace,
        CancellationToken cancellationToken = default)
    {
        // parameters.Body is the typed, schema-validated notification payload
        Console.WriteLine($"Received event: {parameters.Body.EventType}");

        return new(OnEventCallbackResult.Ok());
    }

    public ValueTask&lt;StatusChangeWebhookResult&gt; HandleStatusChangeWebhookAsync(
        StatusChangeWebhookParams parameters,
        JsonWorkspace workspace,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Status changed to: {parameters.Body.NewStatus}");

        return new(StatusChangeWebhookResult.Ok());
    }
}
</code></pre>
<p>The generator separates callbacks (defined inline on path operations) from webhooks (defined at the top level of the spec) into distinct handler interfaces. If your spec defines both, your handler class implements both. If it only uses one, you only implement that interface.</p>
<h2 id="generating-a-callback-client-sending-webhooks">Generating a callback client (sending webhooks)</h2>
<p>On the other side, when your API server needs to dispatch notifications to subscribed clients, you generate a typed client:</p>
<pre><code class="language-bash">corvusjson openapi-callback-client petstore.json \
    --rootNamespace MyApp.WebhookSender \
    --outputPath ./Generated/WebhookSender
</code></pre>
<p>This produces a client class with methods for each callback/webhook operation. You use it in your server's business logic when you need to notify subscribers:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.OpenApi.HttpTransport;
using MyApp.WebhookSender;

// When a pet is adopted, notify the subscriber at their registered URL
using HttpClient httpClient = new() { BaseAddress = new Uri(subscriberCallbackUrl) };
await using HttpClientTransport transport = new(httpClient);
await using ApiWebhooksClient client = new(transport);

await using PetAdoptedWebhookResponse response = await client.PetAdoptedWebhookAsync(
    body: new PetAdoptedEvent.Source((ref PetAdoptedEvent.Builder b) =&gt;
    {
        b.Create(petId: "pet-123"u8, adopterId: "user-456"u8);
    }));
</code></pre>
<p>The payload is validated against the schema before sending, just as with regular client requests. If your notification payload doesn't match the spec, you find out at publish time rather than from a confused subscriber reporting malformed data.</p>
<h2 id="runtime-expressions">Runtime expressions</h2>
<p>OpenAPI callbacks use <a href="https://spec.openapis.org/oas/latest.html#runtime-expressions">runtime expressions</a> to define where the callback URL comes from. A callback defined on a <code>/subscriptions</code> POST operation might use <code>{$request.body#/callbackUrl}</code> as its URL. That means the callback URL is extracted from the request body's <code>callbackUrl</code> field.</p>
<p>The code generator resolves these automatically. The generated response struct captures the context needed:</p>
<table>
<thead>
<tr>
<th>Expression</th>
<th>Resolves from</th>
<th>Generated accessor</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$request.body#/callbackUrl</code></td>
<td>JSON Pointer into the request body</td>
<td><code>sourceRequest.CallbackUrl</code></td>
</tr>
<tr>
<td><code>$request.query.eventType</code></td>
<td>Query parameter from the original request</td>
<td><code>sourceRequest.EventType</code></td>
</tr>
<tr>
<td><code>$request.header.X-Correlation-Id</code></td>
<td>Header from the original request</td>
<td><code>sourceRequest.XCorrelationId</code></td>
</tr>
<tr>
<td><code>$response.body#/id</code></td>
<td>JSON Pointer into the response body</td>
<td><code>response.CreatedBody.Id</code></td>
</tr>
<tr>
<td><code>$response.header.Location</code></td>
<td>Response header</td>
<td><code>response.LocationHeader</code></td>
</tr>
</tbody>
</table>
<p>You don't write the expression resolution code yourself. The generator emits it based on what your spec declares, and the generated response types carry the captured context through so that callback invocations can resolve their bindings.</p>
<h2 id="links-hypermedia-style-navigation">Links: hypermedia-style navigation</h2>
<p>OpenAPI <a href="https://spec.openapis.org/oas/latest.html#link-object">Links</a> define follow-on operations that can be invoked from a response. They share the same runtime expression infrastructure as callbacks, but serve a different purpose: they model the "what can I do next?" question for API consumers.</p>
<p>When a response declares links, the generated response struct provides typed navigation methods:</p>
<pre><code class="language-csharp">// Create a pet - the 201 response links to showPetById
await using CreatePetResponse response = await client.CreatePetAsync(
    body: new NewPet.Source((ref NewPet.Builder b) =&gt;
    {
        b.Create(name: "Luna"u8, tag: "cat"u8);
    }));

// Follow the link - petId is automatically populated from $response.body#/id
response.MatchResult(
    matchCreated: async pet =&gt;
    {
        await using ShowPetByIdResponse petResponse =
            await response.CreatedLinks.ShowPetByIdAsync();
        // petId was extracted from the create response body automatically
        return 0;
    },
    matchDefault: error =&gt;
    {
        Console.WriteLine($"Error: {error.Message}");
        return 0;
    });
</code></pre>
<p>The <code>CreatedLinks</code> property on the response gives you typed access to all linked operations defined for that response status. Parameters bound by runtime expressions (<code>$response.body#/id</code> in this case) are resolved from the captured context. You call the linked operation without manually extracting and passing the ID.</p>
<p>This is particularly powerful for APIs that follow HATEOAS principles, where the response tells you what actions are available next. The generated code makes link-following type-safe and parameter-free.</p>
<h2 id="when-callbacks-and-links-intersect">When callbacks and links intersect</h2>
<p>Callbacks and links share the runtime expression mechanism, but they serve different roles:</p>
<p><strong>Callbacks</strong> are about <em>the server notifying the client asynchronously</em>. The client registers a URL, and the server calls it later when something happens. The code generation produces both sides: a receiver (callback server) for the client, and a sender (callback client) for the server.</p>
<p><strong>Links</strong> are about <em>the client navigating to related operations synchronously</em>. After receiving a response, the client follows a link to a related resource. The code generation adds navigation methods to the response type.</p>
<p><strong>Webhooks</strong> are a special case of callbacks defined at the top level of the spec rather than inline on a specific operation. They represent spec-wide notification contracts that aren't tied to a particular request/response cycle.</p>
<p>In practice, many APIs use all three. A payment API might define links for navigating from a charge to its refunds (synchronous navigation), callbacks for notifying when a charge succeeds (asynchronous server-to-client), and webhooks for general platform events like account updates (top-level notifications).</p>
<h2 id="spec-structure">Spec structure</h2>
<p>For reference, here's how these features appear in an OpenAPI 3.2 spec:</p>
<p>Callbacks are defined inline on a path operation:</p>
<pre><code class="language-yaml">paths:
  /subscriptions:
    post:
      operationId: createSubscription
      callbacks:
        onEvent:
          "{$request.body#/callbackUrl}":
            post:
              operationId: onEventCallback
              requestBody:
                content:
                  application/json:
                    schema:
                      $ref: "#/components/schemas/Event"
              responses:
                "200":
                  description: Received
</code></pre>
<p>Webhooks are defined at the top level:</p>
<pre><code class="language-yaml">webhooks:
  statusChange:
    post:
      operationId: statusChangeWebhook
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StatusChange"
      responses:
        "200":
          description: Acknowledged
</code></pre>
<p>Links are defined on response objects:</p>
<pre><code class="language-yaml">paths:
  /pets:
    post:
      operationId: createPet
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pet"
          links:
            showPetById:
              operationId: showPetById
              parameters:
                petId: "$response.body#/id"
</code></pre>
<p>The generator processes all three forms and produces the corresponding typed code. For the complete webhook and callback reference, including the example recipes that demonstrate both sides of a webhook interaction, see the <a href="https://corvus-oss.org/Corvus.JsonSchema/docs/open-api.html">OpenAPI documentation on corvus-oss.org</a>.</p>
<p>In the [ref slug=openapi-code-generation-with-corvus-filtering text=next post], we'll look at filtering - generating code for just the endpoints you need, which becomes essential when working with large API surfaces.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">OpenAPI Code Generation with Corvus</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Typed HTTP Clients</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Server Stubs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">End-to-End</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-streaming" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Streaming</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-authentication" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Authentication</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">6.</span>
                <span class="series-toc__part-title">Callbacks, Webhooks and Links</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET v7.0 Now Available</title>
      <description>Rx.NET 7.0 is now available, with the potential to reduce deployable application size by up to 90MB.</description>
      <link>https://endjin.com/blog/rx-dotnet-v7-0-released</link>
      <guid isPermaLink="true">https://endjin.com/blog/rx-dotnet-v7-0-released</guid>
      <pubDate>Fri, 17 Jul 2026 08:30:00 GMT</pubDate>
      <category>Rx</category>
      <category>Rx.NET</category>
      <category>Reactive Extensions</category>
      <category>Reactive</category>
      <category>System.Reactive</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Visual Studio Code</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/07/rx-dotnet-v7-0-released.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>We are pleased to announce the release of a new version of the <a href="https://github.com/dotnet/reactive">Reactive Extensions for .NET (Rx.NET)</a>. <a href="https://www.nuget.org/packages/System.Reactive/7.0.0"><code>System.Reactive</code> v7.0.0</a> is now available on NuGet. This release can reduce the size of self-contained deployments by up to 90MB!</p>
<h2 id="whats-new">What's new?</h2>
<p>This release has exactly one new feature: it fixes a long-standing problem that caused some projects to stop using Rx.NET. You could end up with 90MB of unnecessary extra build output if you had a project with both of these characteristics:</p>
<ul>
<li>Self-contained deployment (or Native AoT)</li>
<li>A Windows-specific <code>&lt;TargetFramework&gt;</code> specifying version 10.0.19041 or later (e.g. <code>net8.0-windows10.0.19041</code>)</li>
</ul>
<p>In  projects like these, adding a reference to <code>System.Reactive</code> would cause a complete copy of the Windows Forms and WPF frameworks to be deployed as part of your application. Without trimming, this would add about 90MB to the output size. Self-contained deployment with trimming enabled was slightly less bad, adding 'just' 47MB. NativeAoT is better still, with growth of about 11MB.</p>
<p>Since <code>System.Reactive.dll</code> is around 1.5MB in size, this inflation was absurd. It was the result of a design decision made many years ago that didn't cause any of these problems at the time. Subsequent changes in the .NET ecosystem have made that decision look regrettable in hindsight.</p>
<h3 id="what-does-this-mean-for-me">What does this mean for me?</h3>
<p>For most users of Rx.NET, this update will change nothing. In particular, if you aren't building for a Windows-specific target framework (e.g. if you target <code>net10.0</code>) there is no new or changed functionality. We have dropped support for older versions of .NET (.NET 7.0 and before) but Microsoft already stopped supporting those some time ago.</p>
<p>If you build for a Windows-specific target, then this change may affect you. If you were using the WPF, Windows Forms, or Windows Runtime support built into Rx.NET, you will need to add new NuGet package references. (See the next section for details.) If you were not, then you won't need to change anything, and if you are using self-contained deployment you should see the size of your built output dropping dramatically.</p>
<h2 id="breaking-changes">Breaking changes</h2>
<p>There are three causes of breaking changes in this release:</p>
<ol>
<li>we have removed support for out-of-support .NET runtimes (v7.0 and older).</li>
<li>if your project uses Rx.NET's support for WPF, Windows Forms, UWP, or Windows Runtime, you will get compiler errors after upgrading, and will need to add additional NuGet package references. (This is a compile-time breaking change only.)</li>
<li>we fixed a bug (<a href="https://github.com/dotnet/reactive/issues/2247">#2247</a>) in the nullability of <code>OfType</code>, which, strictly speaking, is a breaking change; the nullability now correctly reflects the (unchanged) behaviour, so this is as benign a breaking change as we think is possible.</li>
</ol>
<p>(We saved that last bugfix for this release specifically because it is, strictly speaking, a breaking change, so for semantic versioning purposes, it needed a major version bump.)</p>
<h3 id="supported.net-versions">Supported .NET versions</h3>
<p>Rx 7.0 removes support for .NET 6.0 and .NET 7.0. Those runtimes have already been out of support for some time, but it was possible to use Rx 6.1 with them because it offered <code>net6.0</code> and <code>net6.0-windows10.0.19041</code> TFMs. (Rx 7.0 might actually still run on these old runtimes thanks to its <code>netstandard2.0</code> support, but we do not test this so we do not support it.)</p>
<p>We offer <code>net8.0</code>, <code>net8.0-windows10.0.19041</code>, <code>net472</code>, <code>netstandard2.0</code>, and <code>uap10.0.18362</code> targets.</p>
<p>We fully support running on .NET 8.0, 9.0 and 10.0. (We don't have a <code>net10.0</code> target, but that's because there's nothing in <code>net10.0</code> that requires code that is any different from the <code>net8.0</code> code.) We will support .NET 11.0 once that ships. (It is likely that this will just mean updating the test suite, but if it turns out that there are any compatibility issues, we will ship an update. But we expect to continue offering the same targets in our NuGet packages.)</p>
<h3 id="ui-framework-support-now-in-separate-packages">UI framework support now in separate packages</h3>
<p>The cause of the 'bloat' problem in which <code>System.Reactive</code> could grow your deployable outputs by 90MB was the fact that support for WPF and Windows Forms was built right into the main <code>System.Reactive</code> package. To fix the problem, this functionality is available only through UI-framework-specific packages</p>
<ul>
<li><a href="https://www.nuget.org/packages/System.Reactive.Windows.Forms"><code>System.Reactive.Windows.Forms</code></a> for Windows Forms</li>
<li><a href="https://www.nuget.org/packages/System.Reactive.Wpf"><code>System.Reactive.Wpf</code></a> for WPF</li>
<li><a href="https://www.nuget.org/packages/System.Reactive.WindowsRuntime"><code>System.Reactive.WindowsRuntime</code></a> for WinRT (e.g., CoreDispatcher) support</li>
<li><a href="https://www.nuget.org/packages/System.Reactive.Uwp"><code>System.Reactive.Uwp</code></a> for UWP</li>
</ul>
<p>Note that if you have existing code that was relying on these features being built into <code>System.Reactive</code>, we include a Code Analyzer in <code>System.Reactive</code> 7.0 that detects this and tells you exactly which NuGet package you need to be using.</p>
<h4 id="binary-compatibility-retained">Binary compatibility retained</h4>
<p>Although we have removed WPF, Windows Forms and UI-related Windows Runtime support from the public API of <code>System.Reactive</code>, it does in fact remain available at runtime. This is to ensure that upgrading to Rx 7.0 doesn't break older components that were built against Rx 6.1, and which expect that functionality still to be there.</p>
<p>We do this by continuing to ship all the relevant code in the DLLs in the <code>lib</code> folder of the NuGet package. These APIs have been removed only from the <code>ref</code> folder. This is sufficient to prevent the 'bloat' problem while also maximizing binary compatibility.</p>
<p>There's one thing to be aware of: projects using the ancient <code>packages.config</code> mechanism don't recognize the distinction between <code>lib</code> and <code>ref</code> folders—they use the <code>lib</code> folder at compile time as well as runtime. So projects still using that system will continue to be able to use the WPF and Windows Forms features without adding the new package references. We do not recommend this (because we hope, many years from now, to remove that code entirely) and we do not support the use of the old <code>packages.config</code> mechanism with Rx.NET.</p>
<h2 id="why-just-this-one-change">Why just this one change?</h2>
<p>This was a surprisingly difficult problem to solve. For the full (very complex) details, you can read the <a href="https://github.com/dotnet/reactive/blob/main/Rx.NET/Documentation/adr/0005-package-split.md">0005-package-split.md ADR</a>, which describes the history that led up to this problem, and all the possible responses we considered.</p>
<p>Since this is a quite significant change in the packaging, we felt it was best to make that the focus of this release. While we have done extensive testing, and have engaged in an extensive consultation period with the community, it's possible that problems will become apparent only after more people start to use it. So we wanted to keep this change separate from anything else.</p>
<h2 id="whats-next">What's next?</h2>
<p>This finally completes the goals we described in the <a href="https://github.com/dotnet/reactive/discussions/1868">roadmap</a> we published back when endjin first took over maintenance of Rx.NET. That roadmap outlined what we saw as the 'must fix' issues. And although we have shipped a few new features in that time, most of the work has gone into dealing with what was essentially technical debt. We are now free to move onto more exciting work.</p>
<p>We are very much open to community input on what the direction should be for future work. Here are some areas we have in mind:</p>
<ul>
<li>Performance, especially:
<ul>
<li>Applying a low-allocation philosophy</li>
<li>Code generation (in other libraries we maintain, we've found code generation to be an extremely effective tool for producing very high performance code, and we have some ideas here)</li>
<li>Investigate whether we can support <code>ref</code>-like elements</li>
</ul>
</li>
<li>Consider outstanding requests for new operators, including
<ul>
<li>Providing operators that actually do some of the various things people imagine <code>Throttle</code> should do</li>
</ul>
</li>
</ul>
<h2 id="please-try-it-out">Please try it out</h2>
<p>This new 7.0 release of <a href="https://www.nuget.org/packages/System.Reactive"><code>System.Reactive</code></a> is available on NuGet today. If you're using Rx in your application, please try upgrading. If you have any problems, please file issues at <a href="https://github.com/dotnet/reactive/issues">https://github.com/dotnet/reactive/issues</a>. Meanwhile, we hope you enjoy this new version of the Reactive Extensions for .NET.</p>
<h2 id="more-rx-content">More Rx content</h2>
<p>As well as the two series from Carmel Eve's <a href="https://endjin.com/blog/understanding-rx-making-interfaces-subscribing-and-other-subjects-click">Rx Operators Deep Dive</a> and Richard Kerslake's <a href="https://endjin.com/blog/event-stream-manipulation-using-rx-part-1">Event stream manipulation for Rx with semantic logging</a>, you can find further information here:</p>
<ul>
<li><a href="https://introtorx.com/">Intro to Rx.NET 3rd Edition (2025)</a></li>
<li><a href="https://www.youtube.com/watch?v=dio_BKsS9hY&amp;list=PLJt9xcgQpM60Fz20FIXBvj6ku4a7WOLGb">Rx playlist</a> (on the <a href="https://www.youtube.com/endjin">endjin YouTube channel</a>)</li>
<li><a href="https://www.youtube.com/watch?v=dio_BKsS9hY&amp;list=PLJt9xcgQpM62UBIgAkHjAhzITWMGeXbGY">Rx 101 Workshop</a></li>
<li><a href="https://endjin.com/blog/rx-talk-at-dotnet-sheffield">Rx talk</a> for the dotnetsheff user group</li>
<li><a href="https://reaqtive.net/">https://reaqtive.net/</a> — a persistent, reliable, distributed stream processing system based on Rx</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>OpenAPI Code Generation with Corvus: Authentication</title>
      <description>Corvus generates per-operation OAuth2 scope constants from your OpenAPI spec - use them with standard .NET DelegatingHandler middleware and Azure.Identity for type-safe authentication without proprietary frameworks.</description>
      <link>https://endjin.com/blog/openapi-code-generation-with-corvus-authentication</link>
      <guid isPermaLink="true">https://endjin.com/blog/openapi-code-generation-with-corvus-authentication</guid>
      <pubDate>Fri, 17 Jul 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>OpenAPI</category>
      <category>code-generation</category>
      <category>authentication</category>
      <category>OAuth2</category>
      <category>Entra ID</category>
      <category>Azure Identity</category>
      <category>system.text.json</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/openapi-code-generation-with-corvus-part-05.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-streaming">previous post</a> we looked at streaming with SSE and NDJSON.</p>
<p>Now let's tackle authentication. It's the part that every API needs but that most code generators either over-abstract or ignore entirely.</p>
<h2 id="the-authentication-philosophy">The authentication philosophy</h2>
<p>Here's a design choice we made early: Corvus doesn't have its own authentication framework. There's no <code>IAuthenticationProvider</code> interface to implement, no built-in token cache to configure, and no proprietary middleware pipeline to learn.</p>
<p>Why? Because .NET already has excellent patterns for this. <code>IHttpClientFactory</code> with <code>DelegatingHandler</code> middleware is the standard composition model. <code>Azure.Identity</code> handles Entra ID (Azure AD) tokens. Polly handles retry on 401. These are well-documented, well-tested, and understood by every .NET developer. Adding a generator-specific auth layer on top would just be another thing to learn.</p>
<p>What Corvus <em>does</em> generate is the information you need to configure these standard patterns correctly: the OAuth2 token URL, the authorisation URL, and - crucially - the per-operation scope constants extracted from the spec's <code>security</code> requirements.</p>
<h2 id="generated-scope-constants">Generated scope constants</h2>
<p>When your OpenAPI spec declares OAuth2 security schemes with per-operation scopes, the generator emits typed constants:</p>
<pre><code class="language-csharp">// From the spec's securitySchemes definition
IApiPetsClient.SecuritySchemes.Oauth2TokenUrl       // "https://auth.example.com/token"
IApiPetsClient.SecuritySchemes.Oauth2AuthorizationUrl // "https://auth.example.com/authorize"
IApiPetsClient.SecuritySchemes.Oauth2AvailableScopes // ["read:pets", "write:pets"]

// From the per-operation security requirements
IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes   // ["read:pets"]
IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes  // ["read:pets", "write:pets"]
IApiPetsClient.SecurityRequirements.AllOauth2Scopes        // ["read:pets", "write:pets"]
</code></pre>
<p>This might seem like a small thing, but it solves a real problem. In most codebases, OAuth2 scopes are hardcoded strings scattered across configuration files and handler registrations. When the API adds a new scope or changes a requirement, you find out at runtime. Worse, you request too-broad a token because it's easier than tracking per-operation scopes.</p>
<p>With generated constants, the scopes come from the spec. If the spec changes and you regenerate, your code either still compiles (no breaking change) or fails to build (the scope constant was renamed or removed). Either way, you stay in sync with the contract.</p>
<h2 id="microsoft-entra-id-azure-ad">Microsoft Entra ID (Azure AD)</h2>
<p>The most common pattern for Azure-hosted services. Use <code>Azure.Identity</code> with a standard <code>DelegatingHandler</code> that acquires tokens using the generated scope constants:</p>
<pre><code class="language-csharp">services.AddSingleton&lt;TokenCredential&gt;(
    new ClientSecretCredential(tenantId, clientId, clientSecret));

services.AddTransient&lt;EntraTokenHandler&gt;();
services.AddHttpClient("petstore")
    .AddHttpMessageHandler&lt;EntraTokenHandler&gt;();

public class EntraTokenHandler(TokenCredential credential) : DelegatingHandler
{
    protected override async Task&lt;HttpResponseMessage&gt; SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        AccessToken token = await credential.GetTokenAsync(
            new TokenRequestContext(IApiPetsClient.SecurityRequirements.AllOauth2Scopes),
            cancellationToken);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
        return await base.SendAsync(request, cancellationToken);
    }
}
</code></pre>
<p>For multi-tenant scenarios or when different operations need different privilege levels, you can request per-operation scopes:</p>
<pre><code class="language-csharp">// Read-only operation - request minimal scopes
new TokenRequestContext(IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes)

// Write operation - request elevated scopes
new TokenRequestContext(IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes)
</code></pre>
<p>The <code>TokenCredential</code> abstraction in <code>Azure.Identity</code> covers all the common flows - <code>ClientSecretCredential</code> for service-to-service, <code>InteractiveBrowserCredential</code> for desktop apps, <code>DeviceCodeCredential</code> for CLI tools, and <code>DefaultAzureCredential</code> for managed identity in Azure-hosted services. They all work with the same handler.</p>
<h2 id="bearer-tokens-non-entra-oauth2">Bearer tokens (non-Entra OAuth2)</h2>
<p>For APIs that use OAuth2 but aren't backed by Entra, the pattern is the same. Just swap the token acquisition:</p>
<pre><code class="language-csharp">public class BearerTokenHandler(ITokenService tokenService) : DelegatingHandler
{
    protected override async Task&lt;HttpResponseMessage&gt; SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        string token = await tokenService.GetTokenAsync(cancellationToken);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        return await base.SendAsync(request, cancellationToken);
    }
}
</code></pre>
<p>Your <code>ITokenService</code> can talk to Auth0, Keycloak, IdentityServer, or any other OAuth2 provider. The generated scope constants still apply. Pass them to your provider's token request.</p>
<h2 id="api-keys">API keys</h2>
<p>For APIs secured with API keys, the setup is even simpler. If the key goes in a header:</p>
<pre><code class="language-csharp">services.AddHttpClient("petstore", client =&gt;
{
    client.DefaultRequestHeaders.Add("X-Api-Key", configuration["ApiKey"]);
});
</code></pre>
<p>If the key goes in a query parameter, use a <code>DelegatingHandler</code> that appends it to the request URI:</p>
<pre><code class="language-csharp">public class ApiKeyQueryHandler(string paramName, string apiKey) : DelegatingHandler
{
    protected override Task&lt;HttpResponseMessage&gt; SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var uriBuilder = new UriBuilder(request.RequestUri!);
        string separator = string.IsNullOrEmpty(uriBuilder.Query) ? "" : "&amp;";
        uriBuilder.Query = uriBuilder.Query.TrimStart('?') + separator
            + Uri.EscapeDataString(paramName) + "=" + Uri.EscapeDataString(apiKey);
        request.RequestUri = uriBuilder.Uri;
        return base.SendAsync(request, cancellationToken);
    }
}
</code></pre>
<h2 id="cookie-authentication">Cookie authentication</h2>
<p>Cookie parameters declared in the spec become regular method parameters on the generated client. The generated code sets the <code>Cookie</code> header for you:</p>
<pre><code class="language-csharp">await using CreatePetResponse response = await petsClient.CreatePetAsync(
    session_token: "sess_k7j2m9x4"u8,
    body: new NewPet.Source((ref NewPet.Builder b) =&gt;
    {
        b.Create(name: "Luna"u8, tag: "cat"u8);
    }));
// Wire format: Cookie: session_token=sess_k7j2m9x4
</code></pre>
<p>On the server side, if the cookie is declared <code>required: true</code> and missing from the request, the generated middleware returns 400 Problem Details automatically. Your handler is never called.</p>
<h2 id="composing-auth-with-resilience">Composing auth with resilience</h2>
<p>Because authentication is just a <code>DelegatingHandler</code>, you can compose it with <code>Microsoft.Extensions.Http.Resilience</code> (Polly v8) in the same pipeline:</p>
<pre><code class="language-csharp">services.AddHttpClient("petstore")
    .AddHttpMessageHandler&lt;EntraTokenHandler&gt;()
    .AddStandardResilienceHandler(options =&gt;
    {
        options.Retry.MaxRetryAttempts = 3;
        options.Retry.BackoffType = DelayBackoffType.Exponential;
    });
</code></pre>
<p>This gives you exponential backoff with jitter, circuit breaking, and token refresh on 401. All of it uses standard .NET middleware that any developer on your team already understands.</p>
<h2 id="whats-next">What's next</h2>
<p>In the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks">next post</a>, we'll look at callbacks, webhooks, and links - generating code for both sides of event notification patterns.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">OpenAPI Code Generation with Corvus</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Typed HTTP Clients</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Server Stubs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">End-to-End</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-streaming" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Streaming</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">5.</span>
                <span class="series-toc__part-title">Authentication</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Callbacks, Webhooks and Links</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>OpenAPI Code Generation with Corvus: Streaming</title>
      <description>Generate typed streaming responses from OpenAPI specs with Corvus - SSE and NDJSON on the server with writer callbacks, IAsyncEnumerable on the client with pooled typed documents and automatic frame parsing.</description>
      <link>https://endjin.com/blog/openapi-code-generation-with-corvus-streaming</link>
      <guid isPermaLink="true">https://endjin.com/blog/openapi-code-generation-with-corvus-streaming</guid>
      <pubDate>Thu, 16 Jul 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>OpenAPI</category>
      <category>code-generation</category>
      <category>SSE</category>
      <category>NDJSON</category>
      <category>streaming</category>
      <category>IAsyncEnumerable</category>
      <category>low-allocation</category>
      <category>system.text.json</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/openapi-code-generation-with-corvus-part-04.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end">previous post</a> we wired a generated client to a generated server over real HTTP.</p>
<p>Now let's look at a pattern that's become increasingly common since the rise of LLM-backed APIs: streaming responses.</p>
<h2 id="why-streaming-matters-now">Why streaming matters now</h2>
<p>A traditional REST call returns a single JSON document. The client waits, the server responds, done. But LLM completions, real-time feeds, and event-driven UIs don't work that way. They produce a <em>sequence</em> of items over time, and the client needs to start processing before the full response is available.</p>
<p>HTTP has two established patterns for this: Server-Sent Events (SSE), where each item is framed as <code>data: {...}\n\n</code>, and Newline-Delimited JSON (NDJSON), where each item is a single JSON line terminated by <code>\n</code>. Both are widely supported and work through proxies, load balancers, and CDNs.</p>
<p>The challenge for code generators is that these aren't regular request/response cycles. The server needs a way to push items incrementally, and the client needs to consume them as they arrive. Ideally, they do so as typed, validated documents rather than raw strings. That's what the Corvus generator handles for both sides.</p>
<h2 id="declaring-a-streaming-response-in-openapi">Declaring a streaming response in OpenAPI</h2>
<p>In your OpenAPI spec, a streaming endpoint looks like a regular response with <code>text/event-stream</code> or <code>application/x-ndjson</code> as the media type, plus an <code>itemSchema</code> that describes the shape of each individual item:</p>
<pre><code class="language-yaml">/chat:
  post:
    operationId: startVetChat
    responses:
      "200":
        content:
          text/event-stream:
            schema:
              type: array
              items:
                $ref: "#/components/schemas/ChatChunk"
</code></pre>
<p>The generator reads this and produces typed infrastructure on both sides.</p>
<h2 id="server-the-writer-callback">Server: the writer callback</h2>
<p>On the server, the generated result factory doesn't return a body directly. Instead, it accepts a writer callback. This is an async delegate that appends typed items to a stream. The generated infrastructure handles serialization and framing:</p>
<pre><code class="language-csharp">public ValueTask&lt;StartVetChatResult&gt; HandleStartVetChatAsync(
    StartVetChatParams parameters,
    JsonWorkspace workspace,
    CancellationToken cancellationToken = default)
{
    return new(StartVetChatResult.Ok(static async (stream, cancellationToken) =&gt;
    {
        ChatChunk greeting = ChatChunk.ParseValue(
            """{"delta":"Hello! ","done":false}"""u8);
        await stream.AppendChatChunk(greeting, cancellationToken);

        ChatChunk answer = ChatChunk.ParseValue(
            """{"delta":"How can I help?","done":true}"""u8);
        await stream.AppendChatChunk(answer, cancellationToken);
    }));
}
</code></pre>
<p>Each call to <code>AppendChatChunk</code> serializes the item as compact JSON and writes the SSE frame (<code>data: {"delta":"Hello! ","done":false}\n\n</code>) to the response stream. The response stays open until the callback returns. There's no explicit "end stream" method. When you're done appending, you just return, and the generated endpoint flushes and closes the HTTP response.</p>
<p>If the client disconnects mid-stream, the cancellation token fires. Your callback can check it between items, or simply let the next <code>Append</code> call throw <code>OperationCanceledException</code>. Either way, you don't leak connections.</p>
<h2 id="client-iasyncenumerable-of-typed-documents">Client: IAsyncEnumerable of typed documents</h2>
<p>On the client side, the generated response type exposes <code>EnumerateOkItems()</code>. It is an <code>IAsyncEnumerable</code> that yields pooled, typed documents as they arrive:</p>
<pre><code class="language-csharp">await using StartVetChatResponse chatResponse = await chatClient.StartVetChatAsync(
    body: new ChatRequest.Source((ref ChatRequest.Builder b) =&gt;
    {
        b.Create(question: "My cat won't eat. What should I do?"u8);
    }));

await foreach (ParsedJsonDocument&lt;ChatChunk&gt; chunk in chatResponse.EnumerateOkItems())
{
    using (chunk)
    {
        Console.Write(chunk.RootElement.Delta);
    }
}
</code></pre>
<p>Each <code>chunk</code> is a pooled document. The <code>using</code> inside the loop returns the pooled memory after you've read the item, so you never accumulate the full stream in memory. This matters for long-running streams where thousands of items might flow through.</p>
<p>The generated code handles the SSE frame parsing transparently. It strips the <code>data:</code> prefix, handles multi-line <code>data</code> fields, ignores comment lines, and recognises the double-newline boundary. You just iterate typed objects.</p>
<h2 id="sse-metadata-event-ids-and-types">SSE metadata: event IDs and types</h2>
<p>Sometimes you need more than just the payload. SSE supports <code>id:</code> and <code>event:</code> fields that carry stream position and event discrimination. If you need those, use <code>EnumerateOkSseItems()</code> instead:</p>
<pre><code class="language-csharp">await foreach (SseItem&lt;ChatChunk&gt; item in chatResponse.EnumerateOkSseItems())
{
    using (item.Document)
    {
        Console.WriteLine($"Event ID: {item.Id}, Type: {item.EventType}");
        Console.Write(item.Document.RootElement.Delta);
    }
}
</code></pre>
<p>This is particularly useful for reconnection scenarios where you need to send a <code>Last-Event-ID</code> header to resume from where you left off.</p>
<h2 id="ndjson-the-simpler-framing">NDJSON: the simpler framing</h2>
<p>For endpoints that use <code>application/x-ndjson</code>, the pattern is identical from your perspective. On the server, the same writer callback appends typed items:</p>
<pre><code class="language-csharp">public ValueTask&lt;StreamPetActivityResult&gt; HandleStreamPetActivityAsync(
    StreamPetActivityParams parameters,
    JsonWorkspace workspace,
    CancellationToken cancellationToken = default)
{
    return new(StreamPetActivityResult.Ok(static async (stream, cancellationToken) =&gt;
    {
        ActivityEvent checkIn = ActivityEvent.ParseValue(
            """{"eventId":"evt-1","timestamp":"2026-05-30T18:00:00Z","type":"check-in","description":"Bella checked in"}"""u8);
        await stream.AppendActivityEvent(checkIn, cancellationToken);
    }));
}
</code></pre>
<p>The difference is purely in the wire format: NDJSON writes <code>{...}\n</code> (one line per item, no <code>data:</code> prefix). The client-side <code>EnumerateOkItems()</code> works the same way, yielding typed documents one per line.</p>
<p>NDJSON is a better fit when you don't need SSE's reconnection semantics or event typing. It's simpler, slightly more compact, and easier to process with command-line tools like <code>jq</code>.</p>
<h2 id="cancellation-and-backpressure">Cancellation and backpressure</h2>
<p>The writer callback model gives you natural backpressure. Each <code>Append</code> call is awaitable. If the transport buffer is full because the client isn't reading fast enough, <code>Append</code> won't complete until there's space. You don't need to implement flow control yourself.</p>
<p>For long-running streams (think a real-time telemetry feed), combine the cancellation token with your data source:</p>
<pre><code class="language-csharp">return new(StreamTelemetryResult.Ok(async (stream, cancellationToken) =&gt;
{
    await foreach (SensorReading reading in sensorService.GetReadingsAsync(cancellationToken))
    {
        await stream.AppendSensorReading(reading, cancellationToken);
    }
}));
</code></pre>
<p>When the client disconnects, the token cancels, the <code>await foreach</code> exits, the callback returns, and the response closes. Clean.</p>
<h2 id="whats-next">What's next</h2>
<p>In the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-authentication">next post</a>, we'll look at authentication - generated OAuth2 scope constants, Entra ID integration, API keys, and cookie-based auth, all using standard .NET middleware patterns.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">OpenAPI Code Generation with Corvus</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Typed HTTP Clients</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Server Stubs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">End-to-End</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">4.</span>
                <span class="series-toc__part-title">Streaming</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-authentication" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Authentication</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Callbacks, Webhooks and Links</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>OpenAPI Code Generation with Corvus: End-to-End</title>
      <description>Wire a Corvus-generated OpenAPI client to a generated server over real HTTP - both sides validated against the same spec, with generated OAuth2 scope constants for type-safe authentication.</description>
      <link>https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end</link>
      <guid isPermaLink="true">https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end</guid>
      <pubDate>Wed, 15 Jul 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>OpenAPI</category>
      <category>code-generation</category>
      <category>ASP.NET Core</category>
      <category>authentication</category>
      <category>OAuth2</category>
      <category>low-allocation</category>
      <category>system.text.json</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/openapi-code-generation-with-corvus-part-03.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the previous two posts we generated a <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients">typed HTTP client</a> and <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs">server stubs</a> from the same OpenAPI spec. This time we wire them together. A generated client calls a generated server over real HTTP, and we look at authentication with generated scope constants.</p>
<h2 id="why-both-sides-from-one-spec">Why both sides from one spec?</h2>
<p>The whole point of a contract-first approach is that the client and server <em>agree</em>. If you generate only the client, you can still drift on the server side. If you generate only the server, clients can send malformed requests that pass your hand-written validation but violate the spec.</p>
<p>When both sides are generated from the same specification, the schema contract is enforced at both boundaries. If the client builds a request that satisfies its generated validation, the server will accept it. If either side drifts from the spec, regeneration breaks the build. That's the feedback loop we want.</p>
<h2 id="setting-up-the-round-trip">Setting up the round-trip</h2>
<p>Generate both sides with separate namespaces:</p>
<pre><code class="language-bash">corvusjson openapi-client petstore.json \
    --rootNamespace Petstore.Client \
    --outputPath ./Generated/Client

corvusjson openapi-server petstore.json \
    --rootNamespace Petstore.Server \
    --outputPath ./Generated/Server
</code></pre>
<p>Start the server with ASP.NET Core minimal APIs:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.OpenApi;
using Corvus.Text.Json.OpenApi.HttpTransport;
using Petstore.Server;
using Petstore.Client;

WebApplicationBuilder builder = WebApplication.CreateBuilder();
WebApplication app = builder.Build();

PetsHandler handler = new();
app.MapApiEndpoints(handler);

await app.StartAsync();
string serverUrl = app.Urls.First();
</code></pre>
<p>Connect the client via <code>HttpClientTransport</code>:</p>
<pre><code class="language-csharp">using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl) };
await using HttpClientTransport transport = new(httpClient);
await using ApiPetsClient client = new(transport);
</code></pre>
<p>Both sides validate against the same JSON Schema. If the client builds a valid request, the server accepts it. If either side drifts from the spec, validation fails at the boundary.</p>
<h2 id="full-round-trip">Full round-trip</h2>
<p>Create a resource through the client, receive it from the server:</p>
<pre><code class="language-csharp">await using CreatePetResponse createResponse = await client.CreatePetAsync(
    session_token: "admin-token"u8,
    body: new NewPet.Source((ref NewPet.Builder b) =&gt;
    {
        b.Create(
            name: "Luna"u8,
            status: "available"u8,
            tags: new NewPet.JsonStringArray.Source(
                (ref NewPet.JsonStringArray.Builder ab) =&gt;
                {
                    ab.AddItem("friendly"u8);
                    ab.AddItem("vaccinated"u8);
                }));
    }));

createResponse.MatchResult(
    matchCreated: pet =&gt;
    {
        Console.WriteLine($"Created: [{pet.Id}] {pet.Name}");
        return 0;
    },
    matchDefault: error =&gt;
    {
        Console.WriteLine($"Error: {error.Message}");
        return 0;
    });
</code></pre>
<p>The generated server handler receives pre-validated parameters and returns typed results:</p>
<pre><code class="language-csharp">public ValueTask&lt;CreatePetResult&gt; HandleCreatePetAsync(
    CreatePetParams parameters,
    JsonWorkspace workspace,
    CancellationToken cancellationToken = default)
{
    return new(CreatePetResult.Created(
        body: new Pet.Source((ref Pet.Builder pb) =&gt;
        {
            pb.Create(
                id: 42,
                name: parameters.Body.Name,
                status: parameters.Body.Status);
        }),
        workspace: workspace));
}
</code></pre>
<h2 id="whats-next">What's next</h2>
<p>So far we've focused on the request/response basics. In the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-streaming">next post</a>, we'll look at streaming - SSE and NDJSON responses where the server pushes a sequence of typed items and the client consumes them as <code>IAsyncEnumerable</code>.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">OpenAPI Code Generation with Corvus</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Typed HTTP Clients</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Server Stubs</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">End-to-End</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-streaming" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Streaming</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-authentication" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Authentication</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Callbacks, Webhooks and Links</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>OpenAPI Code Generation with Corvus: Server Stubs</title>
      <description>Generate ASP.NET Core server stubs from OpenAPI specs with Corvus - handler interfaces with pre-validated parameters, typed result factories, and automatic Problem Details responses for invalid requests.</description>
      <link>https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs</link>
      <guid isPermaLink="true">https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs</guid>
      <pubDate>Tue, 14 Jul 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>OpenAPI</category>
      <category>code-generation</category>
      <category>ASP.NET Core</category>
      <category>REST</category>
      <category>low-allocation</category>
      <category>system.text.json</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/openapi-code-generation-with-corvus-part-02.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients">previous post</a> we generated a strongly-typed HTTP client from an OpenAPI spec.</p>
<p>Now let's flip to the server side.</p>
<h2 id="the-validation-tax">The validation tax</h2>
<p>If you've built an ASP.NET Core API by hand, you know how much code goes into <em>not</em> doing business logic. You parse a query parameter, check it's present, check it's the right type, check it's within range, parse the body, validate required fields, and return a 400 with a useful message if anything is off. You can easily spend more lines on input validation than on the actual operation.</p>
<p>And there's a worse problem: drift. The spec says <code>limit</code> has a maximum of 100. A developer adds a handler that doesn't enforce it. The client and server disagree about the contract, and nobody notices until a customer reports broken pagination.</p>
<p>We wanted a server-side story where your handler <em>only</em> contains business logic. All the parsing, validation, and error response generation come directly from the spec. If the spec changes, the handler's signature changes, and the compiler tells you what to fix.</p>
<h2 id="what-the-generator-produces">What the generator produces</h2>
<pre><code class="language-bash">corvusjson openapi-server petstore.json \
    --rootNamespace Petstore.Server \
    --outputPath ./Generated

dotnet add package Corvus.Text.Json.OpenApi
dotnet add package Corvus.Text.Json
</code></pre>
<p>You get:</p>
<ul>
<li>A <strong>handler interface</strong> (<code>IApiPetsHandler</code>) - one async method per operation. This is the only thing you implement.</li>
<li><strong>Endpoint registration</strong> (<code>MapApiEndpoints</code>) - a single extension method that wires all routes with correct HTTP methods and path templates.</li>
<li><strong>Params structs</strong> - strongly-typed, already-validated request parameters and bodies.</li>
<li><strong>Result structs</strong> - factory methods for each response status code, so you can't accidentally return the wrong shape.</li>
<li><strong>Model types</strong> - the same zero-allocation models from client generation.</li>
</ul>
<h2 id="your-handler-never-validates">Your handler never validates</h2>
<p>This is the core principle. The generated middleware runs the full validation pipeline <em>before</em> your handler is called:</p>
<pre><code class="language-text">HTTP Request arrives
  → Parse path/query/header/cookie params
  → Validate against JSON Schema
  → Parse and validate request body
  → If anything is invalid: return 400 Problem Details
  → Your Handler runs (everything is guaranteed valid)
  → Validate response body, serialize, write HTTP
</code></pre>
<p>If a required parameter is missing, schema validation fails, or the body can't be parsed, the request never reaches your code. The generated middleware returns a properly formatted Problem Details response. Your handler only sees valid, typed data.</p>
<h2 id="wiring-it-up">Wiring it up</h2>
<p>The setup is deliberately minimal:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Petstore.Server;
using Petstore.Server.Models;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

PetsHandler handler = new();
app.MapApiEndpoints(handler);

app.Run();
</code></pre>
<p><code>MapApiEndpoints</code> registers every route from the spec with the correct HTTP method and path template. You don't write <code>app.MapGet(...)</code> by hand. If the spec adds a new operation, the handler interface gains a new method, and the compiler tells you to implement it.</p>
<h2 id="implementing-business-logic">Implementing business logic</h2>
<p>Here's what a handler looks like. Notice what's <em>missing</em>. You don't parse parameters, you don't validate schemas, and you don't handle protocol-level errors. You write business logic (including any business-logic errors you need to surface), and the generated infrastructure takes care of the rest:</p>
<pre><code class="language-csharp">internal sealed class PetsHandler : IApiPetsHandler
{
    public ValueTask&lt;ListPetsResult&gt; HandleListPetsAsync(
        ListPetsParams parameters,
        JsonWorkspace workspace,
        CancellationToken cancellationToken = default)
    {
        // parameters.Limit is already validated - guaranteed &lt;= 100
        ListPetsResult result = ListPetsResult.Ok(
            body: new Pets.Source((ref Pets.Builder b) =&gt;
            {
                b.AddItem(new Pet.Source((ref Pet.Builder pb) =&gt;
                {
                    pb.Create(id: 1, name: "Luna"u8, tag: "cat"u8);
                }));
            }),
            workspace: workspace);

        return new(result);
    }
}
</code></pre>
<p>The <code>JsonWorkspace</code> provides pooled memory for building the response body. The <code>Result</code> factory methods mirror the spec's declared status codes. <code>ListPetsResult.Ok(...)</code> returns a 200, and <code>CreatePetResult.Created(...)</code> returns a 201. You can't accidentally return a 200 body with a 201 status.</p>
<h2 id="typed-results-prevent-response-drift">Typed results prevent response drift</h2>
<p>Each operation gets a result type with factory methods matching the spec:</p>
<pre><code class="language-csharp">public ValueTask&lt;CreatePetResult&gt; HandleCreatePetAsync(
    CreatePetParams parameters,
    JsonWorkspace workspace,
    CancellationToken cancellationToken = default)
{
    // parameters.Body.Name is guaranteed present - schema says required
    string name = (string)parameters.Body.Name;

    return new(CreatePetResult.Created(
        body: new Pet.Source((ref Pet.Builder pb) =&gt;
        {
            pb.Create(id: 42, name: name.AsSpan(), tag: "dog"u8);
        }),
        workspace: workspace));
}
</code></pre>
<p>The generated code also validates <em>your response</em> before writing it. If your handler builds a response body that violates the output schema, you get a 500 in development. A silently malformed response does not reach clients.</p>
<h2 id="whats-next">What's next</h2>
<p>In the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end">next post</a>, we'll wire both sides together - a generated client calling a generated server over real HTTP - and see how the contract guarantee works in practice.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">OpenAPI Code Generation with Corvus</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Typed HTTP Clients</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">Server Stubs</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">End-to-End</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-streaming" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Streaming</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-authentication" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Authentication</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Callbacks, Webhooks and Links</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>OpenAPI Code Generation with Corvus: Typed HTTP Clients</title>
      <description>Generate strongly-typed HTTP clients from OpenAPI 3.x specs using the Corvus CLI - zero-allocation models, exhaustive response matching, and built-in schema validation with a single dotnet tool command.</description>
      <link>https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients</link>
      <guid isPermaLink="true">https://endjin.com/blog/openapi-code-generation-with-corvus-typed-http-clients</guid>
      <pubDate>Mon, 13 Jul 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>OpenAPI</category>
      <category>code-generation</category>
      <category>HTTP</category>
      <category>REST</category>
      <category>low-allocation</category>
      <category>system.text.json</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/openapi-code-generation-with-corvus-part-01.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-schema-validation">previous posts</a> we introduced V5's core JSON Schema engine - zero-allocation models, pooled memory, and full schema validation.</p>
<p>Now let's apply that engine to a higher-level problem: generating HTTP clients from OpenAPI specifications.</p>
<h2 id="the-silent-bug-factory">The silent bug factory</h2>
<p>If you've consumed a REST API from .NET, you've probably written the same kind of glue code dozens of times: constructing a URI, serializing a body, sending the request, checking the status code, then deserializing the response and hoping you got the property names right. Each of those steps is a chance for a silent bug. A misspelt query parameter won't fail at compile time. A missing required header won't surface until the server rejects it. A status code you forgot to handle won't manifest until production.</p>
<p>The frustrating thing is that OpenAPI specifications already contain all the information you need to get this right. The spec knows which parameters go where, what shape the body should be, and which status codes the server can return. But most code generators produce loose wrapper classes that don't enforce any of that at compile time. You still end up writing validation logic and defensive checks by hand.</p>
<p>We wanted a generated client that actually <em>uses</em> the spec as a contract. One where the compiler refuses to build if you haven't handled all the declared response codes, where parameters are validated against their JSON Schema before the request ever hits the wire, and where the generated models use the same zero-allocation pooled memory from the rest of V5.</p>
<p>That's what <code>corvusjson openapi-client</code> gives you.</p>
<h2 id="why-not-kiota">Why not Kiota?</h2>
<p>The obvious question. <a href="https://learn.microsoft.com/openapi/kiota/">Microsoft Kiota</a> is the most widely-used OpenAPI client generator for .NET, and if you need multi-language support (Java, Go, TypeScript, Python) it's the right choice.</p>
<p>But Kiota makes different trade-offs. It generates class-based POCO models that allocate per response. It has no schema validation. Your client happily sends a malformed request and waits for the server to reject it. It doesn't generate server stubs, so you can't use one spec to keep both sides in sync. It doesn't surface per-operation OAuth2 scopes, so you end up hardcoding scope strings. And it uses exception-based error handling for non-2xx responses, which means error paths are invisible until they throw at runtime.</p>
<p>Corvus takes a different position: struct-based models backed by pooled memory (12–17× less allocation than Kiota in benchmarks), built-in schema validation that's <em>still</em> 2–4× faster than Kiota without validation, exhaustive <code>MatchResult</code> for response codes enforced by the compiler, and generated server stubs from the same spec. The cost is that it's .NET only.</p>
<p>If you're building a .NET service where latency and allocation matter, where you want both client and server from one contract, and where the compiler should catch missing error paths, that's the Corvus sweet spot.</p>
<h2 id="what-comes-out">What comes out</h2>
<p>The full reference documentation is on the <a href="https://corvus-oss.org/Corvus.JsonSchema/docs/open-api.html">Corvus.JsonSchema OpenAPI guide</a>, and there's an interactive <a href="https://corvus-oss.org/Corvus.JsonSchema/playground-openapi/">OpenAPI playground</a> where you can paste a spec and see the generated code immediately. This post focuses on the design intent and the key patterns.</p>
<p>Point the generator at any OpenAPI 3.x spec (3.0, 3.1, or 3.2):</p>
<pre><code class="language-bash">dotnet tool install --global Corvus.Json.Cli
dotnet add package Corvus.Text.Json.OpenApi.HttpTransport
dotnet add package Corvus.Text.Json

corvusjson openapi-client petstore.json \
    --rootNamespace Petstore.Client \
    --outputPath ./Generated
</code></pre>
<p>You get four things:</p>
<ul>
<li>A <strong>client class</strong> that orchestrates the full request lifecycle - parameter serialization, schema validation, HTTP transport, response parsing</li>
<li><strong>Request structs</strong> that serialize path, query, header, and cookie parameters into the correct wire format</li>
<li><strong>Response structs</strong> with exhaustive <code>MatchResult</code> - one handler per declared status code, enforced by the compiler</li>
<li><strong>Model types</strong> in a <code>.Models</code> sub-namespace - the same zero-allocation JSON Schema types used throughout V5</li>
</ul>
<p>The key insight is that <em>all the information needed to build a correct HTTP request is already in the spec</em>. The generator just makes it impossible to get wrong.</p>
<h2 id="transport-as-an-abstraction">Transport as an abstraction</h2>
<p>The generated client doesn't know about <code>HttpClient</code>. It talks to an <code>IApiTransport</code> - an abstraction over how HTTP requests are sent:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.OpenApi;
using Corvus.Text.Json.OpenApi.HttpTransport;
using Petstore.Client;
using Petstore.Client.Models;

using HttpClient httpClient = new() { BaseAddress = new Uri("https://petstore.example.com/v1") };
await using HttpClientTransport transport = new(httpClient);
ApiPetsClient client = new(transport);
</code></pre>
<p>This matters for testing. Substitute an in-memory transport that returns canned responses, without needing a running server or mocking <code>HttpClient</code> internals. The generated code doesn't care.</p>
<h2 id="exhaustive-response-handling">Exhaustive response handling</h2>
<p>This is the design choice we're most opinionated about.</p>
<p>When an API declares multiple response codes, you should handle all of them. Not just the happy path. Most HTTP client libraries make the error path easy to ignore. A 404 slips through, an unexpected 503 crashes at runtime, and nobody notices until production.</p>
<p>The generated response types enforce this with <code>MatchResult</code>:</p>
<pre><code class="language-csharp">await using ListPetsResponse listResponse = await client.ListPetsAsync(limit: 10);

listResponse.MatchResult(
    matchOk: pets =&gt;
    {
        foreach (Pet pet in pets.EnumerateArray())
        {
            Console.WriteLine($"[{pet.Id}] {pet.Name} (tag: {pet.Tag})");
        }

        return 0;
    },
    matchDefault: error =&gt;
    {
        Console.WriteLine($"Error {error.Code}: {error.Message}");
        return 0;
    });
</code></pre>
<p>If the spec declares a <code>404</code> response and you don't provide a <code>matchNotFound</code> handler, the code won't compile. Same principle as exhaustive <code>switch</code> expressions on discriminated unions. The compiler is your safety net.</p>
<h2 id="building-requests-without-allocations">Building requests without allocations</h2>
<p>Request bodies use the same <code>Source</code> and <code>Builder</code> pattern from the rest of V5 (if you're not familiar with this, the <a href="https://corvus-oss.org/Corvus.JsonSchema/">Corvus.JsonSchema documentation</a> covers the mutable document model in detail). A ref-struct builder constructs the JSON object in pooled memory, so there are no intermediate objects and no heap traffic:</p>
<pre><code class="language-csharp">await using CreatePetResponse createResponse = await client.CreatePetAsync(
    body: new NewPet.Source(static (ref NewPet.Builder b) =&gt;
    {
        b.Create(name: "Fido"u8, tag: "dog"u8);
    }));

createResponse.MatchResult(
    matchCreated: createdPet =&gt;
    {
        Console.WriteLine($"Created: [{createdPet.Id}] {createdPet.Name}");
        return 0;
    },
    matchDefault: error =&gt;
    {
        Console.WriteLine($"Error {error.Code}: {error.Message}");
        return 0;
    });
</code></pre>
<p>Required properties are mandatory parameters on <code>Create()</code>. Optional ones have defaults. Forget a required property? It won't compile. The schema contract is enforced at the <em>construction site</em>, not at serialization time.</p>
<h2 id="path-parameters-and-typed-headers">Path parameters and typed headers</h2>
<p>Path parameters are type-safe. The generated code URI-encodes the value and substitutes it into the path template. You never build a URL string by hand:</p>
<pre><code class="language-csharp">await using ShowPetByIdResponse showResponse = await client.ShowPetByIdAsync(petId: "pet-123"u8);

string message = showResponse.MatchResult&lt;string&gt;(
    matchOk: static pet =&gt; $"Found: {pet.Name}",
    matchDefault: static error =&gt; $"Not found: {error.Message}");
</code></pre>
<p>Response headers declared in the spec are surfaced as typed properties:</p>
<pre><code class="language-csharp">JsonString nextPage = listResponse.XNextHeader;
</code></pre>
<p>No more digging through <code>response.Headers</code> with magic strings.</p>
<h2 id="validation-before-the-wire">Validation before the wire</h2>
<p>Here's a subtle but important choice. The generated client validates all parameters and request bodies <em>before</em> sending the HTTP request.</p>
<p>Why? Because a cryptic 400 from the server tells you almost nothing. A client-side <code>ArgumentException</code> with the exact schema violation tells you everything.</p>
<pre><code class="language-csharp">try
{
    // limit: 200 exceeds the schema's "maximum: 100" constraint
    await using ListPetsResponse _ = await client.ListPetsAsync(
        limit: 200,
        validationMode: ValidationMode.Detailed);
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Validation caught: {ex.Message}");
}
</code></pre>
<p>You control the level per-call:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Behaviour</th>
<th>Use for</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ValidationMode.Basic</code></td>
<td>Validates; throws with a brief message</td>
<td>Production default</td>
</tr>
<tr>
<td><code>ValidationMode.Detailed</code></td>
<td>Full JSON Schema evaluation output</td>
<td>Development and debugging</td>
</tr>
<tr>
<td><code>ValidationMode.None</code></td>
<td>Skips validation entirely</td>
<td>Trusted inputs in hot paths</td>
</tr>
</tbody>
</table>
<p>Trust but verify. Catch bugs early in development, disable checks in hot paths where you <em>know</em> the data is valid.</p>
<h2 id="whats-next">What's next</h2>
<p>In the <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs">next post</a>, we'll flip to the server side - generating handler interfaces and ASP.NET Core endpoint registration from the same spec, so both sides of the API contract stay in sync automatically.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">OpenAPI Code Generation with Corvus</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Typed HTTP Clients</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-server-stubs" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Server Stubs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-end-to-end" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">End-to-End</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-streaming" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Streaming</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-authentication" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Authentication</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/openapi-code-generation-with-corvus-callbacks-and-webhooks" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Callbacks, Webhooks and Links</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Optimising DAX: Model Design Comparisons and Testing Tips</title>
      <description>Star schema vs flat table vs header/detail - which Power BI model design gives you the best performance? This post compares all three and covers practical testing tips.</description>
      <link>https://endjin.com/blog/optimising-dax-model-design-comparisons</link>
      <guid isPermaLink="true">https://endjin.com/blog/optimising-dax-model-design-comparisons</guid>
      <pubDate>Fri, 10 Jul 2026 05:30:00 GMT</pubDate>
      <category>Power BI</category>
      <category>DAX</category>
      <category>VertiPaq</category>
      <category>Performance</category>
      <category>Star Schema</category>
      <category>Data</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/optimising-dax-model-design-comparisons.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>Hello again. Over the last few posts in the Optimising DAX series, we've covered how VertiPaq stores data, encoding techniques, cardinality, and relationship costs. In this post, we're putting it all together to compare three common model designs and see how they perform in practice.</p>
<h2 id="the-comparison">The Comparison</h2>
<p>During the workshop, we loaded the same data into three different model designs in <a href="https://daxstudio.org/">DAX Studio</a> and compared the results:</p>
<p><strong>Star Schema</strong> - Dimension tables (customers, products, dates) linked to a central fact table.
<strong>Flat Table</strong> - Everything denormalised into a single wide table.
<strong>Header/Detail</strong> - Two fact tables linked by a primary key.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/model-design-comparison.png" alt="Model size comparison: star schema smallest, then flat table, then header/detail" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/model-design-comparison.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/model-design-comparison.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/model-design-comparison.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/model-design-comparison.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>In the example, star schema was the smallest, then flat table, then header/detail. Though it's always worth checking with your specific data!</p>
<h3 id="why-star-schema-wins">Why Star Schema Wins</h3>
<p>The dimension tables are small and compress well. The fact table contains foreign keys (medium cardinality) and measures. Everything benefits from good compression, and the relationships are through relatively low-cardinality keys.</p>
<h3 id="why-flat-tables-are-actually-okay">Why Flat Tables Are Actually Okay</h3>
<p>In a flat table, a lot more values are repeated. This sounds wasteful, but all those repeated customer names and product categories actually compress quite well via run-length encoding (if the sort order works out). The main downsides are that there are more columns to analyse for sort order (remember the 10-second budget), and scans over the flat table are bigger than scans over small dimension tables.</p>
<h3 id="why-headerdetail-is-usually-worst">Why Header/Detail Is Usually Worst</h3>
<p>The link between the two tables is an ID column - so with 100% cardinality, the biggest column is then repeated twice in the model. It gets almost no compression benefit. On top of that, every query traverses this high-cardinality relationship. As we discussed in the <a href="https://endjin.com/blog/optimising-dax-the-cost-of-relationships">previous post</a>: <strong>linking two large fact tables via their primary key is about the worst thing you can do</strong>.</p>
<h2 id="calculated-columns-sometimes-helpful">Calculated Columns: Sometimes Helpful</h2>
<p>Calculated columns have a bit of a bad reputation, and sometimes fairly. They increase model size and are computed during refresh. But there are cases where they can improve query performance.</p>
<p>Calculated columns can sometimes be a good idea, especially if the calculated column has lower cardinality than the columns it's derived from. For example, bucketing a high-precision decimal into ranges ("0-10", "10-20", etc.) creates a much lower-cardinality column that's faster to scan and filter.</p>
<p>But there really aren't any set rules here. Whether it helps depends on your data and queries - it always requires trying and testing.</p>
<h2 id="testing-tips">Testing Tips</h2>
<p>A few practical things from the workshop:</p>
<p><strong>Use real data for model testing.</strong> The distribution of values matters hugely for compression, so synthetic or sampled data can give misleading results.</p>
<p><strong>Enable Server Timings in DAX Studio</strong> to see how long different operations take. Scanning different columns takes different amounts of time - if you do a SUM over a column with high cardinality, it will take noticeably longer. A direct consequence of the compression mechanics.</p>
<p><strong>Measure, don't guess.</strong> Your intuition about which columns are problematic isn't always right. DAX Studio shows you what's actually happening.</p>
<h2 id="whats-next">What's Next</h2>
<p>That wraps up the model optimisation half of the series. Look out for the next post on how the formula engine and storage engine work together when you actually execute a DAX query.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Optimising DAX</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-series-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Series Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">How VertiPaq Stores Your Data</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">VertiPaq Encoding Techniques</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-why-cardinality-matters" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Why Cardinality Matters</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-the-cost-of-relationships" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">The Cost of Relationships</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">6.</span>
                <span class="series-toc__part-title">Model Design Comparisons</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Microsoft Fabric Workspace Topology Patterns</title>
      <description>Workspace topology is one of the first decisions you'll make in Microsoft Fabric, and one of the hardest to undo once you're underway. This post will help you start with the right foundations in place.</description>
      <link>https://endjin.com/blog/fabric-workspace-topology-patterns</link>
      <guid isPermaLink="true">https://endjin.com/blog/fabric-workspace-topology-patterns</guid>
      <pubDate>Thu, 09 Jul 2026 05:30:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>Analytics</category>
      <category>Data</category>
      <category>Data Fabric</category>
      <category>Data Mesh</category>
      <category>Data Strategy</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/04/fabric-workspace-topology-patterns.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<p>Workspace topology is one of the first decisions to make when adopting Microsoft Fabric, and one of the hardest to unpick later. This post walks through the common topology patterns that we implement and the decision drivers around each one, starting simple and progressively introducing granularity as complexity and scale demands. Get this right early and the right governance, deployment, and access control models will all flow naturally.</p>
<h2 id="why-topology-is-an-architectural-decision-not-an-admin-task">Why Topology Is an Architectural Decision, Not an Admin Task</h2>
<p>Workspace topology shapes your security boundaries, determines how independently teams can work, controls what you can isolate from production, and makes cost attribution either transparent or a guessing game. These are architectural problems, not admin problems.</p>
<p>We've seen teams struggle to refactor workspaces after they've gone live because they built everything in one space and later discovered they couldn't deploy changes safely, couldn't give data engineers access without exposing semantic layers to edit, or couldn't track costs by business domain.</p>
<p>Getting topology right at the start means everything else flows naturally - governance decisions inherit from it, your deployment pipeline makes sense, your access control model is simple to manage. Get it wrong and you're making exceptions, working around constraints, and potentially facing rework.</p>
<h2 id="pattern-1-the-single-workspace-and-when-its-enough">Pattern 1: The Single Workspace (and When It's Enough)</h2>
<p>Everything lives in one workspace: your pipelines, lakehouses, dataflows, reports.</p>
<p>This pattern is right for:</p>
<ul>
<li>Early exploration and proof of concept</li>
<li>A single person or a very small team</li>
<li>Something that hasn't gone to production yet</li>
</ul>
<p>The advantage is simplicity - no duplication, no complexity, no management overhead.</p>
<p>But the tipping point arrives very quickly, generally in one of three forms.</p>
<ol>
<li>You need environment separation, because you can't test changes without risking your live data.</li>
<li>More than one team/person needs to work on the same artefacts, and their access needs diverge (engineers can't be allowed to break reports, report builders shouldn't edit pipeline logic etc.).</li>
<li>Your reports start breaking because pipeline changes weren't tested in a safe space first.</li>
</ol>
<p>When any of these arrives, you've outgrown a single workspace. The good news is the decision to split is usually clear by that point.</p>
<h2 id="pattern-2-environment-separation-dev-qa-prod">Pattern 2: Environment Separation (Dev / QA / Prod)</h2>
<p>Separate workspaces per logical environment (relating to stages of the <a href="https://en.wikipedia.org/wiki/Systems_development_life_cycle">software development lifecycle</a>), with the same artefacts promoted through the pipeline: develop in dev, validate in QA, release to prod.</p>
<p>The bare minimum is two stages (dev and prod) but we'd always recommend at least three. Going straight from dev to prod is a reliable source of production incidents because someone always assumes another environment caught a problem that nobody actually tested. QA (or test, or staging) is where integration issues surface that unit tests miss.</p>
<p>But often, three environments isn't enough. The development/engineering/data team might need a dedicated integration environment to test their changes before sharing with business stakeholders. The business might want a dedicated pre-prod environment to validate changes against live data before promoting to prod. There might be a need for dedicated environments containing specific known sets of data for functional/load/performance testing scenarios.</p>
<p>The key is being clear about the purpose of each environment, and adopting a sensible and consistent naming convention. For example:</p>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>dev</code></td>
<td>Active development, experimental changes, shared sandbox</td>
</tr>
<tr>
<td><code>test</code></td>
<td>Integration testing, safe to break</td>
</tr>
<tr>
<td><code>qa</code></td>
<td>User acceptance testing of specific features, safe to break</td>
</tr>
<tr>
<td><code>preprod</code></td>
<td>Representative prod environment, real data, validated changes</td>
</tr>
<tr>
<td><code>prod</code></td>
<td>Live data, live reports, production consumers</td>
</tr>
</tbody>
</table>
<p>The tipping point to move beyond this pattern arrives when multiple independent use cases or business domains start sharing a workspace, because changes to one problem space affect the other, or when your access control needs diverge between them.</p>
<h2 id="pattern-3-splitting-data-engineering-and-reporting-workspaces">Pattern 3: Splitting Data Engineering and Reporting Workspaces</h2>
<p>Separate workspaces for data engineering artefacts (pipelines, lakehouses, dataflows) and Power BI reporting, even within the same environment.</p>
<p>This represents a step-change from Pattern 2 because it acknowledges that these aren't the same problem. Data engineers need to move fast and experiment, but report builders need stability and a clean semantic layer. A broken pipeline shouldn't take down a exec report.</p>
<p>Different deployment cadences matter here too. You might deploy data engineering changes several times a day, whereas reporting changes might be monthly. Different governance boundaries follow from that - depending on your Power BI licensing situation (and the nature of your report usage), you might not even need to assign a Fabric capacity to your reporting workspaces (internal users can use existing Power BI Pro licenses).</p>
<p>Building on the environment naming conventions above, we now need to add another dimension to reflect the type of workspace:</p>
<table>
<thead>
<tr>
<th>Workspace Type</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>de</code></td>
<td>Data engineering - pipelines, lakehouses, dataflows</td>
</tr>
<tr>
<td><code>rpt</code></td>
<td>Reporting - semantic models, reports, dashboards</td>
</tr>
</tbody>
</table>
<p>The tipping point to move beyond this is when multiple independent use cases or business domains need separate governance tracks, or when noisy-neighbour capacity issues start appearing (compute from one reporting workspace competing for resources with another).</p>
<h2 id="pattern-4-use-case-or-domain-bounding">Pattern 4: Use Case or Domain Bounding</h2>
<p>Each logical use case or business domain gets its own set of workspaces, rather than sharing a common pool.</p>
<p>A good way to think about this is that you're drawing security and ownership boundaries around a logic business problem and audience. Finance data engineering and supply chain data engineering are different problems, with different owners, different stakeholders, and different compliance concerns. Sharing workspaces between them creates artificial coupling.</p>
<p>Fabric Domains are the organisational layer that sits above this. One Domain per business unit or strategic capability, containing the workspace sets for all the use cases within that unit. This helps to unlock scaling as your Fabric deployment grows.</p>
<pre class="mermaid">flowchart TD
    D1["Domain: Finance"]
    D2["Domain: Operations"]
 
    subgraph F1["Use Case: Accounts"]
        direction LR
        F1DE["DE Workspaces&lt;br&gt;dev / QA / prod"]
        F1RPT["Reporting Workspaces&lt;br&gt;dev / QA / prod"]
        F1DE --&gt; F1RPT
    end
 
    subgraph F2["Use Case: Portfolios"]
        direction LR
        F2DE["DE Workspaces&lt;br&gt;dev / QA / prod"]
        F2RPT["Reporting Workspaces&lt;br&gt;dev / QA / prod"]
        F2DE --&gt; F2RPT
    end
 
    subgraph O1["Use Case: Logistics"]
        direction LR
        O1DE["DE Workspaces&lt;br&gt;dev / QA / prod"]
        O1RPT["Reporting Workspaces&lt;br&gt;dev / QA / prod"]
        O1DE --&gt; O1RPT
    end
 
    D1 --&gt; F1
    D1 --&gt; F2
    D2 --&gt; O1
</pre>
<p>The tipping point is when the data within a single use case becomes large or complex enough that different layers need independent governance or are owned by different teams. At that point, your next step is usually Pattern 5.</p>
<h2 id="pattern-5-separating-storage-from-compute">Pattern 5: Separating Storage from Compute</h2>
<p>The lakehouse (storage) gets its own workspace, separate from the workspace where pipelines and notebooks run.</p>
<p>Storage artefacts have a different lifecycle from compute artefacts. Your raw data warehouse might sit static for weeks, whereas your pipelines running against it might change daily. Separating them means you can govern them independently, upgrade compute logic without touching storage, and give different teams responsibility.</p>
<p>This also enables sharing. Multiple pipeline workspaces can read from a single curated lakehouse without being coupled to the engineering workspace that built it. The reporting workspaces get access to a clean Bronze/Silver/Gold layer without needing to understand the pipeline logic that produced it.</p>
<p>This approach also helps with cost transparency. Storage costs and compute costs separate naturally, making it clear which workload is actually expensive.</p>
<pre class="mermaid">flowchart TD
    PA["Pipeline Workspace A"]
    PB["Pipeline Workspace B"]
    LH["Lakehouse Workspace&lt;br&gt;Shared storage"]
    RPT["Reporting Workspace"]
 
    PA --&gt;|Write| LH
    PB --&gt;|Write| LH
    LH --&gt;|Curated data| RPT
</pre>
<p>The tipping point is when the data volume becomes large enough, or ownership and usage scenarios become complex enough, that different layers need independent governance and different teams managing them.</p>
<h2 id="pattern-6-medallion-layer-splits">Pattern 6: Medallion Layer Splits</h2>
<p>Bronze, Silver, and Gold layers each in their own workspace.</p>
<pre class="mermaid">flowchart LR
    B["Bronze Workspace&lt;br&gt;Raw ingestion&lt;br&gt;Copy jobs"]
    S["Silver Workspace&lt;br&gt;Curation &amp; cleansing&lt;br&gt;Pipelines / notebooks"]
    G["Gold Workspace&lt;br&gt;Semantic models&lt;br&gt;Pipelines / notebooks"]
    R["Reporting Workspace&lt;br&gt;Power BI / consumers"]
 
    B --&gt; S --&gt; G --&gt; R
</pre>
<p>This applies when raw ingestion, curation, and the semantic reporting layer are owned by different teams or funded by different business units, when the scale of raw data creates genuine noise for downstream consumers (massive ingestion workloads getting in the way of curation), or when you want easily allow and manage access to specific layers of the medallion (e.g. providing Silver layer access for data science experiments).</p>
<p>There's obviously a trade off - more workspaces means more overhead, more complex deployment pipelines, more moving parts to understand and govern.</p>
<p>Most organisations don't need to start here, this is the pattern you reach when the simpler patterns have proven insufficient.</p>
<hr>
<h2 id="choosing-your-starting-point">Choosing Your Starting Point</h2>
<p>The patterns progress from simple to complex, but also aren't necessarily directly linear. You might need use case bounding before environment separation, or storage splitting before data engineering/reporting splitting. Knowing which to start with matters more than understanding the full spectrum.</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>What It Solves</th>
<th>Tipping Point to Move On</th>
</tr>
</thead>
<tbody>
<tr>
<td>1: Single workspace</td>
<td>Simplicity for small teams</td>
<td>Need environment separation or multi-team access</td>
</tr>
<tr>
<td>2: Environment separation</td>
<td>Safe deployment pipeline</td>
<td>Multiple domains need isolation</td>
</tr>
<tr>
<td>3: DE/reporting split</td>
<td>Governance and access control</td>
<td>Noisy-neighbour issues or domain-specific needs</td>
</tr>
<tr>
<td>4: Use case bounding</td>
<td>Scaling beyond one use case</td>
<td>Complex ownership within a single use case</td>
</tr>
<tr>
<td>5: Storage/compute split</td>
<td>Lifecycle and reuse independence</td>
<td>Cost transparency or multi-team governance</td>
</tr>
<tr>
<td>6: Medallion splits</td>
<td>Layer-specific governance</td>
<td>Competing team priorities across layers</td>
</tr>
</tbody>
</table>
<p>In practice, the patterns are more of a multi-select set of options. For anything heading to production, you'll need Pattern 2 (environment separation). If you have any non-technical report builders in the picture, add the DE/reporting split (Pattern 3) from the outset. Both of these are investments that cost very little to implement early but become expensive to add later.</p>
<p>Our typical starting point for any engagement beyond the most trivial is a combination of patterns 2, 3, and 4. Workspaces that are bound by use case, separated by environment, and split out into data engineering and reporting.</p>
<p>This gives you roughly six workspaces per use case: two types (data engineering and reporting) multiplied by three environments (dev, QA, prod).</p>
<pre><code>                ┌─────────────────┐  promote  ┌─────────────────┐  promote  ┌─────────────────┐
Data            │   [use-case]    │ ────────▶ │   [use-case]    │ ────────▶ │   [use-case]    │
Engineering     │   -de-dev       │           │   -de-qa        │           │   -de-prod      │
                └────────┬────────┘           └────────┬────────┘           └────────┬────────┘
                         │ feeds                       │ feeds                       │ feeds
                         ▼                             ▼                             ▼
Reporting       ┌─────────────────┐  promote  ┌─────────────────┐  promote  ┌─────────────────┐
                │   [use-case]    │ ────────▶ │   [use-case]    │ ────────▶ │   [use-case]    │
                │   -rpt-dev      │           │   -rpt-qa       │           │   -rpt-prod     │
                └─────────────────┘           └─────────────────┘           └─────────────────┘
</code></pre>
<h2 id="summary">Summary</h2>
<p>Workspace topology in Microsoft Fabric is a foundational decision that shapes everything downstream - how you deploy changes safely, how you manage access across teams, and how clearly you can attribute costs to the workloads that generate them.</p>
<p>The patterns in this post represent a progression from simple to sophisticated, but they're not a linear checklist - most organisations will find their home somewhere in the middle. Understanding the obvious tipping points before you start should help you get the right foundation in place early to avoid costly rework.</p>]]></content:encoded>
    </item>
    <item>
      <title>Optimising DAX: The Cost of Relationships</title>
      <description>Relationships in Power BI aren't free. This post explains how the cardinality of the relationship key determines traversal cost, and why this has big implications for model design.</description>
      <link>https://endjin.com/blog/optimising-dax-the-cost-of-relationships</link>
      <guid isPermaLink="true">https://endjin.com/blog/optimising-dax-the-cost-of-relationships</guid>
      <pubDate>Thu, 02 Jul 2026 05:00:00 GMT</pubDate>
      <category>Power BI</category>
      <category>DAX</category>
      <category>VertiPaq</category>
      <category>Performance</category>
      <category>Data</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/optimising-dax-the-cost-of-relationships.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>Hello again. In the <a href="https://endjin.com/blog/optimising-dax-why-cardinality-matters">previous post</a>, we looked at why column cardinality is so important for compression and scan speed. But cardinality also has a major impact on something else: how expensive it is to follow relationships between tables.</p>
<p>This concept is important - and it has direct implications for model design. Look out for the next post where we'll put all of this together!</p>
<h2 id="relationship-cost-key-cardinality">Relationship Cost = Key Cardinality</h2>
<p>The cost of traversing a relationship in VertiPaq depends on the <strong>cardinality of the key column</strong>. A relationship with a small key (say, a handful of product categories) is cheap. A relationship with a high-cardinality key (say, CustomerKey across millions of customers) is expensive.</p>
<p>This sounds intuitive enough, but there's a subtlety that's easy to miss.</p>
<h2 id="the-subtle-bit">The Subtle Bit</h2>
<p>Consider this setup:</p>
<ul>
<li>A <strong>Customers</strong> table with <code>CustomerKey</code> and <code>Gender</code>.</li>
<li>An <strong>Orders</strong> table with <code>OrderNumber</code> and <code>CustomerKey</code>.</li>
</ul>
<p>If you want to slice orders by Gender (low cardinality), you might think that would be cheap - Gender only has a couple of unique values, right? But to resolve that filter, the engine has to traverse the <strong>CustomerKey relationship</strong>, which is high-cardinality. The cost is determined by the relationship key, not the column you're ultimately filtering by. So it's still relatively expensive.</p>
<h2 id="why-this-matters-for-model-design">Why This Matters for Model Design</h2>
<p>Now think about a header/detail model design, where you have two large tables linked by a primary key. That key has 100% cardinality - every value is unique. This means:</p>
<p><strong>Every single query</strong> that crosses that relationship pays the maximum possible traversal cost. It's not just that the model is large (because the ID column is stored in both tables with no compression benefit). It's that you're <em>also</em> constantly paying this high traversal cost on top.</p>
<p>It's a double hit. Thinking about header/detail designs - basically everything has to traverse that high-order relationship, so not only is the model huge, but you're constantly paying this cost. Linking two large fact tables via their primary key is about the worst thing you can possibly do.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Optimising DAX</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-series-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Series Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">How VertiPaq Stores Your Data</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">VertiPaq Encoding Techniques</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-why-cardinality-matters" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Why Cardinality Matters</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">5.</span>
                <span class="series-toc__part-title">The Cost of Relationships</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-model-design-comparisons" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Model Design Comparisons</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Announcing Fabric Weekly: a free Microsoft Fabric newsletter</title>
      <description>Fabric Weekly is a free weekly newsletter covering everything Microsoft Fabric. Sign up to stay on top of the latest features, updates, and community content.</description>
      <link>https://endjin.com/blog/announcing-fabric-weekly</link>
      <guid isPermaLink="true">https://endjin.com/blog/announcing-fabric-weekly</guid>
      <pubDate>Wed, 01 Jul 2026 05:30:00 GMT</pubDate>
      <category>Newsletter</category>
      <category>Microsoft Fabric</category>
      <category>Power BI</category>
      <category>Data Engineering</category>
      <category>OneLake</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/open-graph/fw-opengraph.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>We launched <a href="https://azureweekly.info/">Azure Weekly</a> back in 2014, and <a href="https://powerbiweekly.info/">Power BI Weekly</a> in 2019. Both have grown to thousands of subscribers worldwide and are still going strong. Microsoft Fabric has been evolving at an incredible pace, and we've found ourselves curating more and more Fabric content each week - so it felt like the right time to give it a dedicated home!</p>
<p><a href="https://fabricweekly.info/">Fabric Weekly</a> is a free weekly newsletter covering everything Microsoft Fabric - including:</p>
<ul>
<li>Data Engineering</li>
<li>Reporting &amp; Insights</li>
<li>Storage &amp; Platform</li>
<li>Governance &amp; Security</li>
<li>Management &amp; Cost</li>
<li>Copilot, AI &amp; Agents.</li>
</ul>
<p>Whether you're just getting started with Fabric or you're deep into production workloads, there's something for you each week.</p>
<h2 id="how-to-subscribe">How to subscribe</h2>
<p>You can <a href="https://fabricweekly.info/">sign up for the email newsletter</a> (you'll need to confirm your subscription, so check your junk mail!), read <a href="https://fabricweekly.info/">every issue on the website</a>, or subscribe to the <a href="https://fabricweekly.info/rss.xml">RSS feed</a> to be notified when a new issue lands.</p>
<h2 id="why-fabric-weekly">Why Fabric Weekly?</h2>
<p>Microsoft Fabric is moving fast. New features, GA announcements, and community content appear almost daily - keeping up with everything across OneLake, data pipelines, notebooks, Spark, SQL analytics endpoints, Power BI integration (and more!) is almost a full-time job in itself.</p>
<p>Fabric Weekly boils down all the most important updates, blog posts, videos, and community contributions into a single curated email each week - so you don't have to.</p>
<p>If Microsoft Fabric is part of your day job (or you're evaluating it), <a href="https://fabricweekly.info/">sign up to Fabric Weekly</a>, or the <a href="https://fabricweekly.info/rss.xml">RSS feed</a> and keep up to date with everything going on!</p>
<p>And, if you have any content you'd like included, or suggestions of feeds we should keep an eye on, drop us an email at <a href="mailto:fabricweekly@endjin.com">fabricweekly@endjin.com</a> or drop a message via Bluesky at <a href="https://bsky.app/profile/fabricweekly.info">@fabricweekly.info</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Optimising DAX: Why Cardinality Matters</title>
      <description>Column cardinality is one of the biggest factors in Power BI model size and query speed. This post explains why, and covers practical techniques for reducing it.</description>
      <link>https://endjin.com/blog/optimising-dax-why-cardinality-matters</link>
      <guid isPermaLink="true">https://endjin.com/blog/optimising-dax-why-cardinality-matters</guid>
      <pubDate>Tue, 30 Jun 2026 07:15:00 GMT</pubDate>
      <category>Power BI</category>
      <category>DAX</category>
      <category>VertiPaq</category>
      <category>Performance</category>
      <category>Data</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/optimising-dax-why-cardinality-matters.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>Hello again! In the <a href="https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques">previous post</a>, we covered VertiPaq's three encoding techniques (value encoding, hash encoding, and run-length encoding) for compressing your data. From all of that, we can see that alongside the number of columns, the biggest factor in how much room your model takes up is the <strong>cardinality</strong> of those columns - the number of unique values.</p>
<h2 id="why-cardinality-drives-everything">Why Cardinality Drives Everything</h2>
<p>Think back to the encoding techniques:</p>
<p><strong>Hash encoding</strong> stores a dictionary of unique values. Higher cardinality means a larger dictionary, which means more memory.</p>
<p><strong>Run-length encoding</strong> stores how many times values repeat in sequence. Higher cardinality means values change more frequently, so you get shorter runs and less compression.</p>
<p>So a column with 10 unique values and a million rows will compress brilliantly. A column with a million unique values and a million rows will barely compress at all.</p>
<p>And it's not just about memory. Scanning a high-cardinality column is slower too - there's simply more data to read through in the run-length encoded representation. If you do a <code>SUM</code> over a column with high cardinality, it will take noticeably longer than the same operation over a low-cardinality column.</p>
<h2 id="practical-ways-to-reduce-cardinality">Practical Ways to Reduce Cardinality</h2>
<p>Some columns are naturally high-cardinality and you can't do much about it (primary keys, for example). But there are often easy wins hiding in your model.</p>
<h3 id="datetimes">Datetimes</h3>
<p>Datetime columns are a classic culprit. A datetime with second-level precision across a few years can easily have millions of unique values. Some easy ways to reduce this:</p>
<ul>
<li><strong>Split the date and time</strong> into separate columns - times are repeated each day, and dates will have far lower cardinality than the combined column.</li>
<li><strong>Reduce time precision</strong> - do you really need seconds, or would hours suffice?</li>
<li><strong>Remove the time entirely</strong> if it's not needed for your analysis.</li>
</ul>
<h3 id="floating-point-precision">Floating-Point Precision</h3>
<p>Fix floating-point precision before import. A column of decimal values calculated to 15 decimal places has far higher cardinality than the same values rounded to 2 places. If your reporting doesn't need that precision, it's an easy win.</p>
<h3 id="pre-aggregation">Pre-Aggregation</h3>
<p>Group data prior to Power BI where you can. If you can aggregate in your ETL pipeline, you reduce both row count and column cardinality. This is particularly relevant if you're loading transactional data that could be summarised at a higher grain.</p>
<h2 id="finding-problem-columns">Finding Problem Columns</h2>
<p>You can use <a href="https://daxstudio.org/">DAX Studio</a> to inspect your model size and identify "problem" columns with unexpectedly high cardinality or large memory footprints. You can do this by opening your Power BI report, opening DAX studio, and connecting to the model. If you then go on the "Advanced" tab and click "View Metrics", this will run the VertiPaq analyzer and give you your model statistics (number of columns, column sizes, cardinality, etc.).</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/dax-model-analysis.png" alt="Screenshot of DAX Studio showing model statistics with high-cardinality columns highlighted" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/dax-model-analysis.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/dax-model-analysis.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/dax-model-analysis.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/dax-model-analysis.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>It's worth doing this periodically - you might be surprised which columns are taking up the most space.</p>
<h2 id="whats-next">What's Next</h2>
<p>So cardinality matters a lot for compression and scan speed. But it also has a major impact on something else: the cost of traversing relationships between tables. Look out for the next post where we'll dig into that.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Optimising DAX</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-series-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Series Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">How VertiPaq Stores Your Data</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">VertiPaq Encoding Techniques</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">4.</span>
                <span class="series-toc__part-title">Why Cardinality Matters</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-the-cost-of-relationships" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">The Cost of Relationships</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-model-design-comparisons" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Model Design Comparisons</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Auditing UK energy policy without a cluster: a laptop, a duck, and twenty years of wind</title>
      <description>&lt;p&gt;&lt;a href="https://endjin.com/who-we-are/our-people/barry-smart/"&gt;Barry Smart&lt;/a&gt;, Director of Data and AI at endjin, sets out to audit UK energy policy with his dad, a fellow energy-industry veteran, on a single laptop. Their question: is the dash to Net Zero quietly compromising the security, reliability, and affordability of energy? They answer it not with opinion but with twenty years of fragmented government data.&lt;/p&gt;
&lt;p&gt;Twenty-five years ago, work like this meant a fortune and a monolithic data warehouse. This time it is simple Python ingestion guarded by strict data contracts, a DuckDB medallion model, and requirements written in Gherkin as composable, fully tested functions, running where PySpark once failed to scale. The result: man-months of cost and &amp;quot;data fear&amp;quot; collapsed into a short laptop project, and a glimpse of how data teams freed from infrastructure can finally work as innovation teams.&lt;/p&gt;
&lt;h2 id="chapters"&gt;Chapters&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;amp;t=0s"&gt;00:00&lt;/a&gt; Introduction: endjin, DuckDB, and auditing UK energy policy&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;amp;t=50s"&gt;00:50&lt;/a&gt; The data challenge and lessons from a 25-year-old data warehouse&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;amp;t=100s"&gt;01:40&lt;/a&gt; Architecture: data contracts, Parquet, and a DuckDB medallion model&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;amp;t=160s"&gt;02:40&lt;/a&gt; A data-driven approach with Gherkin and composable relations&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;amp;t=220s"&gt;03:40&lt;/a&gt; Generating insights and why DuckDB scales where PySpark did not&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;amp;t=270s"&gt;04:30&lt;/a&gt; New ways of working for data teams&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/auditing-uk-energy-policy-without-a-cluster</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/auditing-uk-energy-policy-without-a-cluster</guid>
      <pubDate>Mon, 29 Jun 2026 05:30:00 GMT</pubDate>
      <category>DuckDB</category>
      <category>Data Engineering</category>
      <category>Medallion Architecture</category>
      <category>Parquet</category>
      <category>Python</category>
      <category>Gherkin</category>
      <category>Analytics</category>
      <category>Talk</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/duckcon-07-2026-auditing-uk-energy-policy-without-a-cluster.jpg" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p><a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart</a>, Director of Data and AI at endjin, sets out to audit UK energy policy with his dad, a fellow energy-industry veteran, on a single laptop. Their question: is the dash to Net Zero quietly compromising the security, reliability, and affordability of energy? They answer it not with opinion but with twenty years of fragmented government data.</p>
<p>Twenty-five years ago, work like this meant a fortune and a monolithic data warehouse. This time it is simple Python ingestion guarded by strict data contracts, a DuckDB medallion model, and requirements written in Gherkin as composable, fully tested functions, running where PySpark once failed to scale. The result: man-months of cost and "data fear" collapsed into a short laptop project, and a glimpse of how data teams freed from infrastructure can finally work as innovation teams.</p>
<h2 id="chapters">Chapters</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;t=0s">00:00</a> Introduction: endjin, DuckDB, and auditing UK energy policy</li>
<li><a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;t=50s">00:50</a> The data challenge and lessons from a 25-year-old data warehouse</li>
<li><a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;t=100s">01:40</a> Architecture: data contracts, Parquet, and a DuckDB medallion model</li>
<li><a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;t=160s">02:40</a> A data-driven approach with Gherkin and composable relations</li>
<li><a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;t=220s">03:40</a> Generating insights and why DuckDB scales where PySpark did not</li>
<li><a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms&amp;t=270s">04:30</a> New ways of working for data teams</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=Ub7Zuf_lLms"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/duckcon-07-2026-auditing-uk-energy-policy-without-a-cluster.jpg"></a></p><p>Thank you very much. Thanks for having me. My name is Barry Smart. I live in Scotland and I'm director of data and AI at endjin. We are a small, fully remote technology company based in the UK, but we've got clients all around the world. We're huge fans of DuckDB. We've written a series of blog posts that share our experiences adopting DuckDB in the field. So if you're interested, please go and have a look at our blogs.</p>
<p>All I want to talk to you about today is a personal project that I'm working on with my dad. We've both spent a lot of time over our careers in the energy industry, and we're keen to use that experience to audit energy policy in the UK. Our concern is that the dash to Net Zero is perhaps compromising the security, reliability, and affordability of energy. Now, there's a lot of opinion out there in the general press, and we're determined to form our own opinion, taking an evidence-based approach.</p>
<p>The data that we need to do that is readily available. It's published by a range of government agencies, but typical of this, it's very siloed and very difficult to work with. The good news is that I've worked with this data before. It was 25 years ago at Scottish Power. We spent a fortune and a lot of man-hours building a traditional monolithic data warehouse. Some of you might be old enough to remember this kind of thing.</p>
<p>And whilst I could reuse the domain knowledge from that experience, I didn't want to reuse that architecture. But thankfully, in the last 25 years, thanks to Moore's Law and, more recently, DuckDB, the most powerful analytics engine I've got access to is now actually on my desktop. It's my laptop, and that's what I've used for this project.</p>
<p>So I've got some simple Python modules to ingest the data, apply strict data contracts to what ends up landing in Parquet format as trusted data. Then in DuckDB I've got medallion layers. Each layer is a schema in the database. Bronze is zero-copy views over the Parquet data. Silver is where all the hard work gets done to aggregate and work with the data. I materialize data in tables and then I project views into gold to generate the insights that I'm working for. So all of this is running on my laptop, but I've got confidence at some point I can push this to the cloud.</p>
<p>So one small technical insight I've chosen is that I've used a data-driven approach to building this solution. I've written my requirements in Gherkin syntax, and this has helped to deliver clean, self-documenting code and an extensible architecture. So what it leads to is functions that look like this. We take in a DuckDB relation type, and we return a DuckDB relation type. So it makes it a unit of functionality that's easy to test, but it also then allows me to compose those units of functionality together to build more sophisticated, complex transformations. And then when it comes to materializing these relation types, I've still got all the benefits of DuckDB with query optimization, predicate pushdown, and the like.</p>
<p>So what that's allowed me to do, because I've got a suite of tests, I've built the functionality incrementally all along. Every new release, every new insight I'm generating, I've got confidence that what I'm showing is right because I've got that suite of tests. We've tried to adopt this approach with PySpark in the past, and it just doesn't scale. But DuckDB just eats this for breakfast. It's a really nice way of working.</p>
<p>So we can generate really detailed insights like this, looking at particular points in time when there's been a major event on the network. And we've also been able to zoom out to look at — now we can detect these events — we've been able to look at the macro scale across big chunks of the data set. So we've gone from many man-months, a whole lot of money, a whole lot of pain, definitely data fear, to a project that's taken me a very short space of time to deliver, with a bit of help from Claude Code along the way, running on my laptop using DuckDB.</p>
<p>But I think what's more impressive about this technology is not necessarily these numbers. It's the new ways of working that it's enabling. It's allowing data teams to not worry about complex infrastructure. They can deal straight with the data, get delivering insights and responding to the feedback from users. And it's really transforming the way that we work, the way that our clients work, because they can work as innovation teams. That socio-technical system that we've been bound to for all of these years is fundamentally changing, and we can really align ourselves to the priorities and value streams within the businesses that we work with.</p>
<p>Thank you very much.</p>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Migration, Analyzers, and What's Next</title>
      <description>Migrating from V4 to V5 is straightforward with Roslyn analyzers and code fixes. V4 isn't going away - both engines are maintained. Plus: 10 production analyzers to help you write correct, high-performance code.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next</guid>
      <pubDate>Fri, 26 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>migration</category>
      <category>analyzers</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-15.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and this is the final post in our series introducing the V5 engine. Over the previous thirteen posts, we've covered pooled parsing, mutable documents, source generators, schema validation, annotations, three query languages, YAML, JSON Patch, extended types, and TOON conversion.</p>
<p>Now let's wrap up with migration from V4 and the production analyzers that ship with V5.</p>
<h2 id="v4-isnt-going-away">V4 isn't going away</h2>
<p>Before we talk about migration, let's be clear: <strong>V4 is not deprecated.</strong> It continues to be maintained and is the right choice when you want the guarantees of an immutable document model.</p>
<table>
<thead>
<tr>
<th></th>
<th>V4</th>
<th>V5</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mutation</strong></td>
<td>Immutable - <code>With*()</code> returns new instance</td>
<td>Mutable - <code>Set*()</code> mutates in-place</td>
</tr>
<tr>
<td><strong>Safety</strong></td>
<td>No aliasing possible</td>
<td>Version-tracked stale detection</td>
</tr>
<tr>
<td><strong>Performance</strong></td>
<td>Good</td>
<td>Considerably faster</td>
</tr>
<tr>
<td><strong>Best for</strong></td>
<td>Thread-safe sharing, functional pipelines</td>
<td>High-throughput request/response</td>
</tr>
</tbody>
</table>
<p>Both engines ship in the same <code>corvusjson</code> CLI tool, share the same schema analysis engine, and support the same JSON Schema drafts. You choose which engine to target with the <code>--engine</code> flag.</p>
<h2 id="migration-path">Migration path</h2>
<p>If you do want to move from V4 to V5, the path is well-supported.</p>
<h3 id="quick-reference">Quick reference</h3>
<table>
<thead>
<tr>
<th>V4</th>
<th>V5</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Corvus.Json</code> namespace</td>
<td><code>Corvus.Text.Json</code> namespace</td>
</tr>
<tr>
<td><code>MyType.Parse(json)</code></td>
<td><code>ParsedJsonDocument&lt;MyType&gt;.Parse(json)</code></td>
</tr>
<tr>
<td><code>entity.Validate(ctx, level)</code></td>
<td><code>entity.EvaluateSchema()</code> or <code>entity.EvaluateSchema(collector)</code></td>
</tr>
<tr>
<td><code>entity.WithProperty(...)</code></td>
<td><code>mutable.SetProperty(...)</code> via builder</td>
</tr>
<tr>
<td><code>JsonAny</code>, <code>JsonString</code>, etc.</td>
<td><code>JsonElement</code> with <code>GetString()</code>, <code>GetInt32()</code>, etc.</td>
</tr>
<tr>
<td><code>--engine V4</code></td>
<td><code>--engine V5</code></td>
</tr>
</tbody>
</table>
<h3 id="migration-analyzers">Migration analyzers</h3>
<p>Install the migration analyzer package:</p>
<pre><code class="language-bash">dotnet add package Corvus.Text.Json.Migration.Analyzers
</code></pre>
<p>The analyzers detect V4 patterns - namespace usages, API calls, mutation patterns - and offer automatic Roslyn code fixes. In Visual Studio, you'll see lightbulb suggestions that convert V4 code to the V5 equivalent.</p>
<h3 id="copilot-assisted-migration">Copilot-assisted migration</h3>
<p>For larger migrations, <a href="https://github.com/features/copilot">GitHub Copilot</a> can help. The V5 documentation includes a <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/blob/main/docs/UsingCopilotForMigration.md">Copilot migration guide</a> with prompts designed to help Copilot understand the V4→V5 mapping and apply it across your codebase.</p>
<h2 id="production-analyzers">Production analyzers</h2>
<p>V5 ships with 10 Roslyn analyzers that help you write correct, high-performance code:</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>What it catches</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>CTJ001</strong></td>
<td>String literal where UTF-8 <code>u8</code> suffix would avoid transcoding</td>
</tr>
<tr>
<td><strong>CTJ002</strong></td>
<td>Unnecessary cast to .NET type when implicit conversion suffices</td>
</tr>
<tr>
<td><strong>CTJ003</strong></td>
<td>Match lambda that should be <code>static</code> to avoid closure allocation</td>
</tr>
<tr>
<td><strong>CTJ004</strong></td>
<td><code>ParsedJsonDocument</code> created without <code>using</code> or <code>Dispose()</code></td>
</tr>
<tr>
<td><strong>CTJ005</strong></td>
<td><code>JsonWorkspace</code> created without <code>using</code> or <code>Dispose()</code></td>
</tr>
<tr>
<td><strong>CTJ006</strong></td>
<td><code>JsonDocumentBuilder</code> created without <code>using</code> or <code>Dispose()</code></td>
</tr>
<tr>
<td><strong>CTJ007</strong></td>
<td><code>EvaluateSchema()</code> result discarded - probably a bug</td>
</tr>
<tr>
<td><strong>CTJ008</strong></td>
<td>String comparison where <code>NameEquals</code> would avoid allocation</td>
</tr>
<tr>
<td><strong>CTJ009</strong></td>
<td>Manual <code>Utf8JsonWriter</code> creation where workspace renting is available</td>
</tr>
<tr>
<td><strong>CTJ010</strong></td>
<td>String-based <code>Parse</code> where <code>ReadOnlyMemory&lt;byte&gt;</code> overload is available</td>
</tr>
</tbody>
</table>
<p>Six of these have automatic code fixes. CTJ004–CTJ006 are particularly important. They catch the most common mistake with pooled memory: forgetting to dispose.</p>
<p>There's also <strong>CTJ-NAV</strong>, a refactoring that lets you navigate from a schema-generated type directly to its JSON Schema source in your IDE.</p>
<h2 id="getting-started">Getting started</h2>
<pre><code class="language-bash"># Core library (includes analyzers)
dotnet add package Corvus.Text.Json

# Source generator
dotnet add package Corvus.Text.Json.SourceGenerator

# CLI tool
dotnet tool install --global Corvus.Json.Cli

# Optional: query languages
dotnet add package Corvus.Text.Json.Jsonata
dotnet add package Corvus.Text.Json.JMESPath
dotnet add package Corvus.Text.Json.JsonLogic

# Optional: YAML
dotnet add package Corvus.Text.Json.Yaml

# Optional: JSON Patch
dotnet add package Corvus.Text.Json.Patch

# Optional: dynamic validation
dotnet add package Corvus.Text.Json.Validator
</code></pre>
<p>The full documentation is at <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">github.com/corvus-dotnet/Corvus.JsonSchema</a>.</p>
<h2 id="series-recap">Series recap</h2>
<p>Over these fourteen posts, we've covered:</p>
<ol>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists">Why V5 Exists</a> - two engines, one toolchain, different trade-offs</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types">Source-Generated Types</a> - schema-first types with full IntelliSense</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation">Schema Validation</a> - over 10× faster, all major drafts</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing">Pooled-Memory Parsing</a> - 136 bytes per document</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents">Mutable Documents</a> - builder pattern with version tracking</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations">Standalone Evaluator</a> - annotations for schema-driven tooling</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata">JSONata</a> - query and transformation with 100% conformance</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath">JMESPath</a> - on average 28× faster JSON queries</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic">JsonLogic</a> - safe business rules as data</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml">YAML 1.2</a> - zero-allocation conversion with event streaming</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch">JSON Patch</a> - RFC 6902 with a fluent builder</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer">JSON Pointer</a> - zero-allocation path resolution</li>
<li><a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types">Extended Types</a> - UTF-8 URIs, BigNumber, and NodaTime</li>
<li>Migration, Analyzers, and What's Next <em>(this post)</em></li>
</ol>
<p>If you have questions, find bugs, or want to contribute, the <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">GitHub repository</a> is the place to go.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">15.</span>
                <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: TOON - Compact JSON for LLMs</title>
      <description>Corvus.Text.Json.Toon provides bidirectional TOON conversion - a compact text format that removes repeated property names and punctuation from JSON, reducing token count for LLM prompts with zero-allocation UTF-8 APIs.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-toon</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-toon</guid>
      <pubDate>Thu, 25 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>TOON</category>
      <category>LLM</category>
      <category>token-reduction</category>
      <category>low-allocation</category>
      <category>system.text.json</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-14.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types">previous post</a> we looked at extended types - URIs, BigNumber, and NodaTime. This time we're crossing into AI territory: how do you feed structured data to an LLM without burning through your token budget?</p>
<p>LLMs process tokens, not bytes. Every <code>{</code>, <code>}</code>, <code>"</code>, and repeated property name costs tokens. Those tokens cost money and latency. TOON (Token-Oriented Object Notation) is a compact text format that preserves the JSON data model while stripping extraneous detail, and making it easier for LLMs to interpret the content.</p>
<h2 id="the-problem-repeated-property-names">The problem: repeated property names</h2>
<p>Consider a 100-row array of objects - a common pattern when you feed query results or catalogue data into an LLM:</p>
<pre><code class="language-json">[
  {"id": 1, "name": "Alice", "score": 95},
  {"id": 2, "name": "Bob", "score": 87},
  {"id": 3, "name": "Carol", "score": 91}
]
</code></pre>
<p>The property names <code>id</code>, <code>name</code>, and <code>score</code> are repeated on every row. The braces, colons, and quotes add overhead that carries no new information after the first row.</p>
<p>In TOON, the same data is a table:</p>
<pre><code class="language-toon">[3]{id,name,score}:
  1,Alice,95
  2,Bob,87
  3,Carol,91
</code></pre>
<p>The field list appears once. Each row is a comma-delimited value list. For arrays with many rows, the token saving is substantial.</p>
<h2 id="packages">Packages</h2>
<table>
<thead>
<tr>
<th>Package</th>
<th>Dependency</th>
<th>Use when</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Corvus.Text.Json.Toon</code></td>
<td><code>Corvus.Text.Json</code></td>
<td>You want <code>ParsedJsonDocument&lt;T&gt;</code> and the Corvus document model</td>
</tr>
<tr>
<td><code>Corvus.Toon.SystemTextJson</code></td>
<td><code>System.Text.Json</code> only</td>
<td>You want TOON conversion without a dependency on <code>Corvus.Text.Json</code></td>
</tr>
</tbody>
</table>
<p>Install with:</p>
<pre><code class="language-bash">dotnet add package Corvus.Text.Json.Toon
</code></pre>
<p>or, for the lighter-weight package:</p>
<pre><code class="language-bash">dotnet add package Corvus.Toon.SystemTextJson
</code></pre>
<h2 id="parsing-toon-into-a-document">Parsing TOON into a document</h2>
<p>Parse TOON into the same pooled document model used by the rest of Corvus.Text.Json:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.Toon;

string toon = """
    name: Alice
    age: 30
    active: true
    scores[3]: 95,87,92
    """;

using ParsedJsonDocument&lt;JsonElement&gt; document = ToonDocument.Parse&lt;JsonElement&gt;(toon);
JsonElement root = document.RootElement;

Console.WriteLine($"Name:   {root.GetProperty("name").GetString()}");
Console.WriteLine($"Age:    {root.GetProperty("age").GetInt32()}");
Console.WriteLine($"Scores: {root.GetProperty("scores")}");
</code></pre>
<p>Output:</p>
<pre><code class="language-text">Name:   Alice
Age:    30
Scores: [95,87,92]
</code></pre>
<p>The returned <code>ParsedJsonDocument&lt;T&gt;</code> uses ArrayPool-backed memory - the same pooled lifetime model described in <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing">Part 4</a>.</p>
<h2 id="converting-toon-to-json">Converting TOON to JSON</h2>
<p>When you need a JSON string (e.g. to pass to another API):</p>
<pre><code class="language-csharp">using Corvus.Text.Json.Toon;

string toon = """
    [2]{id,name,score}:
      1,Alice,95
      2,Bob,87
    """;

string json = ToonDocument.ConvertToJsonString(toon);
// [{"id":1,"name":"Alice","score":95},{"id":2,"name":"Bob","score":87}]
</code></pre>
<h2 id="converting-json-to-toon">Converting JSON to TOON</h2>
<p>The reverse direction detects uniform object arrays and emits them as tables automatically:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.Toon;

string json = """[{"id":1,"name":"Alice","score":95},{"id":2,"name":"Bob","score":87}]""";
string toon = ToonDocument.ConvertToToonString(json);
</code></pre>
<p>Result:</p>
<pre><code class="language-toon">[2]{id,name,score}:
  1,Alice,95
  2,Bob,87
</code></pre>
<h2 id="zero-allocation-utf-8-path">Zero-allocation UTF-8 path</h2>
<p>For hot paths, you can write TOON directly to an <code>IBufferWriter&lt;byte&gt;</code>. There is no intermediate string allocation:</p>
<pre><code class="language-csharp">using System.Buffers;
using Corvus.Text.Json.Toon;

ArrayBufferWriter&lt;byte&gt; buffer = new(256);
ToonDocument.ConvertToToon(
    """[{"id":1,"name":"Alice","score":95},{"id":2,"name":"Bob","score":87}]"""u8,
    buffer);

ReadOnlySpan&lt;byte&gt; utf8Toon = buffer.WrittenSpan;
</code></pre>
<p>This measured <strong>0 B/op</strong> in benchmarks. Prefer the UTF-8 overloads whenever your input is already UTF-8 or your output destination accepts bytes.</p>
<h2 id="reader-and-writer-options">Reader and writer options</h2>
<h3 id="expanding-dotted-keys">Expanding dotted keys</h3>
<p>By default, <code>user.name</code> is a literal property name. Enable path expansion to convert it into nested JSON:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.Toon;

ToonReaderOptions options = new()
{
    ExpandPaths = ToonPathExpansion.Safe,
};

string json = ToonDocument.ConvertToJsonString(
    "user.name: Alice\nuser.age: 30",
    options);
// {"user":{"name":"Alice","age":30}}
</code></pre>
<h3 id="folding-nested-json-keys">Folding nested JSON keys</h3>
<p>The reverse operation folds nested objects into dotted keys in TOON output:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.Toon;

ToonWriterOptions options = new()
{
    KeyFolding = ToonKeyFolding.Safe,
};

using ParsedJsonDocument&lt;JsonElement&gt; document =
    ParsedJsonDocument&lt;JsonElement&gt;.Parse("""{"user":{"name":"Alice"},"active":true}""");

JsonElement root = document.RootElement;
string toon = ToonDocument.ConvertToToon(in root, options);
// user.name: Alice
// active: true
</code></pre>
<h3 id="all-options">All options</h3>
<table>
<thead>
<tr>
<th>Option</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ToonReaderOptions.Strict</code></td>
<td><code>true</code></td>
<td>Checks declared array counts and duplicate object keys</td>
</tr>
<tr>
<td><code>ToonReaderOptions.IndentSize</code></td>
<td><code>2</code></td>
<td>Spaces per indentation level</td>
</tr>
<tr>
<td><code>ToonReaderOptions.ExpandPaths</code></td>
<td><code>Off</code></td>
<td>Expands dotted keys into nested objects when <code>Safe</code></td>
</tr>
<tr>
<td><code>ToonWriterOptions.IndentSize</code></td>
<td><code>2</code></td>
<td>Spaces per indentation level</td>
</tr>
<tr>
<td><code>ToonWriterOptions.Delimiter</code></td>
<td><code>Comma</code></td>
<td>Delimiter for arrays and tables (<code>Comma</code>, <code>Pipe</code>, or <code>Tab</code>)</td>
</tr>
<tr>
<td><code>ToonWriterOptions.KeyFolding</code></td>
<td><code>Off</code></td>
<td>Folds nested objects into dotted keys when <code>Safe</code></td>
</tr>
<tr>
<td><code>ToonWriterOptions.FlattenDepth</code></td>
<td><code>int.MaxValue</code></td>
<td>Max path segments to fold</td>
</tr>
</tbody>
</table>
<h2 id="error-handling">Error handling</h2>
<p>Invalid TOON input throws <code>ToonException</code> with a 1-based line and column location:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.Toon;

try
{
    ToonDocument.ConvertToJsonString("[2]: 1");
}
catch (ToonException ex)
{
    Console.WriteLine(ex.Message);
    // Reports the line and column where parsing failed
}
</code></pre>
<h2 id="corvus-vs-cysharp">Corvus vs Cysharp</h2>
<p><a href="https://github.com/Cysharp/ToonEncoder">Cysharp's ToonEncoder</a> is an established .NET package for encoding <code>System.Text.Json</code> values to TOON. The key difference: Cysharp is an encoder (JSON → TOON only), while Corvus packages are bidirectional converters. If you need to consume TOON and produce JSON, use Corvus. If you only need to serialize POCOs to TOON, Cysharp may be the simpler fit.</p>
<p>Benchmarks on a 100-row person array show Corvus is <strong>1.04–1.74× faster</strong> for encoding, with the UTF-8 buffer path allocating <strong>0 B/op</strong> compared to Cysharp's 368–648 B.</p>
<h2 id="next-up">Next up</h2>
<p>In the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next">final post</a>, we'll cover migration from V4, the production analyzers, and how to get started.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">14.</span>
                <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Extended Types</title>
      <description>V5 extends the JSON type system with UTF-8 URI and IRI parsing, arbitrary-precision numerics via BigNumber, and first-class NodaTime date/time integration. All operating directly on the raw UTF-8 bytes.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types</guid>
      <pubDate>Wed, 24 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>BigNumber</category>
      <category>NodaTime</category>
      <category>URI</category>
      <category>IRI</category>
      <category>arbitrary-precision</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-13.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer">previous post</a> we looked at JSON Pointer resolution.</p>
<p>JSON has a deliberately simple type system - strings, numbers, booleans, null, objects, and arrays. But the data those types carry is often richer than the JSON grammar suggests. A string might be a URI. A number might have 50 significant digits. A date-time might need proper time zone handling. V5 extends the core type system with first-class support for all of these.</p>
<h2 id="utf-8-uris-and-iris">UTF-8 URIs and IRIs</h2>
<p>JSON Schema defines four URI-related format keywords: <code>uri</code>, <code>uri-reference</code>, <code>iri</code>, and <code>iri-reference</code>. V5 validates and parses all four with zero-allocation <code>ref struct</code> types that operate directly on the UTF-8 bytes in the document buffer.</p>
<h3 id="utf8uri-and-utf8iri">Utf8Uri and Utf8Iri</h3>
<p><code>Utf8Uri</code> is a <code>readonly ref struct</code> that parses a URI from a <code>ReadOnlySpan&lt;byte&gt;</code> without allocating. It gives you access to every component - scheme, authority, user, host, port, path, query, and fragment - as <code>ReadOnlySpan&lt;byte&gt;</code> slices into the original buffer:</p>
<pre><code class="language-csharp">Utf8Uri uri = Utf8Uri.CreateUri(
    "https://api.example.com:8080/v1/users?active=true#top"u8);

// Each component is a ReadOnlySpan&lt;byte&gt; slice - no allocation
ReadOnlySpan&lt;byte&gt; scheme = uri.Scheme;       // "https"
ReadOnlySpan&lt;byte&gt; host = uri.Host;           // "api.example.com"
ReadOnlySpan&lt;byte&gt; path = uri.Path;           // "/v1/users"
ReadOnlySpan&lt;byte&gt; query = uri.Query;         // "active=true"
ReadOnlySpan&lt;byte&gt; fragment = uri.Fragment;    // "top"
int port = uri.PortValue;                      // 8080
</code></pre>
<p>For schema-generated types with <code>"format": "uri"</code>, the code generator emits a <code>TryGetValue</code> method and an explicit conversion operator:</p>
<pre><code class="language-csharp">// Schema: { "type": "string", "format": "uri" }
// Generated type: MyEndpoint

if (endpoint.TryGetValue(out Utf8UriValue uriValue))
{
    using (uriValue)
    {
        Utf8Uri uri = uriValue.Uri;
        // Access components via uri.Scheme, uri.Host, uri.Path, etc.
    }
}

// Or via explicit cast (throws FormatException if invalid)
using Utf8UriValue uriValue = (Utf8UriValue)endpoint;
</code></pre>
<p><code>Utf8UriValue</code> is a regular (non-ref) struct that owns its backing memory. It implements <code>IDisposable</code>, so always use a <code>using</code> declaration so the backing buffer is returned to the pool.</p>
<h3 id="canonical-and-display-forms">Canonical and display forms</h3>
<p>URIs have two standard string representations. The <strong>canonical</strong> form percent-encodes reserved characters for safe transmission. The <strong>display</strong> form decodes those sequences for human readability:</p>
<pre><code class="language-csharp">Utf8Uri uri = Utf8Uri.CreateUri(
    "https://example.com/caf%C3%A9?q=hello%20world"u8);

// Display form: decodes percent-encoded sequences for readability
// "https://example.com/café?q=hello world"
string display = uri.ToString();

// Canonical form: percent-encodes reserved characters for safe transmission
Span&lt;byte&gt; buffer = stackalloc byte[256];
if (uri.TryFormatCanonical(buffer, out int written))
{
    // "https://example.com/caf%C3%A9?q=hello%20world"
    ReadOnlySpan&lt;byte&gt; canonical = buffer.Slice(0, written);
}

// Display form as UTF-8 bytes
if (uri.TryFormatDisplay(buffer, out written))
{
    ReadOnlySpan&lt;byte&gt; displayUtf8 = buffer.Slice(0, written);
}
</code></pre>
<p>Both methods write directly to a <code>Span&lt;byte&gt;</code> with no allocation. <code>ToString()</code> is the convenience overload that allocates a string for the display form.</p>
<h3 id="why-not-system.uri">Why not System.Uri?</h3>
<p><code>System.Uri</code> merges several distinct RFC concepts into a single type. It handles absolute URIs, relative references, and IRIs all through one class, which can be confusing. A method that accepts <code>System.Uri</code> gives no indication of whether it expects an absolute URI, a relative reference, or an IRI. V5 separates these into distinct types (<code>Utf8Uri</code>, <code>Utf8UriReference</code>, <code>Utf8Iri</code>, <code>Utf8IriReference</code>) so the semantic intent is clear at the API boundary.</p>
<p>Beyond the type-safety question, <code>System.Uri</code> allocates a managed <code>string</code> and normalises the URI, which can change its representation. The Utf8 variants validate and decompose the URI in place, with no allocation and no normalisation surprises. For JSON Schema format validation, this means checking whether a string is a valid <code>uri-reference</code> costs nothing beyond the parse itself.</p>
<p>All four types are derived from the .NET runtime's own <code>System.Uri</code> parser, rewritten to operate on UTF-8 spans rather than managed strings.</p>
<h2 id="arbitrary-precision-numerics">Arbitrary-precision numerics</h2>
<p>JSON has no precision limit on numbers. The string <code>99999999999999999999999999999.123456789</code> is perfectly valid JSON. But <code>double</code> gives you about 15 significant digits, and <code>decimal</code> gives you 28. Anything beyond that is silently truncated.</p>
<p>In practice, you will almost never need arbitrary-precision types. The vast majority of JSON numbers fit comfortably in <code>int</code>, <code>long</code>, <code>double</code>, or <code>decimal</code>. The right approach is to use the <code>format</code> keyword in your schema to bound your numeric types appropriately. Use <code>"format": "int32"</code>, <code>"format": "double"</code>, <code>"format": "decimal"</code>, and so on. The code generator will then select the matching .NET type, and you get compile-time safety for free.</p>
<p><code>BigNumber</code> and <code>BigInteger</code> exist for the vanishingly small number of scenarios where unbounded precision is genuinely required. That includes cryptographic values, scientific datasets with extreme precision, or financial interop where the source system sends numbers beyond 28 significant digits.</p>
<h3 id="how-v5-handles-numbers-internally">How V5 handles numbers internally</h3>
<p>V5 never converts a JSON number to a floating-point type during validation or comparison. Instead, it parses the raw UTF-8 bytes into normalised components:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Type</th>
<th>Example for <code>1.200e3</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>isNegative</code></td>
<td><code>bool</code></td>
<td><code>false</code></td>
</tr>
<tr>
<td><code>integral</code></td>
<td><code>ReadOnlySpan&lt;byte&gt;</code></td>
<td><code>"1"</code></td>
</tr>
<tr>
<td><code>fractional</code></td>
<td><code>ReadOnlySpan&lt;byte&gt;</code></td>
<td><code>"2"</code></td>
</tr>
<tr>
<td><code>exponent</code></td>
<td><code>int</code></td>
<td><code>2</code></td>
</tr>
</tbody>
</table>
<p>All comparison and validation operates on these components. A 500-digit JSON number is compared with perfect accuracy.</p>
<h3 id="bignumber-and-biginteger">BigNumber and BigInteger</h3>
<p>When you do need to materialise an arbitrary-precision value, there are two types. <code>BigNumber</code> handles decimal numbers (with a fractional part or exponent), while <code>BigInteger</code> handles integers of unlimited size:</p>
<pre><code class="language-csharp">using Corvus.Numerics;

// Arbitrary-precision decimal
BigNumber decimalValue = element.GetBigNumber();
BigNumber result = decimalValue * 2 + BigNumber.Parse("0.001");

// Arbitrary-precision integer
BigInteger intValue = element.GetBigInteger();
</code></pre>
<p><code>BigNumber</code> stores a <code>BigInteger</code> significand and an <code>int</code> exponent (<code>value = significand × 10^exponent</code>). Both types implement <code>INumber&lt;T&gt;</code> on .NET 9+, so they work with generic math APIs.</p>
<h3 id="formatting">Formatting</h3>
<p><code>BigNumber</code> implements <code>IFormattable</code>, <code>ISpanFormattable</code>, and <code>IUtf8SpanFormattable</code> on .NET 9+, and the static formatting methods are available on all targets including <code>netstandard2.0</code>. It works with string interpolation, <code>String.Format</code>, and direct span formatting. All the standard numeric format specifiers are supported:</p>
<pre><code class="language-csharp">BigNumber value = BigNumber.Parse("12345678901234567890.123456789");

value.ToString("G", CultureInfo.InvariantCulture);   // General: "12345678901234567890.123456789"
value.ToString("F2", CultureInfo.InvariantCulture);  // Fixed-point: "12345678901234567890.12"
value.ToString("N0", CultureInfo.InvariantCulture);  // Number with grouping: "12,345,678,901,234,567,890"
value.ToString("E3", CultureInfo.InvariantCulture);  // Scientific: "1.235E+019"
value.ToString("C", CultureInfo.GetCultureInfo("en-GB"));  // Currency: "£12,345,678,901,234,567,890.12"
</code></pre>
<p>For zero-allocation formatting, write directly to a UTF-8 byte span:</p>
<pre><code class="language-csharp">Span&lt;byte&gt; buffer = stackalloc byte[128];
if (value.TryFormat(buffer, out int bytesWritten, "F2", CultureInfo.InvariantCulture))
{
    ReadOnlySpan&lt;byte&gt; utf8Result = buffer.Slice(0, bytesWritten);
    // Use utf8Result directly - no string allocation
}
</code></pre>
<h3 id="extended-numeric-types-in-code-generation">Extended numeric types in code generation</h3>
<p>The code generator reads the JSON Schema <code>format</code> keyword to select the appropriate .NET type:</p>
<table>
<thead>
<tr>
<th>Format</th>
<th>.NET type</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>"int32"</code></td>
<td><code>int</code></td>
<td></td>
</tr>
<tr>
<td><code>"int64"</code></td>
<td><code>long</code></td>
<td></td>
</tr>
<tr>
<td><code>"int128"</code></td>
<td><code>Int128</code></td>
<td>.NET 9+ only; falls back to <code>long</code> on netstandard2.0</td>
</tr>
<tr>
<td><code>"uint128"</code></td>
<td><code>UInt128</code></td>
<td>.NET 9+ only; falls back to <code>ulong</code> on netstandard2.0</td>
</tr>
<tr>
<td><code>"half"</code></td>
<td><code>Half</code></td>
<td>.NET 9+ only; falls back to <code>double</code> on netstandard2.0</td>
</tr>
<tr>
<td><code>"single"</code></td>
<td><code>float</code></td>
<td></td>
</tr>
<tr>
<td><code>"double"</code></td>
<td><code>double</code></td>
<td></td>
</tr>
<tr>
<td><code>"decimal"</code></td>
<td><code>decimal</code></td>
<td></td>
</tr>
<tr>
<td>(none, <code>type: integer</code>)</td>
<td><code>long</code></td>
<td>Default for unformatted integers</td>
</tr>
<tr>
<td>(none, <code>type: number</code>)</td>
<td><code>double</code></td>
<td>Default for unformatted numbers</td>
</tr>
</tbody>
</table>
<p>For types that are only available on modern .NET, the code generator emits <code>#if NET</code> guards with appropriate fallbacks.</p>
<h2 id="nodatime-integration">NodaTime integration</h2>
<p>If you work with dates and times in .NET, <a href="https://nodatime.org/">NodaTime</a> is the de-facto library for rich date and time handling. It helps you think about your data more clearly and express operations on that data more precisely. V5 includes built-in UTF-8 parsers for ISO 8601 formats that produce NodaTime types directly, without going through <code>DateTime</code> or <code>DateTimeOffset</code> as an intermediate step.</p>
<table>
<thead>
<tr>
<th>JSON Schema format</th>
<th>NodaTime type</th>
<th>Example value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>"date"</code></td>
<td><code>LocalDate</code></td>
<td><code>"2026-05-31"</code></td>
</tr>
<tr>
<td><code>"date-time"</code></td>
<td><code>OffsetDateTime</code></td>
<td><code>"2026-05-31T10:30:00+01:00"</code></td>
</tr>
<tr>
<td><code>"time"</code></td>
<td><code>OffsetTime</code></td>
<td><code>"10:30:00+01:00"</code></td>
</tr>
<tr>
<td><code>"duration"</code></td>
<td><code>Period</code></td>
<td><code>"P1Y2M3DT4H5M6S"</code></td>
</tr>
</tbody>
</table>
<p>When the code generator encounters these format keywords, the generated types automatically include NodaTime-typed accessors alongside the standard .NET ones:</p>
<pre><code class="language-csharp">// Generated from a schema with "format": "date-time"
OffsetDateTime when = calendarEvent.When.GetOffsetDateTime();

// The standard .NET accessor is also available
DateTimeOffset whenDto = calendarEvent.When.GetDateTimeOffset();
</code></pre>
<p>The parsers operate directly on the UTF-8 bytes in the document buffer. There is no intermediate string allocation. The <code>NodaTimeExtensions</code> namespace includes custom implementations of the Gregorian calendar calculations needed for validation, so there's no runtime dependency on the NodaTime NuGet package. The parsing is self-contained.</p>
<div class="aside"><p>The NodaTime parsers handle the full complexity of ISO 8601 duration syntax, including fractional seconds, negative durations, and the distinction between date-based periods (<code>P1Y2M</code>) and time-based durations (<code>PT1H30M</code>). The <code>Period</code> type preserves the original components rather than normalising to a single unit, so <code>P1M</code> and <code>P30D</code> remain distinct.</p>
</div>
<h2 id="next-up">Next up</h2>
<p>In the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon">next post</a>, we'll look at TOON - a compact text format for JSON-shaped data that reduces token count when working with LLMs.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">13.</span>
                <span class="series-toc__part-title">Extended Types</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: JSON Pointer - Zero-Allocation Path Resolution</title>
      <description>Corvus.Text.Json V5 includes Utf8JsonPointer, a readonly ref struct that resolves RFC 6901 JSON Pointer paths directly against the pooled-memory document model with no allocation.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer</guid>
      <pubDate>Tue, 23 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>json-pointer</category>
      <category>performance</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-12.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>In the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch">previous post</a>, we saw how JSON Patch uses paths like <code>"/address/city"</code> to target specific locations in a document. Those paths follow <a href="https://datatracker.ietf.org/doc/html/rfc6901">RFC 6901 JSON Pointer</a>, a standard syntax for addressing values within a JSON document.</p>
<p>V5 exposes this as a first-class API through <code>Utf8JsonPointer</code>. It lets you resolve a path against any <code>IJsonElement&lt;T&gt;</code> and get back a typed result, with no allocation.</p>
<h2 id="what-is-json-pointer">What is JSON Pointer?</h2>
<p>A JSON Pointer is a string that identifies a specific value in a JSON document. It uses <code>/</code> as a path separator, with two escape sequences: <code>~0</code> for <code>~</code> and <code>~1</code> for <code>/</code>.</p>
<table>
<thead>
<tr>
<th>Pointer</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>""</code></td>
<td>The root document</td>
</tr>
<tr>
<td><code>"/name"</code></td>
<td>The <code>name</code> property of the root object</td>
</tr>
<tr>
<td><code>"/address/city"</code></td>
<td>Nested property access</td>
</tr>
<tr>
<td><code>"/tags/0"</code></td>
<td>First element of the <code>tags</code> array</td>
</tr>
<tr>
<td><code>"/a~1b"</code></td>
<td>Property named <code>a/b</code> (escaped forward slash)</td>
</tr>
<tr>
<td><code>"/m~0n"</code></td>
<td>Property named <code>m~n</code> (escaped tilde)</td>
</tr>
</tbody>
</table>
<p>JSON Pointer is used throughout the JSON ecosystem. JSON Patch (RFC 6902) uses it for all <code>path</code> and <code>from</code> fields. JSON Schema uses it in <code>$ref</code> URI fragments. OpenAPI uses it to reference components. If you work with JSON tooling, you will encounter JSON Pointer paths regularly.</p>
<h2 id="resolving-a-pointer">Resolving a pointer</h2>
<p><code>Utf8JsonPointer</code> is a <code>readonly ref struct</code> that wraps a <code>ReadOnlySpan&lt;byte&gt;</code>. It validates the pointer syntax on creation and resolves it against any <code>IJsonElement&lt;T&gt;</code>:</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse("""
    {
        "store": {
            "name": "Book Shop",
            "books": [
                { "title": "JSON at Work", "price": 29.99 },
                { "title": "Schema Design", "price": 34.50 }
            ]
        }
    }
    """);

JsonElement root = doc.RootElement;

// Resolve a nested property
if (Utf8JsonPointer.TryCreateJsonPointer("/store/name"u8, out Utf8JsonPointer pointer)
    &amp;&amp; pointer.TryResolve&lt;JsonElement, JsonElement&gt;(root, out JsonElement name))
{
    Console.WriteLine(name.GetString()); // "Book Shop"
}

// Resolve an array element
if (Utf8JsonPointer.TryCreateJsonPointer("/store/books/1/title"u8, out Utf8JsonPointer bookPointer)
    &amp;&amp; bookPointer.TryResolve&lt;JsonElement, JsonElement&gt;(root, out JsonElement title))
{
    Console.WriteLine(title.GetString()); // "Schema Design"
}
</code></pre>
<p>The resolution walks the document's internal metadata table directly. There is no intermediate string allocation, no tree of path objects, and no dictionary lookup. The pointer's <code>ReadOnlySpan&lt;byte&gt;</code> slices are compared against the document's UTF-8 property names byte-by-byte.</p>
<h2 id="why-a-ref-struct">Why a ref struct?</h2>
<p><code>Utf8JsonPointer</code> is a <code>readonly ref struct</code> because it holds a <code>ReadOnlySpan&lt;byte&gt;</code> that points directly into the source buffer. This means it cannot be stored on the heap, boxed, or used as a field in a class. It exists for the duration of the stack frame where you use it.</p>
<p>This is a deliberate design choice. A JSON Pointer is typically created, resolved, and discarded in a single operation. Making it a <code>ref struct</code> means zero GC pressure for this pattern.</p>
<p>If you need to store a pointer for later use, store the raw <code>byte[]</code> or <code>string</code> and create the <code>Utf8JsonPointer</code> when you need to resolve it.</p>
<h2 id="source-location-resolution">Source location resolution</h2>
<p>One of the more unusual features of <code>Utf8JsonPointer</code> is the ability to resolve a path to a source location in the original document. This is useful for tooling that needs to produce error messages with line numbers and column offsets.</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse("""
    {
        "name": "Alice",
        "age": 30
    }
    """);

if (Utf8JsonPointer.TryCreateJsonPointer("/age"u8, out Utf8JsonPointer pointer)
    &amp;&amp; pointer.TryGetLineAndOffset(doc.RootElement, out int line, out int charOffset, out long lineByteOffset))
{
    Console.WriteLine($"'age' is at line {line}, column {charOffset}");
    // 'age' is at line 3, column 12
}
</code></pre>
<p><code>TryGetLineAndOffset</code> first resolves the pointer to find the target element, then walks the document's raw UTF-8 buffer to compute the 1-based line number and character offset. This is the same mechanism that schema validation uses to produce diagnostic locations.</p>
<h2 id="segment-decoding">Segment decoding</h2>
<p>If you need to process pointer segments individually, <code>DecodeSegment</code> handles the <code>~0</code> and <code>~1</code> unescaping:</p>
<pre><code class="language-csharp">ReadOnlySpan&lt;byte&gt; encoded = "a~1b~0c"u8; // represents "a/b~c"
Span&lt;byte&gt; decoded = stackalloc byte[encoded.Length];
int written = Utf8JsonPointer.DecodeSegment(encoded, decoded);
// decoded[..written] contains "a/b~c"
</code></pre>
<p>This is useful when you are building tooling that iterates over the segments of a pointer for custom processing, rather than resolving it against a document.</p>
<h2 id="relationship-with-json-patch">Relationship with JSON Patch</h2>
<p>In the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch">JSON Patch post</a>, we showed the <code>PatchBuilder</code> API for applying RFC 6902 operations. Internally, every <code>path</code> and <code>from</code> field in a patch operation is resolved using <code>Utf8JsonPointer</code>. The two APIs share the same underlying resolution logic, so the path syntax, escaping rules, and performance characteristics are identical.</p>
<p>If you are building tooling that needs to inspect or manipulate JSON Patch documents programmatically, <code>Utf8JsonPointer</code> gives you direct access to the resolution mechanism.</p>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll look at V5's extended type system. It covers UTF-8 URIs and IRIs, arbitrary-precision numerics with <code>BigNumber</code>, and first-class NodaTime integration.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">12.</span>
                <span class="series-toc__part-title">JSON Pointer</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: JSON Patch</title>
      <description>Corvus.Text.Json.Patch implements RFC 6902 JSON Patch with a fluent PatchBuilder and all six operations. Patches operate directly on the mutable document model with no intermediate copies.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch</guid>
      <pubDate>Mon, 22 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>JSON Patch</category>
      <category>RFC 6902</category>
      <category>RFC 6901</category>
      <category>JSON Pointer</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-11.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml">previous post</a> we looked at YAML 1.2 conversion.</p>
<p>Now let's look at a natural companion to the mutable document model: JSON Patch.</p>
<h2 id="why-json-patch">Why JSON Patch?</h2>
<p>APIs commonly need to accept partial updates. That means changing a subset of fields in a document without resending the whole thing. <a href="https://datatracker.ietf.org/doc/html/rfc6902">RFC 6902 JSON Patch</a> is the standard convention for expressing those changes. It defines a JSON format for describing a sequence of operations to apply to a document, and it's widely supported across languages and frameworks.</p>
<p>A patch is a JSON array of operations:</p>
<pre><code class="language-json">[
    { "op": "replace", "path": "/name", "value": "Bob" },
    { "op": "add", "path": "/email", "value": "bob@example.com" },
    { "op": "remove", "path": "/age" }
]
</code></pre>
<p>Each operation specifies what to do (<code>op</code>), where to do it (<code>path</code> in <a href="https://datatracker.ietf.org/doc/html/rfc6901">RFC 6901 JSON Pointer</a> syntax), and optionally a <code>value</code> or <code>from</code> path. All six RFC 6902 operations are supported: <strong>add</strong>, <strong>remove</strong>, <strong>replace</strong>, <strong>move</strong>, <strong>copy</strong>, and <strong>test</strong>.</p>
<h2 id="quick-start">Quick start</h2>
<pre><code class="language-bash">dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.Patch
</code></pre>
<p>Build a patch with the fluent API and apply it:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.Patch;

using JsonWorkspace workspace = JsonWorkspace.Create();
using var builder = JsonDocumentBuilder&lt;JsonElement.Mutable&gt;.Parse(
    workspace,
    """{"name": "Alice", "age": 30}""");

JsonElement.Mutable root = builder.RootElement;

using PatchBuilder patchBuilder = root.BeginPatch(workspace)
    .Replace("/name"u8, "Bob"u8)
    .Add("/email"u8, "bob@example.com"u8)
    .Remove("/age"u8);

JsonPatchDocument patch = patchBuilder.GetPatchAndDispose();

bool success = root.TryApplyPatch(in patch);

Console.WriteLine(builder.RootElement);
// {"name":"Bob","email":"bob@example.com"}
</code></pre>
<p>A few things to notice here:</p>
<ul>
<li><strong><code>BeginPatch(workspace)</code></strong> takes the caller's workspace. The returned <code>JsonPatchDocument</code> is backed by that workspace - you must keep the workspace alive for the lifetime of the patch.</li>
<li><strong><code>GetPatchAndDispose()</code></strong> finalises the patch and disposes the builder's internal resources, signalling that the builder is no longer in use. After this call, the builder must not be reused.</li>
<li><strong>The <code>using</code> on the builder</strong> is a safety net: if an exception is thrown during the fluent chain (before <code>GetPatchAndDispose()</code> is called), the <code>using</code> ensures the builder's resources are still cleaned up. Because <code>Dispose()</code> is idempotent, calling it after <code>GetPatchAndDispose()</code> is harmless.</li>
</ul>
<h2 id="applying-patches-from-external-sources">Applying patches from external sources</h2>
<p>When a patch arrives from an API request or a file and you haven't validated it, use <code>TryValidateAndApplyPatch</code>. This validates the patch document against the RFC 6902 JSON Schema before applying any operations:</p>
<pre><code class="language-csharp">using ParsedJsonDocument&lt;JsonPatchDocument&gt; patchDoc = ParsedJsonDocument&lt;JsonPatchDocument&gt;.Parse(
    """
    [
        { "op": "replace", "path": "/name", "value": "Charlie" },
        { "op": "add", "path": "/active", "value": true }
    ]
    """);

bool success = root.TryValidateAndApplyPatch(patchDoc.RootElement);
</code></pre>
<p>If you constructed the patch locally via <code>PatchBuilder</code>, you can skip validation and call <code>TryApplyPatch</code> directly.</p>
<h2 id="individual-operations">Individual operations</h2>
<p>Each operation is also available as a standalone extension method on <code>JsonElement.Mutable</code> (defined in <code>JsonPatchExtensions</code>), for when you need a single change without constructing a full patch document. Values accept <code>JsonElement.Source</code>, which has implicit conversions from <code>string</code>, <code>ReadOnlySpan&lt;byte&gt;</code>, <code>bool</code>, <code>int</code>, <code>double</code>, and many more types.</p>
<h3 id="add">Add</h3>
<p>Adds a value at the target path. For objects, the property is created (or replaced if it already exists). For arrays, the value is inserted at the given index. The special index <code>-</code> appends to the end.</p>
<pre><code class="language-csharp">root.TryAdd("/email"u8, "alice@example.com"u8);
root.TryAdd("/tags/0"u8, "important"u8);
root.TryAdd("/tags/-"u8, "new-tag"u8);
</code></pre>
<h3 id="remove">Remove</h3>
<p>Removes the value at the target path. The target must exist.</p>
<pre><code class="language-csharp">root.TryRemove("/email"u8);
root.TryRemove("/tags/0"u8);
</code></pre>
<h3 id="replace">Replace</h3>
<p>Replaces the value at the target path. Unlike <code>add</code>, the target must already exist.</p>
<pre><code class="language-csharp">root.TryReplace("/name"u8, "Bob"u8);
root.TryReplace("/age"u8, 31);
</code></pre>
<h3 id="move">Move</h3>
<p>Moves a value from one path to another. This is equivalent to a <code>remove</code> followed by an <code>add</code>.</p>
<pre><code class="language-csharp">root.TryMove("/old_name"u8, "/name"u8);
</code></pre>
<h3 id="copy">Copy</h3>
<p>Copies a value from one path to another without removing the source.</p>
<pre><code class="language-csharp">root.TryCopy("/name"u8, "/display_name"u8);
</code></pre>
<h3 id="test">Test</h3>
<p>Tests that the value at the target path equals the expected value using deep equality. Returns <code>true</code> if they match. No mutation is performed.</p>
<pre><code class="language-csharp">using ParsedJsonDocument&lt;JsonElement&gt; expected = ParsedJsonDocument&lt;JsonElement&gt;.Parse("\"Alice\"");
bool matches = root.TryTest("/name"u8, expected.RootElement);
</code></pre>
<p>This is useful for conditional patches. If the test fails, the entire patch fails:</p>
<pre><code class="language-csharp">using PatchBuilder patchBuilder = root.BeginPatch(workspace)
    .Test("/version"u8, 1)           // guard: only apply if version is 1
    .Replace("/version"u8, 2)        // update version
    .Add("/migrated"u8, true);       // add new field

JsonPatchDocument patch = patchBuilder.GetPatchAndDispose();

bool success = root.TryApplyPatch(in patch);
// success is false if /version was not 1
</code></pre>
<h2 id="json-pointer-paths">JSON Pointer paths</h2>
<p>All paths follow <a href="https://datatracker.ietf.org/doc/html/rfc6901">RFC 6901 JSON Pointer</a> syntax:</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>""</code></td>
<td>The root document</td>
</tr>
<tr>
<td><code>"/name"</code></td>
<td>The <code>name</code> property of the root object</td>
</tr>
<tr>
<td><code>"/address/city"</code></td>
<td>Nested property access</td>
</tr>
<tr>
<td><code>"/tags/0"</code></td>
<td>First element of the <code>tags</code> array</td>
</tr>
<tr>
<td><code>"/tags/-"</code></td>
<td>Past-the-end position (for append)</td>
</tr>
<tr>
<td><code>"/a~1b"</code></td>
<td>Property named <code>a/b</code> (escaped forward slash)</td>
</tr>
<tr>
<td><code>"/m~0n"</code></td>
<td>Property named <code>m~n</code> (escaped tilde)</td>
</tr>
</tbody>
</table>
<p>Path-accepting methods have three overloads: <code>ReadOnlySpan&lt;byte&gt;</code> (UTF-8, preferred), <code>ReadOnlySpan&lt;char&gt;</code>, and <code>string</code>. For best performance, use the <code>"..."u8</code> byte literal form.</p>
<h2 id="error-handling">Error handling</h2>
<p>All <code>Try*</code> methods return <code>bool</code>. When <code>TryApplyPatch</code> returns <code>false</code>, operations applied before the failure are not rolled back. The document is in a partially-modified state. If you need atomic all-or-nothing semantics, take a snapshot before applying and restore it on failure. Remember <code>CreateSnapshot()</code> and <code>Restore()</code> from Part 5? That's exactly what they're for:</p>
<pre><code class="language-csharp">using var snapshot = builder.CreateSnapshot();

bool success = root.TryApplyPatch(in patch);
if (!success)
{
    builder.Restore(snapshot);
}
</code></pre>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll look at JSON Pointer resolution. It's the path syntax that JSON Patch uses under the hood, and V5 exposes it as a first-class zero-allocation API through <code>Utf8JsonPointer</code>.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">11.</span>
                <span class="series-toc__part-title">JSON Patch</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: YAML 1.2 - Zero-Allocation Conversion</title>
      <description>Corvus.Text.Json.Yaml converts YAML 1.2 to JSON with 100% yaml-test-suite conformance using a custom ref struct tokenizer - no intermediate object model, no allocations on the hot path.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-yaml</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-yaml</guid>
      <pubDate>Fri, 19 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>YAML</category>
      <category>configuration</category>
      <category>low-allocation</category>
      <category>Kubernetes</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-10.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic">previous post</a> we looked at JsonLogic for safe business rules.</p>
<p>Now let's talk about a format that sits alongside JSON in almost every modern development workflow: YAML.</p>
<h2 id="yaml-is-everywhere">YAML is everywhere</h2>
<p>Kubernetes manifests, GitHub Actions workflows, Azure DevOps pipelines, Docker Compose files, Helm charts, OpenAPI specifications. Almost every infrastructure-as-code and CI/CD tool uses YAML as its primary configuration format.</p>
<p>If you validate, transform, or process configuration, you inevitably need to convert YAML to JSON. The schema validation, query languages, and processing tools all operate on JSON.</p>
<p>V5 includes a YAML 1.2 to JSON converter that does this with zero allocation on the hot path.</p>
<h2 id="quick-start">Quick start</h2>
<p>Two packages are available:</p>
<pre><code class="language-bash"># Full Corvus document model - when you want ParsedJsonDocument&lt;T&gt;, schema validation, etc.
dotnet add package Corvus.Text.Json.Yaml

# System.Text.Json only - when you want a lightweight JsonDocument, no Corvus dependencies
dotnet add package Corvus.Yaml.SystemTextJson
</code></pre>
<h3 id="parse-yaml-to-a-typed-document">Parse YAML to a typed document</h3>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.Yaml;

string yaml = """
    name: Alice
    age: 30
    hobbies:
      - reading
      - cycling
    """;

using var doc = YamlDocument.Parse&lt;JsonElement&gt;(yaml);
JsonElement root = doc.RootElement;
Console.WriteLine(root.GetProperty("name").GetString()); // "Alice"
Console.WriteLine(root.GetProperty("age").GetInt32());    // 30
</code></pre>
<p>That gives you a <code>ParsedJsonDocument&lt;JsonElement&gt;</code>, the same pooled-memory document we discussed in post 4. From here you can validate against a schema, query with JMESPath or JSONata, mutate with a builder, or just read values.</p>
<h3 id="parse-yaml-directly-to-a-strongly-typed-element">Parse YAML directly to a strongly-typed element</h3>
<p>This is where the real power of YAML-to-JSON conversion becomes clear. Because <code>YamlDocument.Parse&lt;T&gt;</code> is generic over any <code>IJsonElement&lt;T&gt;</code>, you can parse YAML directly into a schema-generated type. There is no intermediate untyped step, and the result is fully validated and strongly typed from the moment you access it.</p>
<p>Consider a Kubernetes Deployment manifest. You'd typically write it in YAML, but the <a href="https://github.com/kubernetes/kubernetes/tree/master/api/openapi-spec">Kubernetes API schema</a> is published as JSON Schema. Generate your types from that schema, and then:</p>
<pre><code class="language-csharp">// Your YAML manifest - the format every Kubernetes user writes in
string manifest = """
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: web-frontend
      labels:
        app: web
    spec:
      replicas: 3
      selector:
        matchLabels:
          app: web
      template:
        metadata:
          labels:
            app: web
        spec:
          containers:
            - name: nginx
              image: nginx:1.27
              ports:
                - containerPort: 80
    """;

// Parse directly to the generated Deployment type
using var doc = YamlDocument.Parse&lt;Deployment&gt;(manifest);
Deployment deployment = doc.RootElement;

// Strongly-typed access - IntelliSense, compile-time safety, no casting
string name = (string)deployment.Metadata.Name;          // "web-frontend"
int replicas = (int)deployment.Spec.Replicas;            // 3
string image = (string)deployment.Spec.Template.Spec
    .Containers[0].Image;                                // "nginx:1.27"

// Schema validation is built in
bool isValid = deployment.EvaluateSchema();
</code></pre>
<p>The YAML bytes flow through the tokenizer into the document's pooled memory, and you get a typed view with full IntelliSense and schema validation. The same pattern works for any schema-defined format that people author in YAML: OpenAPI specifications, GitHub Actions workflows, Helm values files, Azure Resource Manager templates, and more.</p>
<h3 id="convert-to-a-json-string">Convert to a JSON string</h3>
<pre><code class="language-csharp">string json = YamlDocument.ConvertToJsonString("key: value");
Console.WriteLine(json); // {"key":"value"}
</code></pre>
<h3 id="stream-to-a-utf8jsonwriter">Stream to a Utf8JsonWriter</h3>
<p>For pipeline scenarios where you're writing directly to an output buffer:</p>
<pre><code class="language-csharp">using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream,
    new JsonWriterOptions { Indented = true });

YamlDocument.Convert("items:\n  - one\n  - two"u8, writer);
writer.Flush();
</code></pre>
<h3 id="system.text.json-only">System.Text.Json only</h3>
<p>If you don't need the Corvus document model:</p>
<pre><code class="language-csharp">using Corvus.Yaml;

string yaml = "name: Bob\nage: 25";
using JsonDocument doc = YamlDocument.Parse(yaml);
Console.WriteLine(doc.RootElement.GetProperty("name").GetString());
</code></pre>
<p>It uses the same tokenizer and achieves the same conformance, without any Corvus dependency.</p>
<h2 id="how-it-works">How it works</h2>
<p>The converter uses a custom <code>ref struct</code> tokenizer that operates directly on UTF-8 bytes. There's no intermediate object model. The tokenizer emits events (scalar, sequence start, mapping start, etc.) that the converter translates directly into <code>Utf8JsonWriter</code> calls.</p>
<p>This means the hot path allocates nothing. The YAML goes in as bytes, the JSON comes out through a writer, and the only allocations are the ones <code>Utf8JsonWriter</code> makes for its own output buffer (which is pooled if you configure it that way).</p>
<h2 id="event-streaming">Event streaming</h2>
<p>The internal event model is also exposed as a public API. <code>YamlDocument.EnumerateEvents</code> calls your callback for each parse event, giving you zero-copy access to the raw UTF-8 data:</p>
<pre><code class="language-csharp">YamlDocument.EnumerateEvents(yamlBytes, static (in YamlEvent e) =&gt;
{
    switch (e.Type)
    {
        case YamlEventType.Scalar:
            Console.WriteLine($"Scalar: {Encoding.UTF8.GetString(e.Value)}");
            break;
        case YamlEventType.MappingStart:
            Console.WriteLine("Object start");
            break;
        case YamlEventType.SequenceStart:
            Console.WriteLine("Array start");
            break;
    }

    return true; // continue parsing (return false to stop early)
});
</code></pre>
<p>Each <code>YamlEvent</code> is a <code>ref struct</code> whose spans point directly into the source buffer. The event types mirror the YAML specification: <code>StreamStart</code>/<code>End</code>, <code>DocumentStart</code>/<code>End</code>, <code>MappingStart</code>/<code>End</code>, <code>SequenceStart</code>/<code>End</code>, <code>Scalar</code>, and <code>Alias</code>. Events also carry line/column positions, anchor names, tags, and scalar styles. This is useful when you need to process YAML without converting to JSON at all. For example, you might extract specific values from a large file without parsing the whole thing.</p>
<h2 id="json-to-yaml">JSON to YAML</h2>
<p>Conversion works in both directions. <code>YamlDocument.ConvertToYamlString</code> takes a JSON element or raw UTF-8 JSON and produces YAML output:</p>
<pre><code class="language-csharp">string yaml = YamlDocument.ConvertToYamlString(
    """{"name": "Alice", "roles": ["admin", "user"]}""");

// name: Alice
// roles:
// - admin
// - user
</code></pre>
<p>There's also a streaming overload that writes to an <code>IBufferWriter&lt;byte&gt;</code> or <code>Stream</code>:</p>
<pre><code class="language-csharp">YamlDocument.ConvertToYaml(jsonElement, outputStream);
</code></pre>
<p><code>YamlWriterOptions</code> controls the output format. <code>IndentSize</code> sets the indentation width, and <code>SkipValidation</code> disables structural validation for a small performance gain:</p>
<pre><code class="language-csharp">var options = new YamlWriterOptions { IndentSize = 4 };
string yaml = YamlDocument.ConvertToYamlString(json, options);
</code></pre>
<p>This works with both <code>System.Text.Json.JsonElement</code> and Corvus <code>IJsonElement&lt;T&gt;</code> types, so you can round-trip YAML through a <code>ParsedJsonDocument</code>. Once parsed, the document can be changed through a builder and written back out as YAML.</p>
<h2 id="utf8yamlwriter">Utf8YamlWriter</h2>
<p>For fine-grained control over YAML output, <code>Utf8YamlWriter</code> is a <code>ref struct</code> that writes directly to an <code>IBufferWriter&lt;byte&gt;</code> or <code>Stream</code>. Its API mirrors <code>System.Text.Json.Utf8JsonWriter</code>, so the programming model will feel familiar:</p>
<pre><code class="language-csharp">var bufferWriter = new ArrayBufferWriter&lt;byte&gt;();
using var writer = new Utf8YamlWriter(bufferWriter, new YamlWriterOptions { IndentSize = 2 });

writer.WriteStartMapping();
writer.WritePropertyName("name"u8);
writer.WriteStringValue("Alice"u8);
writer.WritePropertyName("roles"u8);
writer.WriteStartSequence();
writer.WriteStringValue("admin"u8);
writer.WriteStringValue("user"u8);
writer.WriteEndSequence();
writer.WriteEndMapping();
</code></pre>
<p>This produces:</p>
<pre><code class="language-yaml">name: Alice
roles:
  - admin
  - user
</code></pre>
<p>The writer supports block and flow collection styles. You can mix them in the same document. For example, use flow style for short inline sequences:</p>
<pre><code class="language-csharp">writer.WritePropertyName("tags"u8);
writer.WriteStartSequence(YamlCollectionStyle.Flow);
writer.WriteStringValue("v5"u8);
writer.WriteStringValue("release"u8);
writer.WriteEndSequence();
// tags: [v5, release]
</code></pre>
<p>When <code>SkipValidation</code> is <code>false</code> (the default), the writer validates structural correctness. Property names must precede values in mappings, containers must be properly closed, and you can't write a second root value. This catches mistakes at the point of the write call rather than producing silently broken output.</p>
<h2 id="schema-modes">Schema modes</h2>
<p>The converter supports four YAML schema modes:</p>
<table>
<thead>
<tr>
<th>Schema</th>
<th>Behaviour</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Core</strong> (default)</td>
<td>YAML 1.2 Core Schema. Recognizes <code>null</code>, <code>true</code>/<code>false</code>, integers (decimal, <code>0o77</code>, <code>0xFF</code>), floats (decimal, <code>.inf</code>, <code>.nan</code>)</td>
</tr>
<tr>
<td><strong>JSON</strong></td>
<td>Strict JSON-only: only <code>null</code>, <code>true</code>/<code>false</code>, and JSON-style numbers</td>
</tr>
<tr>
<td><strong>Failsafe</strong></td>
<td>All scalars become JSON strings. No implicit type coercion</td>
</tr>
<tr>
<td><strong>YAML 1.1</strong></td>
<td>Backward compatibility. Adds <code>yes</code>/<code>no</code>/<code>on</code>/<code>off</code>/<code>y</code>/<code>n</code> booleans, sexagesimal integers, and merge keys (<code>&lt;&lt;</code>)</td>
</tr>
</tbody>
</table>
<pre><code class="language-csharp">var options = new YamlReaderOptions
{
    Schema = YamlSchema.Core,
    DocumentMode = YamlDocumentMode.SingleRequired,
    DuplicateKeyBehavior = DuplicateKeyBehavior.Error,
};

using var doc = YamlDocument.Parse&lt;JsonElement&gt;(yaml, options);
</code></pre>
<h2 id="multi-document-streams">Multi-document streams</h2>
<p>YAML supports multiple documents in a single stream, separated by <code>---</code>:</p>
<pre><code class="language-yaml">---
name: Alice
---
name: Bob
</code></pre>
<p>Set <code>DocumentMode = YamlDocumentMode.MultiAsArray</code> to wrap all documents in a JSON array:</p>
<pre><code class="language-csharp">var options = new YamlReaderOptions
{
    DocumentMode = YamlDocumentMode.MultiAsArray,
};

using var doc = YamlDocument.Parse&lt;JsonElement&gt;(multiDocYaml, options);
// Result: [{"name":"Alice"},{"name":"Bob"}]
</code></pre>
<h2 id="all-yaml-features">All YAML features</h2>
<p>The converter supports every YAML 1.2 feature:</p>
<ul>
<li><strong>Scalar styles</strong>: plain, single-quoted, double-quoted, literal block (<code>|</code>), folded block (<code>&gt;</code>)</li>
<li><strong>Collections</strong>: block and flow sequences, block and flow mappings</li>
<li><strong>Anchors and aliases</strong>: <code>&amp;anchor</code> and <code>*alias</code> with billion-laughs protection</li>
<li><strong>Tags</strong>: <code>!!str</code>, <code>!!int</code>, <code>!!float</code>, <code>!!null</code>, <code>!!bool</code>, <code>!!seq</code>, <code>!!map</code>, and custom tags</li>
<li><strong>Multi-document</strong>: <code>---</code> and <code>...</code> document markers</li>
<li><strong>Comments</strong>: preserved in the event stream (ignored in JSON output)</li>
</ul>
<h3 id="billion-laughs-protection">Billion-laughs protection</h3>
<p>The YAML "billion laughs" attack uses nested anchor/alias expansion to create exponentially large documents from tiny input. The converter enforces two configurable limits:</p>
<pre><code class="language-csharp">var options = new YamlReaderOptions
{
    MaxAliasExpansionDepth = 64,         // Default
    MaxAliasExpansionSize = 1_000_000,   // Default - max nodes from alias expansion
};
</code></pre>
<p>Expansion that exceeds either limit throws a <code>YamlException</code>.</p>
<h2 id="conformance">Conformance</h2>
<p>The converter passes <strong>100% of the JSON-testable cases</strong> in the <a href="https://github.com/yaml/yaml-test-suite">yaml-test-suite</a>. That means 279 valid and 94 error cases (373 of 402 total). The remaining 29 cases exercise YAML features with no JSON equivalent (complex keys, empty keys, bare tags) and don't provide JSON reference output.</p>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll look at JSON Patch. It provides RFC 6902 support with a fluent builder that operates directly on the mutable document model.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">10.</span>
                <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: JsonLogic - Safe Business Rules</title>
      <description>Corvus.Text.Json.JsonLogic provides a safe, side-effect-free rule engine that evaluates JSON-encoded business logic on average 3× faster than JsonEverything - with zero allocations.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic</guid>
      <pubDate>Thu, 18 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>JsonLogic</category>
      <category>business-rules</category>
      <category>rule-engine</category>
      <category>low-allocation</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-09.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath">previous post</a> we looked at JMESPath for JSON querying.</p>
<p>Now let's complete the query language trilogy with something a bit different: a rule engine.</p>
<h2 id="rules-as-data">Rules as data</h2>
<p>Here's a common problem. You have business logic that changes frequently - discount calculations, eligibility checks, risk scoring, feature flags - and every time the rules change, you redeploy.</p>
<p>What if the rules were <em>data</em>? Stored in a database, versioned, auditable, and evaluated safely without executing arbitrary code?</p>
<p>That's the job of <a href="https://jsonlogic.com/">JsonLogic</a>.</p>
<p>A rule is a JSON object:</p>
<pre><code class="language-json">{"if": [
    {"&gt;": [{"var": "age"}, 65]},
    "senior",
    {"&gt;=": [{"var": "age"}, 18]},
    "adult",
    "minor"
]}
</code></pre>
<p>You evaluate it against data:</p>
<pre><code class="language-json">{"age": 42}
</code></pre>
<p>To produce a result: <code>"adult"</code>.</p>
<h3 id="where-schema-validation-ends-and-business-rules-begin">Where schema validation ends and business rules begin</h3>
<p>JSON Schema tells you whether data is <em>structurally valid</em>; it has the correct types, required fields present, values within range. It answers "is this a well-formed order?" But it doesn't answer "does this customer qualify for a discount?" or "should this claim be escalated?" That's business logic, and it changes on a different cadence from your data contracts.</p>
<p>Schema validation and rule evaluation are complementary: you validate the structure first with <code>EvaluateSchema()</code>, then apply business rules with JsonLogic to data you already know is well-formed.</p>
<h2 id="quick-start">Quick start</h2>
<pre><code class="language-bash">dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.JsonLogic
</code></pre>
<p>String in, string out:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.JsonLogic;

string? result = JsonLogicEvaluator.Default.EvaluateToString(
    """{"+":[{"var":"a"},{"var":"b"}]}""",
    """{"a":3,"b":4}""");

Console.WriteLine(result); // "7"
</code></pre>
<p>Zero-allocation evaluation with a workspace:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.JsonLogic;

using var ruleDoc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(
    """{"+":[{"var":"a"},{"var":"b"}]}""");
using var dataDoc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(
    """{"a":3,"b":4}""");

using JsonWorkspace workspace = JsonWorkspace.Create();

JsonLogicRule rule = new(ruleDoc.RootElement);
JsonElement result = JsonLogicEvaluator.Default.Evaluate(
    rule, dataDoc.RootElement, workspace);

Console.WriteLine(result.GetRawText()); // "7"
</code></pre>
<p>The evaluator compiles the rule into a delegate tree on first use and caches it. Subsequent evaluations of the same rule skip compilation entirely.</p>
<h2 id="real-world-example-discount-rules">Real-world example: discount rules</h2>
<p>Say you have a pricing service that applies discounts based on customer attributes:</p>
<pre><code class="language-json">{
    "if": [
        {"and": [
            {"&gt;=": [{"var": "order.total"}, 100]},
            {"==": [{"var": "customer.tier"}, "gold"]}
        ]},
        {"*": [{"var": "order.total"}, 0.85]},
        {"&gt;=": [{"var": "order.total"}, 50]},
        {"*": [{"var": "order.total"}, 0.95]},
        {"var": "order.total"}
    ]
}
</code></pre>
<p>This rule says: gold customers with orders over £100 get 15% off; everyone else with orders over £50 gets 5% off; the rest pay full price. The rule can live in your database and be loaded at startup, ready for evaluation on each request.</p>
<pre><code class="language-csharp">using var ruleDoc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(discountRuleJson);
JsonLogicRule rule = new(ruleDoc.RootElement);

// For each request:
using JsonWorkspace workspace = JsonWorkspace.Create();
JsonElement result = JsonLogicEvaluator.Default.Evaluate(
    rule, orderData, workspace);
decimal discountedTotal = result.GetDecimal();
</code></pre>
<p>Change the rules? Update the database. No redeployment.</p>
<h2 id="custom-operators">Custom operators</h2>
<p>You can extend the rule set with custom operators:</p>
<pre><code class="language-csharp">var evaluator = new JsonLogicEvaluator(
    customOperators: new Dictionary&lt;string, IOperatorCompiler&gt;
    {
        ["dayOfWeek"] = DayOfWeekCompiler
    });
</code></pre>
<p>Custom operators can override built-in operators too. This is useful when you need domain-specific semantics for common operations.</p>
<h2 id="source-generator">Source generator</h2>
<p>For rules known at build time, the source generator eliminates all runtime compilation:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.JsonLogic;

namespace MyApp.Rules;

[JsonLogicRule("Rules/discount-rule.json")]
internal static partial class DiscountRule;
</code></pre>
<p>The generated code constant-folds literal expressions. A rule like <code>{"merge": [[1,2], [3,4]]}</code> is pre-computed at compile time and evaluates in 12 nanoseconds.</p>
<h2 id="cli-code-generation">CLI code generation</h2>
<p>For rules managed outside the build pipeline:</p>
<pre><code class="language-bash">corvusjson jsonlogic Rules/discount-rule.json \
    --className DiscountRule \
    --namespace MyApp.Rules \
    --outputPath Generated/
</code></pre>
<p>If your rules use custom operators, you can supply a <code>.jlops</code> file:</p>
<pre><code class="language-bash">corvusjson jsonlogic Rules/discount-rule.json \
    --className DiscountRule \
    --namespace MyApp.Rules \
    --outputPath Generated/ \
    --operators Rules/custom-operators.jlops
</code></pre>
<p>The generated code is identical to what the source generator produces.</p>
<h2 id="performance">Performance</h2>
<p>All benchmarks compare against <a href="https://github.com/gregsdennis/json-everything">JsonEverything</a> (the established .NET JsonLogic implementation):</p>
<h3 id="time-comparison-selected-scenarios">Time comparison (selected scenarios)</h3>
<table>
<thead>
<tr>
<th>Scenario</th>
<th style="text-align: right;">JsonEverything</th>
<th style="text-align: right;">Corvus RT</th>
<th style="text-align: right;">Corvus CG</th>
<th style="text-align: right;">RT/JE</th>
</tr>
</thead>
<tbody>
<tr>
<td>Simple var</td>
<td style="text-align: right;">64 ns</td>
<td style="text-align: right;">18 ns</td>
<td style="text-align: right;">16 ns</td>
<td style="text-align: right;">0.29</td>
</tr>
<tr>
<td>Comparison</td>
<td style="text-align: right;">223 ns</td>
<td style="text-align: right;">65 ns</td>
<td style="text-align: right;">34 ns</td>
<td style="text-align: right;">0.29</td>
</tr>
<tr>
<td>Arithmetic</td>
<td style="text-align: right;">332 ns</td>
<td style="text-align: right;">113 ns</td>
<td style="text-align: right;">98 ns</td>
<td style="text-align: right;">0.34</td>
</tr>
<tr>
<td>Quantifier (all)</td>
<td style="text-align: right;">1,179 ns</td>
<td style="text-align: right;">227 ns</td>
<td style="text-align: right;">190 ns</td>
<td style="text-align: right;">0.19</td>
</tr>
<tr>
<td>Deep nested</td>
<td style="text-align: right;">4,936 ns</td>
<td style="text-align: right;">110 ns</td>
<td style="text-align: right;">103 ns</td>
<td style="text-align: right;">0.02</td>
</tr>
<tr>
<td>Array map/reduce</td>
<td style="text-align: right;">5,227 ns</td>
<td style="text-align: right;">563 ns</td>
<td style="text-align: right;">278 ns</td>
<td style="text-align: right;">0.11</td>
</tr>
<tr>
<td>Complex rule</td>
<td style="text-align: right;">828 ns</td>
<td style="text-align: right;">219 ns</td>
<td style="text-align: right;">160 ns</td>
<td style="text-align: right;">0.26</td>
</tr>
</tbody>
</table>
<h3 id="memory-comparison">Memory comparison</h3>
<table>
<thead>
<tr>
<th>Scenario</th>
<th style="text-align: right;">JsonEverything</th>
<th style="text-align: right;">Corvus RT</th>
<th style="text-align: right;">Corvus CG</th>
</tr>
</thead>
<tbody>
<tr>
<td>Simple var</td>
<td style="text-align: right;">248 B</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Comparison</td>
<td style="text-align: right;">512 B</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Arithmetic</td>
<td style="text-align: right;">656 B</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Deep nested</td>
<td style="text-align: right;">10,120 B</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Array map/reduce</td>
<td style="text-align: right;">12,856 B</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
</tbody>
</table>
<p>The runtime evaluator is faster than JsonEverything in 18 of 19 scenarios, with a geometric mean of approximately <strong>3× faster</strong> (0.22× JE ratio means ~4.5× on average for the winning scenarios).</p>
<div class="aside"><p>The one scenario where JsonEverything wins is min/max. JE's <code>JsonNode</code> stores pre-parsed <code>double</code> values, while Corvus re-parses UTF-8 bytes on each comparison. Even there, Corvus allocates 0 B vs JE's 136 B.</p>
</div>
<h2 id="when-to-choose-jsonlogic-vs-jsonata">When to choose JsonLogic vs JSONata</h2>
<table>
<thead>
<tr>
<th></th>
<th>JsonLogic</th>
<th>JSONata</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Model</strong></td>
<td>Rules as JSON objects</td>
<td>Expressions as strings</td>
</tr>
<tr>
<td><strong>Side effects</strong></td>
<td>None by design</td>
<td>User-defined functions can have side effects</td>
</tr>
<tr>
<td><strong>Expressiveness</strong></td>
<td>Boolean logic, arithmetic, arrays, strings</td>
<td>Turing-complete with regex, aggregation, recursion</td>
</tr>
<tr>
<td><strong>Best for</strong></td>
<td>Configuration-driven business rules</td>
<td>Data transformation and reshaping</td>
</tr>
<tr>
<td><strong>Authoring</strong></td>
<td>JSON editors, visual rule builders</td>
<td>Text expressions</td>
</tr>
</tbody>
</table>
<p>JsonLogic is the right tool when the <em>rules themselves</em> need to be portable data, stored in a database, edited by non-developers, and versioned independently of code. JSONata is the right tool when you need more expressive power for data transformation.</p>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll look at YAML 1.2 support. It provides zero-allocation conversion from YAML to JSON with 100% conformance.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">9.</span>
                <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: JMESPath - On Average 28× Faster JSON Queries</title>
      <description>Corvus.Text.Json.JMESPath implements the full JMESPath spec with 100% conformance, zero-allocation hot paths, and on average 28× faster than JmesPath.Net across 21 benchmarks.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath</guid>
      <pubDate>Wed, 17 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>JMESPath</category>
      <category>query</category>
      <category>low-allocation</category>
      <category>AWS</category>
      <category>Azure</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-08.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata">previous post</a> we looked at JSONata for query and transformation.</p>
<p>Now let's look at JMESPath. It's a simpler, standardised alternative where the performance story is, frankly, quite something.</p>
<h2 id="what-is-jmespath">What is JMESPath?</h2>
<p><a href="https://jmespath.org/">JMESPath</a> is a query language for JSON. You've likely already used it. It's the expression language behind <code>aws --query</code>, <code>az --query</code>, and <code>jp</code> (the JMESPath CLI). It supports path navigation, projections, filtering, slicing, multiselect, pipe expressions, and built-in functions like <code>sort</code>, <code>sum</code>, <code>min</code>, <code>max</code>, <code>join</code>, and <code>contains</code>.</p>
<p>Unlike JSONata, JMESPath is deliberately limited. There is no arithmetic, no user-defined functions, and no side effects. It's a pure query language with a standardised specification and a <a href="https://github.com/jmespath/jmespath.test">cross-implementation conformance test suite</a>.</p>
<p>The Corvus implementation passes all <strong>892</strong> test cases. It achieves 100% conformance.</p>
<h2 id="quick-start">Quick start</h2>
<pre><code class="language-bash">dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.JMESPath
</code></pre>
<p>This is the simplest approach, with a cloned result and no workspace management:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.JMESPath;

using ParsedJsonDocument&lt;JsonElement&gt; document = ParsedJsonDocument&lt;JsonElement&gt;.Parse("""
    {
        "locations": [
            {"name": "Seattle", "state": "WA"},
            {"name": "New York", "state": "NY"},
            {"name": "Bellevue", "state": "WA"},
            {"name": "Olympia", "state": "WA"}
        ]
    }
    """u8);

JsonElement result = JMESPathEvaluator.Default.Search(
    "locations[?state == 'WA'].name | sort(@) | {WashingtonCities: join(', ', @)}",
    document.RootElement);

Console.WriteLine(result);
// {"WashingtonCities":"Bellevue, Olympia, Seattle"}
</code></pre>
<p>That expression filters an array by state, extracts the name field, sorts alphabetically, and constructs a new object with a joined string. All of that happens in a single pipeline.</p>
<h2 id="zero-allocation-evaluation">Zero-allocation evaluation</h2>
<p>For production use with controlled lifetime:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.JMESPath;

using var dataDoc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(jsonData);
using JsonWorkspace workspace = JsonWorkspace.Create();

JsonElement result = JMESPathEvaluator.Default.Search(
    "people[?age &gt; `30`].name",
    dataDoc.RootElement,
    workspace);
</code></pre>
<p>The workspace pools all intermediate memory. The expression is compiled and cached on first use. Subsequent calls with the same expression are zero-allocation.</p>
<h2 id="performance">Performance</h2>
<p>Here's where JMESPath gets interesting. All benchmarks use <a href="https://github.com/jdevillard/JmesPath.Net">JmesPath.Net</a> as the baseline:</p>
<table>
<thead>
<tr>
<th>Benchmark</th>
<th style="text-align: right;">JmesPath.Net</th>
<th style="text-align: right;">Corvus RT</th>
<th style="text-align: right;">Corvus CG</th>
<th style="text-align: right;">RT Alloc</th>
<th style="text-align: right;">CG Alloc</th>
</tr>
</thead>
<tbody>
<tr>
<td>Simple field (<code>a</code>)</td>
<td style="text-align: right;">6,446 ns</td>
<td style="text-align: right;">44 ns</td>
<td style="text-align: right;">35 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Sub-expression (<code>a.b.c</code>)</td>
<td style="text-align: right;">6,659 ns</td>
<td style="text-align: right;">51 ns</td>
<td style="text-align: right;">49 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Long string (1 KB value)</td>
<td style="text-align: right;">3,874 ns</td>
<td style="text-align: right;">54 ns</td>
<td style="text-align: right;">11 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Chained filter</td>
<td style="text-align: right;">2,697 ns</td>
<td style="text-align: right;">18 ns</td>
<td style="text-align: right;">11 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>50 chained fields</td>
<td style="text-align: right;">21,692 ns</td>
<td style="text-align: right;">563 ns</td>
<td style="text-align: right;">80 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>50 chained pipes</td>
<td style="text-align: right;">24,274 ns</td>
<td style="text-align: right;">384 ns</td>
<td style="text-align: right;">92 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Deep projection (<code>[*].[*].[*]</code>)</td>
<td style="text-align: right;">82,276 ns</td>
<td style="text-align: right;">148 ns</td>
<td style="text-align: right;">18 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Nested sum</td>
<td style="text-align: right;">53,033 ns</td>
<td style="text-align: right;">3,857 ns</td>
<td style="text-align: right;">3,846 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
<tr>
<td>Min by age</td>
<td style="text-align: right;">18,015 ns</td>
<td style="text-align: right;">1,591 ns</td>
<td style="text-align: right;">1,588 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">0 B</td>
</tr>
</tbody>
</table>
<p>Across all 21 benchmarks, the geometric mean speedup is approximately <strong>28×</strong> for the interpreted runtime. Almost every scenario shows zero allocation. That compares with 3–80 KB per query for JmesPath.Net.</p>
<p>The speedups come from several architectural choices:</p>
<ol>
<li><strong>No per-expression object model</strong> - JmesPath.Net parses expressions into an intermediate AST with per-node allocations. Corvus compiles to delegate trees with pooled workspace memory.</li>
<li><strong>Pipe fusion</strong> - consecutive pipe stages are fused into a single pass where possible.</li>
<li><strong>UTF-8 throughout</strong> - property comparisons operate on raw UTF-8 bytes, avoiding transcoding.</li>
<li><strong>Source-generator optimisation</strong> - the code generator eliminates delegate dispatch entirely, which is why CG times are often even lower.</li>
</ol>
<div class="aside"><p>We quote the geometric mean rather than the maximum because the range is enormous (5× to 556×). Individual results depend heavily on the expression pattern. Aggregate functions (sum, min, max) show smaller speedups because both implementations spend most of their time doing arithmetic; simple navigation and projection show the largest gains because the overhead of the expression model dominates in JmesPath.Net.</p>
</div>
<h2 id="source-generator">Source generator</h2>
<p>When expressions are known at build time:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.JMESPath;

namespace MyApp.Expressions;

[JMESPathExpression("Expressions/active-people.jmespath")]
public static partial class ActivePeopleExpression;
</code></pre>
<p>The expression file is registered as an <code>AdditionalFiles</code> item in your project.</p>
<h2 id="cli-code-generation">CLI code generation</h2>
<p>For expressions managed outside the build pipeline:</p>
<pre><code class="language-bash">corvusjson jmespath Expressions/active-people.jmespath \
    --className ActivePeopleQuery \
    --namespace MyApp.Queries \
    --outputPath Generated/
</code></pre>
<p>This reads the expression file, generates an optimized static C# class, and writes it to the output path. The generated code is identical to what the source generator produces.</p>
<h2 id="when-to-choose-jmespath-vs-jsonata">When to choose JMESPath vs JSONata</h2>
<table>
<thead>
<tr>
<th></th>
<th>JMESPath</th>
<th>JSONata</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Complexity</strong></td>
<td>Simple, standardised</td>
<td>Expressive, Turing-complete</td>
</tr>
<tr>
<td><strong>Arithmetic</strong></td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>String manipulation</strong></td>
<td><code>join</code>, <code>reverse</code>, <code>sort</code></td>
<td>Full: <code>$substring</code>, <code>$replace</code>, <code>$split</code>, regex</td>
</tr>
<tr>
<td><strong>User-defined functions</strong></td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>Ecosystem</strong></td>
<td>AWS CLI, Azure CLI, jp</td>
<td>Node-RED, IBM Cloud Pak</td>
</tr>
<tr>
<td><strong>Best for</strong></td>
<td>Querying and extracting</td>
<td>Transforming and reshaping</td>
</tr>
</tbody>
</table>
<p>If your team already uses JMESPath through the AWS or Azure CLI, it's the natural choice for JSON querying in your .NET code. If you need more expressive power, JSONata is the right tool. That includes arithmetic, string manipulation, and custom functions.</p>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll complete the query language trilogy with JsonLogic - a safe, side-effect-free rule engine for evaluating business rules stored as JSON.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">8.</span>
                <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: JSONata - Query and Transform JSON</title>
      <description>Corvus.Text.Json.Jsonata brings the full JSONata language to .NET - 100% test suite conformance, on average 2× faster than Jsonata.Net.Native with 90–100% less memory, plus a source generator for compile-time code generation.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata</guid>
      <pubDate>Tue, 16 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>JSONata</category>
      <category>query</category>
      <category>transformation</category>
      <category>low-allocation</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-07.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations">previous post</a> we looked at the standalone evaluator and annotations.</p>
<p>Now we're moving on to query languages. V5 includes three, and we'll start with JSONata, the most expressive of the trio.</p>
<h2 id="what-is-jsonata">What is JSONata?</h2>
<p><a href="https://jsonata.org/">JSONata</a> is a functional query and transformation language for JSON. It gives you path navigation, filtering, higher-order functions (<code>$map</code>, <code>$filter</code>, <code>$reduce</code>, <code>$sort</code>), object construction, string manipulation, arithmetic, regex, and user-defined functions. It's Turing-complete, and it's the standard expression language for tools like Node-RED and IBM Cloud Pak.</p>
<p>The Corvus implementation passes all <strong>1,665</strong> official test suite cases. It achieves 100% conformance on both .NET 10.0 and .NET Framework 4.8.1.</p>
<h2 id="quick-start">Quick start</h2>
<p>Install the packages:</p>
<pre><code class="language-bash">dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.Jsonata
</code></pre>
<p>The simplest approach is string in, string out:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.Jsonata;

string? result = JsonataEvaluator.Default.EvaluateToString(
    "FirstName &amp; ' ' &amp; Surname",
    """{"FirstName": "Fred", "Surname": "Smith", "Age": 28}""");

Console.WriteLine(result); // "Fred Smith"
</code></pre>
<p>That's three lines of code to evaluate a JSONata expression, with no document model, workspace management, or disposal to worry about.</p>
<h2 id="zero-allocation-evaluation">Zero-allocation evaluation</h2>
<p>For production code where you control the lifetime of parsed documents, use the full API:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;
using Corvus.Text.Json.Jsonata;

using var dataDoc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(
    """
    {
        "FirstName": "Fred",
        "Surname": "Smith",
        "Age": 28,
        "Address": { "City": "London" }
    }
    """);

JsonElement result = JsonataEvaluator.Default.Evaluate(
    "FirstName &amp; ' ' &amp; Surname",
    dataDoc.RootElement);

Console.WriteLine(result.GetRawText()); // "Fred Smith"
</code></pre>
<p>On subsequent calls with the same expression, the compiled delegate tree is cached internally, so you get zero-allocation evaluation from the second call onward.</p>
<p>If you want explicit control over the workspace that pools intermediate memory, pass one in:</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();

JsonElement result = JsonataEvaluator.Default.Evaluate(
    "FirstName &amp; ' ' &amp; Surname",
    dataDoc.RootElement,
    workspace);
</code></pre>
<h2 id="practical-example-reshaping-api-responses">Practical example: reshaping API responses</h2>
<p>JSONata really shines when you need to reshape data. Say you get a complex employee record from an API and need a simpler structure:</p>
<pre><code class="language-jsonata">{
    "name": FirstName &amp; " " &amp; Surname,
    "mobile": Contact.Phone[type = "mobile"].number,
    "city": Address.City
}
</code></pre>
<p>This navigates nested objects, filters arrays by a predicate, and constructs a new shape. It all happens in a single expression. In C#:</p>
<pre><code class="language-csharp">string expression = """
    {
        "name": FirstName &amp; " " &amp; Surname,
        "mobile": Contact.Phone[type = "mobile"].number,
        "city": Address.City
    }
    """;

string? result = JsonataEvaluator.Default.EvaluateToString(
    expression,
    employeeJson);
</code></pre>
<h3 id="evaluating-to-a-strongly-typed-result">Evaluating to a strongly-typed result</h3>
<p><code>EvaluateToString</code> is convenient for interop with existing code, but the real power is evaluating directly to a generated JSON type. The generic <code>Evaluate&lt;T&gt;</code> overload returns the result as any schema-generated type:</p>
<pre><code class="language-csharp">// ContactSummary is generated from a JSON Schema:
// { "type": "object", "properties": {
//     "name": { "type": "string" },
//     "mobile": { "type": "string" },
//     "city": { "type": "string" }
// }}

using var dataDoc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(employeeJson);
using JsonWorkspace workspace = JsonWorkspace.Create();

ContactSummary summary = JsonataEvaluator.Default.Evaluate&lt;ContactSummary&gt;(
    expression,
    dataDoc.RootElement,
    workspace);

// Strongly-typed access - no casting, no string parsing
string name = (string)summary.Name;
string city = (string)summary.City;

// Schema validation is built in
bool isValid = summary.EvaluateSchema();
</code></pre>
<p>The JSONata expression reshapes the data, and the generated type gives you compile-time safety over the result. You can validate the output against its schema, pass it to other V5 APIs, mutate it with a builder, or serialize it directly.</p>
<h2 id="three-modes-of-evaluation">Three modes of evaluation</h2>
<h3 id="interpreted-runtime">1. Interpreted (runtime)</h3>
<p>Best when expressions are determined at runtime. Use this for user-supplied queries, configuration-driven transforms, or any scenario where the expression isn't known at compile time.</p>
<h3 id="source-generator">2. Source generator</h3>
<p>When expressions are known at build time, the source generator compiles them to optimized static C#:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.Jsonata;

namespace MyApp.Expressions;

[JsonataExpression("Expressions/full-name.jsonata")]
public static partial class FullNameExpression;
</code></pre>
<p>The expression file (<code>full-name.jsonata</code>) contains the JSONata expression and is registered as an <code>AdditionalFiles</code> item in your project. The generated code eliminates delegate dispatch and enables constant folding. Pure arithmetic like <code>1 + 2 * 3</code> is folded to a literal value at compile time, giving sub-nanosecond "evaluation."</p>
<h3 id="cli-code-generation">3. CLI code generation</h3>
<p>For expressions managed outside the build pipeline:</p>
<pre><code class="language-bash">corvusjson jsonata Expressions/full-name.jsonata \
    --className FullNameExpression \
    --namespace MyApp.Expressions \
    --outputPath Generated/
</code></pre>
<h2 id="performance">Performance</h2>
<p>Here are some representative benchmarks comparing the Corvus interpreted runtime, code-generated evaluator, and <a href="https://github.com/mikhail-barg/jsonata.net.native">Jsonata.Net.Native</a> v3.0.0 (the reference .NET implementation). All measured on .NET 10.0:</p>
<h3 id="employee-transform-multi-step-expression">Employee transform (multi-step expression)</h3>
<pre><code class="language-jsonata">{
    "name": Employee.FirstName &amp; " " &amp; Employee.Surname,
    "mobile": Contact.Phone[type = "mobile"].number
}
</code></pre>
<table>
<thead>
<tr>
<th>Method</th>
<th style="text-align: right;">Mean</th>
<th style="text-align: right;">Allocated</th>
</tr>
</thead>
<tbody>
<tr>
<td>Corvus (interpreted)</td>
<td style="text-align: right;">1,658 ns</td>
<td style="text-align: right;">960 B</td>
</tr>
<tr>
<td>Corvus (code-gen)</td>
<td style="text-align: right;">1,585 ns</td>
<td style="text-align: right;">240 B</td>
</tr>
<tr>
<td>Jsonata.Net.Native</td>
<td style="text-align: right;">3,032 ns</td>
<td style="text-align: right;">9,920 B</td>
</tr>
</tbody>
</table>
<p>The interpreted evaluator is 1.8× faster with 90% less allocation. Code-gen is 1.9× faster with 97% less allocation.</p>
<h3 id="property-navigation-and-math">Property navigation and math</h3>
<table>
<thead>
<tr>
<th>Scenario</th>
<th style="text-align: right;">Corvus RT</th>
<th style="text-align: right;">Code-Gen</th>
<th style="text-align: right;">Jsonata.Net.Native</th>
<th style="text-align: right;">RT Alloc</th>
<th style="text-align: right;">Native Alloc</th>
</tr>
</thead>
<tbody>
<tr>
<td>Deep path</td>
<td style="text-align: right;">650 ns</td>
<td style="text-align: right;">458 ns</td>
<td style="text-align: right;">544 ns</td>
<td style="text-align: right;">120 B</td>
<td style="text-align: right;">1,816 B</td>
</tr>
<tr>
<td>Array index</td>
<td style="text-align: right;">87 ns</td>
<td style="text-align: right;">79 ns</td>
<td style="text-align: right;">317 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">1,408 B</td>
</tr>
<tr>
<td>$round</td>
<td style="text-align: right;">461 ns</td>
<td style="text-align: right;">298 ns</td>
<td style="text-align: right;">936 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">3,224 B</td>
</tr>
<tr>
<td>$contains</td>
<td style="text-align: right;">252 ns</td>
<td style="text-align: right;">153 ns</td>
<td style="text-align: right;">895 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">3,136 B</td>
</tr>
<tr>
<td>String concat</td>
<td style="text-align: right;">366 ns</td>
<td style="text-align: right;">177 ns</td>
<td style="text-align: right;">332 ns</td>
<td style="text-align: right;">0 B</td>
<td style="text-align: right;">1,408 B</td>
</tr>
</tbody>
</table>
<p>Across 32 benchmarks, the geometric mean speedup is approximately <strong>2×</strong> for the interpreted evaluator, with <strong>90–100% less memory</strong> in almost every scenario. The memory reduction is the stronger headline. Most benchmarks show zero allocation where the reference implementation allocates 1–10 KB.</p>
<div class="aside"><p>The full benchmark suite has 95 scenarios. We quote the geometric mean rather than the maximum because individual expression patterns vary considerably. You can run the benchmarks yourself from the <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">repository</a>.</p>
</div>
<h2 id="try-it-in-your-browser">Try it in your browser</h2>
<p>The <a href="https://endjin.com/playground-jsonata/">JSONata Playground</a> lets you experiment with expressions using the Corvus interpreted runtime - no installation needed.</p>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll look at JMESPath - a simpler, standardised query language used by AWS CLI and Azure CLI, where the performance story is even more dramatic.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">7.</span>
                <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Standalone Evaluator and Annotations</title>
      <description>The standalone evaluator generates a lightweight validator with fully compliant JSON Schema annotation collection - ideal for form generators, schema-driven UIs, and documentation tools.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations</guid>
      <pubDate>Mon, 15 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Code Generation</category>
      <category>annotations</category>
      <category>form-generation</category>
      <category>schema-driven-ui</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-06.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents">previous post</a> we looked at the mutable builder pattern.</p>
<p>Now let's look at a different code generation mode. You'll want this if you're building schema-driven tooling.</p>
<h2 id="not-every-tool-needs-a-full-type-system">Not every tool needs a full type system</h2>
<p>The source generator we covered in post 2 produces strongly-typed C# models: property accessors, validation, serialization, mutable builders, the works. That's exactly what you want when you're building an application that <em>uses</em> JSON data.</p>
<p>But some tools don't use the data. They describe it. A form generator reads the schema's <code>title</code>, <code>description</code>, and <code>default</code> keywords to render input fields. A documentation tool extracts <code>examples</code> and <code>deprecated</code> flags. A configuration editor shows <code>readOnly</code> and <code>writeOnly</code> hints.</p>
<p>These tools need <em>annotations</em>, not types.</p>
<h2 id="what-are-json-schema-annotations">What are JSON Schema annotations?</h2>
<p>The JSON Schema specification defines a set of keywords that are <em>annotation-producing</em>. They carry metadata about the data rather than constraints on it:</p>
<table>
<thead>
<tr>
<th>Keyword</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>title</code></td>
<td>Human-readable name for the schema or property</td>
</tr>
<tr>
<td><code>description</code></td>
<td>Longer explanation of what the data means</td>
</tr>
<tr>
<td><code>default</code></td>
<td>A default value for the property</td>
</tr>
<tr>
<td><code>examples</code></td>
<td>An array of example values</td>
</tr>
<tr>
<td><code>deprecated</code></td>
<td>Whether this property is deprecated</td>
</tr>
<tr>
<td><code>readOnly</code></td>
<td>Whether this property is read-only</td>
</tr>
<tr>
<td><code>writeOnly</code></td>
<td>Whether this property should not be returned in responses</td>
</tr>
<tr>
<td><code>format</code></td>
<td>A format hint (e.g., <code>"email"</code>, <code>"date-time"</code>)</td>
</tr>
<tr>
<td><code>contentMediaType</code></td>
<td>MIME type of string content</td>
</tr>
<tr>
<td><code>contentEncoding</code></td>
<td>Encoding of string content (e.g., <code>"base64"</code>)</td>
</tr>
</tbody>
</table>
<p>These annotations flow through the validation process according to the specification. For example, annotations from a <code>then</code> branch are only collected if the <code>if</code> condition passes. Annotations from composition keywords (<code>allOf</code>, <code>anyOf</code>, <code>oneOf</code>) are merged according to the spec rules.</p>
<p>Getting this right requires a fully compliant evaluator. The V5 standalone evaluator provides exactly that.</p>
<h2 id="generating-the-standalone-evaluator">Generating the standalone evaluator</h2>
<h3 id="with-the-source-generator">With the source generator</h3>
<p>Set <code>EmitEvaluator = true</code> on the attribute to generate both the typed model and the standalone evaluator:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;

namespace MyApp.Models;

[JsonSchemaTypeGenerator("Schemas/person.json", EmitEvaluator = true)]
public readonly partial struct Person;
</code></pre>
<h3 id="with-the-cli-tool">With the CLI tool</h3>
<p>To generate <em>only</em> the evaluator (no typed models):</p>
<pre><code class="language-bash">corvusjson jsonschema Schemas/person.json \
    --rootNamespace MyApp.Evaluators \
    --outputPath Generated/ \
    --codeGenerationMode SchemaEvaluationOnly
</code></pre>
<p>Or generate both:</p>
<pre><code class="language-bash">corvusjson jsonschema Schemas/person.json \
    --rootNamespace MyApp.Models \
    --outputPath Generated/ \
    --codeGenerationMode Both
</code></pre>
<p>The standalone evaluator is a single static class, considerably smaller than the full type hierarchy. It supports the same schema drafts (4, 6, 7, 2019-09, 2020-12, OpenAPI 3.0) and the same validation semantics.</p>
<h2 id="collecting-annotations">Collecting annotations</h2>
<p>To collect annotations, run the evaluator in <code>Verbose</code> mode and use <code>JsonSchemaAnnotationProducer</code>:</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(jsonText);
JsonElement instance = doc.RootElement;

// Validate in Verbose mode - this collects all annotations
using var collector = JsonSchemaResultsCollector.Create(
    JsonSchemaResultsLevel.Verbose);
instance.EvaluateSchema(collector);

// Enumerate annotations - zero-allocation ref struct enumerator
foreach (JsonSchemaAnnotationProducer.Annotation annotation
    in JsonSchemaAnnotationProducer.EnumerateAnnotations(collector))
{
    Console.WriteLine(
        $"  {annotation.GetInstanceLocationText()} " +
        $"[{annotation.GetKeywordText()}] " +
        $"= {annotation.GetValueText()}");
}
</code></pre>
<p>Each <code>Annotation</code> has four pieces of information:</p>
<ul>
<li><strong>Instance location</strong> - the JSON Pointer to the value being annotated (e.g., <code>""</code> for root, <code>"/name"</code> for a property)</li>
<li><strong>Keyword</strong> - which annotation keyword produced it (e.g., <code>"title"</code>, <code>"description"</code>)</li>
<li><strong>Schema location</strong> - where in the schema the annotation was defined</li>
<li><strong>Value</strong> - the raw JSON value of the annotation</li>
</ul>
<div class="aside"><p>The <code>Annotation</code> type is a <code>ref struct</code> whose spans reference the internal buffers of the collector. It's only valid during the current iteration. If you need to capture values for later use, call the string accessors (<code>GetKeywordText()</code>, <code>GetValueText()</code>, etc.).</p>
</div>
<h2 id="writing-annotations-as-json">Writing annotations as JSON</h2>
<p>For structured output, <code>WriteAnnotationsTo</code> produces a JSON object grouped by instance location, then keyword, then schema location. This is useful when feeding the output into other tools, such as a form renderer:</p>
<pre><code class="language-csharp">using var collector = JsonSchemaResultsCollector.Create(
    JsonSchemaResultsLevel.Verbose);
instance.EvaluateSchema(collector);

using var buffer = new MemoryStream();
using (var writer = new Utf8JsonWriter(buffer,
    new JsonWriterOptions { Indented = true }))
{
    JsonSchemaAnnotationProducer.WriteAnnotationsTo(collector, writer);
}
</code></pre>
<p>This produces output like:</p>
<pre><code class="language-json">{
    "": {
        "title": {
            "#": "\"Person\""
        },
        "description": {
            "#": "\"A person with a name and optional age\""
        }
    },
    "/name": {
        "title": {
            "#/properties/name": "\"Full name\""
        }
    }
}
</code></pre>
<p>A form generator can walk this structure and render appropriate inputs for each annotated property. It can use each title as a label alongside the description as help text, pre-fill defaults where provided, and apply readOnly/writeOnly constraints to control editability.</p>
<h2 id="when-to-use-which-mode">When to use which mode</h2>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Mode</th>
</tr>
</thead>
<tbody>
<tr>
<td>Application code that consumes JSON data</td>
<td><code>TypeGeneration</code> (default)</td>
</tr>
<tr>
<td>Form generator, documentation tool, schema-driven UI</td>
<td><code>SchemaEvaluationOnly</code></td>
</tr>
<tr>
<td>Application code that also exposes a schema-driven API</td>
<td><code>Both</code></td>
</tr>
<tr>
<td>Validation-only gateway or middleware</td>
<td><code>SchemaEvaluationOnly</code></td>
</tr>
</tbody>
</table>
<h2 id="next-up">Next up</h2>
<p>We've now covered the core V5 model - types, validation, pooled memory, mutation, and annotations. In the next post, we'll start looking at the query and transformation languages, beginning with JSONata.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">6.</span>
                <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Mutable Documents</title>
      <description>JsonDocumentBuilder and JsonWorkspace provide pooled, version-tracked mutable documents - the core V5 design trade-off that replaces V4's immutable-functional model with in-place mutation.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents</guid>
      <pubDate>Fri, 12 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>low-allocation</category>
      <category>system.text.json</category>
      <category>mutable</category>
      <category>builder-pattern</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-05.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing">previous post</a> we looked at pooled-memory parsing with <code>ParsedJsonDocument&lt;T&gt;</code>.</p>
<p>Now let's look at the other side of the coin: mutation.</p>
<h2 id="the-v4v5-trade-off">The V4/V5 trade-off</h2>
<p>This is the fundamental design decision in V5, so it's worth being explicit about it.</p>
<p>In V4, every document is immutable. If you want to change a property, you call a <code>With*()</code> method that returns a <em>new</em> instance with the modification applied. The old instance is unchanged. This is the functional approach. It is safe, thread-friendly, and easy to reason about.</p>
<p>V4 is smarter than a naive copy-on-write: it avoids copying unmodified parts of the document and defers serialization to write operations. But it still has to create new immutable data structures wherever objects or arrays are modified. In a pipeline where you parse, modify, and write JSON repeatedly, that adds up to a lot of short-lived allocations.</p>
<p>V5 takes the opposite approach. <code>JsonDocumentBuilder</code> lets you mutate documents in place, using pooled memory managed by a <code>JsonWorkspace</code>. You create a workspace, build or modify documents, write the output, and dispose everything. The workspace recycles the memory for the next operation.</p>
<p>This is the <em>builder</em> approach. It is fast and low-allocation, but you need to be mindful of lifetimes and ownership. V5 includes version tracking to catch stale references at runtime, which mitigates the most common class of bugs.</p>
<p><strong>Neither approach is universally better.</strong> If you want safety guarantees, use V4. If you want maximum throughput, use V5.</p>
<h2 id="the-workspace">The workspace</h2>
<p>Every mutable operation starts with a <code>JsonWorkspace</code>:</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();
</code></pre>
<p>The workspace manages pooled buffers and <code>Utf8JsonWriter</code> instances. When you dispose it, all resources go back to the pool. You can create multiple builders within a single workspace. They share the pooled resources.</p>
<h2 id="building-from-scratch">Building from scratch</h2>
<p>The most common pattern is building an object using a builder delegate:</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();

using var doc = JsonElement.CreateBuilder(
    workspace,
    new(static (ref objectBuilder) =&gt;
    {
        objectBuilder.AddProperty("name"u8, "Alice"u8);
        objectBuilder.AddProperty("age"u8, 30);
        objectBuilder.AddProperty("active"u8, true);
    }));

Console.WriteLine(doc.RootElement.ToString());
// {"name":"Alice","age":30,"active":true}
</code></pre>
<p>That <code>new(...)</code> is a target-typed <code>new</code>. The compiler knows it needs a <code>JsonElement.Source</code> from the <code>CreateBuilder</code> parameter type. The <code>static</code> modifier on the delegate prevents <em>accidental</em> closure allocations. Use UTF-8 string literals (<code>u8</code>) for property names to avoid transcoding overhead.</p>
<h3 id="what-is-source">What is <code>Source</code>?</h3>
<p><code>JsonElement.Source</code> is a <code>ref struct</code> that acts as a discriminated union. It can hold any value that might appear in a JSON document. It has implicit conversions from over 30 .NET types, so you rarely need to think about it:</p>
<ul>
<li><strong>Primitives:</strong> <code>bool</code>, <code>int</code>, <code>long</code>, <code>double</code>, <code>decimal</code>, <code>float</code>, <code>short</code>, <code>byte</code>, and the unsigned variants, plus <code>Half</code>, <code>Int128</code>, <code>UInt128</code></li>
<li><strong>Strings:</strong> <code>string</code>, <code>ReadOnlySpan&lt;char&gt;</code>, <code>ReadOnlySpan&lt;byte&gt;</code> (UTF-8)</li>
<li><strong>Dates:</strong> <code>DateTime</code>, <code>DateTimeOffset</code>, and NodaTime types (<code>LocalDate</code>, <code>OffsetDateTime</code>, <code>Period</code>, etc.)</li>
<li><strong>Other:</strong> <code>Guid</code>, <code>Uri</code>, <code>BigNumber</code>, <code>BigInteger</code>, <code>JsonElement</code>, <code>JsonElement.Mutable</code></li>
<li><strong>Delegates:</strong> <code>JsonElement.ObjectBuilder.Build</code> for nested objects, <code>JsonElement.ArrayBuilder.Build</code> for nested arrays</li>
</ul>
<p>This is what makes the builder API feel natural. <code>JsonElement.ObjectBuilder.AddProperty</code> has direct overloads for all these types, so <code>objectBuilder.AddProperty("age"u8, 30)</code> just works. The <code>int</code> matches directly. For <code>CreateBuilder</code> and <code>SetProperty</code>, your value implicitly converts to a <code>Source</code>. Either way, you just pass values. The type system handles the rest.</p>
<p>For objects and arrays, you pass a builder delegate:</p>
<pre><code class="language-csharp">// Object builder - delegate receives ref JsonElement.ObjectBuilder
objectBuilder.AddProperty("address"u8, static (ref addressBuilder) =&gt;
{
    addressBuilder.AddProperty("city"u8, "London"u8);
});

// Array builder - delegate receives ref JsonElement.ArrayBuilder
objectBuilder.AddProperty("tags"u8, static (ref tagsBuilder) =&gt;
{
    tagsBuilder.AddItem("admin"u8);
    tagsBuilder.AddItem("user"u8);
});
</code></pre>
<p>There's also a generic <code>Source&lt;TContext&gt;</code> variant for passing context to a delegate without allocating a closure - useful in hot paths where even a single delegate allocation matters.</p>
<h3 id="nested-objects-and-arrays">Nested objects and arrays</h3>
<p>Builder delegates compose naturally:</p>
<pre><code class="language-csharp">using var doc = JsonElement.CreateBuilder(
    workspace,
    new(static (ref objectBuilder) =&gt;
    {
        objectBuilder.AddProperty("user"u8, static (ref userBuilder) =&gt;
        {
            userBuilder.AddProperty("name"u8, "Alice"u8);
            userBuilder.AddProperty("roles"u8, static (ref rolesBuilder) =&gt;
            {
                rolesBuilder.AddItem("admin"u8);
                rolesBuilder.AddItem("editor"u8);
            });
        });
    }));

// {"user":{"name":"Alice","roles":["admin","editor"]}}
</code></pre>
<h2 id="parse-and-mutate">Parse-and-mutate</h2>
<p>In most real-world scenarios, you're receiving JSON, modifying it, and sending it on. Parse directly into a mutable builder for the best performance:</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();

// Single pass - UTF-8 bytes become the builder's backing store
using var builder = JsonDocumentBuilder&lt;JsonElement.Mutable&gt;.Parse(
    workspace,
    """{"status":"pending","count":5}""");

JsonElement.Mutable root = builder.RootElement;
root.SetProperty("status", "completed"u8);
root.SetProperty("count", 10);

Console.WriteLine(root.ToString());
// {"status":"completed","count":10}
</code></pre>
<p>All the same <code>Parse</code> overloads are available - from strings, UTF-8 bytes, streams, or a <code>Utf8JsonReader</code>.</p>
<h3 id="retaining-the-original">Retaining the original</h3>
<p>If you need to keep an immutable copy alongside the mutable version - for auditing, comparison, or read-only queries - use the two-step approach:</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();

using var sourceDoc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(
    """{"name":"Original","value":100}""");

// Convert to mutable - sourceDoc remains unchanged
using var builder = sourceDoc.RootElement.CreateBuilder(workspace);

builder.RootElement.SetProperty("name", "Modified"u8);

Console.WriteLine(sourceDoc.RootElement.ToString());  // {"name":"Original",...}
Console.WriteLine(builder.RootElement.ToString());     // {"name":"Modified",...}
</code></pre>
<h2 id="version-tracking">Version tracking</h2>
<p>Here's how V5 catches the aliasing bugs that in-place mutation can introduce.</p>
<p>Every <code>JsonDocumentBuilder</code> tracks a <code>ulong</code> version number. When you obtain a mutable element reference, it captures the current version. If you modify the document through a <em>different</em> reference and then try to use the stale one, V5 throws <code>InvalidOperationException</code>:</p>
<pre><code class="language-csharp">JsonElement.Mutable root = builder.RootElement;
JsonElement.Mutable name = root.GetProperty("name");

// Mutate through root - this bumps the version
root.SetProperty("name", "Changed"u8);

// Try to use the stale reference
name.GetString();  // throws InvalidOperationException
</code></pre>
<p>This won't catch every possible misuse, but it catches the most common class of bugs: holding a reference across a mutation boundary.</p>
<h2 id="clone-and-freeze">Clone and freeze</h2>
<p>When you're working with a mutable document, you sometimes need an immutable copy. It is a value that won't go stale when you mutate the builder again. <code>Freeze()</code> gives you exactly that.</p>
<p><code>Freeze()</code> performs a fast blit of the metadata and value backing arrays into a new immutable document registered in the same workspace, without a serialization round-trip. The result is immutable, and you can keep mutating the original builder while the frozen element stays valid:</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse("""{"name": "Alice", "age": 30}""");
using var builder = doc.RootElement.CreateBuilder(workspace);

builder.RootElement.SetProperty("age"u8, 31);

// Freeze - cheap immutable copy, stays in the workspace
JsonElement frozen = builder.RootElement.Freeze();

// Keep mutating - the frozen element is unaffected
builder.RootElement.SetProperty("age"u8, 99);

Assert.Equal(31, frozen.GetProperty("age"u8).GetInt32()); // still 31
</code></pre>
<p>The frozen element is tied to the workspace's lifetime. It's backed by pooled memory and will be cleaned up with the workspace.</p>
<p>On the other hand, if you need data to escape the workspace entirely (e.g. to return it to a caller who shouldn't need to worry about workspaces or lifetimes) then that's what <code>Clone()</code> is for.</p>
<p><code>Clone()</code> serializes the mutable element into a fresh immutable <code>ParsedJsonDocument</code> that owns its own memory on the GC heap. The clone is completely independent of both the builder and the workspace, so it remains valid after both are disposed:</p>
<pre><code class="language-csharp">JsonElement clone;

using (JsonWorkspace workspace = JsonWorkspace.Create())
using (var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse("[[[]]]"))
using (var builder = doc.RootElement.CreateBuilder(workspace))
{
    clone = builder.RootElement[0].Clone();
    // builder and workspace are disposed here
}

// clone is still valid - it owns its own memory
Assert.Equal("[[]]", clone.GetRawText());
</code></pre>
<p>Use <code>Freeze()</code> for cheap immutable copies within the workspace scope. Use <code>Clone()</code> when the result needs to escape entirely.</p>
<h3 id="snapshots-for-rollback">Snapshots for rollback</h3>
<p><code>JsonDocumentBuilderSnapshot&lt;T&gt;</code> captures the complete state of a builder so you can restore it later. This is useful for speculative mutations where you may want to roll back if something goes wrong. We mentioned this briefly in the context of JSON Patch, but it's a general-purpose mechanism.</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse("""{"status": "pending", "retries": 0}""");
using var builder = doc.RootElement.CreateBuilder(workspace);

// Take a snapshot before applying changes
using var snapshot = builder.CreateSnapshot();

builder.RootElement.SetProperty("status"u8, "processing");
builder.RootElement.SetProperty("retries"u8, 1);

// Something went wrong - roll back to the snapshot
builder.Restore(snapshot);

Assert.Equal("pending", builder.RootElement.GetProperty("status"u8).GetString());
Assert.Equal(0, builder.RootElement.GetProperty("retries"u8).GetInt32());
</code></pre>
<p><code>CreateSnapshot()</code> creates a rented copy of the builder's internal state, and <code>Restore()</code> copies it back. The snapshot is <code>IDisposable</code> and must be disposed to return the rented buffers to the pool.</p>
<h2 id="dynamic-construction-with-runtime-data">Dynamic construction with runtime data</h2>
<p>Real-world JSON isn't all static strings. Here's how you mix structure with runtime data:</p>
<pre><code class="language-csharp">string[] tags = ["admin", "user", "active"];

using JsonWorkspace workspace = JsonWorkspace.Create();

using var doc = JsonElement.CreateBuilder(
    workspace,
    new((ref objectBuilder) =&gt;
    {
        objectBuilder.AddProperty("id"u8, Guid.NewGuid());

        // Runtime collection becomes a JSON array
        objectBuilder.AddProperty("tags"u8, (ref tagsBuilder) =&gt;
        {
            foreach (string tag in tags)
            {
                tagsBuilder.AddItem(tag);
            }
        });
    }));
</code></pre>
<p>The delegate in this example captures <code>tags</code> from the enclosing scope, so it can't be <code>static</code>. That's often fine, but if you need to avoid the closure allocation there is a <code>Source&lt;TContext&gt;</code> overload that lets you pass context explicitly:</p>
<pre><code class="language-csharp">string[] tags = ["admin", "user", "active"];

using JsonWorkspace workspace = JsonWorkspace.Create();

using var doc = JsonElement.CreateBuilder(
    workspace,
    tags,
    static (in string[] tags, ref JsonElement.ObjectBuilder objectBuilder) =&gt;
    {
        objectBuilder.AddProperty("id"u8, Guid.NewGuid());

        objectBuilder.AddProperty("tags"u8, tags,
            static (in string[] tags, ref JsonElement.ArrayBuilder tagsBuilder) =&gt;
            {
                foreach (string tag in tags)
                {
                    tagsBuilder.AddItem(tag);
                }
            });
    });
</code></pre>
<p>The context parameter is passed by <code>in</code> reference, so there is no copying or boxing. Every delegate is <code>static</code>, so there are no closure allocations.</p>
<h2 id="generated-types-and-the-builder">Generated types and the builder</h2>
<p>Everything in this post uses <code>JsonElement</code> and <code>JsonElement.Mutable</code>, but the same builder API works for all generated types. If you have a <code>Person</code> type generated from a JSON Schema, you can create a builder, mutate it, and freeze it in exactly the same way.</p>
<p>The difference is that the generated types only emit .NET members that are compatible with the constraints in their schema. A <code>Person.Mutable</code> will have <code>SetProperty</code> for the properties defined in the schema, and conversions to and from .NET types that match the schema's type constraints. For example, numeric conversions are only available if the schema allows the value to be a number. This means the compiler catches type errors at build time rather than at runtime.</p>
<h2 id="at-a-glance">At a glance</h2>
<table>
<thead>
<tr>
<th></th>
<th><code>JsonNode</code></th>
<th>V4 <code>With*()</code></th>
<th>V5 <code>JsonDocumentBuilder</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mutation</strong></td>
<td>In-place, per-node</td>
<td>Returns new immutable instance</td>
<td>In-place, pooled</td>
</tr>
<tr>
<td><strong>Memory</strong></td>
<td>Managed heap per node</td>
<td>Managed heap per copy</td>
<td><code>ArrayPool</code> via workspace</td>
</tr>
<tr>
<td><strong>Safety</strong></td>
<td>No aliasing protection</td>
<td>Immutability prevents aliasing</td>
<td>Version-tracked stale detection</td>
</tr>
<tr>
<td><strong>Best for</strong></td>
<td>Long-lived trees</td>
<td>Safety-critical pipelines</td>
<td>High-throughput request/response</td>
</tr>
</tbody>
</table>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll look at the standalone evaluator. It's a lightweight code generation mode that produces just a validator and annotation collector, without the full type hierarchy. It's ideal for schema-driven tooling like form generators and configuration editors.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">5.</span>
                <span class="series-toc__part-title">Mutable Documents</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Pooled-Memory Parsing</title>
      <description>ParsedJsonDocument&lt;T&gt; uses ArrayPool-backed memory for just 136 bytes of GC pressure per document - 91% less than JsonNode - while providing a familiar System.Text.Json-compatible API.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing</guid>
      <pubDate>Thu, 11 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>low-allocation</category>
      <category>system.text.json</category>
      <category>ArrayPool</category>
      <category>memory</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-04.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation">previous post</a> we looked at schema validation.</p>
<p>Now let's talk about the foundation that makes V5's performance possible: pooled-memory parsing.</p>
<h2 id="the-allocation-problem">The allocation problem</h2>
<p>If you've profiled a .NET service that processes a lot of JSON, you've probably seen a familiar pattern in the GC profiler: a steady stream of small allocations from <code>JsonNode</code>, <code>JsonObject</code>, and <code>JsonArray</code>. Each node in the mutable document model is a separate heap object. For a document with 100 properties, that's 100+ allocations. Each one contributes to GC pressure.</p>
<p><code>System.Text.Json.JsonDocument</code> solves this with a pooled model, but it's read-only. The moment you need to modify the JSON, you're back to <code>JsonNode</code> and its per-node allocations.</p>
<p>V5's <code>ParsedJsonDocument&lt;T&gt;</code> gives you the best of both: a pooled, read-only document that uses <code>ArrayPool&lt;byte&gt;</code> for all its backing memory, with just <strong>136 bytes</strong> of GC pressure per document. This is true regardless of size.</p>
<p>And when you <em>do</em> need to modify things, the <code>JsonDocumentBuilder</code> we'll discuss in the next post uses the same pooled memory model.</p>
<h2 id="basic-usage">Basic usage</h2>
<h3 id="parsing-from-a-string">Parsing from a string</h3>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(
    """
    {
        "name": "Alice",
        "age": 30,
        "address": {
            "city": "London",
            "country": "UK"
        }
    }
    """);

JsonElement root = doc.RootElement;
string name = root.GetProperty("name"u8).GetString();
int age = root.GetProperty("age"u8).GetInt32();
</code></pre>
<p>The <code>using</code> statement is important. When the document is disposed, all rented memory is returned to <code>ArrayPool</code>. This is the core lifetime rule for <code>ParsedJsonDocument&lt;T&gt;</code>. There are no leaked buffers, and no GC pressure beyond the 136 bytes for the document object itself.</p>
<h3 id="parsing-from-utf-8-bytes">Parsing from UTF-8 bytes</h3>
<p>If you already have UTF-8 data from a network buffer, a file read, or an HTTP request body, you can parse directly without transcoding:</p>
<pre><code class="language-csharp">ReadOnlyMemory&lt;byte&gt; utf8Data = GetUtf8FromNetwork();
using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(utf8Data);
</code></pre>
<h3 id="async-stream-parsing">Async stream parsing</h3>
<p>For large files or network streams, async parsing avoids blocking:</p>
<pre><code class="language-csharp">using FileStream stream = File.OpenRead("large-data.json");
using var doc = await ParsedJsonDocument&lt;JsonElement&gt;.ParseAsync(stream);
</code></pre>
<h2 id="utf-8-property-access">UTF-8 property access</h2>
<p>One of the subtle performance wins in V5 is that property names are stored and compared as UTF-8 bytes. The <code>"name"u8</code> syntax gives you a <code>ReadOnlySpan&lt;byte&gt;</code> - no string allocation, no UTF-16 transcoding.</p>
<pre><code class="language-csharp">// Fast: UTF-8 comparison, no allocation
string name = root.GetProperty("name"u8).GetString();

// Also works, but transcodes from UTF-16
string name = root.GetProperty("name").GetString();
</code></pre>
<p>For properties you access frequently, V5 can optionally build an O(1) property map for repeated lookups. This is an opt-in feature. You enable it when you know you'll be accessing the same properties repeatedly and want to avoid the cost of linear scanning.</p>
<h2 id="types-are-views-not-containers">Types are views, not containers</h2>
<p>This is a key concept, and it's worth exploring in more detail.</p>
<p>In most .NET serialization frameworks, a deserialized object <em>owns</em> its data. A <code>Person</code> class has a <code>string Name</code> field backed by its own heap-allocated <code>string</code>. The data lives in the object.</p>
<p>In V5, that's not what happens. A generated <code>Person</code> struct is just two fields: a reference to its parent <code>IJsonDocument</code>, and an <code>int</code> index into that document's metadata table. That's it. The struct doesn't hold the string <code>"Alice"</code>. It holds a pointer to where <code>"Alice"</code> lives in the document's pooled UTF-8 byte buffer.</p>
<pre><code>┌──────────────────┐      ┌─────────────────────────────────────┐
│  Person struct   │      │   ParsedJsonDocument (pooled)       │
│  ┌────────────┐  │      │  ┌─────────────────────────────┐    │
│  │ _parent ───┼──┼─────▶│  │ MetadataDb (token offsets)  │    │
│  │ _idx: 0    │  │      │  ├─────────────────────────────┤    │
│  └────────────┘  │      │  │ UTF-8 value buffer          │    │
└──────────────────┘      │  │ {"name":"Alice","age":30}   │    │
                          │  └─────────────────────────────┘    │
                          └─────────────────────────────────────┘
</code></pre>
<p>This means:</p>
<ol>
<li><strong>Creating a typed view is free.</strong> <code>doc.RootElement</code> doesn't copy anything. It returns a struct with the document reference and index 0. Accessing <code>person.Name</code> returns another struct pointing at the same document with a different index.</li>
<li><strong>Multiple views share the same data.</strong> You can have <code>Person</code>, <code>JsonElement</code>, and <code>Address</code> structs all pointing into the same document. No duplication.</li>
<li><strong>The document owns the lifetime.</strong> When you dispose the <code>ParsedJsonDocument</code>, the pooled memory goes back to <code>ArrayPool</code>. Any struct that still references it becomes invalid. This is why the <code>using</code> statement matters.</li>
</ol>
<p>So far we've used <code>JsonElement</code>, but the real power comes from typed documents. Given a generated <code>Person</code> type (from the source generator in post 2):</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;Person&gt;.Parse(
    """{"name":"Alice","age":30}""");
Person person = doc.RootElement;

string name = (string)person.Name;      // "Alice"
int age = (int)person.Age;              // 30
bool valid = person.EvaluateSchema();   // true
</code></pre>
<p><code>person</code> is a view. <code>person.Name</code> is a view. Neither allocates. The only allocation is the 136-byte document object itself.</p>
<h2 id="extended-types">Extended types</h2>
<p>V5 supports types beyond what <code>System.Text.Json</code> offers natively:</p>
<table>
<thead>
<tr>
<th>JSON Schema format</th>
<th>.NET Type</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>"format": "int128"</code></td>
<td><code>Int128</code></td>
<td>Large integer IDs</td>
</tr>
<tr>
<td><code>"format": "uint128"</code></td>
<td><code>UInt128</code></td>
<td>Large unsigned integer IDs</td>
</tr>
<tr>
<td><code>"format": "half"</code></td>
<td><code>Half</code></td>
<td>Low-precision floats</td>
</tr>
<tr>
<td>Arbitrary precision integer</td>
<td><code>BigInteger</code></td>
<td>Cryptographic or scientific values</td>
</tr>
<tr>
<td>Arbitrary precision</td>
<td><code>BigNumber</code></td>
<td>Financial or scientific values</td>
</tr>
<tr>
<td><code>"format": "uri"</code></td>
<td><code>Utf8UriValue</code></td>
<td>Absolute URIs</td>
</tr>
<tr>
<td><code>"format": "uri-reference"</code></td>
<td><code>Utf8UriReferenceValue</code></td>
<td>Absolute or relative URIs</td>
</tr>
<tr>
<td><code>"format": "iri"</code></td>
<td><code>Utf8IriValue</code></td>
<td>Internationalized URIs</td>
</tr>
<tr>
<td><code>"format": "iri-reference"</code></td>
<td><code>Utf8IriReferenceValue</code></td>
<td>Internationalized URI references</td>
</tr>
<tr>
<td><code>"format": "date"</code></td>
<td><code>NodaTime.LocalDate</code></td>
<td>Calendar dates</td>
</tr>
<tr>
<td><code>"format": "date-time"</code></td>
<td><code>NodaTime.OffsetDateTime</code></td>
<td>Timestamps with offset</td>
</tr>
<tr>
<td><code>"format": "duration"</code></td>
<td><code>NodaTime.Period</code></td>
<td>ISO 8601 durations</td>
</tr>
</tbody>
</table>
<p><code>BigNumber</code> is rarely needed, but when you do need it, it is essential. It's a custom arbitrary-precision decimal type that operates directly on the UTF-8 bytes of the JSON, without intermediate conversion to <code>double</code> or <code>decimal</code>. There is no precision loss and no floating-point surprises.</p>
<h2 id="string-and-utf-8-formatting">String and UTF-8 formatting</h2>
<p>Every JSON type - <code>JsonElement</code> and all generated types - implements <code>IFormattable</code>, <code>ISpanFormattable</code>, and <code>IUtf8SpanFormattable</code> on .NET 9+. On <code>netstandard2.0</code> the interfaces aren't available, but the underlying static formatting methods are still there, so you can call them directly. For numeric elements, this means you can format values with standard .NET format strings:</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse("""{"price": 1234.5}""");
JsonElement price = doc.RootElement.GetProperty("price"u8);

// String formatting with culture support
string display = price.ToString("C", CultureInfo.GetCultureInfo("en-GB"));
// "£1,234.50"

// String interpolation (uses IFormattable)
string message = $"Total: {price:N2}";
// "Total: 1,234.50"
</code></pre>
<p>For zero-allocation hot paths, write directly to a UTF-8 byte span:</p>
<pre><code class="language-csharp">Span&lt;byte&gt; buffer = stackalloc byte[64];
if (price.TryFormat(buffer, out int bytesWritten, "F2", CultureInfo.InvariantCulture))
{
    ReadOnlySpan&lt;byte&gt; utf8Price = buffer.Slice(0, bytesWritten);
    // Write to a Utf8JsonWriter, HTTP response, or log sink - no string allocation
}
</code></pre>
<p>The standard numeric format specifiers are all supported: <code>G</code> (general), <code>F</code> (fixed-point), <code>N</code> (number with grouping), <code>E</code> (scientific), <code>C</code> (currency), and <code>P</code> (percentage).</p>
<h2 id="at-a-glance">At a glance</h2>
<table>
<thead>
<tr>
<th></th>
<th><code>JsonNode</code></th>
<th><code>JsonDocument</code></th>
<th><code>ParsedJsonDocument&lt;T&gt;</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Memory model</strong></td>
<td>Per-node allocation</td>
<td>Pooled, read-only</td>
<td>Pooled, read-only (mutable via builder)</td>
</tr>
<tr>
<td><strong>GC pressure</strong></td>
<td>~1,528B (typical)</td>
<td>~480B</td>
<td>~136B</td>
</tr>
<tr>
<td><strong>Mutable</strong></td>
<td>Yes</td>
<td>No</td>
<td>Via <code>JsonDocumentBuilder</code></td>
</tr>
<tr>
<td><strong>Schema validation</strong></td>
<td>No</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>Property access</strong></td>
<td>O(1)</td>
<td>O(n)</td>
<td>O(n), optional O(1) property map</td>
</tr>
<tr>
<td><strong>Generic</strong></td>
<td>No</td>
<td>No</td>
<td>Yes (<code>IJsonElement&lt;T&gt;</code>)</td>
</tr>
</tbody>
</table>
<h2 id="next-up">Next up</h2>
<p>We've seen how V5 pools memory for read-only documents. But what about mutation? In the next post, we'll look at <code>JsonDocumentBuilder</code> and <code>JsonWorkspace</code>. They provide the pooled, version-tracked builder pattern that's at the heart of V5's design trade-off with V4.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">4.</span>
                <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Schema Validation - 10× Faster</title>
      <description>Corvus.Text.Json V5 validates JSON against all major schema drafts over 10× faster than other .NET validators - with a simple EvaluateSchema() API and optional detailed diagnostics.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation</guid>
      <pubDate>Wed, 10 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>JSON Validation</category>
      <category>system.text.json</category>
      <category>OpenAPI</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-03.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types">previous post</a> we saw how the source generator produces complete typed models from JSON Schema.</p>
<p>Now let's look at what happens when you call <code>person.EvaluateSchema()</code>.</p>
<h2 id="validation-is-not-optional">Validation is not optional</h2>
<p>If you're working with JSON that crosses a trust boundary - API requests, message queues, configuration files, user-supplied data - you need to validate it. Not "check a couple of fields" validate. <em>Schema</em> validate. Against a specification that defines what valid means, and that both producer and consumer agree on.</p>
<p>JSON Schema is that specification, and it covers all the major drafts: 4, 6, 7, 2019-09, and 2020-12 - plus OpenAPI 3.0 and 3.1.</p>
<p>V5 validates over 10× faster than other .NET JSON Schema validators.</p>
<h2 id="the-generated-validator">The generated validator</h2>
<p>If you've followed the source generator approach from the previous post, validation is already built in:</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;Person&gt;.Parse(
    """{"name":"Alice","age":30}""");
Person person = doc.RootElement;

bool valid = person.EvaluateSchema();  // true
</code></pre>
<p>That's it. The source generator produced a validation method tailored to your specific schema, with every keyword compiled into type-specific checks. There is no interpretation overhead and no runtime schema walking.</p>
<h3 id="getting-diagnostic-output">Getting diagnostic output</h3>
<p>When validation fails and you need to know <em>why</em>, pass a results collector:</p>
<pre><code class="language-csharp">using var collector = JsonSchemaResultsCollector.Create(
    JsonSchemaResultsLevel.Detailed);

bool valid = person.EvaluateSchema(collector);

if (!valid)
{
    foreach (var result in collector.EnumerateResults())
    {
        if (!result.IsMatch)
        {
            Console.WriteLine(
                $"  Failed: {result.GetMessageText()} " +
                $"at {result.GetDocumentEvaluationLocationText()}");
        }
    }
}
</code></pre>
<p>When you call <code>EvaluateSchema()</code> without a collector, you get a simple pass/fail <code>bool</code> with maximum performance. If you need diagnostics, create a <code>JsonSchemaResultsCollector</code> and choose how much detail you want:</p>
<table>
<thead>
<tr>
<th>Level</th>
<th>What it collects</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Basic</code></td>
<td>Failure messages only (lowest overhead)</td>
</tr>
<tr>
<td><code>Detailed</code></td>
<td>Failures with schema location and evaluation path</td>
</tr>
<tr>
<td><code>Verbose</code></td>
<td>Every evaluation step, including successes</td>
</tr>
</tbody>
</table>
<p>Use the no-collector overload in production hot paths; use <code>Detailed</code> or <code>Verbose</code> for diagnostics.</p>
<h2 id="dynamic-validation-at-runtime">Dynamic validation at runtime</h2>
<p>Not every scenario has schemas known at compile time. Schema registries, configuration validators, and user-supplied schemas all need runtime compilation.</p>
<p>The <code>Corvus.Text.Json.Validator</code> package handles this. Internally, it uses the same code generation engine as the source generator, but invokes Roslyn dynamically:</p>
<pre><code class="language-csharp">using Corvus.Text.Json.Validator;

// Load a schema - from a file, string, stream, or URI
JsonSchema schema = JsonSchema.FromFile("Schemas/person.json");

// Validate
bool valid = schema.Validate("""{"name": "Alice", "age": 30}""");
</code></pre>
<p>Compiled schemas are cached automatically by their canonical URI, so the first validation incurs a compilation cost (around 100ms) but subsequent calls are sub-millisecond.</p>
<h3 id="multiple-input-formats">Multiple input formats</h3>
<p>The <code>Validate()</code> method accepts JSON in whatever format you already have it:</p>
<pre><code class="language-csharp">// UTF-8 bytes (zero copy from a network buffer)
bool valid = schema.Validate(utf8Bytes);

// Stream (HTTP request body, file stream)
bool valid = schema.Validate(stream);

// Pre-parsed element
using var doc = ParsedJsonDocument&lt;JsonElement&gt;.Parse(json);
bool valid = schema.Validate(in doc.RootElement);
</code></pre>
<h3 id="pre-loading-referenced-schemas">Pre-loading referenced schemas</h3>
<p>If your schema uses <code>$ref</code> to reference other schema files, you can pre-load them to avoid file system or network resolution:</p>
<pre><code class="language-csharp">var options = new JsonSchema.Options(
    additionalSchemaFiles: new[]
    {
        new AdditionalSchemaFile(
            "https://example.com/schemas/address.json",
            "Schemas/address.json")
    });

JsonSchema schema = JsonSchema.FromFile("person.json", options: options);
</code></pre>
<p>This is particularly useful in CI/CD environments where external resolution may be unreliable or disallowed.</p>
<h2 id="conformance">Conformance</h2>
<p>We take correctness seriously. The V5 engine passes the official <a href="https://github.com/json-schema-org/JSON-Schema-Test-Suite">JSON Schema Test Suite</a> for all supported drafts, and we publish results to <a href="https://bowtie.report/">Bowtie</a> - the cross-implementation conformance project.</p>
<div class="aside"><p>Bowtie runs every JSON Schema implementation against the same test suite and publishes comparative results. It's the definitive way to verify a validator's correctness, and we encourage you to check the latest results for any implementation you're evaluating.</p>
</div>
<h2 id="next-up">Next up</h2>
<p>We've seen validation from the caller's perspective. In the next post, we'll go deeper into the V5 memory model. We'll look at how <code>ParsedJsonDocument&lt;T&gt;</code> uses <code>ArrayPool</code> to achieve just 136 bytes per document.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Source-Generated Types</title>
      <description>Annotate a partial struct with [JsonSchemaTypeGenerator] and get strongly-typed properties, validation, serialization, and mutable builders - all generated at build time with full IntelliSense.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types</guid>
      <pubDate>Tue, 09 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Code Generation</category>
      <category>Roslyn</category>
      <category>source-generators</category>
      <category>system.text.json</category>
      <category>JSON Validation</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-02.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, and in the <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists">previous post</a> we gave an overview of the V5 engine and what it brings to JSON in .NET.</p>
<p>In this post, we'll look at the heart of the developer experience: source-generated types from JSON Schema.</p>
<h2 id="the-idea-schema-first-types-for-free">The idea: schema first, types for free</h2>
<p>If you've been following our <a href="https://endjin.com/blog/json-schema-patterns-in-dotnet-data-object">JSON Schema Patterns in .NET</a> series, you'll know we're fans of a <em>schema-first</em> approach. You define your JSON document structure in a schema, and let tooling generate the C# for you.</p>
<p>With V5, that workflow runs at build time with a Roslyn incremental source generator. You annotate a <code>partial struct</code>, point it at a schema file, and get a complete implementation: properties, validation, serialization, implicit conversions, and mutable builders, all with full IntelliSense as you type.</p>
<p>There is no runtime reflection and no code-first attributes. You just need a schema and a struct.</p>
<h2 id="quick-start">Quick start</h2>
<h3 id="create-a-json-schema">1. Create a JSON Schema</h3>
<p>Here's a simple schema describing a person:</p>
<pre><code class="language-json">{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "required": ["name"],
    "properties": {
        "name": { "type": "string", "minLength": 1 },
        "age": { "type": "integer", "format": "int32", "minimum": 0 }
    }
}
</code></pre>
<h3 id="add-the-nuget-packages">2. Add the NuGet packages</h3>
<pre><code class="language-xml">&lt;PackageReference Include="Corvus.Text.Json.SourceGenerator" Version="5.1.0"&gt;
  &lt;PrivateAssets&gt;all&lt;/PrivateAssets&gt;
  &lt;IncludeAssets&gt;runtime; build; native; contentfiles; analyzers; buildtransitive&lt;/IncludeAssets&gt;
&lt;/PackageReference&gt;
&lt;PackageReference Include="Corvus.Text.Json" Version="5.1.0" /&gt;
</code></pre>
<h3 id="register-the-schema-and-declare-a-struct">3. Register the schema and declare a struct</h3>
<p>In your <code>.csproj</code>:</p>
<pre><code class="language-xml">&lt;ItemGroup&gt;
  &lt;AdditionalFiles Include="Schemas/person.json" /&gt;
&lt;/ItemGroup&gt;
</code></pre>
<p>In a <code>.cs</code> file:</p>
<pre><code class="language-csharp">using Corvus.Text.Json;

namespace MyApp.Models;

[JsonSchemaTypeGenerator("Schemas/person.json")]
public readonly partial struct Person;
</code></pre>
<h3 id="use-it">4. Use it</h3>
<p>Build, and everything is available immediately:</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;Person&gt;.Parse(
    """{"name":"Alice","age":30}""");
Person person = doc.RootElement;

string name = (string)person.Name;      // "Alice"
int age = (int)person.Age;              // 30
bool valid = person.EvaluateSchema();   // true
</code></pre>
<h2 id="what-gets-generated">What gets generated</h2>
<p>For each <code>[JsonSchemaTypeGenerator]</code> attribute, the source generator produces:</p>
<ol>
<li><strong>Type-safe property accessors</strong> for every property in the schema - <code>person.Name</code>, <code>person.Age</code>, etc.</li>
<li><strong>Validation</strong> via <code>EvaluateSchema()</code> with full support for whatever schema draft your <code>$schema</code> keyword indicates</li>
<li><strong>Parsing</strong> from strings, byte arrays, streams, and sequences via <code>ParsedJsonDocument&lt;T&gt;.Parse()</code></li>
<li><strong>Serialization</strong> via <code>WriteTo(Utf8JsonWriter)</code> and <code>ToString()</code></li>
<li><strong>Implicit conversions</strong> to and from .NET primitive types</li>
<li><strong>A mutable builder</strong> via <code>CreateBuilder(JsonWorkspace)</code> for in-place modification (we'll cover this in a later post)</li>
<li><strong>Pattern matching</strong> via <code>Match()</code> for <code>oneOf</code>/<code>anyOf</code> discriminated unions</li>
<li><strong>Equality</strong> operators and <code>GetHashCode()</code></li>
</ol>
<p>All generated types are <code>readonly struct</code> values. They're lightweight indexes into pooled JSON data, not heap-allocated objects. This is fundamental to the V5 memory model, and we'll explore it in detail in the post on pooled-memory parsing.</p>
<h2 id="duck-typing-if-it-validates-it-fits">Duck typing: if it validates, it fits</h2>
<p>Here's something that might surprise you if you're used to C#'s nominal type system.</p>
<p>Because every generated type is just a <em>view</em> into the same underlying JSON data - a struct containing a document reference and an integer index - you can reinterpret any value as any other type. The conversion doesn't copy data or change the JSON. It just creates a new view with different typed accessors.</p>
<pre><code class="language-csharp">// "address" is some JSON element - maybe it came from a JsonElement, maybe
// from a different schema type, maybe from a query result
Address address = Address.From(ref someElement);

// Did it actually validate as an Address?
if (address.EvaluateSchema())
{
    // Yes - we can safely use the typed accessors
    string city = (string)address.City;
}
</code></pre>
<p>This is <em>duck typing</em> in the JSON Schema sense: if the JSON data satisfies the schema, you can use it as that type - regardless of where it came from or what type it was originally parsed as.</p>
<p>This is possible because the types don't own or transform the data. They're views. <code>Address.From(ref someElement)</code> doesn't deserialize into an <code>Address</code>. It wraps the same document location in a struct that has <code>City</code>, <code>Street</code>, and <code>Postcode</code> accessors. The data hasn't moved. We've just put on a different pair of glasses.</p>
<h3 id="pattern-matching-with-match">Pattern matching with <code>Match()</code></h3>
<p>This duck-typing model is the foundation for our <code>oneOf</code> and <code>anyOf</code> support. When your schema says a value can be one of several types, <code>Match()</code> tries each variant's schema in turn and dispatches to the first one that validates:</p>
<pre><code class="language-csharp">// Schema: Shape = oneOf [Circle, Rectangle, Triangle]
string description = shape.Match(
    static (in Circle c) =&gt; $"Circle with radius {(double)c.Radius}",
    static (in Rectangle r) =&gt; $"Rectangle {(double)r.Width}×{(double)r.Height}",
    static (in Triangle t) =&gt; $"Triangle with {(int)t.Sides} sides",
    static (in Shape s) =&gt; "Unknown shape");
</code></pre>
<p>Each branch receives a <em>view</em> of the same JSON data, reinterpreted as the matching type. The lambda parameters are <code>static</code> to avoid <em>accidental</em> closure allocations. There's also a <code>Match</code> overload that accepts a context parameter if you need to pass state in without allocating.</p>
<p>This isn't just syntactic sugar. It's a direct expression of how JSON Schema composition works: <code>oneOf</code> means "exactly one of these schemas validates", and <code>Match()</code> finds which one.</p>
<h2 id="multi-schema-projects">Multi-schema projects</h2>
<p>If your schema uses <code>$ref</code> to reference other schema files, you need to register all of them:</p>
<pre><code class="language-xml">&lt;ItemGroup&gt;
  &lt;AdditionalFiles Include="Schemas/**/*.json" /&gt;
&lt;/ItemGroup&gt;
</code></pre>
<p>And you can generate types from multiple definitions within a single schema:</p>
<pre><code class="language-csharp">[JsonSchemaTypeGenerator("Schemas/api.json#/$defs/Address")]
public readonly partial struct Address;

[JsonSchemaTypeGenerator("Schemas/api.json#/$defs/PhoneNumber")]
public readonly partial struct PhoneNumber;
</code></pre>
<p>The <code>#/$defs/Address</code> part is a JSON Pointer fragment that targets a specific definition within the schema.</p>
<h2 id="tuning-the-generator">Tuning the generator</h2>
<p>Several MSBuild properties let you control how types are generated:</p>
<pre><code class="language-xml">&lt;PropertyGroup&gt;
  &lt;!-- Use an explicit cast to string (makes allocations visible) --&gt;
  &lt;CorvusTextJsonUseImplicitOperatorString&gt;false&lt;/CorvusTextJsonUseImplicitOperatorString&gt;

  &lt;!-- Treat optional properties as nullable .NET types --&gt;
  &lt;CorvusTextJsonOptionalAsNullable&gt;NullOrUndefined&lt;/CorvusTextJsonOptionalAsNullable&gt;

  &lt;!-- Control format keyword enforcement --&gt;
  &lt;CorvusTextJsonAlwaysAssertFormat&gt;true&lt;/CorvusTextJsonAlwaysAssertFormat&gt;
&lt;/PropertyGroup&gt;
</code></pre>
<div class="aside"><p>Setting <code>CorvusTextJsonOptionalAsNullable</code> to <code>NullOrUndefined</code> means optional properties generate as <code>T?</code>. A JSON <code>null</code> or missing value maps to C# <code>null</code>. When this is omitted (the default), optional properties use the full type and you check for <code>Undefined</code> explicitly.</p>
</div>
<h2 id="the-cli-alternative">The CLI alternative</h2>
<p>The source generator and CLI tool produce identical output. The CLI is useful when you want to pre-generate code, inspect it, or integrate into a pipeline that doesn't use Roslyn:</p>
<pre><code class="language-bash">corvusjson jsonschema Schemas/person.json \
    --rootNamespace MyApp.Models \
    --outputPath Generated/ \
    --outputRootTypeName Person
</code></pre>
<p>You can also use <code>--engine V4</code> to target the V4 code generator instead. It's the same tool and the same schema analysis, but with different output.</p>
<h2 id="inspecting-the-generated-code">Inspecting the generated code</h2>
<p>If you want to see what the source generator produces, enable compiler-generated file output:</p>
<pre><code class="language-xml">&lt;PropertyGroup&gt;
  &lt;EmitCompilerGeneratedFiles&gt;true&lt;/EmitCompilerGeneratedFiles&gt;
&lt;/PropertyGroup&gt;
</code></pre>
<p>The generated files will appear under <code>obj/</code> in your build output. This is handy for debugging or just satisfying curiosity.</p>
<h2 id="next-up">Next up</h2>
<p>In the next post, we'll look at what happens when you call <code>person.EvaluateSchema()</code>. It's schema validation that runs over 10× faster than other .NET validators.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why V5 Exists</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">Source-Generated Types</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introducing Corvus.Text.Json V5: Why V5 Exists</title>
      <description>Corvus.Text.Json V5 is a new engine for high-performance JSON in .NET - pooled-memory parsing, mutable documents, source-generated types, and three query languages. Here's why we built it.</description>
      <link>https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists</link>
      <guid isPermaLink="true">https://endjin.com/blog/introducing-corvus-text-json-v5-why-v5-exists</guid>
      <pubDate>Mon, 08 Jun 2026 05:30:00 GMT</pubDate>
      <category>json</category>
      <category>json-schema</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>Code Generation</category>
      <category>system.text.json</category>
      <category>JSON Validation</category>
      <category>JSON Serialization</category>
      <category>High Performance</category>
      <category>low-allocation</category>
      <category>OpenAPI</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/06/introducing-corvus-text-json-v5-part-01.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, an open source <a href="https://endjin.com/blog/csharp-serialization-with-system-text-json-schema">high-performance library for serialization and validation of JSON using JSON Schema</a>.</p>
<p>Today we're introducing something we've been working on for a while: the V5 engine. It is a new code generator and runtime library that sits alongside our existing V4 engine, and represents a fundamentally different set of design trade-offs.</p>
<p>In this series, we'll walk through what V5 brings, why it exists, and how to get started.</p>
<h2 id="the-problem-were-solving">The problem we're solving</h2>
<p>If you work with JSON in .NET, you've probably noticed a few gaps in the built-in tooling.</p>
<p><code>System.Text.Json</code> provides excellent low-level readers and writers, and a high-performance serialization framework for code-first projects. But it has no built-in schema validation, no query language support, and its mutable document model (<code>JsonNode</code>) allocates per node with no control over memory lifetime.</p>
<p>Meanwhile, JSON itself has become the lingua franca of APIs, configuration, message passing, and data exchange. You need to parse it, validate it, query it, transform it, patch it, and write it back out. This often happens in a high-throughput request/response pipeline where every allocation matters.</p>
<p>That's what Corvus.Text.Json is for.</p>
<h2 id="two-engines-one-toolchain">Two engines, one toolchain</h2>
<p>The V5 engine doesn't replace V4. It offers a different set of trade-offs.</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>V4 (Corvus.Json)</th>
<th>V5 (Corvus.Text.Json)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mutation model</strong></td>
<td>Immutable-functional - <code>With*()</code> returns a new instance</td>
<td>Mutable builder - <code>Set*()</code> mutates in-place</td>
</tr>
<tr>
<td><strong>Memory</strong></td>
<td><code>JsonElement</code> backed by <code>JsonDocument</code></td>
<td><code>ParsedJsonDocument&lt;T&gt;</code> backed by <code>ArrayPool&lt;byte&gt;</code></td>
</tr>
<tr>
<td><strong>Safety</strong></td>
<td>Stale references impossible (immutable)</td>
<td>Version-tracked stale reference detection</td>
</tr>
<tr>
<td><strong>Performance</strong></td>
<td>Good</td>
<td>Considerably faster, lower allocation</td>
</tr>
<tr>
<td><strong>Target frameworks</strong></td>
<td>net8.0, net10.0, netstandard2.0</td>
<td>net9.0, net10.0, netstandard2.0, netstandard2.1</td>
</tr>
</tbody>
</table>
<p><strong>V4 is the right choice</strong> when you want the guarantees of an immutable document model - no aliasing bugs, no stale references, and the ability to share values freely across threads.</p>
<p><strong>V5 is the right choice</strong> when you're building request/response pipelines, message processing, or other scenarios where you create a document, use it briefly, and dispose it. The builder pattern pools memory across operations, dramatically reducing GC pressure.</p>
<p>Both engines ship in the same CLI tool (<code>corvusjson</code>) and share the same JSON Schema analysis engine. You choose which to use with the <code>--engine</code> flag.</p>
<h2 id="what-v5-brings">What V5 brings</h2>
<h3 id="pooled-memory-parsing">Pooled-memory parsing</h3>
<p><code>ParsedJsonDocument&lt;T&gt;</code> uses <code>ArrayPool&lt;byte&gt;</code> for its backing memory. It adds just 136 bytes of GC pressure per document, regardless of size, compared with 1,528 bytes for a comparable <code>JsonNode</code>. That's 92% less memory.</p>
<pre><code class="language-csharp">using var doc = ParsedJsonDocument&lt;Person&gt;.Parse(
    """{"name":"Alice","age":30}""");
Person person = doc.RootElement;
string name = (string)person.Name;  // "Alice"
</code></pre>
<h3 id="mutable-documents-with-jsonworkspace">Mutable documents with JsonWorkspace</h3>
<p><code>JsonDocumentBuilder&lt;T&gt;</code> and <code>JsonWorkspace</code> provide a pooled builder pattern for creating and modifying JSON in place, with version tracking to catch stale element references at runtime.</p>
<pre><code class="language-csharp">using JsonWorkspace workspace = JsonWorkspace.Create();
using var builder = person.CreateBuilder(workspace);
Person.Mutable root = builder.RootElement;
root.SetAge(31);
Console.WriteLine(root.ToString()); // {"name":"Alice","age":31}
</code></pre>
<h3 id="source-generated-types">Source-generated types</h3>
<p>The <code>[JsonSchemaTypeGenerator]</code> attribute triggers a Roslyn incremental source generator that produces strongly-typed C# models from JSON Schema at build time. You get full IntelliSense as you type.</p>
<pre><code class="language-csharp">using Corvus.Text.Json;

namespace MyApp.Models;

[JsonSchemaTypeGenerator("Schemas/person.json")]
public readonly partial struct Person;
</code></pre>
<h3 id="schema-validation-over-10-faster">Schema validation - over 10× faster</h3>
<p>Full JSON Schema validation for drafts 4, 6, 7, 2019-09, and 2020-12 (including OpenAPI 3.0 and 3.1). Over 10× faster than other .NET JSON Schema validators, with detailed hierarchical diagnostics when you need them.</p>
<pre><code class="language-csharp">bool valid = person.EvaluateSchema();  // true
</code></pre>
<h3 id="three-query-and-transformation-languages">Three query and transformation languages</h3>
<p>V5 includes complete implementations of three JSON query/transformation languages, all with 100% conformance against their official test suites:</p>
<ul>
<li><strong>JSONata</strong> - a Turing-complete functional query and transformation language. On average 2× faster than Jsonata.Net.Native with 90–100% less memory allocation across 32 benchmarks.</li>
<li><strong>JMESPath</strong> - a standard query language used by AWS CLI and Azure CLI. On average 28× faster than JmesPath.Net with zero allocation across 21 benchmarks.</li>
<li><strong>JsonLogic</strong> - a safe, side-effect-free rule engine for evaluating business rules stored as JSON. On average 3× faster than JsonEverything with zero allocation across 19 benchmarks.</li>
</ul>
<p>All three support interpreted runtime evaluation, build-time source generation, and CLI code generation.</p>
<h3 id="yaml-1.2-to-json">YAML 1.2 to JSON</h3>
<p>YAML is a superset of JSON, and in practice the two are interchangeable in many configuration and data-exchange scenarios. Kubernetes manifests, OpenAPI specifications, GitHub Actions workflows, Azure DevOps pipelines, Helm charts, Docker Compose files - a huge amount of the infrastructure-as-code ecosystem uses YAML as its primary format. If you're processing these in .NET, you eventually need to convert YAML to JSON so you can validate, query, or transform it with standard JSON tooling.</p>
<p>V5 includes a zero-allocation <code>ref struct</code> YAML 1.2 tokenizer that converts directly to a <code>ParsedJsonDocument&lt;T&gt;</code> or a <code>System.Text.Json.JsonDocument</code> with 100% yaml-test-suite conformance. There is no intermediate object model and no per-node allocations.</p>
<h3 id="json-patch-json-pointer-and-extended-types">JSON Patch, JSON Pointer, and extended types</h3>
<ul>
<li><strong>JSON Patch</strong> - RFC 6902 with a fluent <code>PatchBuilder</code> and pooled-memory operations</li>
<li><strong>JSON Pointer</strong> - RFC 6901 zero-allocation path resolution with source location mapping</li>
<li><strong>BigNumber</strong> - arbitrary-precision decimal arithmetic without floating-point conversion</li>
<li><strong>NodaTime</strong> - first-class support for <code>LocalDate</code>, <code>OffsetDateTime</code>, <code>Period</code>, and other temporal types</li>
</ul>
<h2 id="at-a-glance-system.text.json-vs-corvus.text.json">At a glance: System.Text.Json vs Corvus.Text.Json</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>System.Text.Json</th>
<th>Corvus.Text.Json</th>
</tr>
</thead>
<tbody>
<tr>
<td>Read-only parsing</td>
<td><code>JsonDocument</code> (pooled)</td>
<td><code>ParsedJsonDocument&lt;T&gt;</code> (pooled, generic)</td>
</tr>
<tr>
<td>Mutable documents</td>
<td><code>JsonNode</code> (allocates per node)</td>
<td>Builder pattern on pooled memory</td>
</tr>
<tr>
<td>Schema validation</td>
<td>None built-in</td>
<td>Draft 4/6/7/2019-09/2020-12, detailed diagnostics</td>
</tr>
<tr>
<td>Code generation</td>
<td>Serialization to/from POCOs</td>
<td>Strongly-typed entities from JSON Schema</td>
</tr>
<tr>
<td>Date/Time</td>
<td><code>DateTime</code>, <code>DateTimeOffset</code></td>
<td>All .NET types plus NodaTime</td>
</tr>
<tr>
<td>Numeric precision</td>
<td><code>decimal</code> (28 digits)</td>
<td><code>BigNumber</code> (arbitrary precision), <code>Int128</code>, <code>Half</code></td>
</tr>
<tr>
<td>Query languages</td>
<td>None</td>
<td>JSONata, JMESPath, JsonLogic</td>
</tr>
<tr>
<td>YAML support</td>
<td>None</td>
<td>YAML 1.2 with zero-allocation tokenizer</td>
</tr>
</tbody>
</table>
<h2 id="getting-started">Getting started</h2>
<pre><code class="language-bash"># Core library
dotnet add package Corvus.Text.Json

# Source generator
dotnet add package Corvus.Text.Json.SourceGenerator

# CLI code generator
dotnet tool install --global Corvus.Json.Cli
</code></pre>
<h2 id="whats-coming-in-this-series">What's coming in this series</h2>
<p>We'll start with the developer experience: how the source generator and CLI tool turn a JSON Schema into a strongly-typed C# struct with full IntelliSense, and how the standalone evaluator gives you annotation collection for schema-driven tooling like form generators.</p>
<p>Then we'll dig into the runtime. We'll look at how <code>ParsedJsonDocument&lt;T&gt;</code> achieves 136 bytes per document with pooled-memory parsing, how the mutable <code>JsonDocumentBuilder</code> trades V4's immutable safety for in-place mutation with version tracking, and how schema validation runs over 10× faster across all major drafts.</p>
<p>From there, we'll move into the three query and transformation languages. JSONata handles general-purpose JSON transformation, JMESPath provides the concise extraction queries used by AWS and Azure CLIs, and JsonLogic offers safe, side-effect-free business rules you can store in a database and evaluate without code execution.</p>
<p>We'll cover YAML 1.2 conversion with a zero-allocation tokenizer that achieves 100% yaml-test-suite conformance, JSON Patch for RFC 6902 document mutation with a fluent builder, JSON Pointer for zero-allocation path resolution, and V5's extended type system with UTF-8 URI/IRI parsing, arbitrary-precision numerics, and first-class NodaTime integration.</p>
<p>Finally, we'll wrap up with the migration path from V4 to V5, the 10 production Roslyn analyzers that ship with the library, and how to get started.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Introducing Corvus.Text.Json V5</h3>
        <span class="series-toc__count">15 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Why V5 Exists</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-source-generated-types" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Source-Generated Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-schema-validation" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Schema Validation - 10× Faster</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-pooled-memory-parsing" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Pooled-Memory Parsing</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-mutable-documents" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Mutable Documents</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-standalone-evaluator-and-annotations" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Standalone Evaluator and Annotations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonata" class="series-toc__link">
                    <span class="series-toc__part-number">7.</span>
                    <span class="series-toc__part-title">JSONata - Query and Transform JSON</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jmespath" class="series-toc__link">
                    <span class="series-toc__part-number">8.</span>
                    <span class="series-toc__part-title">JMESPath - On Average 28× Faster JSON Queries</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-jsonlogic" class="series-toc__link">
                    <span class="series-toc__part-number">9.</span>
                    <span class="series-toc__part-title">JsonLogic - Safe Business Rules</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-yaml" class="series-toc__link">
                    <span class="series-toc__part-number">10.</span>
                    <span class="series-toc__part-title">YAML 1.2 - Zero-Allocation Conversion</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-patch" class="series-toc__link">
                    <span class="series-toc__part-number">11.</span>
                    <span class="series-toc__part-title">JSON Patch</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-json-pointer" class="series-toc__link">
                    <span class="series-toc__part-number">12.</span>
                    <span class="series-toc__part-title">JSON Pointer</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-extended-types" class="series-toc__link">
                    <span class="series-toc__part-number">13.</span>
                    <span class="series-toc__part-title">Extended Types</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-toon" class="series-toc__link">
                    <span class="series-toc__part-number">14.</span>
                    <span class="series-toc__part-title">TOON - Compact JSON for LLMs</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-corvus-text-json-v5-migration-and-whats-next" class="series-toc__link">
                    <span class="series-toc__part-number">15.</span>
                    <span class="series-toc__part-title">Migration, Analyzers, and What's Next</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Optimising DAX: VertiPaq Encoding Techniques</title>
      <description>VertiPaq fits millions of rows in memory by compressing columns. Learn how value, hash and run-length encoding work, and what they mean for model performance.</description>
      <link>https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques</link>
      <guid isPermaLink="true">https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques</guid>
      <pubDate>Thu, 04 Jun 2026 05:30:00 GMT</pubDate>
      <category>Power BI</category>
      <category>DAX</category>
      <category>VertiPaq</category>
      <category>Performance</category>
      <category>Data</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/optimising-dax-vertipaq-encoding-techniques.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>In the <a href="https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data">previous post</a>, we covered how VertiPaq uses column-oriented storage to make aggregations fast. But we left a fairly important question unanswered: how does it fit all this data into memory in the first place?</p>
<p>The answer is compression. VertiPaq uses three encoding techniques, and understanding them is key to understanding why certain model designs perform better than others. Bear with me through the mechanics - it'll pay off when we get to the practical implications in later posts.</p>
<h2 id="value-encoding">Value Encoding</h2>
<p>Value encoding is the simplest of the three. The idea is to reduce the number of bits needed per value by applying a mathematical transformation.</p>
<p>A concrete example: say you have a column containing 194, 205, 114, and 216. Storing these raw requires 8 bits per value. But if you instead store values as offsets from 114 (so 80, 91, 0, 102), and the engine knows to add 114 back when reading, you'll need fewer bits. This is a simplified example - In practice, the engine finds the transformation that minimises storage.</p>
<p>Doing this means you can store smaller columns and therefore do faster reads. Again, this adds load to the CPU as the values need to be decoded, but CPU is cheap and getting ever-faster (a recurring theme in this series).</p>
<h2 id="hash-encoding">Hash Encoding</h2>
<p>Hash encoding is used for any non-numerical values. Instead of storing actual values in the column, VertiPaq stores small integers and keeps a separate <strong>dictionary</strong> that maps those integers back to the original values.</p>
<p>This is especially powerful when values are repeated. Imagine a column of customer names - large strings appearing across millions of order rows. Without hash encoding, every row stores the full string, which would be a huge amount of memory. With hash encoding, each unique string appears once in the dictionary, and the column itself contains only tiny integers.</p>
<p>Clearly, if a column has very few repeated values (like... for example... an ID column), the dictionary ends up being as large as the column, and you've gained nothing. We'll come back to this when we talk about cardinality in a couple of posts' time.</p>
<p>A couple of details worth knowing:</p>
<p><strong>Any column that participates in a relationship must be hash encoded.</strong> This is a VertiPaq requirement for how it handles joins.</p>
<p>Hash encoding also means that <strong>VertiPaq is essentially type-independent</strong> under the hood. Strings, dates, decimals - everything ends up as hashed integers internally.</p>
<h2 id="run-length-encoding">Run-Length Encoding</h2>
<p>The final technique is run-length encoding (RLE). Instead of storing every value in a column, RLE stores how many times each value repeats in sequence.</p>
<p>For example, a column:</p>
<table>
<thead>
<tr>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q1</td>
</tr>
<tr>
<td>Q2</td>
</tr>
<tr>
<td>Q2</td>
</tr>
</tbody>
</table>
<p>Can instead be stored as (combining run-length and hash encoding):</p>
<table>
<thead>
<tr>
<th>Value</th>
<th>Repeated</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>5</td>
</tr>
<tr>
<td>2</td>
<td>2</td>
</tr>
<tr>
<td>1</td>
<td>5</td>
</tr>
</tbody>
</table>
<p>With a dictionary:</p>
<table>
<thead>
<tr>
<th>Hash</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Q1</td>
</tr>
<tr>
<td>2</td>
<td>Q2</td>
</tr>
</tbody>
</table>
<p>Which is a significant reduction, especially when tables get much larger!</p>
<p>The effectiveness of RLE depends heavily on the <strong>sort order</strong> of the data. If all the Q1 values were grouped together rather than split across two runs, you'd get even better compression.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/run-length-encoding.png" alt="Diagram showing how RLE compresses a well-sorted column vs a poorly-sorted one" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/run-length-encoding.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/run-length-encoding.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/run-length-encoding.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/run-length-encoding.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h3 id="the-10-second-sort-budget">The 10-Second Sort Budget</h3>
<p>When you import data, VertiPaq analyses the optimal sort order for run-length encoding. By default, it spends <strong>10 seconds</strong> on this analysis. If it hasn't found the optimal ordering in that time, it uses the best one found so far.</p>
<p>This is one of the main reasons that <strong>reducing the number of columns in your model is a good idea</strong>. Fewer columns means fewer permutations to evaluate, making it more likely the engine finds a good sort order within the time budget.</p>
<h2 id="putting-it-together">Putting It Together</h2>
<p>These techniques work together, but not all at once. Value encoding and hash encoding are <strong>mutually exclusive</strong> - numeric columns generally use value encoding, while text and other non-numeric data use hash encoding. The exception is that any column participating in a relationship <strong>must</strong> be hash encoded, so a numeric foreign key column will use hash encoding rather than value encoding. Run-length encoding is then applied on top of whichever encoding is used, compressing consecutive repeated values.</p>
<p>The result is that VertiPaq can hold impressively large datasets in memory - but <em>how</em> well it compresses depends very much on the characteristics of your data. Look out for the next post on why cardinality matters.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Optimising DAX</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-series-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Series Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">How VertiPaq Stores Your Data</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">VertiPaq Encoding Techniques</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-why-cardinality-matters" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Why Cardinality Matters</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-the-cost-of-relationships" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">The Cost of Relationships</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-model-design-comparisons" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Model Design Comparisons</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Optimising DAX: How VertiPaq Stores Your Data</title>
      <description>VertiPaq stores Power BI data column-by-column rather than row-by-row. That makes aggregations fast and cross-column queries the trickiest part of DAX.</description>
      <link>https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data</link>
      <guid isPermaLink="true">https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data</guid>
      <pubDate>Wed, 27 May 2026 07:30:00 GMT</pubDate>
      <category>Power BI</category>
      <category>DAX</category>
      <category>VertiPaq</category>
      <category>Performance</category>
      <category>Data</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/optimising-dax-how-vertipaq-stores-your-data.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>VertiPaq is the in-memory, column-oriented database engine behind Power BI's import mode. It's the reason a million-row aggregation can come back in milliseconds, and it's also the reason a query that mixes several columns can suddenly feel slow. Both behaviours fall out of the same design choice: storing data column-by-column rather than row-by-row. Before we get anywhere near actual DAX, it's worth being clear on what that means.</p>
<p>Hello again, and welcome to the <a href="https://endjin.com/blog/optimising-dax-series-introduction">Optimising DAX</a> series. In this post we're starting at the very bottom of the stack: how VertiPaq actually stores your data when you import it into Power BI.</p>
<p>I'll be honest - before attending <a href="https://www.sqlbi.com/author/alberto-ferrari/">Alberto Ferrari's</a> workshop at SQLBits, I had a fairly vague mental model of "it goes into memory and is fast". Turns out there's quite a lot more to it than that, and understanding the storage layer is really the foundation for everything else in this series.</p>
<h2 id="what-is-vertipaq">What is VertiPaq?</h2>
<p>VertiPaq is the in-memory database engine that powers Power BI's import mode. When you import data into a Power BI model, VertiPaq is what's holding that data in memory and serving it up when your DAX queries ask for it.</p>
<p>The key characteristic of VertiPaq is that it's a <strong>column-oriented</strong> data store. This means that data is stored column-by-column, rather than the row-by-row approach you'd find in a traditional SQL database.</p>
<h2 id="why-column-oriented">Why Column-Oriented?</h2>
<p>Imagine you want to calculate a <code>SUM</code> over a single column in a table with 20 columns and a million rows. In a row-oriented store, you'd have to read every row - all 20 columns per row - and then throw away 19 of them. You end up reading the full file but throwing away almost everything.</p>
<p>In a column-oriented store, you just read the one column you need. You can directly access the data without touching anything else, which makes reading an entire column (for, e.g., a SUM) incredibly fast.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/row-vs-column-oriented.png" alt="Diagram comparing row-oriented vs column-oriented storage - showing how a SUM reads data in each case" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/row-vs-column-oriented.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/row-vs-column-oriented.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/row-vs-column-oriented.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/row-vs-column-oriented.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="the-catch-cross-column-operations">The Catch: Cross-Column Operations</h2>
<p>Column-oriented storage is brilliant for operations on individual columns, but it does add complexity as soon as you need relationships <em>between</em> columns.</p>
<p>A <code>GROUPBY</code>, for example, is more complicated than you might think. The engine has to read each column separately and build up some kind of index map to work out how the values relate to one another. It's a bit more work than just reading rows in order.</p>
<p>However - CPU is very cheap these days (a recurring theme in this series), so more complexity isn't necessarily an issue in practice.</p>
<p>Where things can genuinely break down is with more and more complex queries spanning multiple columns. At a certain point, the engine may just give up and <strong>rebuild the entire table from scratch</strong> - which is a big cause of slow queries. We'll explore this properly when we get to data materialisation later in the series.</p>
<h2 id="whats-next">What's Next</h2>
<p>So that's the basic storage model - column-oriented, great for aggregations, less great for complex cross-column work. But we've skipped over something important: how does VertiPaq actually fit all this data into memory? The answer is compression, and that's what we'll cover next. Look out for the next post on encoding techniques!</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Optimising DAX</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-series-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Series Introduction</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">How VertiPaq Stores Your Data</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">VertiPaq Encoding Techniques</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-why-cardinality-matters" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Why Cardinality Matters</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-the-cost-of-relationships" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">The Cost of Relationships</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-model-design-comparisons" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Model Design Comparisons</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Optimising DAX: A Series Introduction</title>
      <description>Optimising DAX in Power BI: VertiPaq storage, compression, model design, how queries run through the storage, formula engines, and performance patterns.</description>
      <link>https://endjin.com/blog/optimising-dax-series-introduction</link>
      <guid isPermaLink="true">https://endjin.com/blog/optimising-dax-series-introduction</guid>
      <pubDate>Wed, 27 May 2026 05:30:00 GMT</pubDate>
      <category>Power BI</category>
      <category>DAX</category>
      <category>VertiPaq</category>
      <category>Performance</category>
      <category>Data</category>
      <category>SQLBits</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/optimising-dax-series-introduction.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>Optimising DAX queries in Power BI comes down to two things: how VertiPaq stores and compresses your data, and how the storage and formula engines work together to run a query. This series covers both: the model-design decisions you make before writing any DAX, and the query patterns that keep your reports fast.</p>
<p>Hello! This is the first in a series of posts about optimising DAX queries in Power BI. The content is based on a full-day workshop I attended at <a href="https://endjin.com/blog/sqlbits-2026-a-conference-recap">SQLBits 2026</a> by <a href="https://www.linkedin.com/in/albertoferrarisqlbi/">Alberto Ferrari</a> of <a href="https://www.sqlbi.com/">SQLBI</a>. I learnt a huge amount about how the underlying engines that power Power BI work, and I wanted to write it all up partly to share it, and partly because I find that writing things down is the only way I actually retain them!</p>
<p>The series is split roughly into two halves. The first covers <strong>model optimisation</strong> - understanding how VertiPaq stores and compresses your data, and how the design decisions you make at the model level affect performance before you ever write a line of DAX. We look at three common model designs and compare how they perform:</p>
<ul>
<li><strong>Star schema</strong> - dimension tables (customers, products, dates) linked to a central fact table. Generally the smallest and fastest.</li>
<li><strong>Flat table</strong> - everything denormalised into a single wide table. Actually compresses reasonably well because of all the repeated values.</li>
<li><strong>Header/detail</strong> - two fact tables (e.g. an Orders table and an OrderDetails table) linked by a primary key like OrderID. Spoiler: linking two large fact tables via their primary key is about the worst thing you can possibly do.</li>
</ul>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/model-design.png" alt="Diagram showing the three model designs side by side" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/model-design.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/model-design.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/model-design.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/model-design.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The second half covers <strong>query execution</strong> - how the two engines under the hood (the formula engine and the storage engine) divide up the work, what "data materialisation" means, and practical techniques for keeping your queries in the fast lane.</p>
<p>One thing Alberto stressed at the end of the workshop (and I think it's worth saying up front): <strong>at a lot of model sizes, these optimisations aren't worth the effort</strong>. Unoptimised DAX is often perfectly fine. These techniques become useful when you're working with larger datasets, hitting real performance problems, or operating under capacity constraints. That said, I do think understanding what's going on under the hood makes you write better DAX even when you're not actively optimising - so hopefully this series is useful regardless!</p>
<p>If you're looking to brush up on DAX fundamentals before diving in, we have plenty of content on the blog. My <a href="https://endjin.com/blog/learning-dax-and-power-bi-filter-contexts">Learning DAX</a> series covers the basics from filter contexts through to CALCULATE, and Elisenda Gascon's <a href="https://endjin.com/blog/evaluation-contexts-in-dax-filter-and-row-contexts">Evaluation Contexts in DAX</a> series is another great starting point. Jessica Hill has written in depth about <a href="https://endjin.com/blog/calculate-in-dax">CALCULATE</a>, <a href="https://endjin.com/blog/context-transition-in-dax">context transition</a>, and <a href="https://endjin.com/blog/measures-vs-calculated-columns-in-dax">measures vs calculated columns</a>. Her <a href="https://endjin.com/blog/performance-optimisation-tools-for-power-bi">Performance Optimisation Tools for Power BI</a> post is a particularly good companion to this series.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/power-bi-black-box.png" alt="A doodlegram of me sitting next to a Power BI black box" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/power-bi-black-box.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/power-bi-black-box.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/power-bi-black-box.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/power-bi-black-box.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Optimising DAX</h3>
        <span class="series-toc__count">6 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Series Introduction</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-how-vertipaq-stores-your-data" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">How VertiPaq Stores Your Data</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-vertipaq-encoding-techniques" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">VertiPaq Encoding Techniques</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-why-cardinality-matters" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Why Cardinality Matters</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-the-cost-of-relationships" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">The Cost of Relationships</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/optimising-dax-model-design-comparisons" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">Model Design Comparisons</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Microsoft Fabric variable libraries: best practices guide</title>
      <description>Variable libraries in Microsoft Fabric manage environment-specific configuration. Learn where you can use them and how to set safer defaults for Dev/Test/Prod.</description>
      <link>https://endjin.com/blog/variable-libraries-in-microsoft-fabric-best-practices</link>
      <guid isPermaLink="true">https://endjin.com/blog/variable-libraries-in-microsoft-fabric-best-practices</guid>
      <pubDate>Tue, 19 May 2026 05:30:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>Variable Libraries</category>
      <category>CI/CD</category>
      <category>Data Factory</category>
      <category>OneLake</category>
      <category>SQLBits</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/variable-libraries-in-microsoft-fabric-best-practices.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>At <a href="https://endjin.com/blog/sqlbits-2026-a-conference-recap">SQLBits 2026</a>, I attended a session by <a href="https://kevinarnold.com/blog/">Kevin Arnold</a> on variable libraries in Microsoft Fabric. It was originally a 20-minute slot, but they let him continue into the break and he ended up speaking for around 45 minutes - which was a good thing, as there was plenty of ground to cover. Variable libraries are one of those features that seems straightforward on the surface, but getting the details right really matters - especially when it comes to protecting your production environment.</p>
<p>This post covers what variable libraries are, where you can use them, and some best practices that came out of the session (along with some thoughts of my own).</p>
<h2 id="what-are-variable-libraries">What Are Variable Libraries?</h2>
<p>Variable libraries are Microsoft Fabric's intended mechanism for managing <strong>environment-specific configuration</strong>. If you've worked with configuration management in software development, this will feel familiar - think appsettings per environment, but for Fabric items.</p>
<p>The basic concept is:</p>
<ol>
<li>You define a <strong>set of variables</strong> with <strong>default values</strong></li>
<li>You then create a <strong>variable set for each environment</strong> (e.g. Dev, Test, Prod)</li>
<li>Each variable set needs to be <strong>"activated"</strong> for the environment it's deployed to</li>
</ol>
<p>The variable values are all stored in Git, which means they're updated as part of your deployment process. However - and this is important - <strong>which variable set is activated for each environment is a separate concern</strong>. If you're using Fabric deployment pipelines, you can configure a different active value set per stage, which helps. But it's still worth being aware that activation and deployment are distinct steps, and getting them out of sync is a real risk.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/variable-libraries-1.png" alt="Diagram showing variable libraries with Default, Dev, Test, Prod sets" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/variable-libraries-1.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/variable-libraries-1.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/variable-libraries-1.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/variable-libraries-1.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="where-can-you-use-them">Where Can You Use Them?</h2>
<p>Variable libraries can be used across a range of Fabric item types:</p>
<ul>
<li><strong>Lakehouse shortcuts</strong> - allowing your shortcuts to point to different storage locations per environment</li>
<li><strong>Notebooks</strong> - either directly with <code>%%configure</code> (including setting the default lakehouse) or via <code>notebookutils</code></li>
<li><strong>Data pipelines</strong> - for parameterising pipeline activities</li>
<li><strong>Dataflow Gen2</strong> - support has been expanding here, with integration via <code>Variable.Value</code> and <code>Variable.ValueOrDefault</code> functions in M code</li>
<li><strong>Copy jobs</strong> - as source or destination</li>
<li><strong>User Defined Functions</strong></li>
</ul>
<p>It's worth noting that the variable values themselves are just stored as part of the Git-tracked configuration, so they can't contain secrets or sensitive information. For secrets, you'd still need to use a solution like Azure Key Vault.</p>
<h2 id="a-best-practice-dont-map-defaults-to-real-environments">A Best Practice: Don't Map Defaults to Real Environments</h2>
<p>One of the most interesting takeaways from this session was around how you structure your variable sets. The presenter's setup had four sets: Default, Dev, Test, and Prod - where the Default set had clearly non-functional placeholder values.</p>
<p>This is in contrast to a pattern where you might map Default directly to your Dev environment, like: Default (= Dev), Test, Prod.</p>
<p>The problem with the latter approach is subtle but significant. If you deploy to an environment and the correct variable set hasn't been activated (or if activation is accidentally skipped), your Fabric items will fall back to the Default values. If Default points to Dev, you could accidentally end up reading from or writing to your Dev environment from a workspace that's supposed to be pointing elsewhere.</p>
<p>However, if your Default values are clearly non-functional placeholders (e.g. a connection string pointing to a non-existent lakehouse), then a missed activation would result in an obvious failure rather than a silent cross-environment issue. <strong>Failing loudly is almost always better than failing silently</strong>, especially when data integrity is at stake.</p>
<p>Of course, the ideal solution is to properly isolate your workspaces so that cross-environment writes aren't possible in the first place. But adopting the "non-functional default" pattern as an additional safety net is a simple and effective precaution.</p>
<h2 id="things-to-watch-out-for">Things to Watch Out For</h2>
<p>There are a few additional things to be aware of:</p>
<p><strong>SharePoint shortcuts can't currently use variable libraries.</strong> This is a notable limitation if you're working with SharePoint data across multiple environments. It's likely coming in a future update, but for now, it means you may need an alternative approach for environment-specific SharePoint integration.</p>
<p><strong>Activation needs attention.</strong> While deployment pipelines do support configuring a different active value set per stage, the activation step is still separate from the deployment itself. Building in validation steps (e.g. checking which variable set is active as part of your deployment verification) would help mitigate the risk of things getting out of sync.</p>
<p><strong>Git storage means no secrets.</strong> As mentioned, variable values are stored in Git, so anything sensitive needs to be managed separately. Combining variable libraries with Azure Key Vault is a good pattern for handling this.</p>
<h2 id="summary">Summary</h2>
<p>Variable libraries are a welcome addition to the Fabric CI/CD story, providing a structured way to manage environment-specific configuration across a range of Fabric items. The key things to remember are: use non-functional default values as a safety net, be aware that activation is a manual step, and combine with Key Vault for any secrets.</p>
<p>I'll be keeping an eye on how this feature evolves - particularly around automation of the activation step and expanded support for item types like SharePoint shortcuts.</p>]]></content:encoded>
    </item>
    <item>
      <title>The GenAI Reality Check: New Instrument, Same Orchestra</title>
      <description>AI is like introducing a powerful new instrument to an orchestra. It creates possibilities that didn't exist before. But it still requires musicians who can read music, a conductor with a vision, rehearsal time, and the discipline to play together. An orchestra that lacks these fundamentals won't be saved by a new instrument — they'll just make new kinds of noise.</description>
      <link>https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra</link>
      <guid isPermaLink="true">https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra</guid>
      <pubDate>Thu, 14 May 2026 06:30:00 GMT</pubDate>
      <category>GenAI</category>
      <category>Generative AI</category>
      <category>AI</category>
      <category>Machine Learning</category>
      <category>Software Engineering</category>
      <category>Engineering Discipline</category>
      <category>DevOps</category>
      <category>Total Cost of Ownership</category>
      <category>Human In The Loop</category>
      <category>RAG</category>
      <category>LLM</category>
      <category>Prompt Engineering</category>
      <category>Context Rot</category>
      <category>Continuous Learning</category>
      <category>Innovation Management</category>
      <category>Digital Transformation</category>
      <category>Organisational Change</category>
      <category>Multi-disciplinary Teams</category>
      <category>AI Strategy</category>
      <category>AI Adoption</category>
      <category>Wardley Mapping</category>
      <category>Code As Commodity</category>
      <enclosure length="0" type="audio/mpeg" url="https://endjincdn.blob.core.windows.net/assets/podcast/2026-05-14-the-genai-reality-check-new-Instrument-same-orchestra.mp3" />
      <dc:creator>Barry Smart</dc:creator>
      <media:thumbnail url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/the-genai-reality-check-new-instrument-same-orchestra.png" />
      <content:encoded><![CDATA[<p>TLDR; GenAI is a powerful new instrument, but it doesn't exempt you from the engineering disciplines that have always separated successful technology projects from expensive disappointments. Based on our experience helping organisations navigate enterprise-scale GenAI adoption, we've distilled seven principles that consistently separate the projects that deliver value from those that generate noise.</p>
<p>Whether you're a CTO embarking on your first enterprise AI initiative or an engineering lead wondering why your pilot isn't scaling, we hope our experiences which are distilled in this blog will help you on the path to success.</p>
<p>Adoption of <a href="https://en.wikipedia.org/wiki/Generative_artificial_intelligence">Generative AI (GenAI)</a> is like introducing a powerful new instrument to an orchestra.</p>
<p>Yes, GenAI creates possibilities that didn't exist before. But it still requires musicians who have mastered playing and can read music, a conductor with a vision, rehearsal time, and the discipline to play together as a team. An orchestra that lacks these fundamentals won't be saved by a new instrument, they'll just make new kinds of noise.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/orchestra.png" alt="Illustration of an orchestra with an AI section of robots." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/orchestra.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/orchestra.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/orchestra.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/orchestra.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The learnings have been distilled into seven principles for GenAI success. Each is framed as a contrast pair — a choice between the approach that leads to success and the approach that leads to expensive disappointment.</p>
<ul>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#goal-driven-not-technology-led">Goal-driven, not technology-led</a></li>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#transformational-not-incremental">Transformational, not incremental</a></li>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#focused-not-sprawling">Focused, not sprawling</a></li>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#exceptional-but-not-exempt">Exceptional, but not exempt</a></li>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#verified-not-assumed">Verified, not assumed</a></li>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#continuous-learning-not-just-delivering">Continuous learning, not just delivering</a></li>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#mode-2-not-mode-1">Mode 2, not Mode 1</a></li>
<li><a href="https://endjin.com/blog/genai-reality-check-new-instrument-same-orchestra#ready-to-tune-your-orchestra">Ready to tune your orchestra?</a></li>
</ul>
<p>These principles will feel familiar — because they're the same disciplines that have always separated successful technology projects from expensive disappointments.</p>
<h2 id="goal-driven-not-technology-led">Goal-driven, not technology-led</h2>
<p>The latest GenAI foundation models are genuinely remarkable. But remarkable technology has a gravitational pull that can divert attention away from the most important question: <em>why does this matter to the organisation?</em></p>
<p>GenAI initiatives which cannot answer this question tend to become "solutions looking for a problem." They attract feature requests from stakeholders who see this as their one chance to secure budget for anything GenAI-related. They drift, lose focus, and eventually collapse under the weight of expectations. All of the hard graft goes unrecognised and morale suffers.</p>
<p>Successful GenAI initiatives look different. They start with a clear and compelling business goal that existed <em>before</em> anyone mentioned GenAI. The technology serves that goal, not the other way around. The discipline of choosing those goals well is the subject of a companion piece: <a href="https://endjin.com/blog/2026/04/ai-strategy-think-top-down-experiment-bottom-up">AI Strategy: Think Top-Down, Experiment Bottom-Up</a>.</p>
<p>One practical step: avoid labelling the project as an "AI project" at all.  Frame it as a business initiative that happens to use GenAI.  In doing so, you will give the initiative a clear purpose and keep the focus where it belongs.</p>
<h2 id="transformational-not-incremental">Transformational, not incremental</h2>
<p>The trap is to apply GenAI to existing processes for incremental gains, rather than re-imagining how the process itself could be transformed. Bolting AI onto an unchanged workflow rarely generates significant impact; the value comes from rethinking how the work gets done. McKinsey identifies this as the single strongest predictor of value, yet very few organisations commit to it.</p>
<p>In their book <a href="https://www.amazon.co.uk/Human-Machine-Updated-Expanded-Reimagining/dp/1647827205/">Human + Machine: Reimagining Work in the Age of AI</a> Paul Daugherty and H. James Wilson argue that the central opportunity of AI is not the automation or replacement of human work, but the design of new ways for humans and machines to collaborate. The authors describe a "missing middle": the space where humans and AI work together, with humans amplifying machines (training, explaining, sustaining their behaviour) and machines amplifying humans (by extending their cognitive, analytic and physical capabilities). Their core contention is that the firms unlocking the greatest value from AI are those that have explicitly reimagined business processes around this collaboration rather than bolting AI onto existing workflows.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/humans-plus-machines.png" alt="Illustration from book Human + Machine: Reimagining Work in the Age of AI with humans amplifying machines (training, explaining, sustaining their behaviour) and machines amplifying humans (by extending their cognitive, analytic and physical capabilities)" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/humans-plus-machines.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/humans-plus-machines.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/humans-plus-machines.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/humans-plus-machines.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The true <a href="https://endjin.com/blog/2022/10/how-to-calculate-the-total-cost-of-ownership">total cost of ownership (TCO)</a> extends far beyond licenses and token consumption: you will still need people to build it, evaluate it, govern it, audit its outputs, and intervene when it gets things wrong.  What you are really designing is a new operating model in which humans and AI work together which is a harder design problem than swapping one for the other.</p>
<p>Before committing investment, ask two questions:</p>
<ul>
<li><p><strong>Is GenAI actually cheaper?</strong>  - is the anticipated TCO significantly less than the current operating cost? Many organisations are surprised to find it is not.</p>
</li>
<li><p><strong>Does GenAI unlock something new?</strong> - does it provide benefits beyond what is possible through the current operating model - such as scale, speed, risk mitigation or an innovation that will opens up new growth opportunities?</p>
</li>
</ul>
<p>If the answer to both of these questions is "no," you're automating for the sake of it.</p>
<p>Early projects may focus on simpler, non-transformational use cases to help the organisation learn what it takes to implement enterprise scale GenAI and climb the AI maturity curve. But these should be positioned as stepping stones not the destination.</p>
<p>A rigorous focus on TCO forces better questions: not "how do we automate what we do today?" but "what new operating models does GenAI make possible that were previously out of reach?".  This line of questioning will enable you to isolate where the true value of GenAI lies.</p>
<p>In one recent engagement, a client came to us with a requirement to use GenAI to automate an existing process. We pushed back and asked them to look wider. Using a <a href="https://www.wardleymaps.com/">Wardley Map</a> to examine their full value chain, we identified a genuinely transformational opportunity. The payoff of the pivot was roughly tenfold the impact of the original plan.</p>
<h2 id="focused-not-sprawling">Focused, not sprawling</h2>
<p><a href="https://martinfowler.com/bliki/ConwaysLaw.html">Conway's Law</a> and application of <a href="https://teamtopologies.com/">Team Topologies</a> reminds us that systems reflect the communication structures of the organisations that build them. AI projects are no exception. If your initiative involves multiple disconnected teams with competing priorities, you will build a disconnected solution that loses sight of the original goal.</p>
<p>Be wary of a new "GenAI team" forming within your organisation.  GenAI doesn't exist in isolation.</p>
<p>Adopt "product mindset" using tools such as endjin's <a href="https://endjin.com/blog/2025/10/the-data-product-canvas-stop-building-products-that-fail">Data Product Canvas</a> to focus the initiative  on solving a real problem for a specific audience.  This will enable you to minimise scope and increase your chances of success.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/endjin-data-product-canvas.png" alt="Image of the Data Product Canvas developed by endjin inspired by Business Model Generation and Data Mesh" title="Image of the Data Product Canvas" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/01/endjin-data-product-canvas.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/01/endjin-data-product-canvas.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/01/endjin-data-product-canvas.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/01/endjin-data-product-canvas.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Aim to form a small, multi-disciplinary team around each "product" idea. This team needs to span software engineering, data engineering, AI skills and most importantly people who understand the business domain. The interaction between these disciplines is where the hard problems get solved.  The GenAI will introduce capabilities and behaviours that are new to everyone involved. This is inherently complex. You manage that complexity by keeping the team small, the scope tight, and the communication lines short.</p>
<p>Strong leadership is also required to resist the pressure of stakeholders who want to feature-load the initiative or pull it in a different direction, treating it as their one opportunity to get budget for AI. Every additional requirement increases cognitive load on the team and redirects attention away from the core problem.</p>
<p>And when something new and shiny emerges mid-project, as it inevitably will, resist the temptation to chase it unless it solves an existing blocker. During one engagement, Anthropic's Claude models became available on <a href="https://learn.microsoft.com/en-us/azure/foundry/">Microsoft Foundry</a>. Whilst it was tempting to try them out, we had already met our acceptance criteria with existing models.  So we put the trial of the new models on the backlog and stayed focused on tasks that were more relevant to our goal.</p>
<h2 id="exceptional-but-not-exempt">Exceptional, but not exempt</h2>
<p>There is a temptation to treat GenAI as being so novel that normal rules don't apply. New technology, new paradigm, new ways of working: surely we need to throw out the old playbook?</p>
<p>This is a mistake.  The GenAI is new. The engineering isn't. GenAI changes what you can build. It doesn't change how you build well. You are adopting a powerful new technology, that is evolving at pace, it now encompasses agentic, reasoning, and tool-using capabilities far beyond its original "chat" origins.  But you will always rely on the same core engineering disciplines for success.</p>
<div class="aside"><p>GenAI is exceptional but not exempt — exceptional in its capabilities, but not exempt from the disciplines that make software engineering projects successful.</p>
</div>
<p>A GenAI project is a software engineering project. It combines application development, systems integration, data engineering skills and devops.  You will be required to leverage familiar platforms and patterns: such as REST APIs, responsive user experiences, lakehouse architectures, middleware, ELT and schema-driven validation. The GenAI component, however sophisticated, sits as a component within this broader ecosystem.</p>
<p>The initiative will place a spotlight on your engineering maturity. If you don't have established <a href="https://azure.microsoft.com/en-gb/resources/cloud-computing-dictionary/what-is-devops">DataOps</a> practices, you will struggle to operate with the agility that a production-scale GenAI project demands. <a href="https://learn.microsoft.com/en-us/devops/deliver/what-is-infrastructure-as-code">Infrastructure as code</a>, robust DTAP environments, automated deployments, observability, least-privilege access and resource tagging for downstream <a href="https://www.finops.org/introduction/what-is-finops/">FinOps</a> are examples of first-order capabilities baked into the solution from day one, not bolted on later.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2024/01/dataops-is-devops-for-data-projects.PNG" alt="DataOps illustration adapts the classic DevOps infinity diagram to reinforce that DataOps is essentially DevOps applied to data projects." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2024/01/dataops-is-devops-for-data-projects.PNG 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2024/01/dataops-is-devops-for-data-projects.PNG 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2024/01/dataops-is-devops-for-data-projects.PNG 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2024/01/dataops-is-devops-for-data-projects.PNG 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>If you have weaknesses in these areas, the GenAI project will expose them. Be honest about the gaps and address them proactively. This may be the moment for an organisational readiness assessment to highlight skills that need strengthening: not just gaps in knowhow about applying AI, but also gaps software and data engineering skills that are necessary to enable the AI to be implemented in a secure, scalable and safe manner.</p>
<p>The same principle applies to technology choices. The GenAI space moves fast; new toolkits and SDKs emerge and fade within months. Choose wisely, but don't agonise of that choice. Start as simple as possible, focused on the initial use case.  Understand your software supply chain, and as ever, keep it as shallow as possible.  Don't try to design a platform for all possible future scenarios: you will learn so much through the first few implementations that an architectural pivot becomes likely.</p>
<p>On one recent project, we debated whether to use <a href="https://www.llamaindex.ai/">LlamaIndex</a>, <a href="https://www.langchain.com/">LangChain</a>, or <a href="https://github.com/microsoft/agent-framework">Microsoft Agent Framework</a> as the orchestration framework for a <a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation">RAG</a> solution. In the end, we chose to build our own after hitting limitations baked into these frameworks. The discipline of understanding your requirements before committing to a framework served us better than the framework itself.</p>
<h2 id="verified-not-assumed">Verified, not assumed</h2>
<p>GenAI models don't generate knowledge, they generate plausible outputs based on patterns learned from vast quantities of training data and the context you provide as input.</p>
<p>Think of GenAI as a knowledge <em>processor</em> rather than a knowledge <em>generator</em>. We find that 80% of the work required to get value from a GenAI model involves retrieving, validating, curating and structuring knowledge so the model can process it effectively.</p>
<p>GenAI will shine a spotlight on your data estate. The quality, consistency, and reliability of your data, information, and knowledge become mission-critical when you're feeding it to a model that will confidently generate outputs based on whatever you provide. Organisations that have neglected data governance, have allowed inconsistent taxonomies to proliferate, or tolerated undocumented tribal knowledge will find these sins exposed.  Often publicly, in the form of contradictions or confidently wrong answers. GenAI doesn't create data quality problems; it amplifies them. If your foundations are shaky, GenAI will make that painfully visible.</p>
<p>Two principles apply consistently, regardless of how sophisticated your architecture becomes:</p>
<ul>
<li><p><strong>Quality in, quality out.</strong> - we know that the output of any model is constrained by the quality of the input it is provided.  Quantity is also a factor to consider: research into <a href="https://research.trychroma.com/context-rot">context rot</a> demonstrates that LLM performance degrades with input length, even when the model retrieves relevant information perfectly. Curate ruthlessly. Structure clearly. Less is more.</p>
</li>
<li><p><strong>Don't trust, verify.</strong> - everything a GenAI produces should be fact-checked. Your architecture must provide verification at each step, with feedback loops to correct the model when needed.  Feedback loops should be established at both build time and run time.</p>
</li>
</ul>
<div class="aside"><p>Give the GenAI model concise, well-structured, verified knowledge to process and fact-check what it generates.</p>
</div>
<p>We recommend specifying structured outputs such as JSON, then validating both schema and contents. Are categorical values valid? Do citations actually exist? On one project, we discovered the model was confidently hallucinating citations to documents that weren't in the source corpus. This wasn't a prompting failure, it was a side effect of using an LLM. Expect this behaviour and engineer accordingly.</p>
<p>Verification needn't be manual for every output. Think of a factory production line: you don't have a human inspecting every widget, but you do have a laser scanning each one against known tolerances. For GenAI, this means layered verification: automated guardrails validating 100% of outputs, adding an "LLM-as-judge" to flag anomalies and human review for random samples.</p>
<p>In all cases, the human <em>governs</em> the loop setting policy, reviewing edge cases, calibrating the system even when they're not <em>in</em> the loop for every transaction.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/orchestra-flow.png" alt="Input of raw data in top left. &quot;Conveyor belt&quot; showing the stages of &quot;acquisition, validation, curation and structuring&quot; to drop knowledge into a knowledge store in upper centre. The GenAI / LLM model (shown as robot) sits below the knowledge base can then be presented with relevant knowledge acting as a knowledge processor. It puts its output onto a final conveyor belt which puts the output through the stages of &quot;automated guardrails, LLM-as-judge and human-in-the-loop&quot;. Final result is verified output." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/orchestra-flow.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/orchestra-flow.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/orchestra-flow.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/orchestra-flow.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Finally, acceptance criteria matter enormously. Don't rely on informal testing. We apply software engineering discipline, by building a suite of executable specifications that run can be run on demand. GenAI is non-deterministic: so we don't expect outputs to be identical every time, or tests to pass every time. But you can define a minimum pass rate that signals the solution is performing within acceptable bounds.</p>
<p>In Q3 2025 our suite of tests enabled us to detect model drift triggered by <a href="https://www.anthropic.com/engineering/a-postmortem-of-three-recent-issues">three separate infrastructure changes</a> implemented by Anthropic that combined to cause a regression that took days to diagnose. We were able to detect this quickly, flip to a different model and prove that this action had restored the performance of the whole system to an acceptable level.</p>
<h2 id="continuous-learning-not-just-delivering">Continuous learning, not just delivering</h2>
<p>If you're doing AI, you need to follow the scientific <a href="https://theleanstartup.com/principles"><strong>Build, Measure, Learn</strong></a> method across the end-to-end architecture. Software engineering disciplines provide automation and robust processes that enable rapid feedback loops. This allows you to understand, govern and manage the unpredictable, non-deterministic element: the GenAI model.</p>
<p>The most valuable output from a GenAI project is often not the solution itself: it's the learning.</p>
<p>Treat blockers as opportunities. On one project, we discovered that our mental model of how <a href="https://azure.microsoft.com/en-gb/products/ai-services/ai-search">Azure AI Search</a> worked was fundamentally wrong. This wasn't a failure, it was a moment of insight that reshaped our approach. The ability to pause, reflect, and re-orient in response to unexpected findings is becoming critical in an environment where uncertainty is the norm.</p>
<p>There are many variables that dramatically affect GenAI performance: prompt structure, chunk size, retrieval strategy, model selection, temperature settings. Expect timescales and budget to be generally twice to enable continuous learning, deliberately set aside budget and project time for:</p>
<ul>
<li><strong>Establishing foundations</strong> - creating space for the team to align their understanding and put important artefacts in place.  Expect the pace to feel slow at the start. Activities such as data curation,  agreeing terminology (the <a href="https://en.wikipedia.org/wiki/Domain-driven_design">ubiquitous language</a>) and alignment on success criteria all take time. Hold your nerve. Pushing forward without these foundations costs more downstream.</li>
<li><strong>Experimentation time</strong> build time into the plan for experimentation, don't treat it as an indulgence to squeeze in if there's time left over.  For example, a short experiment on one project to evaluate models lead us to understand that Gemini Flash 2 performed as well as Gemini 2.5 Pro for the specific use case generated significant benefit because it enabled us to reduce latency and cost.</li>
<li><strong>Re-evaluating prior decisions</strong> against new models. The pace of change is so fast that a use case that doesn't work in September might work well in October with the next model increment.</li>
<li><strong>Sharing lessons learned</strong> - set aside time for the team to share what they have learned at regular intervals, not just what has worked but what has worked but also what has not.  We adopt a weekly "show and tell" for this purpose where the meeting is recorded and the video / transcript / AI summary is captured for future reference.</li>
</ul>
<h2 id="mode-2-not-mode-1">Mode 2, not Mode 1</h2>
<p>To succeed your organisation will need to become <a href="https://www.gartner.com/en/information-technology/glossary/bimodal">bi-modal</a>:</p>
<ul>
<li><p><strong>Mode 1</strong> - many organisations are comfortable operating in what we might call "Mode 1": waterfall project lifecycles, fixed-price tenders, confident milestones, and a clear line of sight from requirements to delivery. This assumes you broadly understand both the problem and the solution — you're executing against a known destination.</p>
</li>
<li><p><strong>Mode 2</strong> - early-stage GenAI work demands "Mode 2": a fundamentally different posture where you are more focused on learning than delivering. The destination itself is uncertain. You need the flexibility to pivot week to week as you discover what the model can and cannot do, what your data will and will not support, and where the real value lies.  Fixed-price contracts and rigid milestone plans are a poor fit for this type of work. Instead, think in terms of time-boxed experiments, learning objectives rather than delivery objectives, and the discipline to stop when the evidence tells you to.</p>
</li>
</ul>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/orchestra-modes.png" alt="Diagram which shows two contrasting project methodologies: the traditional &quot;Mode 1&quot; project which is well understood, you can step confidently through the design, build, test, deploy, maintain phases with confidence in a linear fashion from left to right.  Contrast this with a &quot;Mode 2&quot; where the process is better shown as a path with many loops: ie the need to loop around a phase again or loop back to an earlier stage." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/orchestra-modes.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/orchestra-modes.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/orchestra-modes.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/orchestra-modes.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>GenAI projects are expensive endeavors, with a high degree of uncertainty: you will hit dead ends, you will need to pivot when the model doesn't perform as expected. Set clear decision points (at least fortnightly) where viability is reviewed openly. Apply a "Mode 2 mindset" to pause the project if it is no longer viable, and treat that decision as a success. Better to  release resources to tackle the next initiative than to push on with a thankless task.</p>
<p>Mode 2 isn't permanent.  As understanding crystallises and the solution stabilises, you can shift back toward Mode 1 for scaling and operationalisation. But forcing Mode 1 thinking onto Mode 2 problems is a recipe for expensive disappointment.</p>
<h2 id="ready-to-tune-your-orchestra">Ready to tune your orchestra?</h2>
<p>GenAI adoption will continue to accelerate. Organisations that treat it as an opportunity to abandon engineering discipline will generate noise. Those that recognise GenAI as a powerful new capability that is <em>exceptional but not exempt</em> will create something worth listening to.</p>
<p>There are aspects of GenAI that genuinely are new: the stochastic nature of outputs, the emergent capabilities that surprise even model creators, the speed at which the frontier moves. These warrant attention. But they don't warrant abandoning the disciplines that make software engineering work.</p>
<p>The principles set out in this blog are not limited to GenAI. The same patterns apply to every disruptive technology wave of the last thirty years: the web, cloud, mobile, blockchain, big data. The hype, the transformational promise, and the engineering disciplines that separate success from failure recur each time. At its heart, this is about effective innovation management. Organisations with <a href="https://endjin.com/blog/2015/03/step-by-step-guide-to-bootstrapping-your-new-product-development-part-1-principles">solid innovation processes</a> will be well placed for GenAI and whatever appears over the horizon next.</p>
<p>It is also worth disambiguating GenAI from the wider field of AI. "AI" has become an umbrella term — much as "cloud" once was — that conflates GenAI with more traditional and proven Machine Learning techniques. The devil is in the detail and it's <strong>best not to talk about it like a silver bullet</strong>. When cloud was at the peak of its hype cycle, I was as guilty as anyone of reaching for "we'll just stick it in the cloud" as the answer to every problem. The AI umbrella covers a broad spectrum of capabilities — each with its own strengths, weaknesses, and use cases it is genuinely suited to.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/endjin-ai-landscape.png" alt="Large Language Models are just one element of the wider AI ecosystem. Computer Vision, Supervised Learning, Unsupervised Learning, Predictive Analytics, Neural Networks, Deep Learning, Speech to Text, Translation, Search, Solvers, Planners, Knowledge Graphs, Ontologies, Robotics and Agents to name a few. Multi-Modal LLMs can encompass a number of these techniques." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/endjin-ai-landscape.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/endjin-ai-landscape.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/endjin-ai-landscape.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/endjin-ai-landscape.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This blog is one half of a pair. It addresses the question <em>"once we've decided to do this, how do we do it well?"</em>. Its companion piece, <a href="https://endjin.com/blog/2026/04/ai-strategy-think-top-down-experiment-bottom-up">AI Strategy: Think Top-Down, Experiment Bottom-Up</a>, addresses the question that comes first: <em>"how do we decide what to do?"</em>. Engineering discipline without strategic clarity produces well-built solutions to the wrong problem. Strategic clarity without engineering discipline produces ambitions that never reach production. Both are needed.</p>
<p>The question isn't whether your organisation will adopt GenAI, it's whether you'll make music or noise. The instrument is ready. The question is: is your orchestra? If you are preparing for your first enterprise-scale AI initiative, or reflecting on one that didn't deliver what you hoped, we would be happy to talk.  Our <a href="https://endjin.com/what-we-do/azure-data-strategy-briefing">90-minute Data Strategy Briefing</a> provides an end-to-end view of the data and AI landscape, how it is evolving and how leading organisations are setting themselves up for success.</p>]]></content:encoded>
    </item>
    <item>
      <title>Ingesting SharePoint Data into Microsoft Fabric: Your Options</title>
      <description>SharePoint isn't going away. This post compares five ways to ingest SharePoint data into Microsoft Fabric, with guidance on cost, complexity, and incremental loading.</description>
      <link>https://endjin.com/blog/ingesting-sharepoint-data-into-microsoft-fabric</link>
      <guid isPermaLink="true">https://endjin.com/blog/ingesting-sharepoint-data-into-microsoft-fabric</guid>
      <pubDate>Tue, 12 May 2026 06:30:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>SharePoint</category>
      <category>Data Factory</category>
      <category>OneLake</category>
      <category>Data Ingestion</category>
      <category>SQLBits</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/ingesting-sharepoint-data-into-microsoft-fabric.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>There are five ways to ingest SharePoint data into Microsoft Fabric: Dataflow Gen2, Copy Jobs, shortcuts, pipelines, and notebooks. Each makes different trade-offs around capacity cost, incremental loading, and how well they cope with multi-environment setups - and the right choice depends entirely on what you're trying to do.</p>
<p>This post walks through all five, with practical guidance on when to reach for each one. It's based on a great session by <a href="https://www.linkedin.com/in/lauragb/">Laura Graham-Brown</a> at <a href="https://endjin.com/blog/sqlbits-2026-a-conference-recap">SQLBits 2026</a> - and let's be honest, SharePoint isn't going anywhere in most organisations, so knowing how to get that data into Fabric efficiently is worth doing well.</p>
<h2 id="the-options">The Options</h2>
<p>Pretty much all of the ingestion options below can work with either a SharePoint <strong>list</strong> or a <strong>folder</strong> (which sometimes actually means an entire document library). Let's walk through each one.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/sharepoint-ingestion.png" alt="Diagram showing the five ingestion paths from SharePoint to Fabric" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/sharepoint-ingestion.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/sharepoint-ingestion.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/sharepoint-ingestion.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/sharepoint-ingestion.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h3 id="dataflow-gen2">1. Dataflow Gen2</h3>
<p>Dataflow Gen2 provides a visual, low-code way to ingest and transform data from SharePoint. The demo in the session worked smoothly, and it's a familiar experience if you've used Power Query before.</p>
<p>One important thing to be aware of: <strong>Dataflows eat capacity</strong>. They're convenient, but if you're running a lot of them, the capacity consumption can add up. Keep this in mind if you're cost-conscious!</p>
<p>An interesting aside: you can actually use Dataflows to ingest from <strong>SharePoint on-premises</strong> (which is, apparently, still very much a thing in some organisations). This might be the easiest path if you're dealing with legacy on-prem SharePoint installations.</p>
<h3 id="copy-jobs">2. Copy Jobs</h3>
<p>Copy Jobs are a relatively simple way to move data from SharePoint into Fabric. When you select a list copy, you can set up <strong>incremental ingestion</strong> - this uses the modified column to only update records that have changed, performing a merge on ID, as described in the <a href="https://learn.microsoft.com/en-gb/fabric/data-factory/what-is-copy-job">Microsoft Fabric Copy Job documentation</a>.</p>
<p>One thing to watch out for: the default schedule for Copy Jobs is <strong>every 15 minutes</strong>. Depending on your use case, this might be more frequent (and more expensive) than you need. Make sure to review and adjust the schedule to match your actual requirements.</p>
<h3 id="shortcuts">3. Shortcuts</h3>
<p>You can create a shortcut directly to a SharePoint folder or document library. This makes the data available in your Lakehouse without physically copying it - the data stays in SharePoint and is referenced in place.</p>
<p>However, there's a significant limitation right now: <strong>SharePoint shortcuts can't use variable libraries</strong>. This means that if you're working across multiple environments (Dev, Test, Prod), you can't use variable libraries to point the shortcut to different SharePoint locations per environment. The current SharePoint shortcut capabilities and constraints are covered in the <a href="https://learn.microsoft.com/en-gb/fabric/onelake/onelake-shortcuts">Microsoft Fabric OneLake shortcuts documentation</a>.</p>
<h3 id="pipelines">4. Pipelines</h3>
<p>Using pipelines, you can set up a copy activity to ingest SharePoint data. A neat trick highlighted in the session: if you reference a SharePoint list as a folder, it actually lists the files in that folder <em>including the last modified date</em>. This means you can build logic to <strong>re-ingest only files that have changed</strong> - essentially implementing your own incremental pattern.</p>
<h3 id="notebooks">5. Notebooks</h3>
<p>Notebooks offer the most flexibility, with two main approaches:</p>
<p><strong>Using the Microsoft Graph API</strong> - this is a code-first approach where you call the Microsoft Graph API to access SharePoint data programmatically. If you've done something similar in Azure Synapse before, the pattern will be familiar.</p>
<p><strong>Using shortcuts via the Lakehouse</strong> - you can add a shortcut to a SharePoint folder or document library, and then read the data directly from the Lakehouse within your notebook. This combines the simplicity of shortcuts with the processing power of Spark.</p>
<p>The notebook approach is the most flexible, but also requires the most technical skill. It's a good fit when you need to do something more complex that the other options don't easily support.</p>
<h2 id="when-to-use-what">When to Use What?</h2>
<p>Here's a rough guide to choosing between the options:</p>
<ul>
<li>If you want a <strong>quick, visual setup</strong> and don't mind the capacity cost - <strong>Dataflow Gen2</strong></li>
<li>If you want <strong>simple, scheduled ingestion</strong> with incremental support - <strong>Copy Jobs</strong> (but review that default 15-minute schedule!)</li>
<li>If you want <strong>zero-copy access</strong> and aren't working across multiple environments - <strong>Shortcuts</strong></li>
<li>If you already use pipelines - <strong>Pipelines with copy activities</strong></li>
<li>If you need <strong>maximum flexibility</strong> or are doing complex transformations - <strong>Notebooks</strong></li>
</ul>
<h2 id="summary">Summary</h2>
<p>There are more options than you might expect for getting SharePoint data into Fabric, and each has its place. The right choice depends on your specific requirements around cost, complexity, change detection, and multi-environment support.</p>
<p>The biggest gap right now is the lack of variable library support for SharePoint shortcuts, which limits their usefulness in multi-environment setups. But given the pace of Fabric development, I'd expect this to be addressed soon.</p>
<p>As always, the practical reality of working with SharePoint is a bit messy - but at least we now have a decent range of tools to tame the mess!</p>]]></content:encoded>
    </item>
    <item>
      <title>AI Strategy: Think Top-Down, Experiment Bottom-Up</title>
      <description>Top-down AI strategy and bottom-up experimentation both fail alone: leading organisations combine them to drive real business results.</description>
      <link>https://endjin.com/blog/ai-strategy-think-top-down-experiment-bottom-up</link>
      <guid isPermaLink="true">https://endjin.com/blog/ai-strategy-think-top-down-experiment-bottom-up</guid>
      <pubDate>Fri, 08 May 2026 05:30:00 GMT</pubDate>
      <category>AI Strategy</category>
      <category>AI Adoption</category>
      <category>GenAI</category>
      <category>Generative AI</category>
      <category>Agentic AI</category>
      <category>LLM</category>
      <category>LLMs</category>
      <category>Frontier Models</category>
      <category>Copilot</category>
      <category>Organisational Change</category>
      <category>Digital Transformation</category>
      <category>Innovation Management</category>
      <category>Fail Fast</category>
      <category>Art of the Possible</category>
      <category>OST Framework</category>
      <category>Executive Sponsorship</category>
      <category>Business Strategy</category>
      <category>Performance Engine</category>
      <category>Dedicated Team</category>
      <category>Govindarajan</category>
      <category>Trimble</category>
      <enclosure length="0" type="audio/mpeg" url="https://endjincdn.blob.core.windows.net/assets/podcast/2026-05-07-ai-strategy-think-top-down-experiment-bottom-up.mp3" />
      <dc:creator>Barry Smart</dc:creator>
      <media:thumbnail url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/04/ai-strategy-think-top-down-experiment-bottom-up.png" />
      <content:encoded><![CDATA[<p>TLDR; Most AI initiatives fail because organisations pick one lens. Top-down planning without bottom-up experimentation produces disconnected roadmaps; bottom-up experimentation without top-down direction produces personal productivity wins that never scale. Running both simultaneously, with a feedback loop between them, is what separates the 20% that succeed from the 80% that don't.</p>
<p>This blog is for leaders who have either watched a previous AI initiative stall, or are about to launch one and want to avoid the same pitfalls. If you find your organisation stagnating when it comes to taking meaningful steps towards embracing AI — this is for you.</p>
<p>Most organisations approach AI strategy from one direction only:</p>
<ul>
<li>Some wait for a grand plan to be created and handed down for implementation, or;</li>
<li>Others unleash enthusiasm without direction, hoping something transformative will emerge from the activity.</li>
</ul>
<p>Both approaches tend to fail.</p>
<p>The organisations making real, durable progress with AI have discovered that <strong>you need to move in both directions at the same time</strong>. Top-down, to ensure that everything connects to goals that actually matter to the business. Bottom-up, to surface what is genuinely possible with today's tools.  At the intersection of these two approaches you will discover opportunities that no amount of strategic planning could have anticipated.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/04/top-down-bottom-up-alignment.png" alt="Illustration of top down and bottom up in alignment" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/04/top-down-bottom-up-alignment.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/04/top-down-bottom-up-alignment.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/04/top-down-bottom-up-alignment.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/04/top-down-bottom-up-alignment.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This is not a new idea. But the arrival of agentic AI has made it more urgent than ever.</p>
<h2 id="what-has-changed-from-chat-to-agentic">What has changed: from chat to agentic</h2>
<p>The reason this dual-lens approach has become more urgent is that the technology has shifted in ways that many organisations have not yet absorbed.</p>
<p>Two years ago, AI tools were primarily conversational. You prompted, the model responded, you reviewed the output, accepted it or re-worked the prompt and tried again. The human was in the loop at every step. This made it relatively easy to experiment, but also limited the scope of what could be achieved.</p>
<p>Around Christmas 2025, a new generation of "agentic AI" arrived with Claude Opus leading the way.  They unlocked a new paradigm: rather than answering questions, the AI could now be given a goal (e.g. tidy up my inbox, generate this report, research this set of opportunities) and then use multiple tools to act autonomously to complete it, returning to the human only when a decision or approval is genuinely needed. The human becomes a conductor rather than a performer.</p>
<p>This matters for strategy because it changes the economics of what is feasible. Tasks that previously required dedicated engineering or analyst time can now be completed in a fraction of the time by a well-configured agent. The bottleneck has shifted from "can we build this?" to "have we identified the right problem to solve and is the environment ready to enable us to use AI to solve it?"</p>
<p>Both of those questions are answered by the dual-lens approach: the top-down lens identifies the right problems, and the bottom-up lens surfaces the feasibility realities.</p>
<h2 id="a-conversation-we-keep-having">A conversation we keep having</h2>
<p>We have been having a version of the same conversation with clients across a range of sectors recently. It goes something like this:</p>
<ul>
<li><p>First the optimist - someone in the business (who has seen what modern AI tools can do) wants to get people together and start experimenting. They are frustrated by the pace of progress and worried that the organisation is falling behind. Their instinct is right.</p>
</li>
<li><p>Then there is the pessimist - someone else who is pushing back. Not because they are opposed to AI, but because they have seen this before. A previous wave of enthusiasm driven by technology X (replace X with any of: blockchain, chatbots, home assistants, cloud, RPA, big data, IoT or the metaverse), where a working group met for a few months, explored a few ideas, and then quietly dissolved. Nothing changed. Their caution is also right.  They want to take a more measured approach.</p>
</li>
</ul>
<p>What makes this dynamic interesting is that both people are responding to a real risk. The first is worried about analysis paralysis. The second is worried about wasted energy. And yet the tension between them often results in exactly the outcome both wanted to avoid: delay, confusion, and a business that is neither planning well nor learning fast.</p>
<p>The resolution is not to choose one side. It is to run both simultaneously with a clear understanding of what each is trying to achieve.</p>
<h2 id="the-top-down-lens-goals-before-tools">The top-down lens: goals before tools</h2>
<p>Later in this blog we examine <a href="https://endjin.com/blog/ai-strategy-think-top-down-experiment-bottom-up#why-most-ai-initiatives-fail-and-how-this-approach-changes-the-odds">why 80% AI projects fail</a> and it is striking that the top 3 root causes have nothing to do with technology: the problem is not well defined, there is no executive sponsor or there is no intent to transform operating model to take advantage of AI; all organisational failures, not technical ones.</p>
<p>This is why the top-down lens is critical. Without it, even the most energetic experimentation will produce personal productivity gains at best, and expensive dead ends at worst.</p>
<div class="aside"><p>Don't create an AI strategy. Instead, revisit your business strategy through the lens of what AI now makes possible: make it "AI infused".</p>
</div>
<p>The framing we find most useful here is the <strong>OST model: Objective → Strategy → Tactics</strong>.</p>
<p>The <em>objective</em> answers the question: what is the organisation actually trying to achieve? Not "use AI more" because this is a tactic masquerading as an objective. Real objectives sound like: grow revenue in new markets, improve the efficiency of the new customer onboarding, or reduce the time it takes to respond to new business opportunities. These are measurable. They existed before anyone mentioned AI.</p>
<p>The <em>strategy</em> then asks: what is our approach? Are we working within existing tooling constraints, or are we prepared to introduce new platforms? What does success look like in 12 months?</p>
<p>The <em>tactics</em> identify the specific tools, agents, models and workflows. They should sit last. Choosing your tactics before you have defined your objectives is one of the most reliable ways to waste significant time and money on AI.</p>
<p>In practice, we find that organisations frequently conflate these levels. When people say they want an "AI strategy," they often mean they want clarity on objectives: what it is they are actually trying to do, and why it matters. Getting explicit about that distinction is frequently the most valuable conversation a leadership team can have.</p>
<p>One good example of the OST framework is what AstraZeneca communicated at <a href="https://www.connected-data.london/">Connected Data London</a> 2025, it's structured as objective, strategy, tactics:</p>
<div class="aside"><p>Our Bold Ambition is to be pioneers in science, lead in our disease areas, and transform patient outcomes.</p>
<p>By 2030, we will:</p>
<ul>
<li>Deliver 20 new medicines</li>
<li>Be an $80bn company</li>
<li>... and sustained growth thereafter.</li>
</ul>
<p>To make our ambition a reality, we focus on:</p>
<ul>
<li>Advancing the next wave of science and technologies to deliver differentiated treatments</li>
<li>Supporting our medicines and investment into our pipeline to transform care and maximise patient benefits</li>
<li>Investing in the right teams and ways of working, keeping sustainability at our core</li>
</ul>
</div>
<p>In contrast, here's the anti-pattern for an OST aligned strategy:</p>
<div class="aside"><p>CEO: "We need an AI Strategy"</p>
<p>CTO: "We're adopting Copilot"</p>
<p>Employees: "What am I supposed to do with this?"</p>
</div>
<h3 id="what-good-top-down-planning-looks-like">What good top-down planning looks like</h3>
<p>Good top-down AI planning does not require a lengthy programme of work before anything else can begin. It requires a clear and honest answer to a small number of questions:</p>
<ul>
<li>What are the two or three areas of the business where improvement would have the most significant impact?</li>
<li>In each of those areas, what does the process actually look like today and where are the bottlenecks?</li>
<li>Is the bottleneck a lack of capacity, a lack of speed, or something else?</li>
<li>Who in the executive team owns this outcome, and are they prepared to provide active sponsorship?</li>
</ul>
<p>The last question matters more than it might appear. An executive sponsor provides the air cover to push through organisational resistance, the authority to make operating model changes, and crucially, the permission for early ideas to fail without the team being penalised — provided the failure produces learning that gets shared. Without that sponsorship, even well-designed experiments tend to stall.</p>
<h2 id="the-bottom-up-lens-showing-the-art-of-the-possible">The bottom-up lens: showing the art of the possible</h2>
<p>Top-down planning has a structural limitation. It depends on people knowing what problems exist and what solutions might be feasible. In practice, neither of those things is as clear as it appears from a strategy document.</p>
<p>People often do not know a problem exists until they start using a tool and discover a better way of working. Furthermore, decision-makers frequently cannot accurately assess what AI can and cannot do because their reference point is what it could do twelve or twenty-four months ago which, given the pace of change, is a very different thing.</p>
<p>This is where the bottom-up lens is indispensable.</p>
<div class="aside"><p>The prompt-a-thon era is over. The question is no longer "what could we do with AI?", it is "what business processes are inefficient or unscalable, and how do we apply AI to fix them?"</p>
</div>
<p>The language of "prompt-a-thon" was appropriate when AI was genuinely novel and people needed permission to explore unfamiliar tools. That era has passed. The models have now evolved to the point where they are generally capable of getting the outcome that you wanted without too much extra guidance.  What remains is the application question.  Answering that requires real business context, not a blank-canvas ideation exercise.</p>
<p>What bottom-up experimentation looks like today is closer to a <strong>community of practice</strong>: a small group of motivated people who are in touch with the state of the art (SoTA), actively building things, developing new skills and new ways of working.  The most important aspect is that they share: their thinking, their mindset, the scenarios they explored, failures, wins and lightbulb moments.</p>
<p>Everything they work on should have a clear connection back to the strategic agenda. An experiment that produces a useful capability but has no link to an organisational objective is interesting but not durable. An experiment that demonstrates a specific bottleneck can be eliminated, that links directly to a top-level goal, is investable.</p>
<h3 id="what-bottom-up-experimentation-achieves-that-planning-cannot">What bottom-up experimentation achieves that planning cannot</h3>
<p>There are three things that only hands-on experimentation can surface:</p>
<ul>
<li><strong>Feasibility signals.</strong>  An idea that looks strong on paper may hit an insurmountable obstacle the moment someone tries to implement it, most commonly, poor data quality. Discovering this through a small contained experiment is far cheaper than discovering it six months into a funded programme.</li>
<li><strong>Opportunity discovery.</strong> Some of the most compelling AI use cases emerge when someone, in the process of solving a specific problem, stumbles into a capability that neither they nor their leadership team had anticipated.</li>
<li><strong>Education through demonstration.</strong>  Leaders who have not worked directly with modern agentic tools consistently underestimate what is now possible.  Showing is more effective than telling: a live demo of an agent completing in seconds what would take a human several hours changes the conversation at the strategic level in a way no slide deck can.</li>
</ul>
<h2 id="why-most-ai-initiatives-fail-and-how-this-approach-changes-the-odds">Why most AI initiatives fail, and how this approach changes the odds</h2>
<p>Drawing on our own experience, backed up by research from <a href="https://www.rand.org/pubs/research_reports/RRA2680-1.html">RAND</a>, <a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/">MIT</a> and <a href="https://www.mckinsey.com/capabilities/quantumblack/our-insights/the-state-of-ai">McKinsey</a> there is consensus on a single, uncomfortable finding:</p>
<div class="aside"><p><strong>AI projects fail for organisational reasons, not technical ones.</strong>  The models work. The infrastructure exists. What breaks is everything around them.</p>
</div>
<p>Here are the ten most common root causes, which rarely occur in isolation. We have grouped them by which lens of the dual-lens approach is best placed to surface them early.</p>
<p><strong>Failures the top-down lens prevents:</strong></p>
<ol>
<li><strong>Problem not clearly defined</strong>: people embark on the project without a clear view of the goal or success criteria.</li>
<li><strong>No executive sponsorship</strong>: nominal endorsement without genuine ownership. Nothing changes because no one with authority has a stake in the outcome.</li>
<li><strong>Workflow not redesigned</strong>: AI is bolted onto existing processes rather than used as the occasion to rethink how work gets done. McKinsey identifies this as the single strongest predictor of value, yet only 20% of organisations do it.</li>
<li><strong>Technology-led, not problem-led</strong>: adopting the latest model or platform before establishing whether it solves a real problem more effectively than the current approach.</li>
</ol>
<p><strong>A failure that needs both lenses working together:</strong></p>
<ol start="5">
<li><strong>Cultural and organisational unreadiness</strong>: the technology works, but people don't trust it, don't understand it, or have no incentive to change how they work. Integration, compliance, and change management complexity are consistently underestimated. Top-down sponsorship signals that change is real; bottom-up demonstration shows people what "good" looks like in practice.</li>
</ol>
<p><strong>Failures the bottom-up lens surfaces early:</strong></p>
<ol start="6">
<li><strong>Data readiness used as a blocker, not a starting point</strong>: fragmented or ungoverned data is real, but it is more often used as a reason to do nothing than as a problem to solve. AI does not require perfect data. Start with the use cases where the data is good enough; let early experiments expose which data problems matter most, and prioritise from there.</li>
<li><strong>Pilot purgatory / failing slowly</strong>: sunk cost bias keeps projects alive long after the evidence says otherwise. No pre-agreed criteria for when to stop.</li>
<li><strong>Building instead of buying</strong>: internal builds succeed roughly half as often as purchasing specialist tools, yet many organisations default to building, particularly in regulated sectors.</li>
<li><strong>Technology not ready</strong>: the supporting infrastructure is not in place, or the AI is available but is not <em>yet</em> capable of solving this specific problem.</li>
<li><strong>Outputs not verified</strong>: hallucinated or incorrect content acted upon without adequate checks. Governance treated as a compliance task rather than a design principle.</li>
</ol>
<p>The dual-lens approach is specifically designed to surface each of these failure modes early, ideally before significant time, energy and budget have been expended.</p>
<p>If you have defined clear objectives and secured genuine executive sponsorship before any experimentation begins, you have eliminated the two most common causes of failure before you have started. And small, focused experiments will reveal data, capability and technology limitations at minimal cost, long before they become expensive problems. The goal is not to succeed every time; it is to learn as quickly and cheaply as possible, and to efficiently find the 20% of ideas worth investing in with confidence.</p>
<div class="aside"><p>The cheapest thing you can do is talk. The most expensive thing you can do is build. Fail ideas on paper before you commit engineering resource.</p>
</div>
<p>This reframing is important. In our experience, organisations that treat early failure as a sign that the approach is not working are applying the wrong success criteria. Early failure of an idea is a success: it has released resources for something more promising. The organisations that make real progress are those that have built the discipline to kill bad ideas quickly, and the structures to identify good ones before they are buried under the weight of everything else.</p>
<h2 id="the-incubator-model-protecting-the-work-from-cultural-friction">The incubator model: protecting the work from cultural friction</h2>
<p>In many organisations, bottom-up experimentation runs into cultural resistance because the established culture has developed strong immune responses to anything that challenges the existing way of working.  The resistance is rarely explicit. It manifests as lack of engagement, approval processes that move slowly, and a quiet failure to adopt new ways of working.</p>
<p>This dynamic is framed with clarity by Vijay Govindarajan and Chris Trimble's <a href="https://www.amazon.co.uk/Other-Side-Innovation-Execution-Challenge-ebook/dp/B003VIWO8O/"><em>The Other Side of Innovation</em></a> published in 2010. Every established organisation runs what they call a <em>Performance Engine</em> — the systems, processes and people optimised for repeatable, predictable execution of today's business. The Performance Engine is what pays the bills. But its instinct, when faced with anything that disrupts that repeatability, is to push it away. Innovation and ongoing operations, they argue, are inevitably in conflict.</p>
<p>Govindarajan and Trimble's prescription is to set up what they call a <em>Dedicated Team</em>, we have used the term <strong>incubator</strong> to describe the same idea:</p>
<ul>
<li><strong>Small</strong>: no more than four to six people, senior enough to have credibility and access, focused enough to move quickly.</li>
<li><strong>Cross-functional</strong>: custom-built for the initiative, combining business domain expertise with technical capability: fueling the interactions where the hard problems get solved.</li>
<li><strong>Mandate-driven</strong>: connected explicitly to one or two strategic objectives, so the work is always anchored to something the business has said it cares about.</li>
<li><strong>Protected</strong>: insulated from the day-to-day pressures and approval cycles that slow down mainstream teams.</li>
<li><strong>Effective communicators</strong>: capable of reporting learnings back to the wider organisation in an engaging, constructive way.  A primary output is knowledge, and demonstrating that change is possible.</li>
</ul>
<p>Crucially, this is not a walled-off <a href="https://en.wikipedia.org/wiki/Skunkworks_project">skunk works project</a>. Govindarajan and Trimble are emphatic that the Dedicated Team must work in active partnership with the Performance Engine, drawing on its expertise, infrastructure and customer relationships. The incubator is a different operating model running alongside the main business, not a parallel universe disconnected from it.  This is also not a permanent structure: it is a mechanism for generating the proof points that allow the broader organisation to move forward with confidence.</p>
<h2 id="putting-it-together-a-practical-starting-point">Putting it together: a practical starting point</h2>
<p>For an organisation beginning this journey, or restarting after a previous initiative that did not gain traction, the following sequence tends to work well.</p>
<ol>
<li><p><strong>Start with a short, honest conversation at leadership level</strong> about the business. What are the two or three things the organisation most needs to be true in three years that are not true today? Where are the constraints on growth, efficiency, or resilience? This conversation should avoid mentioning AI at all. Its purpose is to establish the objectives (think OST!) that will anchor everything that follows.</p>
</li>
<li><p><strong>Identify a small group of motivated experimenters.</strong> People with the best combination of business insight, technical curiosity, and the judgment to connect what they discover to what the organisation actually needs. The best people tend to be busy generating revenue, but the truth is you want to scale and automate what makes your rainmakers successful.  Taking them away from the "day job" to contribute to disciplined experimentation with AI is a significant decision, but it also signals strategic intent. Give them a clear remit: to explore what is possible, connect it to objectives, and a commitment to share what they learn.</p>
</li>
<li><p><strong>Create a feedback loop between the two.</strong> The experimenting group should be reporting back to the leadership regularly.  Not to present polished findings, but to share raw discoveries: what worked, what failed, what surprised them, and what they now believe is possible that they did not before. These discoveries should be able to reshape the strategic agenda, not just be assessed against it. Be prepared for surprises that may lead to a significant strategic "pivot".</p>
</li>
<li><p><strong>Kill ideas early and often.</strong> Evaluate each emerging idea against the objectives. Does it address a clearly defined problem? Is the data in good enough shape to support it? Is there an executive owner who will champion adoption? If the answers are no, retire the idea and move on.  Capture <em>why</em> each idea was killed in a lightweight log — this prevents the organisation rediscovering the same dead ends six months later, and gives executive sponsors visibility of the funnel rather than just the survivors.</p>
</li>
<li><p><strong>Invest in the 20%.</strong> When an idea passes these tests and it addresses a real problem and has a credible path to adoption, commit to it properly with a dedicated team, empowered with budget, clear objectives, a realistic timeline, and an executive sponsor who is prepared to do the work of organisational change as well as technology delivery.</p>
</li>
</ol>
<h2 id="the-dual-lens-in-practice">The dual lens in practice</h2>
<p>The organisations we work with that are making the most durable progress with AI are not the ones with the most sophisticated strategies, the most active experimentation programmes, the biggest budgets or who have simply invested in the latest AI tools.  They are the ones that have built an effective "top down, bottom up" approach.</p>
<ul>
<li><p><strong>Top-down without bottom-up: produces a strategy that isn't deliverable or optimised.</strong>  The idea of a plan, but without the "how".  Disconnected from what is actually possible.  Not tested against reality.</p>
</li>
<li><p><strong>Bottom-up without top down: is a "solution looking for a problem".</strong>  It produces impressive demonstrations that do not accumulate into business transformation, with leadership asking "What did we actually get for all of this?".</p>
</li>
<li><p><strong>Top-down and bottom-up creates something more valuable than either can produce alone: an "AI infused" strategy enabled by a learning system that gets smarter about where to invest, faster at identifying what will not work, and increasingly capable of delivering the outcomes that the organisation actually needs.</strong></p>
</li>
</ul>
<p>The technology is ready. The question, as always, is whether the organisation is.</p>
<p>If you are navigating this challenge and would like to explore how the dual-lens approach could apply to your organisation, our <a href="https://endjin.com/what-we-do/azure-data-strategy-briefing">90-minute Data Strategy Briefing</a> provides a practical starting point. And our <a href="https://endjin.com/blog/2025/10/the-data-product-canvas-stop-building-products-that-fail">Data Product Canvas</a> offers a structured way to evaluate the portfolio of ideas that bottom-up experimentation will generate — helping you identify the 20% worth investing in before you commit significant resources to them.</p>]]></content:encoded>
    </item>
    <item>
      <title>SQLBits 2026: A Conference Recap</title>
      <description>SQLBits is one of the largest data platform conferences in Europe. Here's a recap of my experience at SQLBits 2026, held at the ICC Wales.</description>
      <link>https://endjin.com/blog/sqlbits-2026-a-conference-recap</link>
      <guid isPermaLink="true">https://endjin.com/blog/sqlbits-2026-a-conference-recap</guid>
      <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>SQLBits</category>
      <category>Data</category>
      <category>Power BI</category>
      <category>DAX</category>
      <category>OneLake</category>
      <category>SQL Server</category>
      <category>Data Factory</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/sqlbits-2026-a-conference-recap.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>I recently attended <a href="https://sqlbits.com/">SQLBits 2026</a> at the ICC Wales - one of the largest data platform conferences in Europe. In the run-up to the conference, I was part of the SQLBits <a href="https://sqlbits.com/about/inclusivity/">DEI (Diversity, Equity and Inclusion) panel</a>, where we worked on various initiatives to make the event more inclusive - from the "One SQLBits, Many Nations" map in the community hub, to non-sensory food options, all-day snacks, and the "Find Your People" networking sessions. It was brilliant to see a lot of this come together on the day (and especially lovely to see so many kids and parents at the <a href="https://codeclub.org/">Code Club</a> sessions on the Saturday!).</p>
<p>Across four days, I went to sessions on everything from the SQL Server roadmap and DAX deep dives, to ingesting SharePoint data into Fabric, home automation, inclusive team building, and avoiding burnout. It was a packed few days, and I came away with a lot of new knowledge and plenty to think about.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/sqlbits-poster.jpeg" alt="SQLBits Poster" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/sqlbits-poster.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/sqlbits-poster.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/sqlbits-poster.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/sqlbits-poster.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This post gives an overview of the sessions I attended. I'll also be writing some deeper technical posts on the topics that I found most interesting!</p>
<h2 id="day-1">Day 1</h2>
<h3 id="keynote-the-sql-roadmap">Keynote - The SQL Roadmap</h3>
<p><em>Priya Sathy, Shiva Gurumurthy, Bob Ward, Anna Hoffman</em></p>
<p>The conference kicked off with a Keynote focused on the SQL Server roadmap. There were a lot of announcements about migrating to Azure SQL Managed Instance, including cost estimation tools, the ability to check the effect of changes via lineage, and the fact that you can now manage memory and performance independently. They also touched on VMs - updating to the new series can provide huge throughput increases for analytics, even with smaller VMs.</p>
<p>Anna Hoffman did a couple of great demos. The first used GitHub Copilot to analyse why a stored procedure was running slowly. The second showcased the notebook experience in the mssql VS Code extension, where you can create reports and export them for sharing.</p>
<p>There was also a section on SQL Database in Fabric, highlighting how it's natively integrated into OneLake, autonomous, secure, and optimised for AI. Anna demonstrated using the mssql extension in VS Code whilst connected to Fabric to make changes to a database, and then showing how those changes were reflected in the Fabric UI and could be committed to Git. It's clear that the developer experience for SQL in Fabric is getting a lot of attention.</p>
<p>Finally, there was a section on monitoring in SQL and database agents (all still in preview). This included estate-level triage, asking Copilot for insights, and agents putting alerts straight into Teams and tagging relevant people - a promising step towards more proactive database management.</p>
<h3 id="variable-libraries-in-fabric">Variable Libraries in Fabric</h3>
<p><em>Kevin Arnold</em></p>
<p>Next up was a session on Microsoft Fabric variable libraries - originally scheduled as a 20-minute slot, but they let him continue into the break and he ended up speaking for around 45 minutes (which was a good thing, as there was plenty to cover!).</p>
<p>Variable libraries are the mechanism intended for managing environment-specific configuration in Fabric. The session covered what they are, how they work across different Fabric item types, and some best practices for using them safely.</p>
<p>Watch out for a new post on best practices when using variable libraries!</p>
<h3 id="microsoft-fabric-and-the-mess-of-sharepoint">Microsoft Fabric and the Mess of SharePoint</h3>
<p><em>Laura Graham-Brown</em></p>
<p>Let's be honest - there's no escaping SharePoint. This session from Laura Graham-Brown explored the various options for ingesting SharePoint data into Microsoft Fabric. She was a great presenter and covered a lot of ground, walking through the practical trade-offs of each approach.</p>
<p>Again, keep an eye out for an upcoming post which will go through this in more detail.</p>
<h3 id="conversational-analytics-at-lloyds-banking-group">Conversational Analytics at Lloyd's Banking Group</h3>
<p><em>Andrew Herman, Sean Hughes</em></p>
<p>This was an interesting session about Lloyd's Banking Group's journey from traditional reporting to "GenBI" (Generative BI). The scale involved was impressive - over 100,000 reports, 3 million interactions per month, 27,000 workspaces, and 65,000 active users.</p>
<p>As part of their journey, they did a PoC to prove whether switching from traditional reports to conversational AI would help with the classic challenges of slow insights, inefficiency, and data silos.</p>
<p>What I found most interesting was less the AI angle itself, and more the broader transformation approach they described: proving value early, measuring impact quantitatively, winning senior sponsorship, building capability across the organisation, establishing a single source of truth for key datasets, and baking in CI/CD and governance from the start.</p>
<p>These are solid data transformation principles regardless of whether AI is involved - though the AI angle certainly helps with getting that initial buy-in and excitement!</p>
<h3 id="fabric-admin-panel">Fabric Admin Panel</h3>
<p>I attended a Fabric admin panel session hoping for discussion around workspace management and change management best practices. It ended up being more of a Q&amp;A driven by the audience, which was mostly made up of Fabric administrators.</p>
<p>A few interesting things came up:</p>
<ul>
<li>The Fabric team are considering workspace-level tags (high/medium/low priority), where jobs can then be prioritised accordingly.</li>
<li>They also recommended building your own Fabric capacity metrics app - which, reading between the lines, suggests the built-in monitoring story still has room for improvement.</li>
<li>They heavily implied that per-operation metrics are coming, which would be a welcome addition!</li>
</ul>
<p>There was also an interesting discussion about governance versus usability. The general advice was to lean towards openness and invest in education, with the reasoning being that overly restrictive policies tend to backfire - users will find creative workarounds. (The example given was users screenshotting Power BI reports and extracting data from the screenshots after exports were disabled!)</p>
<h3 id="shine-bright-like-a-star-without-burning-out">Shine Bright Like a Star, Without Burning Out!</h3>
<p><em>Gloria Georgieva Clare</em></p>
<p>As I always try to at conferences, I made sure to attend at least one less technical talk. Gloria Georgieva Clare gave an open and personable session on avoiding burnout, and I'm really glad I went.</p>
<p>She talked about the concept of having four "hobs" - health, social, work, and hobbies - and how you can't have all four firing at full power at any one time. She recommended a few books: <em>The Burnout Society</em>, <em>Fair Play</em> (about unseen, unpaid domestic labour), and <em>Solve Your Stress Cycle</em>.</p>
<p>One of the things that resonated with me most was the idea of defining personal "drivers and guardrails" - concrete commitments like "I want to meet a friend after work once a week" or "I shouldn't be eating lunch at my desk". She also made the important point that just because a stressful situation has ended, doesn't mean the effects of it instantly go away.</p>
<p>I think reflecting on these things semi-regularly is really valuable, and a conference is a good prompt for doing it.</p>
<h2 id="day-2-optimising-dax-queries-full-day-workshop">Day 2 - Optimising DAX Queries (Full-Day Workshop)</h2>
<p><em>(<a href="https://www.sqlbi.com/author/alberto-ferrari/">Alberto Ferrari</a> - <a href="https://www.sqlbi.com/">SQLBI</a>)</em></p>
<p>Day 2 I attended a full-day training workshop with <a href="https://www.sqlbi.com/author/alberto-ferrari/">Alberto Ferrari</a> on optimising DAX queries. I learnt a huge amount about how the underlying engines that power Power BI work, and how the in-memory database (VertiPaq) is optimised. We went through real examples of slow queries and why they were slow - often for reasons I would never have known about beforehand.</p>
<p>The workshop covered everything from how VertiPaq stores data using column-oriented storage and various encoding techniques, through to how the formula engine and storage engine work together when executing DAX queries, and practical techniques for identifying and fixing performance bottlenecks.</p>
<p>Watch out for an upcoming series, which will run through this in much more detail.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/power-bi-sqlbits.jpeg" alt="Power BI logo printed on coffee foam" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="day-3">Day 3</h2>
<p>Day 3 was a bit more relaxed. One of the nice things about SQLBits is that the conference social events (in this case, a pub quiz the night before) are actually a good way to meet people. I spent the morning chatting to various people I'd met, which is probably as valuable as any session.</p>
<h3 id="what-an-automaton-nerd-learnt-by-automating-their-home">What an Automaton Nerd Learnt by Automating Their Home</h3>
<p><em>Rob Sewell</em></p>
<p>This was a fun one. Rob Sewell talked about how he'd automated his house using <a href="https://www.home-assistant.io/">Home Assistant</a> - an open-source home automation platform. He had a network of hundreds of devices all connected via a local network, which was pretty impressive.</p>
<p>What I liked most about this talk was his point that even when you're doing something for fun at home, proper engineering practices and requirements gathering still matter. He had some great examples of what happens when you skip them - including a security setup designed to alert them if someone was in the garden after dark (via a foghorn noise and all the lights turning on), which worked perfectly until they realised they'd forgotten to account for the cat.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg" alt="Rob Sewell presenting his home networking diagram" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h3 id="people-first-building-strong-inclusive-data-teams-that-actually-deliver">People first: building strong, inclusive data teams that actually deliver</h3>
<p><em>Hollie Whittles</em></p>
<p>This was a 20-minute session, and while it was fairly high-level, there were some points that stuck with me.</p>
<p>Hollie Whittles gave some statistics around how communication is almost always what causes projects to fail, and talked about steps they'd taken to improve it within their team - including anonymous feedback mechanisms and surveys to better understand working styles.</p>
<p>There was a section on neurodiversity that I found particularly interesting. An estimated 30% of people in tech are neurodiverse, but only 16% say there's ever been a conversation about it in their workplace. She highlighted some common workplace practices that can unintentionally disadvantage neurodiverse team members:</p>
<p>Meetings without agendas - people who need more prep time are disadvantaged before the meeting has even begun. Only giving feedback in group settings - many people find public criticism destabilising and need privacy to process constructively. "Open door" policies - which only work for people who are already comfortable stepping through the door.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/invisible-friction.jpeg" alt="Slide: examples of workplace practices that can disadvantage neurodiverse colleagues" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/invisible-friction.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/invisible-friction.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/invisible-friction.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/invisible-friction.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>She also talked about recognising how different team members respond when pressure rises - some withdraw and go quiet, some escalate everything to urgent, and some people-please and agree to everything until they burn out. Being able to recognise these patterns in your team is a useful skill.</p>
<h3 id="get-creative-with-power-bi-making-core-visuals-shine">Get Creative with Power BI - Making Core Visuals Shine</h3>
<p><em>Valerie Junk</em></p>
<p>This session was about visualisation and report design in Power BI. A lot of it was fairly straightforward, but there were a few practical tips I picked up.</p>
<p>On tables specifically: use data bars (but not for every column), only highlight what's really important rather than colour-coding everything (just the top and bottom 3, perhaps - I'm definitely guilty of over-colouring), and grey out less important information. Though it's worth noting that heavy conditional formatting can be terrible for accessibility, so having an option to toggle it off is a good idea.</p>
<p>On that note, she showed a neat technique for toggling conditional formatting on and off using a button slicer, without resorting to bookmarks (which, in my experience, can be a bit of a nightmare). The approach is:</p>
<ol>
<li>Create a table with two values: "Formatting" and "No Formatting"</li>
<li>Add a button slicer connected to that table</li>
<li>Update your conditional formatting rules to check <code>SELECTEDVALUE</code> of that column</li>
</ol>
<p>Simple, but effective - and avoids the fragility that comes with bookmark-based approaches.</p>
<h2 id="day-4">Day 4</h2>
<p>Day 4 was the final day, and featured two talks from my colleague <a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart</a>.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/barry-sqlbits.jpeg" alt="Barry Smart presenting at SQLBits" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/barry-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/barry-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/barry-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/barry-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h3 id="spark-unplugged-how-in-process-analytics-is-making-distributed-computing-an-expensive-investment">Spark Unplugged: How In-Process Analytics Is Making Distributed Computing An Expensive Investment</h3>
<p>(<a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart</a> - endjin)</p>
<p>Barry's first talk challenged the assumption that distributed computing (Spark, in particular) is always the right tool for analytical workloads. With in-process analytics engines like DuckDB and Polars becoming increasingly capable, there's a growing argument that for many workloads, you can get comparable (or better) performance without the overhead and cost of a distributed compute cluster. If you're interested in this topic, keep an eye out for a follow-up post!</p>
<h3 id="no-compromise-data-apps-why-streamlit-is-the-missing-piece-in-your-analytics-stack">No-Compromise Data Apps: Why Streamlit is the Missing Piece in Your Analytics Stack</h3>
<p>(<a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart</a> - endjin)</p>
<p>Barry's second talk focused on <a href="https://streamlit.io/">Streamlit</a> - a Python framework for building interactive data applications. The session made the case that Streamlit fills an important gap in the analytics stack: the space between a Jupyter notebook (great for exploration, not great for sharing) and a full web application (powerful, but heavy to build). If you're a data team looking to get interactive tools into the hands of business users without a full frontend development effort, it's well worth a look!</p>
<h2 id="overall">Overall</h2>
<p>SQLBits is a well-run conference with a good mix of deep technical content, practical sessions, and softer topics around wellbeing, inclusion, and career development. I came away with a lot of things to think about and try out - which is about all you can ask from a conference, really.</p>
<p>If you're working in the data space - whether that's SQL Server, Fabric, Power BI, or anything in between - it's well worth considering. The 2026 Cartoon theme was a nice touch too!
<img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/me-sqlbits.jpeg" alt="Carmel Eve with her SQLBits lanyard" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/me-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/me-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/me-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/me-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>]]></content:encoded>
    </item>
    <item>
      <title>Multi-layer Caching with the Decorator Pattern</title>
      <description>Databricks SQL cold starts kill web API performance. Fix it with two-layer caching: Azure Blob Storage &amp; IMemoryCache, using the Decorator pattern.</description>
      <link>https://endjin.com/blog/multi-layer-caching-with-the-decorator-pattern</link>
      <guid isPermaLink="true">https://endjin.com/blog/multi-layer-caching-with-the-decorator-pattern</guid>
      <pubDate>Fri, 01 May 2026 05:30:00 GMT</pubDate>
      <category>dotnet</category>
      <category>Data Engineering</category>
      <category>Databricks</category>
      <category>SQL Serverless</category>
      <category>Application Development</category>
      <category>Caching</category>
      <category>Azure</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/multi-layer-caching-with-the-decorator-pattern.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p>TL;DR; Querying a Databricks SQL Serverless endpoint for analytical data is fast once the cluster is warm, but cold-start latency and query execution times make it unsuitable as the direct backing store for a web API. We solved this with two layers of caching: Azure Blob Storage for persistence across restarts, and <code>IMemoryCache</code> for sub-millisecond in-process reads, implemented cleanly using the Decorator pattern.</p>
<h2 id="the-performance-challenge">The Performance Challenge</h2>
<p>As part of a recent project, we were building an analytical web API that serves sales data: products, retailers, historical figures and projections, to a React front-end. The data is produced by an ETL process that runs in Databricks and writes the results to Delta tables in a data lake. The natural way to query that data is through Databricks SQL Serverless: it handles the complex analytical workloads, scales well, and integrates cleanly with the rest of the stack.</p>
<p>There's a catch, though. Databricks SQL Serverless clusters can be paused when idle, and cold-start latency can add several seconds—sometimes tens of seconds to the first request after a period of inactivity. Even on a warm cluster, query execution time for some of the larger datasets runs into multiple seconds. For a web API that needs to feel responsive, that's a problem.</p>
<p>The good news is that most of the data retrieved from Databricks is <em>reference data</em>: it changes infrequently, and when it does change, it changes in a controlled, versioned way. That observation is what makes aggressive caching safe, and it's the insight that shaped the approach described here.</p>
<p><strong>Key observation:</strong> the application works with specific named versions of the sales data. Within a given version, the data is completely immutable. There's no risk of serving stale data from a cache, because the data simply doesn't change once a version is published.</p>
<h2 id="understanding-the-data-access-requirements">Understanding the Data Access Requirements</h2>
<p>All data access in the application flows through a single interface, <code>ISalesDataRepository</code>. Here's a trimmed version of its key methods:</p>
<pre><code class="language-csharp">public interface ISalesDataRepository
{
    Task&lt;string&gt; GetLatestVersionIdAsync();
    Task&lt;VersionInfo&gt; GetVersionAsync(string versionId);
    Task&lt;Product[]&gt; GetProductsAsync(string versionId);
    Task&lt;Retailer[]&gt; GetRetailersAsync(string versionId);
    Task&lt;SalesSummary[]&gt; GetSalesAsync(string versionId, DateRange dateRange);
    Task&lt;SalesSummary[]&gt; GetSalesByIdsAsync(string versionId, string[] ids);
}
</code></pre>
<p>The versioning model is central to everything. A version is created by the ETL process and is immutable once published. The application operates within a specific version, most requests include a <code>versionId</code> that scopes the data being retrieved.</p>
<p>Not all data falls into the same category, though. When we analyse the methods, three distinct types emerge:</p>
<ul>
<li><strong>Fully immutable within a version</strong> — products, retailers, and other reference data. Once fetched for a given version, these can be cached indefinitely. They will never change.</li>
<li><strong>Near-real-time</strong> — the latest version ID. This needs a short time-to-live (we use five minutes) to pick up new versions when they're published without hammering the source on every request.</li>
<li><strong>On-demand lookups</strong> — targeted queries like <code>GetSalesByIdsAsync</code>, where the combination of parameters is effectively unbounded. Caching these isn't meaningful; they always go direct to Databricks.</li>
</ul>
<p>That analysis drives a <em>selective</em> caching strategy rather than a blanket one. Not everything is worth caching, and not everything can be cached with the same TTL.</p>
<h2 id="the-decorator-pattern-a-primer">The Decorator Pattern: A Primer</h2>
<p>If you haven't used the Decorator pattern before, the idea is straightforward. A decorator implements the same interface as the class it wraps. It adds behaviour: before, after, or instead of, delegating calls to the inner implementation. The consumer doesn't know, or care, whether it's talking to a "real" implementation or a decorator. It just uses <code>ISalesDataRepository</code>.</p>
<p>This is a natural fit for caching. The actual data access code stays clean and focused on talking to Databricks. The caching logic lives entirely in the decorators. The two concerns don't touch each other.</p>
<p>In our case, the chain looks like this:</p>
<pre><code>Client
  └─► MemoryCachingSalesDataRepository             (Layer 2: in-process, sub-millisecond)
        └─► BlobStorageCachingSalesDataRepository  (Layer 1: shared, persistent, fast)
              └─► SalesDataRepository              (Real implementation: Databricks SQL)
</code></pre>
<p>Each layer wraps the one below it. The <code>MemoryCachingSalesDataRepository</code> doesn't know it's wrapping a blob cache, it just knows it has an <code>ISalesDataRepository</code> to delegate to when it misses. Dependency injection wires the chain together; the decorators themselves have no knowledge of each other.</p>
<h2 id="layer-1-azure-blob-storage-cache">Layer 1: Azure Blob Storage Cache</h2>
<h3 id="why-blob-storage">Why Blob Storage?</h3>
<p>An in-process memory cache is lost when the API restarts or when a new instance spins up. Our application runs in Azure Container Apps, which scales out to multiple replicas and restarts during deployments. Without a persistent cache layer, every new instance would need to hit Databricks on its first request, exactly the cold-start problem we're trying to avoid.</p>
<p>Blob Storage is cheap, fast for reads, and shared across all replicas. It's not as fast as in-process memory, but it's orders of magnitude faster than waiting for a Databricks cluster to warm up.</p>
<h3 id="how-it-works">How It Works</h3>
<p><code>BlobStorageCachingSalesDataRepository</code> implements <code>ISalesDataRepository</code> and wraps the real <code>SalesDataRepository</code>. For each cacheable method, it constructs a deterministic blob path based on the data type, version ID, and any relevant parameters, for example, <code>sales/{versionId}/products.bin</code> for the products list.</p>
<p>The core of the implementation is a generic <code>GetOrCreateAsync&lt;T&gt;()</code> helper:</p>
<pre><code class="language-csharp">private async Task&lt;T&gt; GetOrCreateAsync&lt;T&gt;(
    string blobPath,
    Func&lt;Task&lt;T&gt;&gt; factory)
{
    var blobClient = _containerClient.GetBlobClient(blobPath);

    if (await blobClient.ExistsAsync())
    {
        var content = await blobClient.DownloadContentAsync();
        return MemoryPackSerializer.Deserialize&lt;T&gt;(content.Value.Content.ToArray());
    }

    // Cache miss: fetch from inner repository
    var result = await factory();

    // Write to blob storage for next time
    var bytes = MemoryPackSerializer.Serialize(result);
    await blobClient.UploadAsync(BinaryData.FromBytes(bytes), overwrite: true);

    return result;
}
</code></pre>
<p>The data is stored as binary blobs using <a href="https://github.com/Cysharp/MemoryPack">MemoryPack</a>, a high-performance binary serialiser for .NET. Compared to JSON, this keeps blob sizes small and deserialisation fast, both matter at the scale of multiple API instances reading from shared storage.</p>
<h3 id="preventing-the-thundering-herd">Preventing the Thundering Herd</h3>
<p>A naïve implementation has a race condition that's particularly nasty during cold starts. If the blob doesn't exist and multiple requests arrive concurrently, which is exactly what happens when a new container instance starts up and the front-end fires several API calls at once, they all miss the cache and all hit Databricks simultaneously.</p>
<p>This is the <a href="https://en.wikipedia.org/wiki/Thundering_herd_problem">thundering herd problem</a>. In the worst case, you end up making dozens of parallel queries to a cluster that's still warming up.</p>
<p>The solution is a <code>ConcurrentDictionary&lt;string, SemaphoreSlim&gt;</code> keyed by blob path. When a cache miss occurs, we acquire the semaphore for that blob path before proceeding. Inside the lock, we check the blob again, another request may have already populated it while we were waiting. If it's still a miss, we fetch from the inner repository and write the result. Here's the pattern in full:</p>
<pre><code class="language-csharp">private async Task&lt;T&gt; GetOrCreateAsync&lt;T&gt;(
    string blobPath,
    Func&lt;Task&lt;T&gt;&gt; factory)
{
    var blobClient = _containerClient.GetBlobClient(blobPath);

    // Fast path: blob already exists
    if (await blobClient.ExistsAsync())
    {
        var content = await blobClient.DownloadContentAsync();
        return MemoryPackSerializer.Deserialize&lt;T&gt;(content.Value.Content.ToArray());
    }

    // Slow path: acquire per-blob semaphore to prevent thundering herd
    var semaphore = _semaphores.GetOrAdd(blobPath, _ =&gt; new SemaphoreSlim(1, 1));
    await semaphore.WaitAsync();

    try
    {
        // Double-check: another waiter may have populated the blob
        if (await blobClient.ExistsAsync())
        {
            var content = await blobClient.DownloadContentAsync();
            return MemoryPackSerializer.Deserialize&lt;T&gt;(content.Value.Content.ToArray());
        }

        var result = await factory();

        var bytes = MemoryPackSerializer.Serialize(result);
        await blobClient.UploadAsync(BinaryData.FromBytes(bytes), overwrite: true);

        return result;
    }
    finally
    {
        semaphore.Release();
    }
}
</code></pre>
<p>Only one request per unique blob path reaches Databricks. All others wait for the semaphore, benefit from the result, and return immediately. It's worth noting that this applies <em>per instance</em>, if there are multiple container replicas running, each will independently populate its own copy of the blob. In practice that's fine: the first request to any instance pays the Databricks cost; subsequent requests benefit from the cached blob. If it does become a problem, there are more complex solutions using shared locks, for example, <a href="https://github.com/corvus-dotnet/Corvus.Leasing">Corvus.Leasing</a> which uses Azure blob storage to provide a means to acquire, release and extend exclusive leases to mediate resource access in distributed processing.</p>
<h3 id="whats-deliberately-not-cached-here">What's Deliberately Not Cached Here</h3>
<p>Version lookups, <code>GetLatestVersionIdAsync</code> and <code>GetAvailableVersionsAsync</code> always go to the source. We want the application to notice when a new version is published within a reasonable time. Caching these in blob storage would give us no meaningful benefit over the in-memory TTL we apply at the layer above.</p>
<p>Targeted on-demand lookups by ID also bypass the blob cache. The combination space, different sets of IDs against different versions, is too large to cache meaningfully.</p>
<h3 id="graceful-degradation">Graceful Degradation</h3>
<p>All blob I/O is wrapped in try/catch. If the cache layer fails for any reason—transient connectivity, permissions, a bad serialisation, it logs a warning and falls through to the inner repository. The application keeps working; it's just slower until the cache is warm again.</p>
<h2 id="layer-2-in-memory-cache">Layer 2: In-Memory Cache</h2>
<h3 id="why-a-second-layer">Why a Second Layer?</h3>
<p>Even a fast Blob Storage read involves a network round-trip and deserialisation overhead. For a busy API serving the same reference data many times per second, that adds up. <code>IMemoryCache</code> keeps deserialised objects in the process's heap. Reads are effectively instantaneous, no network, no deserialisation, just a dictionary lookup.</p>
<h3 id="how-it-works-1">How It Works</h3>
<p><code>MemoryCachingSalesDataRepository</code> wraps the Blob Storage decorator (which in turn wraps the real repository). It uses the standard <code>cache.GetOrCreateAsync()</code> pattern, with expiry configured per data type:</p>
<ul>
<li><strong>Immutable data within a version</strong>: no expiry, held in memory until the process restarts.</li>
<li><strong>Latest version ID</strong>: five-minute sliding expiry, so new versions are picked up in a timely manner.</li>
</ul>
<p>Cache keys incorporate the version ID where relevant, so different versions don't collide.</p>
<p>Here's a representative method:</p>
<pre><code class="language-csharp">public async Task&lt;Product[]&gt; GetProductsAsync(string versionId)
{
    using var activity = _activitySource.StartActivity("GetProducts");

    var cacheKey = $"{ProductsCacheKeyPrefix}{versionId}";

    if (_cache.TryGetValue(cacheKey, out Product[]? cached))
    {
        activity?.SetTag("cache.hit", true);
        return cached!;
    }

    activity?.SetTag("cache.hit", false);

    var result = await _innerRepository.GetProductsAsync(versionId);
    _cache.Set(cacheKey, result);

    return result;
}
</code></pre>
<h3 id="observability-with-activity-source">Observability with Activity Source</h3>
<p>Each method creates an <code>Activity</code> via <code>ActivitySource</code>, which participates in distributed tracing through OpenTelemetry. We record whether the request was a cache hit or miss as a tag on the activity: <code>cache.hit = true/false</code>.</p>
<p>This turns out to be genuinely useful. When we look at the observability dashboard, we can see at a glance what proportion of requests are being served from memory versus falling through to lower layers. It's how we validated that the cache was actually working as expected after deployment.</p>
<h3 id="what-doesnt-get-cached-in-memory">What Doesn't Get Cached In Memory</h3>
<p>Targeted on-demand lookups by ID, as with the blob layer, always delegate to the inner repository. The combination space makes in-memory caching impractical, we'd risk holding enormous amounts of data with a very low hit rate.</p>
<h2 id="wiring-it-together-with-dependency-injection">Wiring It Together with Dependency Injection</h2>
<p>The decorator chain is composed in the service registration. The order matters: each decorator needs to wrap the layer below it, not the one above. We register the concrete types first, then register the <code>ISalesDataRepository</code> abstraction as the fully-composed outermost decorator:</p>
<pre><code class="language-csharp">// Innermost: the real Databricks implementation
services.AddSingleton&lt;SalesDataRepository&gt;();

// Middle layer: Blob Storage cache wrapping the real implementation
services.AddSingleton&lt;BlobStorageCachingSalesDataRepository&gt;(sp =&gt;
    new BlobStorageCachingSalesDataRepository(
        sp.GetRequiredService&lt;SalesDataRepository&gt;(),
        sp.GetRequiredService&lt;BlobServiceClient&gt;(),
        sp.GetRequiredService&lt;IOptions&lt;SalesDataBlobCacheOptions&gt;&gt;(),
        sp.GetRequiredService&lt;ILogger&lt;BlobStorageCachingSalesDataRepository&gt;&gt;(),
        sp.GetRequiredService&lt;ActivitySource&gt;()
    )
);

// Outermost: in-memory cache wrapping the blob storage cache
// This is what consumers receive when they depend on ISalesDataRepository
services.AddSingleton&lt;ISalesDataRepository&gt;(sp =&gt;
    new MemoryCachingSalesDataRepository(
        sp.GetRequiredService&lt;BlobStorageCachingSalesDataRepository&gt;(),
        sp.GetRequiredService&lt;IMemoryCache&gt;(),
        sp.GetRequiredService&lt;ActivitySource&gt;()
    )
);
</code></pre>
<p>Any component that depends on <code>ISalesDataRepository</code> via the DI container automatically gets the fully-composed chain. The decorators themselves have no knowledge of how they're composed, they just know they have an <code>ISalesDataRepository</code> to delegate to.</p>
<h2 id="tracing-a-request-through-the-cache-layers">Tracing a Request Through the Cache Layers</h2>
<p>Let's walk through what happens for a call to <code>GetProductsAsync</code> under three different scenarios.</p>
<h3 id="first-request-after-deployment-cold-everything">First Request After Deployment (Cold Everything)</h3>
<ol>
<li><strong><code>MemoryCachingSalesDataRepository</code></strong> — cache miss; delegates to inner.</li>
<li><strong><code>BlobStorageCachingSalesDataRepository</code></strong> — blob not found; acquires semaphore; delegates to inner.</li>
<li><strong><code>SalesDataRepository</code></strong> — queries Databricks SQL. If the cluster has been idle, this may take several seconds while it warms up.</li>
<li>The result flows back up: written to Blob Storage, then stored in the in-process cache.</li>
</ol>
<p>This is the expensive path. It only happens once per unique dataset per application instance.</p>
<h3 id="second-request-in-the-same-process">Second Request in the Same Process</h3>
<ol>
<li><strong><code>MemoryCachingSalesDataRepository</code></strong> — cache hit; returns immediately from memory.</li>
</ol>
<p>That's it. Sub-millisecond response time regardless of what Databricks is doing.</p>
<h3 id="first-request-after-a-replica-starts-or-a-process-restart">First Request After a Replica Starts (or a Process Restart)</h3>
<ol>
<li><strong><code>MemoryCachingSalesDataRepository</code></strong> — cache miss (new process, empty memory cache).</li>
<li><strong><code>BlobStorageCachingSalesDataRepository</code></strong> — blob found; deserialises and returns.</li>
<li>The result is held in memory for all subsequent requests.</li>
</ol>
<p>The new instance pays a Blob Storage round-trip on its first request for each dataset, but never needs to hit Databricks. The warm-up time for a new replica is a handful of Blob Storage reads rather than a cluster cold-start.</p>
<h2 id="results-and-trade-offs">Results and Trade-offs</h2>
<h3 id="what-we-gained">What We Gained</h3>
<p>After the initial warm-up, the vast majority of read requests are served from the in-memory cache in sub-millisecond time. New replicas warm quickly from Blob Storage without touching Databricks. The thundering herd problem is eliminated: Databricks is hit at most once per unique dataset per version per application instance, regardless of how many concurrent requests arrive.</p>
<h3 id="honest-trade-offs">Honest Trade-offs</h3>
<p>It would be misleading to present this as a straightforward win with no downsides. There are real trade-offs:</p>
<ul>
<li><strong>Complexity.</strong> We now have three classes where one might seem simpler at first glance. Each decorator is individually straightforward, but the chain requires understanding to navigate.</li>
<li><strong>Staleness by design.</strong> The latest version ID has a five-minute lag. The application has to tolerate that, and the product team has to accept it. In our case that's fine, new versions aren't published on a minute-by-minute basis, but it's a deliberate constraint.</li>
<li><strong>Cache invalidation is implicit.</strong> When a new version is published, the old version's blobs remain in Blob Storage, they're just never requested for that version again. A separate cleanup process could remove them if storage cost becomes a concern, but for now the cost is negligible.</li>
<li><strong>Memory pressure.</strong> Keeping large datasets in <code>IMemoryCache</code> indefinitely is a deliberate choice that works because our process's memory budget accommodates it. For larger datasets or more memory-constrained environments, you'd want to think carefully about size limits and eviction policies.</li>
<li><strong>The Blob Storage layer adds latency on cold reads.</strong> If the Databricks cluster happens to be warm when a blob is missing, going via Blob Storage is actually slower than going direct. In practice, the cluster being warm on a cold-start scenario is the exception rather than the rule, but it's worth being aware of.</li>
</ul>
<p>As always, the answer is "it depends." This approach made sense for our workload profile. For a different set of constraints, larger datasets, more frequent version changes, tighter memory budgets — some of these trade-offs might tip the other way.</p>
<h2 id="conclusions">Conclusions</h2>
<p>The Decorator pattern is a clean fit for layered caching because it keeps caching logic entirely separate from data access logic. Adding a new cache tier is additive, it doesn't require changes to existing classes. The chain is composed by the DI configuration, not by the decorators themselves.</p>
<p>The design decisions that made this work were:</p>
<ol>
<li><strong>Understanding which data is truly immutable.</strong> The versioning model gave us a strong guarantee that made aggressive, indefinite caching safe.</li>
<li><strong>Choosing the right storage tier for each layer.</strong> Blob Storage for persistence and cross-replica sharing; <code>IMemoryCache</code> for the fast path.</li>
<li><strong>Protecting against the thundering herd.</strong> The semaphore-based double-check at the Blob Storage layer is easy to overlook but critical at cold start.</li>
</ol>
<p>Databricks SQL Serverless is a powerful analytical query engine. The trick is to use it for what it's good at, processing and transforming large analytical datasets, and let fast caches absorb the high-frequency, low-latency reads that a web API demands. The Decorator pattern gives us the architectural seam to do that cleanly.</p>
<p>The same pattern applies well beyond Databricks. Anywhere you have a slow or expensive data source serving data that changes infrequently, layering caches using decorators is a maintainable and extensible approach worth considering.</p>
<p>If you've got any questions or would like to discuss anything we've talked about, please feel free to leave a comment below.</p>]]></content:encoded>
    </item>
    <item>
      <title>Fabric Performance Benchmarking - Spark versus Python Notebooks</title>
      <description>Benchmarking Pandas, PySpark, Polars, and DuckDB on Microsoft Fabric: in-process Python engines run 4-5x cheaper and faster than Spark for common workloads.</description>
      <link>https://endjin.com/blog/fabric-performance-benchmarking-spark-versus-python-notebooks</link>
      <guid isPermaLink="true">https://endjin.com/blog/fabric-performance-benchmarking-spark-versus-python-notebooks</guid>
      <pubDate>Wed, 22 Apr 2026 05:00:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>Notebooks</category>
      <category>Spark</category>
      <category>Pyspark</category>
      <category>Python</category>
      <category>DuckDB</category>
      <category>SQL</category>
      <category>Polars</category>
      <category>DataFrame</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>Performance</category>
      <category>Data Processing</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-performance-benchmarking-part-1-spark-versus-python-notebooks.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> — This post analyses the results of benchmarking different data processing engines on <a href="https://www.microsoft.com/en-us/microsoft-fabric">Microsoft Fabric</a>. We compare <a href="https://pandas.pydata.org/">Pandas</a>, <a href="https://spark.apache.org/docs/latest/api/python/index.html">PySpark</a>, <a href="https://pola.rs/">Polars</a>, and <a href="https://duckdb.org/">DuckDB</a> across various compute configurations. The results provide concrete, Fabric-specific evidence for a broader industry trend: for medium-scale datasets (anything up to ~100GB), modern in-process engines like DuckDB and Polars on single-node Python notebooks are consistently faster and up to 5x cheaper than distributed Spark clusters.  The code used to generate the benchmark is <a href="https://github.com/endjin/fabric-performance-benchmark">available in a public repo on GitHub</a>.</p>
<p><a class="github-repo-card" href="https://github.com/endjin/fabric-performance-benchmark" target="_blank" rel="noopener noreferrer" data-github-repo="endjin/fabric-performance-benchmark"><span class="github-repo-card__row"><svg class="github-repo-card__logo" aria-hidden="true" viewBox="0 0 16 16" width="24" height="24" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path></svg><span class="github-repo-card__content"><span class="github-repo-card__title">endjin/fabric-performance-benchmark</span><span class="github-repo-card__description" data-field="description" hidden=""></span><span class="github-repo-card__meta"><span class="github-repo-card__language" data-field="language" hidden=""><span class="github-repo-card__lang-dot"></span><span data-field="language-name"></span></span><span class="github-repo-card__stars" data-field="stars" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"></path></svg><span data-field="stars-count"></span></span><span class="github-repo-card__forks" data-field="forks" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0zM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0z"></path></svg><span data-field="forks-count"></span></span></span></span><img class="github-repo-card__avatar" src="https://github.com/endjin.png?size=120" alt="endjin avatar" loading="lazy" referrerpolicy="no-referrer"></span></a></p>
<h2 id="the-broader-context-a-platform-responding-to-a-shift">The Broader Context: A Platform Responding to a Shift</h2>
<p>This benchmarking study doesn't exist in isolation. It is the latest instalment in our <a href="https://endjin.com/what-we-think/topics/polars-series/">Adventures in Polars</a> and <a href="https://endjin.com/what-we-think/topics/duckdb-series/">DuckDB</a> series, in which we have been making the case that a fundamental shift is underway in how organisations approach analytical data processing.</p>
<p>The argument, which we have explored in depth across both series, centres on what Hannes Mühleisen (co-creator of DuckDB and Professor of Data Engineering at the University of Nijmegen) calls the <strong>"data singularity"</strong>: the point at which the processing power of mainstream single-node machines surpasses the requirements of the vast majority of analytical workloads. We introduced this concept in <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">DuckDB: The Rise of In-Process Analytics and Data Singularity</a> and revisited it from a Polars perspective in <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">Why Polars Matters for Decision Makers</a>.</p>
<p>The core observation is straightforward: CPU core counts, RAM, and NVMe storage throughput have all improved dramatically over the past decade, while the size of most <em>useful</em> analytical datasets has grown far more slowly. Amazon's own internal Redshift telemetry, <a href="https://motherduck.com/blog/redshift-files-hunt-for-big-data/">analysed by MotherDuck</a>, suggests we are already close to the singularity — the 99th percentile of datasets in a production big data platform fits comfortably on a modern laptop. Their data suggests we are spending around 94% of query dollars on computation that doesn't actually need big data infrastructure.</p>
<p>The problem, historically, was that most data tools were designed <em>before</em> this shift. They could not take advantage of modern hardware capabilities. That gap gave rise to a new generation of <strong>in-process analytics engines</strong> which are built to exploit the full potential of a single, well-resourced machine. Two tools stand out: <a href="https://duckdb.org/">DuckDB</a> for those who prefer SQL, and <a href="https://pola.rs/">Polars</a> for those who prefer a DataFrame API. Both re-engineer the analytics stack from the ground up: column-oriented storage, vectorized execution across all available CPU cores, and intelligent query planning that eliminates unnecessary work. Neither requires a cluster. Neither has network overhead, authentication complexity, or the coordination costs of distributed systems.</p>
<p>The practical implication, and the hypothesis this benchmarking study was designed to test, is that for the majority of enterprise analytical workloads, these tools running on a single well-resourced node will outperform Spark at a fraction of the cost.</p>
<h2 id="microsoft-fabrics-response-the-python-notebook">Microsoft Fabric's Response: The Python Notebook</h2>
<p>It is telling that Microsoft has recognised this shift at a platform level. The introduction of <a href="https://learn.microsoft.com/en-us/fabric/data-engineering/using-python-experience-on-notebook"><strong>Python Notebooks</strong></a> to Microsoft Fabric is a direct response to the in-process analytics movement.</p>
<p>Where Fabric's Spark Notebooks provision a distributed cluster on demand (with all the associated overhead in spin-up time, coordination cost, and capacity consumption) Python Notebooks provide a single, configurable execution node. They come <strong>pre-installed with both DuckDB and Polars</strong>, a notable design choice that signals Microsoft's acknowledgement that these tools have earned their place in the enterprise data stack. Microsoft explicitly recommends both as alternatives to Pandas for memory-intensive workloads.</p>
<p>In parallel, both DuckDB and Polars have added direct support for <strong>OneLake</strong> — the storage platform that underpins Fabric. DuckDB's native Delta extension enables querying Delta tables stored in a Fabric Lakehouse via <code>delta_scan()</code>, reading directly from OneLake paths without additional configuration. Polars similarly supports reading and writing Delta tables via <code>pl.read_delta()</code>, <code>pl.scan_delta()</code>, and <code>df.write_delta()</code>, with OneLake authentication handled through Fabric's <code>notebookutils</code>. Both tools also support standard ABFS paths, enabling direct interaction with raw files in the Lakehouse Files area. We have covered these integration patterns in detail in <a href="https://endjin.com/blog/duckdb-in-practice-enterprise-integration-architectural-patterns">DuckDB Workloads on Microsoft Fabric</a> and <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric">Polars Workloads on Microsoft Fabric</a>.</p>
<p>The Python Notebook is configurable across a range of single-node sizes (2, 4, 8, 16, 32, and 64 vCores, with memory scaling proportionally). This is a meaningful range: a 32-vCore Python Notebook gives DuckDB or Polars access to substantially more parallelism than even a well-resourced Spark executor, without any of the coordination overhead of distributed execution.</p>
<p>What follows is our attempt to put hard numbers behind these claims, using a realistic enterprise workload on real Fabric infrastructure.</p>
<h2 id="data-source">Data Source</h2>
<p>The use case is implemented using open data provided by the <a href="https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads">UK Land Registry House Price Data open data repository</a>, made available under an <a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/">Open Government Licence</a>.</p>
<p>The data is provided as a set of CSV files (one per calendar year) which have been downloaded to a Fabric Lakehouse. The dataset covers property sales since 1995, with approximately 1 million transactions per year. In total, 30 years of historic data amounts to roughly 30 million rows and ~5GB of raw CSV.</p>
<p>This scale is deliberate. We find that many published benchmarks focus on processing datasets that are rarely encountered in practice. Our objective is to focus on the scale we more commonly encounter in enterprise client engagements — medium-scale datasets that are interesting enough to stress-test the differences between engines, but representative of the workloads that most data teams actually run day to day. The data fits in memory for all configurations tested, which is precisely where in-process engines are designed to excel.</p>
<h2 id="use-case">Use Case</h2>
<p>The use case mimics a common set of data transformations typical for data of this nature:</p>
<ol>
<li><strong>Start Up &amp; Set Up</strong> — provisioning the platform (Spark or Python), then completing startup tasks such as importing Python packages.</li>
<li><strong>Ingestion &amp; Transform</strong> — reading raw data from a set of CSV files, standardising, cleaning, and adding derived features.</li>
<li><strong>Write Dimensional Model</strong> — writing different slices of the transformed data to the Lakehouse in Delta format for downstream consumption, in this case as a dimensional model for Power BI.</li>
<li><strong>Read and Summarise</strong> — reading the Delta tables back and running analysis based on filtering, joining, and summarising data across different dimensions.</li>
<li><strong>Benchmark Capture &amp; Clean Up</strong> — capturing timestamps and memory consumption metrics at each stage and persisting them to the Lakehouse for analysis.</li>
</ol>
<pre class="mermaid">flowchart TB
A["1. Start Up &amp; Set Up"] --&gt; B["2. Ingestion &amp; Transform"]
B --&gt; C["3. Write Dimensional Model"]
C --&gt; D["4. Read &amp; Summarise"]
D --&gt; E["5. Benchmark Capture &amp; Clean Up"]
</pre>
<p>This pipeline exercises the full range of operations that matter to data engineers: reading raw files at scale, executing complex transformations, writing Delta tables, and querying structured data back. It is a fair test of what each engine is actually optimised to do.</p>
<h2 id="fabric-platforms">Fabric Platforms</h2>
<p>Fabric offers multiple notebook environments. For this study we used two:</p>
<ul>
<li><p><strong>Spark notebooks</strong> — a notebook experience over an on-demand Spark cluster, configurable in terms of vCores, memory, and number of executor nodes. Enables polyglot development (Python, R, SQL) across a distributed compute environment.</p>
</li>
<li><p><strong>Python notebooks</strong> — a more recent addition to Fabric. Python notebooks provision a single execution node sized according to pre-defined configurations (vCores and memory). Whilst positioned for "smaller" workloads, we find that the majority of enterprise use cases can be comfortably accommodated on this platform when the right tooling is chosen.</p>
</li>
</ul>
<h2 id="workloads">Workloads</h2>
<p>The study compares four data processing engines running the same use case on Fabric:</p>
<ol>
<li><p><strong>Pandas</strong> — the default Python library for data engineering. Vast ecosystem, but single-threaded and constrained to datasets that fit in memory. Serves as a baseline.</p>
</li>
<li><p><strong>PySpark</strong> — the Python API for Apache Spark. Designed for distributed computation across clusters and widely deployed via Databricks and Azure Synapse. The incumbent choice for enterprise-scale data engineering.</p>
</li>
<li><p><strong>Polars</strong> — a Rust-based engine with a Python API. Designed from the ground up to exploit modern hardware: automatic parallelisation across all available cores, lazy evaluation with query plan optimisation, and memory-efficient columnar processing. We explored Polars' technical foundations in detail in <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast">Polars Technical Deep Dive</a>.</p>
</li>
<li><p><strong>DuckDB</strong> — a C++ in-process analytical database with a Python API. Optimised for OLAP workloads, capable of querying CSV files and Delta tables directly, and able to use disk for larger-than-memory datasets. We covered DuckDB's internals — columnar storage, vectorized execution, zone maps — in <a href="https://endjin.com/blog/duckdb-in-depth-how-it-works-what-makes-it-fast">DuckDB In Depth: How It Works and What Makes It Fast</a>.</p>
</li>
</ol>
<h2 id="fabric-capacity-and-capacity-units-cus">Fabric Capacity and Capacity Units (CUs)</h2>
<p>A Fabric capacity is a dedicated pool of compute resources purchased from Azure — a fixed amount of computational horsepower continuously available to workspaces assigned to it. When you purchase a Fabric capacity (e.g. F8, F64), you are reserving that number of capacity units (CUs) for continuous use across all workloads: notebooks, pipelines, warehouses, Power BI, and so on.</p>
<p>CUs are Fabric's abstraction layer for billing compute across heterogeneous workloads. Different engines have different conversion rates, meaning your F64 capacity represents different amounts of practical compute depending on which engine is consuming it.</p>
<p>Consumption is measured in <strong>CU Seconds</strong>: the number of CUs consumed multiplied by duration in seconds. The fundamental formula for both Spark and Python Notebooks in Fabric is 0.5 CU per second per vCore, though for Spark the total vCore count must account for driver and all executor nodes:</p>
<ul>
<li>A Python Notebook sized at 8 vCores consumes <strong>4 CUs per second</strong>.</li>
<li>A Spark Notebook with 1 driver (8 vCores) and 1 executor (8 vCores) consumes <strong>8 CUs per second</strong>.</li>
</ul>
<p>This billing asymmetry is significant and, as the results below will show, it creates a strong economic case for Python Notebooks when the workload doesn't genuinely require distributed execution.</p>
<h2 id="configurations">Configurations</h2>
<p>Achieving direct parity across Spark and Python notebook platforms is not straightforward. We opted for configurations that allow a range of CU-per-second comparisons, including some like-for-like points:</p>
<table>
<thead>
<tr>
<th>CUs Per Second</th>
<th>Python Notebook Configuration</th>
<th>Spark Pool Configuration</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><strong>2 vCores, 16G RAM</strong> [Default for Python]</td>
<td></td>
</tr>
<tr>
<td>2</td>
<td>4 vCores, 32G RAM</td>
<td></td>
</tr>
<tr>
<td>4</td>
<td>8 vCores, 64G RAM</td>
<td>1 Executor 4/4 vCores 28G/28G RAM</td>
</tr>
<tr>
<td>6</td>
<td></td>
<td>2 Executors 4/4 vCores 28G/28G RAM</td>
</tr>
<tr>
<td>8</td>
<td>16 vCores, 128G RAM</td>
<td><strong>1 Executor 8/8 vCores 56G/56G RAM</strong> [Default for Spark]</td>
</tr>
<tr>
<td>10</td>
<td></td>
<td>4 Executors 4/4 vCores 28G/28G RAM</td>
</tr>
<tr>
<td>12</td>
<td></td>
<td>2 Executors 8/8 vCores 56G/56G RAM</td>
</tr>
<tr>
<td>16</td>
<td>32 vCores, 256G RAM</td>
<td></td>
</tr>
<tr>
<td>20</td>
<td></td>
<td>4 Executors 8/8 vCores 56G/56G RAM</td>
</tr>
</tbody>
</table>
<p>The default Python Notebook (2 vCores, 16GB RAM) is <strong>8x cheaper per second</strong> to run than the default Spark Notebook (1 Executor 8/8 vCores 56G/56G RAM). The smallest Spark configuration (1 Executor 4/4 vCores 28G/28G) is equivalent in CU cost to the 8-vCore Python Notebook which provides ~2.5x the RAM, and as the results show, very significant computational throughput when running DuckDB or Polars.</p>
<p>Spin-up times are also materially different, and matter for development workflows. Python Notebooks at the default 2-vCore configuration provision in under 30 seconds. Spark clusters in this study took between 3 and 3.5 minutes at all configurations tested:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Platform</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: right;">Median Provisioning Time</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><strong>Fabric PySpark Notebook</strong> [Default for Spark]</td>
<td style="text-align: left;">01 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: right;">18.6</td>
</tr>
<tr>
<td style="text-align: left;"><strong>Fabric Python Notebook</strong> [Default for Python]</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">24.356</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: right;">125.15</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: right;">129.215</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">32 vCores</td>
<td style="text-align: right;">132.767</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">136.243</td>
</tr>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">02 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: right;">188.749</td>
</tr>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">04 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: right;">192.767</td>
</tr>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">01 executors 04/04 cores 28g/28g memory</td>
<td style="text-align: right;">194.956</td>
</tr>
</tbody>
</table>
<p>When developing directly in Fabric notebooks we use the default platform configurations operating on test data.  Then scale up the configuration as needed in production.</p>
<p>With DuckDB and Polars, we favour local development given the access this gives us to modern IDEs, coding agents and unit testing frameworks.  Faster provisioning directly improves the developer inner loop. Shorter iteration cycles during development compound over the course of a project. This is consistent with our experience migrating client workloads from Spark to in-process engines, where test suite runtimes dropped from minutes to seconds and local development became a practical reality again.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png" alt="Boxplot visualizing the distribution of spin up times for different configurations, showing Python notebooks are significantly faster to provision than Spark notebooks." title="Boxplot visualizing the distribution of spin up times for different configurations, showing Python notebooks are significantly faster to provision than Spark notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="methodology">Methodology</h2>
<p>Multiple runs were completed for each combination of engine, workload, and configuration. Median execution times are reported throughout to reduce the influence of outliers.</p>
<h2 id="analysis">Analysis</h2>
<h3 id="elapsed-time-analysis">Elapsed Time Analysis</h3>
<p>Elapsed time analysis <strong>includes</strong> the time to spin up the required Spark or Python environment. Environments with faster provisioning therefore have an inherent advantage here. Note that spin-up time does not incur a CU cost on Fabric — if cost is your primary concern, skip ahead to the Execution Time Analysis.</p>
<p>The table below shows median elapsed times for each workload on its default environment:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Platform</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: left;">Workload</th>
<th style="text-align: right;">CUs Per Second</th>
<th style="text-align: right;">Median Elapsed Time</th>
<th style="text-align: right;">Percentage of Min Elapsed Time</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">01 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: left;">pyspark_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">125.691</td>
<td style="text-align: right;">100</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">133.59</td>
<td style="text-align: right;">106.3</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">174.876</td>
<td style="text-align: right;">139.1</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">pandas_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">275.515</td>
<td style="text-align: right;">219.2</td>
</tr>
</tbody>
</table>
<p>Key observations:</p>
<ul>
<li>PySpark (on its default 8-CU environment) is the fastest at elapsed time (~126 seconds), but only marginally so.</li>
<li>DuckDB on the default Python Notebook (1 CU per second) runs in ~134 seconds — just 6% slower — at <strong>one-eighth the CU cost</strong>.</li>
<li>Polars on the same minimal configuration completes in ~175 seconds, also at one-eighth the cost.</li>
<li>Pandas, the incumbent default, takes over four minutes — roughly twice Spark's elapsed time.</li>
</ul>
<h3 id="execution-time-analysis">Execution Time Analysis</h3>
<p>Execution time <strong>excludes</strong> spin-up and environment provisioning, enabling a direct comparison between engines across configurations at equivalent CU costs.</p>
<p>The <strong>Top 10</strong> results are presented below:</p>
<ul>
<li>DuckDB achieves the fastest pure execution time, on an 8-vCore Python Notebook with 64GB RAM.</li>
<li>DuckDB and Polars occupy 8 of the top 10 positions before Spark appears.</li>
<li>The fastest Spark execution time is more than twice that of DuckDB — which achieves its best result on infrastructure with half the CU cost.</li>
</ul>
<table>
<thead>
<tr>
<th style="text-align: right;">Rank</th>
<th style="text-align: left;">Platform</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: left;">Workload</th>
<th style="text-align: right;">CUs Per Second</th>
<th style="text-align: right;">Median Execution Time</th>
<th style="text-align: right;">Percentage of Min Execution Time</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: right;">1</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">47.037</td>
<td style="text-align: right;">100</td>
</tr>
<tr>
<td style="text-align: right;">2</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">66.405</td>
<td style="text-align: right;">141.2</td>
</tr>
<tr>
<td style="text-align: right;">3</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">72.794</td>
<td style="text-align: right;">154.8</td>
</tr>
<tr>
<td style="text-align: right;">4</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">32 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">16</td>
<td style="text-align: right;">80.182</td>
<td style="text-align: right;">170.5</td>
</tr>
<tr>
<td style="text-align: right;">5</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">86.28</td>
<td style="text-align: right;">183.4</td>
</tr>
<tr>
<td style="text-align: right;">6</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">88.203</td>
<td style="text-align: right;">187.5</td>
</tr>
<tr>
<td style="text-align: right;">7</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">32 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">16</td>
<td style="text-align: right;">90.787</td>
<td style="text-align: right;">193</td>
</tr>
<tr>
<td style="text-align: right;">8</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">102.398</td>
<td style="text-align: right;">217.7</td>
</tr>
<tr>
<td style="text-align: right;">9</td>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">01 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: left;">pyspark_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">107.091</td>
<td style="text-align: right;">227.7</td>
</tr>
<tr>
<td style="text-align: right;">10</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">107.871</td>
<td style="text-align: right;">229.3</td>
</tr>
</tbody>
</table>
<p>The line chart below shows median execution times across all engines and configurations, with CUs per second as the common measure of both environment size and cost:</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-time.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-time.png" alt="Line chart showing median execution times for each engine, demonstrating that DuckDB and Polars outperform PySpark at lower CU levels." title="Line chart showing median execution times for each engine, demonstrating that DuckDB and Polars outperform PySpark at lower CU levels." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-execution-time.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-execution-time.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-execution-time.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-execution-time.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<p>Key observations:</p>
<ul>
<li>Execution time for all engines initially decreases as more cores and memory become available, but there is a clear <strong>"sweet spot"</strong> beyond which performance plateaus or even degrades slightly. Throwing more infrastructure at a problem does not guarantee faster results and can actually make things slower.</li>
<li>At comparable CU levels (e.g. 4 CUs per second), DuckDB and Polars on Python Notebooks significantly outperform PySpark on Spark Notebooks.</li>
</ul>
<p>This "sweet spot" behaviour is consistent with what we know about how DuckDB and Polars are engineered. Both tools use automatic parallelisation across available cores, but there is an overhead to thread coordination that grows with core count. Beyond the point where all available parallelism is fully utilised, adding more cores yields diminishing returns.</p>
<h2 id="cu-cost-analysis">CU Cost Analysis</h2>
<p>Shifting focus from execution time to cost, the following table lists the 10 cheapest engine/configuration combinations:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Workload</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: right;">CUs Per Second</th>
<th style="text-align: right;">Median Execution Time</th>
<th style="text-align: right;">Total Cost (CUs)</th>
<th style="text-align: right;">Percentage of Min Cost</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">107.871</td>
<td style="text-align: right;">107.871</td>
<td style="text-align: right;">100</td>
</tr>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">66.405</td>
<td style="text-align: right;">132.81</td>
<td style="text-align: right;">123.1</td>
</tr>
<tr>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">156.885</td>
<td style="text-align: right;">156.885</td>
<td style="text-align: right;">145.4</td>
</tr>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">47.037</td>
<td style="text-align: right;">188.148</td>
<td style="text-align: right;">174.4</td>
</tr>
<tr>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">102.398</td>
<td style="text-align: right;">204.796</td>
<td style="text-align: right;">189.9</td>
</tr>
<tr>
<td style="text-align: left;">pandas_benchmark</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">250.84</td>
<td style="text-align: right;">250.84</td>
<td style="text-align: right;">232.5</td>
</tr>
<tr>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">88.203</td>
<td style="text-align: right;">352.812</td>
<td style="text-align: right;">327.1</td>
</tr>
<tr>
<td style="text-align: left;">pandas_benchmark</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">224.122</td>
<td style="text-align: right;">448.244</td>
<td style="text-align: right;">415.5</td>
</tr>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">72.794</td>
<td style="text-align: right;">582.352</td>
<td style="text-align: right;">539.9</td>
</tr>
<tr>
<td style="text-align: left;">pyspark_benchmark</td>
<td style="text-align: left;">01 executors 04/04 cores 28g/28g memory</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">149.619</td>
<td style="text-align: right;">598.478</td>
<td style="text-align: right;">554.8</td>
</tr>
</tbody>
</table>
<p>The cheapest Spark run costs more than 5x the cheapest DuckDB run and approximately 4x the cheapest Polars run. Even Pandas on the default minimal Python Notebook is cheaper than the most economical Spark configuration.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs.png" alt="Chart displaying the relative costs of different engine and configuration combinations, highlighting the cost-effectiveness of Python notebooks." title="Chart displaying the relative costs of different engine and configuration combinations, highlighting the cost-effectiveness of Python notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-costs.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-costs.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-costs.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-costs.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="execution-time-versus-cost">Execution Time versus Cost</h2>
<p>The following scatter chart provides a two-dimensional view of all engine/configuration combinations tested. The horizontal x-axis shows execution time; the vertical y-axis shows cost on a logarithmic scale.</p>
<p>The sweet spot (best combination of fast execution and low cost) lies in the <strong>bottom-left quadrant</strong>. That space is dominated by DuckDB and Polars.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png" alt="Scatter plot of execution time versus cost, showing the 'sweet spot' dominated by DuckDB and Polars in the bottom-left quadrant." title="Scatter plot of execution time versus cost, showing the &quot;sweet spot&quot; dominated by DuckDB and Polars in the bottom-left quadrant." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="stage-analysis-what-separates-duckdb-from-polars">Stage Analysis: What Separates DuckDB from Polars?</h2>
<p>Both DuckDB and Polars are modern in-process query engines built for exactly this kind of workload, yet DuckDB consistently comes out ahead. The stage-level analysis reveals where that advantage is won.</p>
<p>DuckDB's edge appears consistently in the stages that involve <strong>reading from the Fabric Lakehouse</strong> — specifically, reading Delta format tables. DuckDB uses its own native Delta extension for this purpose, reading directly from OneLake without additional dependencies. Polars, by contrast, currently uses the <code>delta-rs</code> package for Delta reads.</p>
<p>This is an important distinction in the context of Microsoft Fabric's architecture. OneLake stores data in Delta format, and any engine that can query Delta tables natively has a structural advantage. It is worth watching whether Polars develops its own native Delta reader over time; if it does, the gap between the two engines may narrow.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png" alt="Stage-level analysis comparing DuckDB and Polars, revealing DuckDB's advantage in reading from the Fabric lakehouse." title="Stage-level analysis comparing DuckDB and Polars, revealing DuckDB's advantage in reading from the Fabric lakehouse." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="conclusions">Conclusions</h2>
<p>This benchmarking study provides concrete, Fabric-specific evidence for what we have been arguing across the broader series: in-process analytics engines running on single-node infrastructure are a serious and, for most workloads, superior alternative to distributed Spark.</p>
<ol>
<li><p><strong>Modern in-process engines outperform Spark for medium-scale workloads</strong> — DuckDB and Polars delivered faster execution times than PySpark across all comparable configurations, often by a factor of 2x or more. The claims we made in our <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">DuckDB</a> and <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">Polars</a> series hold up under real Fabric workloads.</p>
</li>
<li><p><strong>Cost efficiency strongly favours Python Notebooks with modern engines</strong> — the cheapest Spark configuration costs 4-5x more than the cheapest DuckDB run for equivalent work. For teams with finite Fabric capacity budgets, this is a material consideration. Profile your workload, start small, and scale to Spark only when you genuinely need it.</p>
</li>
<li><p><strong>More resources don't always mean faster execution</strong> — there is a "sweet spot" for resource allocation beyond which performance plateaus or degrades. This challenges the intuition that scaling infrastructure will proportionally improve throughput. Both DuckDB and Polars are efficient enough that 4-8 vCores often delivers the best balance of speed and cost.</p>
</li>
<li><p><strong>Default configurations are not equal — and the gap matters</strong> — the default Python Notebook (2 vCores) is 8x cheaper per second to run than the default Spark Notebook, yet delivers comparable elapsed-time performance when using DuckDB or Polars. For development workloads, the Python Notebook should be the default choice.</p>
</li>
<li><p><strong>DuckDB's native Delta reader provides a measurable edge</strong> — the stage analysis suggests DuckDB's advantage over Polars comes primarily from its native Delta reading capability. This is a meaningful finding in the context of Fabric, where Delta is the dominant table format. It reinforces one of DuckDB's core design principles: eliminate friction wherever data meets compute.</p>
</li>
<li><p><strong>Microsoft is responding to the in-process analytics movement</strong> — the introduction of Python Notebooks pre-installed with DuckDB and Polars, combined with OneLake support in both tools, signals that the platform is evolving to accommodate this shift. Teams investing in these tools are aligned with the direction of travel, not swimming against it.</p>
</li>
</ol>
<p>Note - all code used to generate this benchmark and the supporting analysis is <a href="https://github.com/endjin/fabric-performance-benchmark">available in a public repo on GitHub</a>.</p>
<p><a class="github-repo-card" href="https://github.com/endjin/fabric-performance-benchmark" target="_blank" rel="noopener noreferrer" data-github-repo="endjin/fabric-performance-benchmark"><span class="github-repo-card__row"><svg class="github-repo-card__logo" aria-hidden="true" viewBox="0 0 16 16" width="24" height="24" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path></svg><span class="github-repo-card__content"><span class="github-repo-card__title">endjin/fabric-performance-benchmark</span><span class="github-repo-card__description" data-field="description" hidden=""></span><span class="github-repo-card__meta"><span class="github-repo-card__language" data-field="language" hidden=""><span class="github-repo-card__lang-dot"></span><span data-field="language-name"></span></span><span class="github-repo-card__stars" data-field="stars" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"></path></svg><span data-field="stars-count"></span></span><span class="github-repo-card__forks" data-field="forks" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0zM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0z"></path></svg><span data-field="forks-count"></span></span></span></span><img class="github-repo-card__avatar" src="https://github.com/endjin.png?size=120" alt="endjin avatar" loading="lazy" referrerpolicy="no-referrer"></span></a></p>
<p>The data singularity is not a theoretical future state — it is arriving now, and platforms like Microsoft Fabric are starting to reflect that. For most enterprise analytical workloads, the question is no longer whether single-node in-process engines can handle the job. Based on the evidence here, they can deliver a faster and cheaper solution than the distributed alternative.</p>
<p>So which workloads do still justify Spark's overhead?  Reserve Spark for datasets that genuinely exceed single-node memory capacity, or for workloads where Fabric-specific Spark optimisations (V-ORDER, Liquid Clustering) are demonstrably valuable. For everything else, DuckDB and Polars on a Python Notebook are the pragmatic choice.</p>
<p>The good news is that Microsoft Fabric makes all of these compute options available to you, underpinned by Delta format and <a href="https://learn.microsoft.com/en-us/fabric/onelake/onelake-overview">OneLake</a> as the common storage layer.  So you are not forced to make a choice up front, you have the flexibility to adapt without being forced to move data or adopt a different platform.</p>]]></content:encoded>
    </item>
    <item>
      <title>Medallion Architecture in Excel</title>
      <description>Apply the Medallion Architecture to Excel: use the three-tab rule to separate raw data, logic, and output for cleaner, maintainable spreadsheets.</description>
      <link>https://endjin.com/blog/medallion-architecture-in-excel</link>
      <guid isPermaLink="true">https://endjin.com/blog/medallion-architecture-in-excel</guid>
      <pubDate>Tue, 21 Apr 2026 05:30:00 GMT</pubDate>
      <category>Data</category>
      <category>Risk</category>
      <category>Reporting</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/medallion-architecture-in-excel.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<h2 id="the-three-tab-rule">The three-tab rule</h2>
<p>I've worked in technology for a long time. In fact, I've had a 25-year long career in software and data, which means I've had to use a lot of different programming languages, frameworks, platforms and tech stacks. But it's also meant that I've used Excel a lot. I would describe myself as fairly competent in Excel. Not <a href="https://www.bbc.co.uk/news/articles/cj4qzgvxxgvo">world championship level</a> by any means, and probably not even Joel Spolsky level (although I did <a href="https://www.youtube.com/watch?v=JxBg4sMusIg">start to suck slightly less after watching this 10 years ago</a>). But fairly competent.</p>
<p>However, I came across an interesting article recently that really grabbed my attention. <a href="https://www.howtogeek.com/microsoft-excel-3-tab-rule-structure-spreadsheet-like-a-software-developer/">This post on How-To Geek describes a "three-tab rule" in Excel</a>, separating source data, logic, and presentation. It's framed by making comparisons to the Model-View-Controller (MVC) pattern in web development, something I'm very familiar with, but had never considered applying to Excel. I was pretty surprised that I'd never come across this before (especially as I've spent a lot of time thinking about engineering practices in software), but I was also immediately struck by how useful, sensible and simple it was.</p>
<p>The pattern described proposes structuring Excel workbooks using three distinct tabs: Source, Logic, and Interface. The premise is straightforward - most spreadsheets fail because they mix raw data, calculations, and final reports on the same screen. By separating these concerns, you create workbooks that are easier to audit, maintain, and scale.</p>
<p>The MVC-based pattern works like this: the Source tab holds your raw, unmodified data in a structured format (ideally as an Excel Table). The Logic tab does all the heavy lifting - transformations, calculations, lookups, using modern Excel functions like FILTER, SORT, and LAMBDA. The Interface tab presents the final, polished output that stakeholders actually see.</p>
<p>This resonates strongly with how we think about data architecture at endjin. But while the original article frames this through the Model-View-Controller pattern, I think there's a more relevant mental model from the data engineering world that fits this use case perfectly.</p>
<h2 id="from-mvc-to-medallion">From MVC to Medallion</h2>
<p>If you've worked with modern data platforms - whether that's Databricks, Microsoft Fabric, or Azure Synapse - you'll likely be familiar with the Medallion Architecture. As Carmel described in her <a href="https://endjin.com/blog/2025/05/what-is-the-medallion-architecture">recent deep-dive on the topic</a>, it's a data design pattern that consists of three tiers: Bronze (raw), Silver (cleaned and validated), and Gold (projected for specific use cases).</p>
<p>The parallel to the three-tab Excel rule is obvious:</p>
<table>
<thead>
<tr>
<th>Medallion Tier</th>
<th>Excel Tab</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Bronze</strong></td>
<td>Source</td>
<td>Raw data in its original form. No transformations, no cosmetic formatting. A historical archive of what was received.</td>
</tr>
<tr>
<td><strong>Silver</strong></td>
<td>Clean</td>
<td>Cleaned, validated, and structured data. Schema standardisation. This is where raw data becomes useful information.</td>
</tr>
<tr>
<td><strong>Gold</strong></td>
<td>Output</td>
<td>Transformed and projected for a specific use case. Logic and calculations applied. Polished, formatted, and ready for consumption by stakeholders. This might be a pivot table, a chart, or another targeted, tabular presentation.</td>
</tr>
</tbody>
</table>
<p>This isn't just a semantic rebrand. The Medallion Architecture brings with it a wealth of best practices from the data engineering community - around data quality, validation, lineage, and governance that can inform how you approach your data workloads, even in Excel.</p>
<h2 id="the-benefits-of-this-approach">The benefits of this approach</h2>
<p>When you start thinking about your Excel workbook as a miniature data pipeline, several good practices naturally follow.</p>
<p><strong>Data lineage becomes visible.</strong> With clear separation, you can trace any value in your Output tab back through the Clean tab to its origin in the Source tab. When a number looks wrong, you know where to look.</p>
<p><strong>Validation can be added systematically.</strong> The Clean tab becomes the natural place to add data quality checks - are there unexpected blanks, or duplicates? Do totals reconcile? Are values within expected ranges? This is the equivalent of creating quality gates as data moves from Bronze to Silver.</p>
<p><strong>The Source tab becomes immutable.</strong> Just as the Bronze tier in a data lakehouse preserves raw data for historical playback, your Source tab should remain untouched. If new data arrives, append it rather than overwrite. This gives you an audit trail and the ability to reprocess if your logic changes.</p>
<p><strong>Multiple projections from the same source.</strong> You might need different views of the same underlying data - one for the finance team, one for operations, one for the board. In the Medallion Architecture, this is exactly what the Gold tier enables. In Excel, you can create multiple Output tabs, all drawing from the same Clean layer.</p>
<h2 id="proceed-with-caution">Proceed with caution</h2>
<p>I should be clear that whilst this pattern makes Excel workbooks more robust, more maintainable, and more professional, it doesn't make Excel the right tool for every job. <a href="https://endjin.com/blog/2020/10/the-public-health-england-october-2020-test-and-trace-excel-error-could-have-been-prevented-by-this-one-simple-step">I hold some strong opinions about when and where Excel is appropriate</a>, and when the stakes are high - when significant decisions are being made and errors could impact financial, regulatory compliance, or even public health outcomes, Excel isn't enough. You more than likely need proper software engineering discipline and quality control barriers across technology, process, and people. This pattern doesn't prevent someone accidentally filtering and deleting rows in the Source tab. It doesn't stop formulas breaking silently. It doesn't provide version control, automated testing, or audit logs.</p>
<p>But, for those situations where Excel genuinely is appropriate, or where constraints mean it's your only option, this approach represents a genuine step-change in how you structure your work.</p>
<h2 id="summary">Summary</h2>
<p>The three-tab rule for Excel is a pattern worth adopting. It has a direct comparison to the well established Medallion Architecture, connecting your Excel work to the broader principles of modern data engineering: separation of concerns, data quality, lineage, and fit-for-purpose projections.</p>
<p>Of course, this approach doesn't come without limitations. Excel remains a tool designed for individual productivity, not enterprise data management. If your workbook is becoming mission-critical - if it updates regularly, if others depend on it, if errors would be costly, then it's worth asking whether you've outgrown what Excel can safely provide.</p>]]></content:encoded>
    </item>
    <item>
      <title>LINQ Max and nullable value types</title>
      <description>LINQ's projecting Max operator has a trap for the unwary when used with value types. Understand what goes wrong, and how to avoid it.</description>
      <link>https://endjin.com/blog/csharp-linq-max-nullable-values</link>
      <guid isPermaLink="true">https://endjin.com/blog/csharp-linq-max-nullable-values</guid>
      <pubDate>Fri, 17 Apr 2026 04:30:35 GMT</pubDate>
      <category>Nullable Reference Types</category>
      <category>Nullable Reference Types in C#</category>
      <category>Nullable Types in C#</category>
      <category>C# Nullable Types</category>
      <category>Nullable</category>
      <category>Null Reference Exceptions</category>
      <category>Nullable Values</category>
      <category>NRTs</category>
      <category>NRT</category>
      <category>non-nullable</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Visual Studio Code</category>
      <category>C# Tutorials</category>
      <category>C# Programming</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/csharp-linq-max-nullable-values.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>While working on a project for a customer, we came across a slight oddity of LINQ's <a href="https://learn.microsoft.com/dotnet/api/system.linq.enumerable.max"><code>Max</code></a> operator when you use it with a value type. In some cases <code>Max</code> returns <code>null</code> when supplied with an empty list, and there are cases where this works even with value types—<a href="https://learn.microsoft.com/en-gb/dotnet/api/system.linq.enumerable.max?view=net-10.0#system-linq-enumerable-max-1(system-collections-generic-ienumerable((-0))-system-func((-0-system-nullable((system-int32)))))">this overload</a> returns <code>int?</code> for example. But in some cases it will not do this, and will instead throw an exception if its input is empty. The reasons behind it are non-obvious and somewhat subtle, so I thought I'd write about it.</p>
<p>The <code>Max</code> operator offers a <a href="https://learn.microsoft.com/dotnet/api/system.linq.enumerable.max#system-linq-enumerable-max-2(system-collections-generic-ienumerable((-0))-system-func((-0-1)))">projection-based overload</a> with this signature:</p>
<pre><code class="language-cs">public static TResult? Max&lt;TSource,TResult&gt;(
    this IEnumerable&lt;TSource&gt; source,
    Func&lt;TSource,TResult&gt; selector);
</code></pre>
<p>This will iterate through the <code>source</code>, pass each item to the <code>selector</code> callback, and then return the highest of the values the callback returns.</p>
<p>Notice how although the selector function returns a <code>TResult</code>, the return type of <code>Max</code> itself is <code>TResult?</code>. That nullability is there to handle the case where the <code>source</code> enumerable is empty: in that case there is no maximum value (because there are no values at all) and that <code>TResult?</code> return type means <code>Max</code> can return <code>null</code> to indicate that.</p>
<p>But it goes a bit weird if the <code>selector</code> returns a value type. Suppose you've got this type (which is a reference type, but crucially, two of its properties use value types):</p>
<pre><code class="language-cs">public record WithValues(string Label, int Number, DateTimeOffset Date);
</code></pre>
<p>First, let's verify that <code>Max</code> does what I've said with an empty list when the projection retrieves a reference type:</p>
<pre><code class="language-cs">WithValues[] empty = [];
string? maxLabel = empty.Max(x =&gt; x.Label);
Console.WriteLine(maxLabel is null);
</code></pre>
<p>This prints out <code>True</code>, confirming that <code>Max</code> here does indeed return <code>null</code> to let us know that there was no maximum value. (The notion of a <em>maximum string value</em> raises the awkward fact that <code>Max</code> doesn't let you pass an <code>IComparer&lt;T&gt;</code> here, but let's ignore that for now.)</p>
<p>With that in mind, what type do you suppose <code>maxDate</code> has in this example?</p>
<pre><code class="language-cs">WithValues[] empty = [];
var maxDate = empty.Max(d =&gt; d.Date);
</code></pre>
<p>If you look at the definition of <code>Max</code> you could correctly conclude that <code>TSource</code> here becomes <code>WithValues</code> and that <code>TResult</code> is <code>DateTimeOffset</code>. (And as we're doing all this type inference in our heads, we might reflect on whether using <code>var</code> here has really saved us any time and effort.) And since <code>Max</code> returns <code>TResult?</code> you might conclude that <code>maxDate</code> must be of type <code>DateTimeOffset?</code> (which is an alias for <code>Nullable&lt;DateTimeOffset&gt;</code>).</p>
<p>But that would be wrong. Here's exactly equivalent code using an explicit type declaration instead of <code>var</code>:</p>
<pre><code class="language-cs">WithValues[] empty = [];
DateTimeOffset maxDate = empty.Max(d =&gt; d.Date);
</code></pre>
<p>It is now clear that <code>maxDate</code>'s type is <code>DateTimeOffset</code>. If we were to try to declare it as a <code>DateTimeOffset?</code>, that would actually compile, but it wouldn't be equivalent to the <code>var</code> example: in the case where we use <code>var</code>, <code>maxDate</code> really does have the non-nullable <code>DateTimeOffset</code> type.</p>
<p>And if we do try to use <code>DateTimeOffset?</code>, it goes wrong. This compiles:</p>
<pre><code class="language-cs">DateTimeOffset? maxDate = empty.Max(d =&gt; d.Date);
if (maxDate.HasValue)
{
    Console.WriteLine(maxDate.Value);
}
else
{
    Console.WriteLine("No dates found.");
}
</code></pre>
<p>but it only compiles without error because an implicit conversion is available from <code>Max</code>'s return type of <code>DateTimeOffset</code> to the variable's type of <code>DateTimeOffset?</code>.</p>
<p>The most important thing to know about this code is that it will actually fail at runtime with an <code>InvalidOperationException</code> complaining that the <code>Sequence contains no elements</code>!</p>
<p>Earlier I linked to a non-generic overload of <code>Max</code> that returns an <code>int?</code> so you might think that this would work:</p>
<pre><code class="language-cs">WithValues[] empty = [];
int? maxNumber = empty.Max(x =&gt; x.Number);
</code></pre>
<p>but this will also fail with an exception at runtime instead of returning <code>null</code>. In fact it ends up using a different overload that returns an <code>int</code>, and not the one that returns an <code>int?</code>.</p>
<p>So that's weird. The first two examples call the same single overload of <code>Max</code>, and yet it handles an empty list completely differently depending on whether our selector returns the <code>Label</code> or <code>Date</code>. (When it returns <code>Number</code>, we end up using the <code>int</code>-specific overload, but the fact remains that an empty list causes an exception when we select a value-typed property, but the method returns <code>null</code> when selecting a reference-typed property.) What's going on?</p>
<p>Well it turns out that this particular <code>Max</code> method actually has two different code paths: and it effectively uses this test to choose which path to use:</p>
<pre><code class="language-cs">TResult val = default;
if (val == null)
...
</code></pre>
<p>If <code>val == null</code>, then it goes down the code path that returns <code>null</code> if the list is empty. If not, it goes down the path that throws an exception if the list is empty.</p>
<p>This is a deliberate design choice. If <code>default(TResult)</code> is something other than <code>null</code>—e.g. <code>default(int)</code> is 0—then there might be no way to tell the difference between an empty list, and a list where <code>default(TResult)</code> really was the maximum value. For example, in the list <code>[-3,-2,-1,0]</code>, the maximum value is <code>0</code>, so how could we distinguish between that case and the empty list case if we were getting back <code>0</code> in either case?</p>
<p>So there's a rationale for this behaviour, but it's not obvious that this one method can behave in two quite different ways. The documentation doesn't mention that this particular overload may throw an <code>InvalidOperationException</code>.</p>
<p>We can explore what that test will do with various types:</p>
<pre><code class="language-cs">static void ShowNull&lt;T&gt;()
{
    T? val = default;
    Console.WriteLine(val == null);
}

ShowNull&lt;string&gt;();
ShowNull&lt;string?&gt;();
ShowNull&lt;int&gt;();
ShowNull&lt;DateTimeOffset&gt;();
ShowNull&lt;int?&gt;();
ShowNull&lt;DateTimeOffset?&gt;();
</code></pre>
<p>This prints out:</p>
<pre><code>True
True
False
False
True
True
</code></pre>
<p>So this tells us that <code>Max</code> will consider <code>TResult</code> to be potentially nullable if it's a reference type like <code>string</code>, or if it's a nullable value type like <code>int?</code> or <code>DateTimeOffset?</code>. But plain value types like <code>int</code> and <code>DateTimeOffset</code> are considered not to be nullable.</p>
<p>That explains why using the <code>x =&gt; x.Label</code> lambda makes <code>Max</code> return <code>null</code> when the list is empty, while with <code>d =&gt; d.Date</code> or <code>d =&gt; d.Number</code>, it throws an exception. The first has a return type of <code>string</code> (a reference type) while the other two have non-nullable value-typed return types (<code>DateTimeOffset</code> and <code>int</code>).</p>
<p>But why does <code>Max</code> even have these two different code paths? It's perfectly possible for a method to return a <code>DateTimeOffset?</code>, so why does <code>Max</code> not do that here? If the argument for the type parameter <code>TResult</code> is <code>DateTimeOffset</code>, and the method declares a return type of <code>TResult?</code>, shouldn't that make the return type <code>DateTimeOffset?</code>?</p>
<p>The reason it doesn't work out that way is because nullability handling for reference types was a bit of an afterthought in C#. (See my extensive <a href="https://endjin.com/blog/2020/04/dotnet-csharp-8-nullable-references-non-nullable-is-the-new-default">series on nullable reference types for (a lot) more detail</a>.)</p>
<p>In the beginning (C# 1.0) there were value types, which could not be <code>null</code>, and reference types, which were always capable of being <code>null</code>. There simply wasn't any concept of a value type being nullable, and nor was there any way to constrain a reference type to be non-null. This reflected the underlying reality of the .NET runtime's type system. Then C# 2.0 added support for nullable value types, enabling us to write <code>int?</code>. But this was an entirely different way of representing nullability: an <code>int?</code> is really a <code>Nullable&lt;int&gt;</code>, and <code>Nullable&lt;T&gt;</code> essentially combines a value with a <code>bool</code> indicating whether the value is present. This is fundamentally different from how reference types like <code>string</code> represent <code>null</code>. (This is more of a library feature than a runtime feature. OK, strictly speaking there's some special handling for <code>Nullable&lt;T&gt;</code> when it comes to boxing and unboxing, but otherwise, this is mainly a language feature that doesn't directly reflect how the underlying runtime type system really works.) Although C# lets us write code that works with <code>int?</code> in ways that are (sometimes) similar to how we might work with a reference, the generated code is really quite different, and that causes challenges for generic code. And finally, C# 8.0 introduced nullability annotations for reference types, so that now, we write <code>string?</code> if we mean a reference that might be <code>null</code> whereas <code>string</code> is (in theory) never null, in a way that is conceptually similar to the fact that an <code>int</code> can never be null.</p>
<p>But although we've ended up in a place where there are apparently two dimensions—value vs reference, and nullable vs non-nullable—the history of how we got here means these aren't truly independent. Nullability works very differently for values vs references in practice. And two of the four combinations (nullable values, and non-nullable references) aren't really first class citizens in the .NET type system. (A nullable value in a null state looks different from the <code>null</code> reference value. And a 'non-nullable' reference might in fact be <code>null</code>.)</p>
<p>And this difference tends to poke out from time to time with surprising behaviour like we're seeing with this <code>Max</code> operator. It would be completely reasonable to expect it to deal with the <code>Label</code> and <code>Date</code> properties in exactly the same way. But the history of nullability in .NET means it doesn't work in practice.</p>
<p>So how do we fix this? We can use this slightly ugly hack:</p>
<pre><code class="language-cs">DateTimeOffset? maxDate = empty.Max(d =&gt; (DateTimeOffset?)d.Date);
</code></pre>
<p>That cast means that the lambda's type is now <code>Func&lt;WithValues, DateTimeOffset?&gt;</code>. (Before it was <code>Func&lt;WithValues, DateTimeOffset&gt;</code>, with a non-nullable return type.) Since <code>default(DateTimeOffset?) == null</code>, <code>Max</code> will select the code path that returns <code>null</code> when the input collection is empty. (It doesn't do that without this cast, because <code>default(DateTimeOffset)</code> is not <code>null</code>. It's a value representing midnight on the 1st January in the year 1, with a zero time zone offset.)</p>
<p>But what about that specialized (non-generic) overload of <code>Max</code> I linked to earlier that returns an <code>int?</code>? Well it turns out that it only comes into play when the selector also returns an <code>int?</code>. You get a different overload when the selector returns a plain <code>int</code>. So it ends up looking similar to the <code>DateTimeOffset</code> case (which used the generic overload). We need to cast to a nullable value:</p>
<pre><code class="language-cs">int? maxNumber = empty.Max(x =&gt; (int?)x.Number);
</code></pre>
<p>So we can make it work how we want, it's just slightly messy. That's the reality of a 25+ year old language that has made two major changes to the nature of what it means to be <code>null</code>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Returning to work after a career break, with help from remote work</title>
      <description>After years away, I returned to work in the UK. Here's how remote flexibility protected my mental health and made that transition possible!</description>
      <link>https://endjin.com/blog/returning-to-work-after-a-career-break</link>
      <guid isPermaLink="true">https://endjin.com/blog/returning-to-work-after-a-career-break</guid>
      <pubDate>Thu, 16 Apr 2026 05:30:00 GMT</pubDate>
      <category>Remote</category>
      <category>Remote Working</category>
      <category>Wellbeing</category>
      <category>Career</category>
      <category>Digital Nomad</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/04/returning-to-work-after-a-career-break.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>In 2021 I left work, the UK, and most of my friends and family behind. After what felt like half a decade trapped (at points literally) in a small flat in Manchester, I couldn't wait to get out and explore the world. It was an amazing, life-changing experience. But, after some years away, it was time to return home. And, in doing so, I had some decisions to make.</p>
<p>When I first arrived at Gatwick airport I had no idea what life was going to look like - I stayed with a friend, found some temporary work, and tried to reacclimatise to life at home. There were many reunions, tears, and more than one panic attack due to complete overwhelm - reverse culture shock is a real thing, who knew?</p>
<p>But, when all was said and done, though I made some vague attempts to see what was out there in the working world, it felt like there was really only one place I wanted to end up - and that was back at endjin.</p>
<p>I've now been back in the UK nearly two years, and back at work for 18 months, and there is definitely a lot to reflect on.</p>
<h2 id="remote-work-and-digital-nomading">Remote Work and Digital Nomading</h2>
<p>I know "digital nomad" can sound a bit buzzword-y, but it is the simplest label for how I have been living since I got back.</p>
<p>Having spent almost 3 years without staying in one place more than a month, the idea of signing a year-long lease felt terrifying in the extreme. And, endjin being a totally remote company (and having been so since 2018 - before it was cool...), I luckily didn't have to.</p>
<p>A lot of Airbnbs offer discounts for stays over a month, and with the current price of electricity and gas, you can often find options for far less than you'd pay at a standard rental - especially if (like me) you are drawn to places in the middle of nowhere, even in the depths of winter. So, that is what I did...</p>
<p>I lived all over the UK - Devon, Yorkshire, Bristol, North Wales, and even spent the winter in Spain (something that convinced me more than ever that January in the UK just isn't for me). And, in all of this, I learnt a lot about what I value in the places that I live. The feeling of being able to walk out the door into nature is something that, for me, is unparalleled. That being said, somewhere with train connections allowing me to attend the 5+ weddings I need to go to (yes, I am in my 30s) is equally important. Two months spent in a depression fog in a village in Yorkshire taught me that access to a gym or at least <em>some</em> way to exercise when it's raining is also a must for my mental health...</p>
<p>Honestly, I do not think returning to work would have been possible for me at that point without this flexibility. My mental state was not great, and trying to go straight from years of constant movement into a rigid routine would have been too big a shock. Being able to choose where I lived, reduce pressure where I could, and make changes gradually meant I could build back up rather than burn out.</p>
<p>Remote-first work gave me the space to re-enter life in the UK on my own terms, and that flexibility is what has made the first year back at work feel in some ways like a continuation of the adventure.</p>
<h2 id="what-i-want-to-carry-forward">What I Want to Carry Forward</h2>
<p>I know that not everyone has the opportunity, means, or even desire to live month-to-month unpacking and re-packing, but I do think that there are some lessons that I've learnt which are applicable whatever your situation:</p>
<ul>
<li>Over-planning the next 1/3/5 years can make you more anxious, not less. One of the biggest things I had to accept when travelling was that nothing ever goes fully to plan. That's as true for life in general as it is for catching 4 buses in a day. Spending all my time running through every possible scenario and outcome is never as helpful as it feels in the moment. (As someone with anxiety, I know that's easier said than done. And, to be clear, I'm not saying don't plan at all - I'm told <em>some</em> financial planning is probably a good idea...)</li>
<li>You don't need to work everything out at once. Trying something and deciding it doesn't work is far better than never changing at all. Most of the time, all you need to plan is the next step.</li>
<li>Notice what makes you happy. Nothing makes me feel better than being in nature, so building a life around that feels not only logical, but necessary.</li>
<li>Also notice what drains you. For me, winter has always been hard. Once I accepted that, it became much easier to make decisions that were actually good for me.</li>
<li>Revisit your priorities every few months. What mattered to you last year might not be what you need now.</li>
<li>Don't confuse discomfort with failure. Some uncertainty is just part of change, and it does not always mean you've made the wrong decision.</li>
</ul>
<p>And, if you are considering stepping into the world of "digital nomading", some advice:</p>
<ul>
<li>If you are working, make sure that you have enough time to appreciate a place. The first couple of places I stayed, I was only there for a month. By the time I'd moved the first weekend and left the final one, I felt like I had barely found my feet before I was moving again. Plan a good margin between moves - back-to-back travel plus work can be exhausting very quickly.</li>
<li>Think about the practical - what are some of the things that you do every day / every week that you'd struggle without - a swimming pool? A gym? A library? A pub within walking distance..?</li>
<li>How much travel do you need to do whilst you are there? Can you find somewhere you love that doesn't mean spending 5 hours on a train multiple times per month?</li>
<li>Always have a backup internet option. A local SIM/hotspot can save a lot of panic on work days.</li>
<li>Budget for comfort, not just cost. Sometimes paying a bit more for location, heating, or a proper desk is worth it.</li>
<li>Remember that it can be lonely. Moving around means you don't always build a base where you are living. Make sure you know what you will do if you are feeling alone - are there friends nearby? Do you have people you can call? Are there local groups you can get involved in?</li>
</ul>
<h2 id="final-thought">Final thought</h2>
<p>The biggest thing I have learned is that you do not need to blow up your whole life to make things better. You can use the same approach wherever you are: pay attention to what helps, be honest about what drains you, and focus on the next sensible step instead of waiting for a perfect long-term plan. That shift has made life feel less overwhelming, work feel far more sustainable, and I'm excited about building a life that works for me - whatever that might look like!</p>]]></content:encoded>
    </item>
    <item>
      <title>AI-assisted coding is four decisions, not one</title>
      <description>A simple mental model for making sense of the AI-assisted coding landscape: four layers, four decisions.</description>
      <link>https://endjin.com/blog/ai-assisted-coding-is-four-decisions-not-one</link>
      <guid isPermaLink="true">https://endjin.com/blog/ai-assisted-coding-is-four-decisions-not-one</guid>
      <pubDate>Mon, 13 Apr 2026 23:08:00 GMT</pubDate>
      <category>AI</category>
      <category>GenAI</category>
      <category>Claude Code</category>
      <category>GitHub Copilot</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-is-four-decisions-not-one.png" />
      <dc:creator>Mike Evans-Larah</dc:creator>
      <content:encoded><![CDATA[<p>The pace of change in the world of AI-assisted coding is overwhelming. With new tools, frameworks, and platforms emerging and evolving constantly, it can be hard to keep up, let alone understand how all the pieces fit together.</p>
<p>People often ask questions like "Should I use Cursor or ChatGPT?" or "Is Claude better than Copilot?" but to make decisions about which tools to use (or even ask the right sort of questions), it's important to understand the underlying architecture.</p>
<p>In this post, I want to share a simple mental model that has helped me make sense of the AI-assisted coding landscape.</p>
<h2 id="the-four-layers">The four layers</h2>
<p>At a high level, we can think of AI-assisted coding as being composed of four distinct layers:</p>
<ol>
<li><strong>Harness</strong>: The user interface and experience for interacting with the AI. It includes things like code editors, chat interfaces, CLI tools, and the system prompts that shape the AI's behaviour.</li>
<li><strong>Capabilities</strong>: The tools, instructions, skills, and context sources that extend what the AI can do — increasingly portable across harnesses.</li>
<li><strong>Model</strong>: The AI model that processes input and generates tokens — text, code, images, or other outputs. This is where the "intelligence" lives.</li>
<li><strong>Provider</strong>: The infrastructure and services that host and run the model. This includes cloud platforms, APIs, and the computational resources (GPUs, memory, state) needed to power it.</li>
</ol>
<p>These layers build on each other: the harness provides the interface, capabilities extend what's possible, the model does the reasoning, and the provider supplies the compute.</p>
<h3 id="harness">Harness</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-harness.png" alt="Harness illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-harness.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-harness.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-harness.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-harness.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The harness is what you actually interact with day-to-day, and there's a surprisingly wide range of options. Broadly, they fall into a few categories:</p>
<ul>
<li><strong>Chat interfaces</strong>: Web-based or desktop tools like claude.ai or ChatGPT, where you paste code in and get responses back. Great for quick questions and exploration, but limited when it comes to working with full projects.</li>
<li><strong>IDE extensions</strong>: Tools like GitHub Copilot Chat or Roo Code that plug into your existing editor (VS Code, Visual Studio, JetBrains, etc.). These meet you where you already work, with direct access to your codebase.</li>
<li><strong>Purpose-built IDEs</strong>: Editors like Cursor and Google Antigravity that have been built from the ground up with AI at their core (usually forks of VS Code). They offer deep integration between the editor experience and the AI capabilities.</li>
<li><strong>App builders</strong>: Tools like Lovable that focus on generating entire applications from natural language descriptions, targeting less technical users or rapid prototyping.</li>
<li><strong>CLI tools</strong>: Command-line agents like Claude Code, OpenCode, and Copilot CLI that let you work with AI directly from your terminal. These tend to appeal to developers who prefer keyboard-driven workflows.</li>
</ul>
<p>Even within these categories, there has been a push recently towards different user interactions. For example, integrating voice control or continuing conversations across devices.</p>
<p>But the harness isn't just about where you interact with the AI, it's about how the harness shapes the AI's behaviour. Modern harnesses go well beyond simple chat, adding capabilities such as:</p>
<ul>
<li><strong>Agentic workflows</strong>: The ability for the AI to plan, execute multi-step tasks, spawn sub-agents, run commands, and iterate on its own output. This might happen locally in the harness, or it might hand off to cloud-based agents running on the provider layer.</li>
<li><strong>System prompts</strong>: The invisible instructions that shape how the AI behaves. This is a bigger deal than most people realise. The same model can perform dramatically differently depending on the harness it's running in, because each harness ships its own system prompt.</li>
<li><strong>Memory and context</strong>: Persistent memory across sessions, project-level instructions, and the ability to pull in relevant files and documentation automatically.</li>
</ul>
<p>These harness-level capabilities can make a huge difference to your productivity, often more so than the choice of model itself. A great model in a limited harness won't perform as well as a good model in a harness that gives it the right tools and context.</p>
<h3 id="capabilities">Capabilities</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png" alt="Capabilities illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The capabilities layer is what you layer on top of the harness to extend what the AI can do. This has arguably been one of the biggest developments in the last year. It includes:</p>
<ul>
<li><strong>Tools and MCP</strong>: The Model Context Protocol (MCP) has emerged as a standard way to give AI access to external tools — running tests, querying databases, calling APIs, searching the web, interacting with design tools, and more. These tools are increasingly portable: the same MCP server can work across Claude Code, GitHub Copilot, Cursor, and other harnesses.</li>
<li><strong>Instructions and skills</strong>: Project-level instruction files (like <code>.instructions.md</code> or <code>.cursorrules</code>) that tell the AI about your codebase conventions, preferred patterns, and how to approach tasks. Custom skills and agent definitions let you package domain-specific knowledge that the AI can draw on.</li>
<li><strong>Context sources</strong>: Documentation, codebase indexing, knowledge bases, and other reference material that help the AI understand your specific domain and codebase.</li>
</ul>
<p>What makes capabilities a distinct layer (rather than just a feature of the harness) is their portability. You can take the same MCP servers, the same instruction files, and in many cases the same context sources, and use them across different harnesses. Your investment in configuring capabilities isn't locked to a single tool.</p>
<h3 id="model">Model</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-model.png" alt="Model illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-model.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-model.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-model.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-model.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The model is where the "thinking" happens. When you send a prompt, it's the model that interprets your intent, reasons about the problem, and generates tokens in response — whether that's code, prose, or increasingly other modalities like images and audio. Models differ across several key dimensions:</p>
<ul>
<li><strong>Reasoning ability</strong>: How well the model can break down complex problems, plan multi-step solutions, and handle nuanced logic. The emergence of dedicated reasoning modes (like "extended thinking") has been a significant step forward here, though higher reasoning levels consume substantially more tokens.</li>
<li><strong>Code generation quality</strong>: The accuracy, correctness, and idiomatic quality of the code it produces across different languages and frameworks.</li>
<li><strong>Tool use</strong>: How reliably the model can decide when and how to call external tools provided by the harness and capabilities layer, and how well it can structure its output to work with those tools.</li>
<li><strong>Context window</strong>: How much text the model can "see" at once. Larger context windows mean the model can work with bigger codebases without losing track of important details.</li>
<li><strong>Speed</strong>: How quickly the model generates responses. For interactive coding, latency matters, so a slower but more capable model isn't always the best choice for every task.</li>
</ul>
<p>Today's landscape includes several categories of model:</p>
<p><strong>Frontier models</strong> - like Claude, GPT, and Gemini - are the most capable, hosted in the cloud, and accessed via API. They're constantly being updated and represent the cutting edge.</p>
<p><strong>Local models</strong> can run on your own hardware. Tools like Ollama or LM Studio make it straightforward to run open-weight models (e.g. Qwen, Llama, DeepSeek). They're typically less capable than frontier models, but they offer advantages in terms of privacy, cost (no per-token charges), and the ability to work offline. It's worth noting that "open-weight" doesn't always mean fully open — you can download and run the model, but you typically don't know how it was trained or on what data.</p>
<p><strong>Specialist models</strong> are smaller, narrowly focused models tuned for specific tasks: speech-to-text (e.g. Whisper), text-to-speech, classification, summarisation, OCR, and more. While frontier models are incredibly powerful, they're also expensive; for high-volume business tasks, smaller and more cost-efficient models often make more sense.</p>
<p>And you don't have to pick just one. Many harnesses let you switch models on the fly, so you can use a fast, lightweight model for simple tasks and a more powerful frontier model when you need heavy reasoning.</p>
<h3 id="provider">Provider</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-provider.png" alt="Provider illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-provider.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-provider.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-provider.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-provider.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The provider layer is often invisible to individual developers, but it's where many of the most important enterprise concerns live. When an organisation is evaluating AI coding tools, the questions at this layer tend to dominate the conversation:</p>
<ul>
<li><strong>Data residency</strong>: Where are your prompts and context being sent, and where are they processed? For regulated industries, data staying within a specific geographic region can be a hard requirement.</li>
<li><strong>Security and compliance</strong>: Does the provider meet the organisation's security standards? Are prompts and code snippets logged or used for training? What certifications does the provider hold (SOC 2, ISO 27001, etc.)?</li>
<li><strong>Copyright and IP</strong>: There are risks associated with generating code using models trained on public data, or agents retrieving proprietary information. Some providers offer guarantees around IP ownership and indemnity (such as Microsoft's Copilot Copyright Commitment), which can be crucial for commercial use.</li>
<li><strong>Rate limits and availability</strong>: How many requests can you make before hitting throttling? Is there an SLA for uptime? For a team of developers relying on AI throughout the day, rate limits can become a real bottleneck.</li>
<li><strong>Cost management</strong>: Pricing varies significantly, from flat-rate subscriptions to per-token usage billing. At scale, understanding and controlling costs becomes critical. This is compounded by the cost of reasoning: enabling higher reasoning levels on capable models can dramatically increase token consumption.</li>
</ul>
<p>This is where options like <strong>Microsoft Foundry</strong>, and <strong>Amazon Bedrock</strong> come in. They let enterprises access frontier models through their existing cloud provider (though not always within the same infrastructure), with the governance, networking, and compliance controls they already have in place. You get your own dedicated capacity, and billing flows through your existing agreements. Model marketplaces like <strong>Hugging Face</strong> also play a role, providing a catalogue of models (both open-weight and commercial) that can be deployed on your own infrastructure.</p>
<p>For most individual developers, the provider layer is something you don't think about much — it just works. But for teams and organisations adopting AI coding tools at scale, it's often the layer that determines which tools are actually allowed to be used.</p>
<h3 id="bundled-vs.mix-and-match">Bundled vs. mix-and-match</h3>
<p>In practice, you'll see these layers packaged together in different ways. Some products bundle all layers tightly, while others give you the freedom to pick and choose.</p>
<p>For example, a <strong>Claude Pro subscription</strong> bundles everything: the claude.ai chat interface, Claude desktop app, and Claude Code CLI (harness), Anthropic's Claude models (model), and Anthropic's own infrastructure (provider). It's a clean, simple experience — but you're largely locked into Anthropic's choices at every layer.</p>
<p><strong>GitHub Copilot</strong> takes a more flexible approach. You get VS Code / Visual Studio IDE extensions (or Copilot CLI) as your harness, but you can choose from a wide selection of models: Claude, GPT, Gemini, and others. The models are hosted on different providers behind the scenes, but this is abstracted away. You can also bring your own capabilities via MCP servers and instruction files. You can even use alternative harnesses like the Claude SDK from within the Copilot ecosystem, or connect local models.</p>
<p>Then there are tools like <strong>OpenCode</strong> or <strong>Roo Code</strong>, which are open-source harnesses that let you bring your own model <em>and</em> your own provider. You could run a local model on your own hardware, connect to an API key with OpenAI, or point it at an Azure OpenAI deployment your team manages. This gives maximum flexibility, but you're responsible for wiring it all up.</p>
<h3 id="blurring-boundaries">Blurring boundaries</h3>
<p>It's worth noting that the boundaries between these layers are starting to blur. Agentic capabilities that used to live purely in the harness are increasingly being pushed into the model and provider layers. Code can run on your local device, in the cloud, or as a fleet of sub-agents. And you can orchestrate all of it from your IDE, a terminal, or even a mobile device. The mental model is still useful for making decisions, but the sharp lines between layers are softening as the ecosystem matures.</p>
<h2 id="conclusion">Conclusion</h2>
<p>This post isn't intended to recommend a specific tool or combination - what's right for you will depend on your constraints, your team's needs, and the kind of work you're doing. But by understanding that there are four distinct decision points - harness, capabilities, model, and provider - and the trade-offs at each layer, you can make informed choices rather than getting lost in the noise. When someone asks "Should I use Cursor or Claude?", you'll know that's not quite the right question, and you'll know what questions to ask instead.</p>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 5: Reqnroll in Build Pipeline</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 5 covers running your Corvus.Testing specs in Azure DevOps and GitHub Actions pipelines.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline</guid>
      <pubDate>Sat, 11 Apr 2026 06:35:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the previous posts in this series, we introduced the Corvus.Testing.AzureFunctions.ReqnRoll project and showed how you can use the bindings and classes it provides to start functions apps as part of your scenarios and features. We're going to finish with some pointers on how to ensure these functions can run as part of your build pipelines.</p>
<p>Depending on how your build system works, it's relatively easy to ensure that tests using these methods are able to run as part of the build pipeline.</p>
<p>If, like us, you're using Azure DevOps with hosted agents, you'll need to add a step to your pipeline to install the Azure Functions Core Tools. For YAML build definitions, it looks like this:</p>
<pre><code class="language-YAML">- task: Npm@1
  displayName: 'Install Azure Functions V4 Core Tools'
  inputs:
    command: custom
    verbose: false
    customCommand: 'install -g azure-functions-core-tools@4 --unsafe-perm true'
</code></pre>
<p>Once that step has run, the test will be able to execute as it does locally. The <code>Corvus.Testing.AzureFunctions.ReqnRoll</code> library targets .NET 8, which is cross-platform, so this should work on both Windows and Linux build agents.</p>
<p>GitHub Actions is similarly straightforward. Simply add a step to install the Azure Functions Core Tools towards the start of your pipeline.</p>
<p>This approach should also work for other CI servers and their hosted build agents. If you're using a private agent you have the option of installing the tools globally, meaning your build scripts can just assume they are present - this very much depends on how you prefer to manage build agents.</p>
<h2 id="summary">Summary</h2>
<p>For Reqnroll users, the techniques I've shown in these posts will help ensure your integration tests are more complete by ensuring that functions under test are hosted in a way that closely matches the Azure environment they will ultimately run in.</p>
<p>I've tried to keep the posts simple by only covering testing HTTP triggered functions, but these techniques can equally be used to test functions that use other trigger types too. In our projects we've used it to test functions with blob, queue and Event Hubs endpoints, as well as functions using Durable extensions. This will generally require other infrastructure for your integration testing - for example, <a href="https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite">Azurite</a> and/or <a href="https://testcontainers.com/">Testcontainers</a> to make storage available to trigger blob and queue endpoints - but the principles remain the same.</p>
<p>As mentioned above, the Corvus.Testing projects (<a href="https://github.com/corvus-dotnet/Corvus.Testing.ReqnRoll">Corvus.Testing.ReqnRoll</a> and <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a>) are open source and contributions are accepted. If you encounter problems with them, please feel free to raise an Issue - and if you're able, submit a pull request. And if you have any questions or feedback, just ask!</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">5.</span>
                <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 4: Reqnroll Configuration</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 4 shows how to supply or override configuration values for the functions apps under test.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration</guid>
      <pubDate>Sat, 11 Apr 2026 06:34:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the previous posts in this series, we introduced the Corvus.Testing.AzureFunctions.ReqnRoll project and showed how you can use the bindings and classes it provides to start functions apps as part of your scenarios and features. Here, we look at how you can vary the behaviour of those functions apps by providing or overriding configuration values.</p>
<p>When the <code>FunctionsController</code> is used to start a new function, it will check the <code>ScenarioContext</code> (if available) and <code>FeatureContext</code> for an instance of the <code>FunctionConfiguration</code> class. Any configuration provided here will be made available to the functions app when it starts.</p>
<p>If you're using step bindings to start your function, you can do this by writing an additional step binding to provide configuration from wherever you need to retrieve it. Note that this step must come before the one that starts the function. You can see an example in <code>ScenariosUsingStepBindings.feature</code>:</p>
<p>If you're using a <code>BeforeScenario</code> or <code>BeforeFeature</code> hook, you can add the configuration at the same time - as shown in <code>ScenariosUsingPerScenarioHookWithAdditionalConfiguration.feature</code> (and the corresponding hook method in <code>DemoFunctionPerScenario</code>, <code>StartFunctionWithAdditionalConfigurationAsync</code>), as well as the per-feature equivalents.</p>
<p>If you need to use different configuration at different times, you can create separate hook methods for setting up configuration - just ensure you use the <code>Order</code> parameter on the hook attributes to ensure the configuration is set prior to the functions being started.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline">In the next (and final) post in the series, we'll cover how to ensure the tests you've written using the techniques covered in these posts can run in your build pipelines.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">4.</span>
                <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 3: Reqnroll hooks</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 3 uses scenario and feature hooks to start functions apps and keep your BDD specs readable.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions</guid>
      <pubDate>Sat, 11 Apr 2026 06:33:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the first two posts in this series, <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction">we introduced the Corvus.Testing.AzureFunctions.ReqnRoll project</a> and <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions">showed how you can use the Reqnroll step bindings it provides to start functions apps</a> as part of your scenarios. This approach has the drawback of making your scenarios harder to read for non-technical users, so in this post, we're going to take a look at using scenario and feature hooks to address that problem.</p>
<h2 id="using-per-scenario-hooks-to-start-and-stop-functions-as-shown-in-scenariosusingperscenariohook.feature">Using per-scenario hooks to start and stop functions - as shown in <code>ScenariosUsingPerScenarioHook.feature</code></h2>
<p>Reqnroll hooks allow us to add code that's executed at specific points during a test run. With this method, we make use of the <code>BeforeScenario</code> and <code>AfterScenario</code> hooks, and use the <code>Corvus.Testing.AzureFunctions.FunctionsController</code> class directly to start and stop our functions. This can be seen in the <code>DemoFunctionPerScenario</code> class:</p>
<p>The parameters that the <code>StartFunctionsInstance</code> method takes are the same as those shown in the step binding above, allowing you to specify project, port and runtime. You'll see that the created <code>FunctionsController</code> instance is stored in the <code>ScenarioContext</code>; this allows us to pull it out in the <code>AfterScenario</code> method (which you should add yourself, as shown in the test code) to tear down the functions.</p>
<h3 id="advantages-to-this-method">Advantages to this method</h3>
<p>Using this method conceals the technical detail of what the setup step involves, reducing it to a single tag for the function. This makes your scenarios much more readable. If you're writing lots of tests for a specific functions app, it also reduces the duplication needed when every scenario has to contain the setup step.</p>
<p>In addition, test output (and the associated functions output) can be viewed in exactly the same way as above.</p>
<h3 id="disadvantages-to-this-method">Disadvantages to this method</h3>
<p>The main disadvantage to this approach is one that's associated with pretty much all integration testing: speed. Whilst setting up and tearing down all dependencies for each test is the gold standard, spinning up functions takes time and this can mean your test suite takes a long time to execute. In some scenarios this may be unavoidable. However, others may lend themselves to using the third method to strike a balance between speeding up execution and isolating tests.</p>
<h2 id="using-per-feature-hooks-to-start-and-stop-functions-as-shown-in-scenariosusingperfeaturehook.feature">Using per-feature hooks to start and stop functions - as shown in <code>ScenariosUsingPerFeatureHook.feature</code></h2>
<p>Visually, this approach looks extremely similar to the previous method. The scenario definitions are not affected at all and the only differences in the underlying code (other than using <code>BeforeFeature</code> and <code>AfterFeature</code> attributes) being that the <code>FeatureContext</code> is used in place of the <code>ScenarioContext</code> to store and retrieve the <code>FunctionsController</code>. The other difference is that the hook methods themselves need to be static to be used with per-feature hooks - this is a Reqnroll requirement.</p>
<h3 id="advantages-to-this-method-1">Advantages to this method</h3>
<p>If you can group related scenarios and be sure they won't conflict with one another, this is a relatively easy way of speeding up test execution.</p>
<h3 id="disadvantages-to-this-method-1">Disadvantages to this method</h3>
<p>As implied above, this approach does have the potential to cause unexpected results if your tests conflict with one another in any way. The other disadvantage is that because the function output is gathered and written to console when the function is terminated, it can no longer be seen in the test output. If you don't mind duplication, you can get round this by adding an additional <code>AfterScenario</code> hook to write the function output to the console after every scenario.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration">In the next post, we'll show how you can provide additional configuration to functions apps started as part of tests.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 2: Reqnroll step bindings</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 2 uses Corvus.Testing step bindings to start and stop functions apps in your scenarios.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions</guid>
      <pubDate>Sat, 11 Apr 2026 06:32:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction">first post in this series</a>, we introduced the <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library. In this post, we're going to take a look at the simplest way of using it to start functions apps for testing purposes, which is to use the provided step bindings.</p>
<p>This is demonstrated in <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs/AzureFunctionsTesting/ScenariosUsingStepBindings.feature"><code>ScenariosUsingStepBindings.feature</code></a> in the <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs">Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs</a> project.</p>
<p>The Corvus.Testing.AzureFunctions.ReqnRoll project contains a <code>Given</code> step definition for the following pattern:</p>
<pre><code class="language-gherkin">[Given("I start a functions instance for the local project '(.*)' on port (.*) with runtime '(.*)'")]
</code></pre>
<p>If you include steps that match this pattern in your scenario, they will cause the functions defined in the specified project to be run, with HTTP functions listening on the specified port. If your function doesn't actually have any HTTP endpoints you can supply a dummy value for the port. Runtime will most likely be <code>net8.0</code> (for Functions v4), though <code>net9.0</code> and <code>net10.0</code> are also options depending on your target framework.</p>
<p>The project to run is currently resolved by traversing up the folder tree until it finds a folder that, when combined with the function name, runtime and build folder, provides a valid path.</p>
<p>As well as bindings for these steps, there's an additional AfterScenario hook that goes with them to tear down the functions instances they start. You can start multiple functions in a single scenario using these bindings if necessary.</p>
<h3 id="viewing-function-output">Viewing function output</h3>
<p>Once the test run is complete, output from the functions app can be seen in the Test Detail Summary. In Visual Studio, this is visible in the Test Explorer by selecting the scenario that's been executed and clicking the scenario that's been selected:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2020/03/Test-explorer.png" alt="Test Explorer" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2020/03/Test-explorer.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2020/03/Test-explorer.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2020/03/Test-explorer.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2020/03/Test-explorer.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Clicking the link "Open additional output for this result" will show Reqnroll's standard output capture:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2020/03/Test-output.png" alt="Test output window" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2020/03/Test-output.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2020/03/Test-output.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2020/03/Test-output.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2020/03/Test-output.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>As you can see from the screenshot above, this starts with the output from the <code>BeforeScenario</code> binding showing the solution and runtime location. If starting the function failed for some reason, you'd most likely see the reason here.</p>
<p>This is followed by Reqnroll's standard per-step output. Finally the output from the <code>AfterScenario</code> binding is shown, which is where the StdOut and StdErr for each function is added.</p>
<p>Note that the log shown in this window is frequently a truncated version of the whole. If this is the case, you'll see a message explaining how to access the full log by copying and pasting into another tool.</p>
<h3 id="advantages-to-this-method">Advantages to this method</h3>
<p>Using step bindings in this way makes it crystal clear to the developer what's going on as part of their spec. You can easily see which functions are being run and on what ports.</p>
<h3 id="disadvantages-to-this-method">Disadvantages to this method</h3>
<p>Whilst it's nice for developers to see exactly what technical setup is taking place, this does go against the goals of Behaviour Driven Development. Specifically, we should be striving to make the feature readable in the end user's language. When testing an API using a BDD spec, you can make a case that the end user whose language we should be using is a technical one - the consumers of APIs are most likely to be developers - but even so, this is an overly technical step to include in your scenarios.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions">In the next post, I'll show how this problem can be addressed using Reqnroll hooks to start and stop functions apps.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions with Reqnroll Part 1: Introduction</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 1 sets out the testing challenge and introduces the open-source Corvus.Testing library.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction</guid>
      <pubDate>Sat, 11 Apr 2026 06:31:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>If you use Azure Functions on a regular basis, you'll likely have grappled with the challenge of testing them. The testing story for functions is not hugely well defined. If you're building your functions well, then there won't be a lot of code in them - they will be thin facades calling into code that does the bulk of the work, in which case you will likely have used a standard unit testing approach on that code. Nevertheless, that likely leaves some functionality untested - for example, ensuring your models are correctly bound to input, and ensuring that correct status codes, headers and so on are returned from your requests.</p>
<p>As such, it becomes necessary to step up a level and look at how to test the functions as a whole. There are two options for this:</p>
<ul>
<li>You can test in-process, using <a href="https://techcommunity.microsoft.com/blog/fasttrackforazureblog/azure-functions---part-2---unit-and-integration-testing/3769764">the approach defined in Microsoft's docs</a> (note that this doesn't seem to have been updated to take account of functions that use instance methods, but that's unlikely to affect the approach). This is good, but doesn't ensure that your function is configured correctly, and if you're using automatic model binding, it doesn't test that this is working as you expect.</li>
<li>Alternatively, you can test out of process, either against a deployed instance of the function or against one that's running locally. Testing against a deployed instance is a great idea, but this is normally reserved for another level of testing, meant to ensure that things are working as expected in a deployed environment. It doesn't address the needs of the developer as the feedback loop from making a change to deploying a function to Azure is likely just too long. This leaves us with the challenge of testing against a function running locally.</li>
</ul>
<p>So, how do we go about this?</p>
<p>Before I continue I should note that while I'm specifically addressing how to do this with <a href="https://reqnroll.net/">Reqnroll</a>, a very similar approach can be taken with other frameworks. Reqnroll is the community-driven successor to SpecFlow, created by the original SpecFlow creator after SpecFlow reached end-of-life. If you're migrating from SpecFlow, the <a href="https://docs.reqnroll.net/latest/guides/migrating-from-specflow.html">Reqnroll migration guide</a> is a great place to start.</p>
<h2 id="goals">Goals</h2>
<p>As always, it's worth starting with what we want to achieve:</p>
<ol>
<li>We want a way of automatically starting a function, and then shutting it down once the test is completed.</li>
<li>We want this to work in as close a way as possible to a deployed function</li>
<li>Ideally, we want to be able to capture the output from the function while it's running.</li>
<li>It's useful to be able to easily affect the configuration of the function under test.</li>
<li>We want an approach that can work as part of a CI pipeline.</li>
</ol>
<p>So, let's have a look at how we achieve these goals.</p>
<h2 id="running-the-function-locally">Running the function locally</h2>
<p>When you hit F5 to run a function in Visual Studio, it uses a copy of the Azure Functions Core Tools that's managed by Visual Studio. Normally they get automatically installed into <code>C:\Users\username\AppData\Local\AzureFunctionsTools\Releases</code> and Visual Studio selects the correct version to use based on your project's runtime.</p>
<p>However, this is an internal detail of how Visual Studio implements the Functions SDK, so it's not really something we can rely on. Fortunately <a href="https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local">you can install and use Azure Functions Core Tools directly</a>.</p>
<p>We recommend using Azure Functions v4 with the isolated worker model and .NET 8 or later. The isolated worker model is the recommended approach for new Azure Functions projects, and in-process support is scheduled to end in November 2026.</p>
<p>To get the tools installed, you have a few choices. If you're on Windows, you can use winget:</p>
<pre><code class="language-bash">winget install Microsoft.Azure.FunctionsCoreTools
</code></pre>
<p>or Chocolatey</p>
<pre><code class="language-bash">choco install azure-functions-core-tools
</code></pre>
<p>Otherwise, you'll need npm:</p>
<pre><code class="language-bash">npm i -g azure-functions-core-tools@4 --unsafe-perm true
</code></pre>
<p>This will install the tools locally - you can verify they are there using the new <code>func</code> command from the command prompt. If you do this, you'll see all the things you can do with it - scaffolding new functions apps and functions, and running functions locally. The latter is what we're concerned with - you'll see that you can start a new function using the command <code>func start</code>, providing port number and other details as part of the command. This is what we're going to use when setting up our test.</p>
<h2 id="introducing-corvus.testing">Introducing Corvus.Testing</h2>
<p>The code to start, stop and manage functions as part of a Reqnroll test is part of the endjin-sponsored Corvus.Testing libraries. The original <a href="https://github.com/corvus-dotnet/Corvus.Testing">Corvus.Testing</a> repository has been split into separate, focused repos:</p>
<ul>
<li><a href="https://github.com/corvus-dotnet/Corvus.Testing.ReqnRoll">Corvus.Testing.ReqnRoll</a> — general Reqnroll testing utilities</li>
<li><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> — Azure Functions-specific testing classes and bindings</li>
</ul>
<p>The classes that we're interested in are part of <code>Corvus.Testing.AzureFunctions.ReqnRoll</code> and are:</p>
<p><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/blob/main/Solutions/Corvus.Testing.AzureFunctions/Corvus/Testing/AzureFunctions/FunctionsController.cs"><strong>FunctionsController.cs</strong></a> - this contains methods to start a new functions instance, and to tear down all functions it manages. It's intended to live for the lifetime of the test as it captures the output and error streams from the function and write them all to the Console when the functions are terminated. When running in Reqnroll, this results in that information being written to the test's output.</p>
<p><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/blob/main/Solutions/Corvus.Testing.AzureFunctions/Corvus/Testing/AzureFunctions/FunctionConfiguration.cs"><strong>FunctionConfiguration.cs</strong></a> - this is part of the mechanism by which the test project can provide settings to the function under test.</p>
<p><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/blob/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll/Corvus/Testing/AzureFunctions/ReqnRoll/FunctionsBindings.cs"><strong>FunctionsBindings.cs</strong></a> - this provides a couple of standard step bindings that can be used as part of a scenario to start a function.</p>
<p>This code is all open source, and contributions are accepted. It's available under the Apache 2.0 open source license meaning you're free to use and modify the code as you see fit. The license does impose some conditions around retaining copyright attributions and so on - <a href="https://www.apache.org/licenses/LICENSE-2.0">you can read the full details here</a>.</p>
<p>This code ticks the boxes for the first four of the five goals I set out above, providing mechanisms to keep functions running for the duration of test execution, as well as a way to supply additional configuration. The next few sections explain the different ways of using this.</p>
<p>I'll be doing this with reference to the demo projects that are part of the Corvus.Testing.AzureFunctions.ReqnRoll codebase. Before continuing, I recommend downloading the project so you can examine the code. There are two demo functions projects — <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.Demo.InProcess">Corvus.Testing.AzureFunctions.Demo.InProcess</a> for the in-process model and <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.Demo.Isolated">Corvus.Testing.AzureFunctions.Demo.Isolated</a> for the isolated worker model — that contain a slightly modified version of code that's generated when you create a new HTTP-triggered function in Visual Studio. They accept GET and POST requests, looking for a parameter called <code>name</code> in either the querystring or request body, and returning a configurable string containing that parameter.</p>
<p>It also contains a Reqnroll test project, <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs">Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs</a> containing feature files which relate to the following next few posts in this series.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions">In the next post, I'll show you how you can add steps to your Reqnroll scenarios to run your functions apps.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Introduction</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>From Prompt Engineering to AI Programming: Enterprise GenAI Solutions</title>
      <description>Shift from prompt engineering to AI programming by applying rigorous software engineering principles to your LLM integrations.</description>
      <link>https://endjin.com/blog/programming-not-prompting</link>
      <guid isPermaLink="true">https://endjin.com/blog/programming-not-prompting</guid>
      <pubDate>Fri, 13 Mar 2026 05:30:00 GMT</pubDate>
      <category>GenAI</category>
      <category>Generative AI</category>
      <category>AI</category>
      <category>Machine Learning</category>
      <category>Software Engineering</category>
      <category>Engineering Discipline</category>
      <category>LLM</category>
      <category>Prompt Engineering</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/programming-not-prompting.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<p>As organizations race to integrate generative AI into their business workflows, they are hitting a familiar challenge - the gap between a cool demo and a reliable enterprise solution (one that you'd be confident betting the business, or at the very least your reputation on). To bridge this, we must shift the mindset from prompt engineering to rigorous AI programming and systematic evaluation, just like with any other software engineering project.</p>
<h2 id="the-quality-challenge-in-the-age-of-ai">The quality challenge in the age of AI</h2>
<p>Over the last year or so we have seen the world move at 100mph, with AI integration into every product (whether it's a good fit or not!) and organisations eager to deploy LLMs and AI services into production in their own systems and workflows. With frontier models readily available through cloud APIs, and deep enterprise integration into big data platforms through wrapper/portal services like <a href="https://azure.microsoft.com/en-us/products/ai-foundry">Microsoft Foundry</a>, it's very easy to get started and get excited about what's possible.</p>
<p>This is exacerbated by the fact that AI is now pretty much ubiquitous to all consumers of digital products. If business stakeholders are used to being able to get results instantly from native LLM app interfaces on their devices, then expectations are high right from the offset.</p>
<p>But there's a big gap between a PoC and a working, reliable enterprise solution. On the face of it, adding an LLM service to your application is just another API integration, but their behaviour brings with it a new set of engineering concerns that can cause problems if not understood fully.</p>
<p>This post explains what those things are, and why they should be treated the same as any other engineering quality concern, so that you can build AI-integrated solutions with confidence.</p>
<h2 id="a-timeline-of-engineering-quality">A timeline of engineering quality</h2>
<p>Before we dig into the specifics of LLM models and AI services, it's useful to look back at other technology and software architecture patterns, and understand how we thought about them in terms of ensuring quality. The easiest way for me to do that is to look back at my own career. Clearly this won't be a fully comprehensive guide, but there's enough experience in there to highlight commonalities across technology stacks and ecosystems.</p>
<h3 id="establishing-the-foundations">Establishing the foundations</h3>
<p>When I began working as a software developer in 2001, after a brief dabble with Borland Delphi and ASP pages, I quickly found myself immersed in the world of .NET. This was .NET 1.1 territory and <a href="https://en.wikipedia.org/wiki/ASP.NET_Web_Forms">ASP.NET Web Forms</a> (around which there were a lot of strong opinions!). As a framework that was designed to magically generate HTML web pages using a set of server-side components, it was inherently difficult to pull apart business logic from the user interface layer, which made it hard to test.</p>
<p>But what we were building was complicated (<a href="https://en.wikipedia.org/wiki/Transport_Direct_Portal">a government funded, UK-wide, multi-model journey planner, pre-Google Maps</a>) and we needed to prove that it was working correctly. The "auto-magic" that the framework provided allowed for rapid development of things that were simple, but started to get in the way and make things harder as the logic and interactions became more complex. I learned about unit testing as these tools started to become available for .NET (NUnit, MbUnit and then MSTest), and how to refactor code to be able to validate the things we needed to. Having confidence that things were working as they should shifted the dial from slow and brittle feedback loops to rapid, reliable validation cycles.</p>
<p>By the time the ALT.NET movement gained traction towards the end of the 2000's, I was a full-blown <a href="https://en.wikipedia.org/wiki/Test-driven_development">TDD</a> aficionado. Shifting the focus to test-first made for better system design, and along with it came more advanced techniques and tooling like mocking, inversion-of-control containers and continuous integration processes to automate quality gates.</p>
<p>Around this time, I was also lucky enough to attend one of JP Boodhoo's .NET engineering bootcamps in Vancouver, which embedded the value of executable specifications with <a href="https://en.wikipedia.org/wiki/Behavior-driven_development">Behaviour Driven Development (BDD)</a>, highlighting the importance of natural language and encouraging closer collaboration between business and technical teams.</p>
<p>These core principles have been the foundation underpinning all software development I've been involved in since, enabling high-quality delivery of well documented code in iterative development cycles. They've been applied across web and native application stacks and across a variety of architecture patterns. But whilst there's been specific implementations and frameworks that became the flavour of the month according to the language or toolset of the moment, it's the concepts and approach that have always been the thing.</p>
<h3 id="contract-first-observability-and-resilience-in-the-cloud">Contract-first, observability and resilience in the cloud</h3>
<p>Fast forward to the 2010s and the world had moved on to API-first, REST-based architectures. At this point I was leading a small team responsible for building the payment processing engine for a large Middle-Eastern airline. With these APIs any mistakes could literally cost money, so as well as ensuring that we had comprehensive test coverage, we also focused on instrumentation and observability to help us diagnose things in our production environment. This was how I learned (the hard way!) that <a href="https://en.wikipedia.org/wiki/ISO_4217#Minor_unit_fractions">not all currencies have 2 decimal places</a>! And more generally that if you're building an API, you also need a way to execute it. This was pre-Postman, and pre-Swagger, so the only thing to do was build our own version of an API client - a test harness that could be used to execute and validate the various endpoints.</p>
<p>In parallel to this, we were moving everything into the cloud, and started to encountered a new set of quality challenges. Distributed systems brought transient failures, eventual consistency, and the need for sophisticated retry logic.</p>
<p>This experience taught me that integration testing isn't just about verifying that your code works, it's about understanding the contract between systems, the innate behavioural patterns and capabilities of those systems, and building evaluation harnesses that can systematically validate behaviour across different scenarios.</p>
<h3 id="data-machine-learning-and-the-challenge-of-uncertainty">Data, machine learning, and the challenge of uncertainty</h3>
<p>By the time we get to 2015, the cloud had also allowed us to capture a lot more data. And so the landscape shifted again with an increasing focus on data engineering - machine learning, data science, cloud data platforms and advanced analytics. The data space was, and in many ways still is, less mature when it comes to engineering practices. But whilst the challenges were different, I found myself still applying the same "there's always a way to test something" mentality.</p>
<p>With machine learning and data science, the tendency to draw conclusions from patterns in data that are really just random noise could be balanced with <a href="https://endjin.com/blog/machine-learning-the-process-is-the-science">structured experimentation, upfront definition of success metrics and rigorous validation</a>.</p>
<p>Cloud data pipelines that were asynchronous and long-running meant you couldn't just run a quick unit test and get instant feedback. So we needed new approaches - schema validation to <a href="https://endjin.com/blog/creating-quality-gates-in-the-medallion-architecture-with-pandera">catch structural changes and data quality issues early</a>, <a href="https://endjin.com/what-we-think/talks/fake-it-til-you-make-it-generating-production-quality-test-data-at-scale">synthetic data generation</a> to test edge cases that might not appear in production for months and data snapshot testing to validate consistency of pipeline outputs over time. We developed approaches for testing data quality at scale, validating not just that pipelines completed successfully, but that the data they produced was fit for purpose.</p>
<p>The key insight from this is that uncertainty doesn't mean untestable, it just means we need different validation strategies. This sometimes meant shifting from testing specific values to testing behaviours and patterns. My <a href="https://endjin.com/what-we-think/talks/how-to-ensure-quality-and-avoid-inaccuracies-in-your-data-insights">talk at SQL Bits in 2024 - "Do those numbers look right?"</a> summarises how we were thinking about engineering quality in our data solutions, and deep dives into practical approaches for testing Power BI reports, data pipelines and Spark and Python interactive notebooks.</p>
<h2 id="so-are-llms-really-any-different">So are LLMs really any different?</h2>
<p>Yes and no. On the one hand, they're just another integration - either via an API, or through local model deployment. But on the other hand, they exhibit characteristics that require us to think differently about validation.</p>
<p><strong>They're non-deterministic</strong>: Unlike traditional APIs where the same input always produces the same output, LLMs can generate different responses each time due to their probabilistic nature (even when you set the temperature to 0). This makes traditional unit testing approaches which rely on exact output matching ineffective.</p>
<p><strong>They can hallucinate</strong>: LLMs can confidently generate plausible-sounding but false information. Unlike a database query that either returns valid data or throws an error, an LLM might return a well-constructed response that is actually wrong - syntactically correct, but semantically and factually incorrect. This requires us to validate not just the structure of responses, but their factual accuracy and relevance.</p>
<p><strong>They produce qualitative, unstructured responses</strong>: Traditional software returns structured data (JSON objects, numerical values, boolean flags etc.). LLMs return natural language, which is inherently ambiguous and context-dependent. Despite advancements in the area of structured outputs, this still isn't 100% reliable. And how do you write an assertion that validates "the response should be friendly and helpful"?</p>
<p>However, none of these challenges are entirely new. Non-determinism shows up in async operations, race conditions, rate limiting, or time-dependent behaviour. User experience validation can be qualitative in nature. And we've had to deal with integration points that might return unexpected results.</p>
<blockquote>
<p>On that basis we can, and should, apply the same core engineering principles we've always used, albeit adapted to the unique characteristics of LLMs.</p>
</blockquote>
<h2 id="break-open-the-black-box">1. Break open the black box</h2>
<p>Just as ASP.NET Web Forms made it hard to test by tightly coupling UI and logic, LLM integrations can become black boxes if we treat them as magic, closed systems. The solution is the same - refactor to separate concerns. This means treating your LLM interaction as a discrete component with clear inputs and outputs. Which are the configurable bits that you have control over (e.g. the model version, the prompt, the temperature etc.), and which bits are "inside the box" (e.g. the inner workings of the model, system prompts etc.)?</p>
<p>For example, don't embed prompts directly in your application code. Instead, create a prompt management layer that allows you to version, test, and iterate on prompts independently of your application logic.</p>
<p>At endjin, when we build LLM-powered solutions, we structure them so that the prompt construction, model invocation, and response parsing are separate, testable components. This helps to unlock the ability to swap models, adjust parameters, or refine prompts without touching core business logic.</p>
<h2 id="embrace-natural-language-as-a-feature">2. Embrace natural language as a feature</h2>
<p>One of the biggest insights from Behaviour Driven Development was that natural language specifications bridge the gap between business stakeholders and technical teams. LLMs flip this on its head - natural language isn't just the specification, it's also the programming interface.</p>
<p>This could be seen of as an advantage. You can write evaluation criteria in plain English: "<em>The response should identify the customer's primary concern</em>", "<em>The summary should be under 100 words</em>", "<em>The sentiment should be appropriate to the context</em>". Then you can use LLMs themselves to evaluate these criteria. Don't forget that the LLM can also be instructed to return additional numerical or categorical information that can augment the natural language response (for example a confidence level between 0 and 1), which can enable more traditional testing to still be performed.</p>
<p>Taking it a step further, part of the specification could be a feedback loop to improve the specification (akin to getting someone else to review your work) before you execute the steps. This might mean LLMs all the way down, but in a good way.</p>
<p>The key is to be systematic about it. There's possible weaknesses around this approach when you consider the vagaries of language, but creating a feedback loop to explore the context with an LLM can be very powerful. Define your success criteria upfront, create evaluation prompts that assess those criteria, and validate them against labelled examples before you rely on them in production.</p>
<h2 id="test-first-even-for-prompts">3. Test-first, even for prompts</h2>
<p>The discipline of Test-Driven Development teaches us to think about desired outcomes before implementation. This is even more important with LLMs, where it's easy to iterate endlessly on prompts without a clear definition of success.</p>
<p>Start by defining your test cases - specific inputs with expected behaviours. Not exact outputs (remember, we're dealing with non-deterministic responses) but behavioural expectations. For a customer service chatbot, you might want to identify what a complaint is about and make sure the right resolution is offered.</p>
<p>For example:</p>
<pre><code class="language-gherkin">Given the customer comment is "Despite paying extra for speedy postage, the promised delivery date was missed by 3 days!"
When the chatbot generates a customer service response
Then the response should identify the core issue as 'delivery delay'
And the tone should be 'apologetic'
And the resolution offered should be 'refund of premium postage'
</code></pre>
<p>Then iterate on your prompt design until your system reliably meets these criteria. Track your success rate over time. If you're getting 85% success on your test suite, that's a quantifiable baseline you can work to improve. This is infinitely better than the "it seems to work pretty well" approach.</p>
<h2 id="build-or-use-evaluation-harnesses">4. Build or use evaluation harnesses</h2>
<p>Just as we built and used custom API clients to test our APIs, we need evaluation harnesses for LLM integrations. The good news here is that lots of <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/develop/cloud-evaluation?view=foundry&amp;tabs=python">AI developer services have eval frameworks built in</a>. These aren't just test scripts, they're tools that allow you to systematically evaluate performance across multiple dimensions.</p>
<p>Your evaluation harness should:</p>
<ul>
<li>Run your prompts against a diverse set of test inputs</li>
<li>Capture and version the outputs for comparison</li>
<li>Apply multiple evaluation metrics (accuracy, relevance, tone, safety)</li>
<li>Track performance over time as you refine prompts, change models or a <a href="https://www.anthropic.com/engineering/a-postmortem-of-three-recent-issues">model version is incremented</a></li>
<li>Handle transient failures and rate limits gracefully</li>
</ul>
<p>There's comparisons here with machine learning models that are trained / used in Data Science experiments. Whilst you can't do input / output testing, you can test acceptable tolerance ranges, error rates etc.</p>
<h2 id="you-need-labelled-datasets">5. You need labelled datasets</h2>
<p>Just as you can't train a machine learning model without labelled data, you can't validate an LLM integration without example inputs and expected behaviours. Even a small amout of well-chosen examples can provide meaningful validation. But also focus on edge cases and failure modes. What happens when the input is ambiguous? When it's in a different language? When it contains unusual formatting or special characters?</p>
<p>As your system matures, invest in building a larger, more diverse evaluation dataset. Involve domain experts that can set the acceptance criteria of the system. This becomes your regression test suite, allowing you to confidently refine prompts or change models while ensuring you haven't broken existing functionality. Given the volume of tests that will be required, don't underestimate the the level of effort required to curate them. This contributes to the TCO of the solution - production grade LLM solutions are expensive endeavours.</p>
<h2 id="embrace-transient-failures-and-build-resilience">6. Embrace transient failures and build resilience</h2>
<p>LLM APIs, like any external service, can experience transient failures, rate limits, performance degradation or varying response times. Your integration needs to handle these gracefully.</p>
<p>Implement retry logic with exponential backoff. Cache responses where appropriate. Monitor latency and error rates. Build fallback strategies for when the LLM service is unavailable. These are the same patterns we use for any cloud API integration , just applied to a different integration point.</p>
<h2 id="test-behaviours-not-values">7. Test behaviours, not values</h2>
<p>Finally, apply the same patterns for validating data quality - focus on behaviours and patterns rather than specific values. Instead of asserting that a generated email contains the exact phrase "<em>Thank you for your inquiry</em>", check that it:</p>
<ul>
<li>Addresses the customer's stated concern</li>
<li>Maintains an appropriate tone</li>
<li>Includes relevant next steps</li>
<li>Doesn't contain factual inaccuracies</li>
</ul>
<p>This is more robust to the natural variation in LLM outputs, while still catching the failure modes that matter. It's often beneficial to use a separate LLM model or service to do this validation so that you avoid the innate bias used in generating the original output being used to validate it (i.e. don't ask an LLM to mark its own work). This technique is referred to as LLM-as-a-judge.</p>
<h2 id="instrument-and-observe">8. Instrument and observe</h2>
<p>Across all of these approaches, there's a common thread - you need visibility into what's happening. As more work is handed to the LLM, this accentuates the need for human oversight, supervision, quality checks, curation of more examples, responding proactively to new situations (e.g. a new type of enquiry driven by external factors). Instrument your LLM integrations thoroughly. Log inputs, outputs, and evaluation metrics. Track latency, error rates, and cost. Monitor for drift in model behaviour over time.</p>
<p>At endjin, we treat observability as a first-class concern for LLM integrations, just as we do for any other production system.</p>
<h2 id="summary-from-black-box-to-engineering-discipline">Summary: From black box to engineering discipline</h2>
<p>The journey from prompt engineering to AI programming is really a journey from treating LLMs as magic to treating them as engineered components. Yes, they have unique characteristics (non-determinism, hallucinations, qualitative outputs), but these aren't deal-breakers, they're just new constraints that require adapted approaches.</p>
<p>To succeed in deploying reliable, enterprise-grade AI solutions, treat LLM integrations with the same engineering standards that you apply to any other system component. That means systematic evaluation, defensive engineering, instrumentation, and continuous improvement.</p>
<p>At endjin, we've seen this transformation happen with our clients. The shift from "let's see if this prompt works" to "here's our evaluation framework and current performance metrics" represents a fundamental change in how teams think about AI integration. And it's that shift that enables moving from demos to production-ready solutions with confidence.</p>
<p>In subsequent posts, I'll dive deeper into the practical tools and frameworks that make this systematic evaluation possible.</p>]]></content:encoded>
    </item>
    <item>
      <title>Scaling API Ingestion with the Queue-of-Work Pattern</title>
      <description>The queue-of-work pattern enables massive parallelism for API ingestion by breaking large jobs into thousands of independent work items processed by concurrent workers. This approach reduced data ingestion time for our use case from 15 hours to under 2 hours while providing automatic retry handling and fault tolerance at a fraction of the cost of traditional orchestration tools.</description>
      <link>https://endjin.com/blog/scaling-api-ingestion-with-the-queue-of-work-pattern</link>
      <guid isPermaLink="true">https://endjin.com/blog/scaling-api-ingestion-with-the-queue-of-work-pattern</guid>
      <pubDate>Fri, 06 Mar 2026 06:30:00 GMT</pubDate>
      <category>python</category>
      <category>engineering</category>
      <category>data engineering</category>
      <category>pyspark</category>
      <category>synapse</category>
      <category>notebooks</category>
      <category>azure container apps</category>
      <category>azure synapse analytics</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/scaling-api-ingestion-with-the-queue-of-work-pattern.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p>TL;DR; The queue-of-work pattern enables massive parallelism for HTTP based API ingestion by breaking large jobs into thousands of independent work items processed by concurrent workers. This approach reduced our data ingestion time from 15 hours to under 2 hours while providing automatic retry handling and fault tolerance at a fraction of the cost of traditional orchestration tools.</p>
<p>Sample code to go with this blog post <a href="https://github.com/endjin/python-queue-of-work-pattern-demo">can be found in GitHub</a></p>
<h2 id="the-problem-when-sequential-api-calls-take-days">The Problem: When Sequential API Calls Take Days</h2>
<p>It's common in data platforms to need to acquire data from HTTP based APIs. If written well, these APIs will be reliable, fast and will allow you to control filtering, windowing, and pagination. But even if all of this is true, ingesting large amounts of data via an API can be challenging.</p>
<p>We recently faced one of these challenges when building out a new workload on an existing Azure Synapse-based modern data platform: the initial load of data from a source system required us to ingest about 2,000,000 records and the only way of doing this was via an HTTP API. A full synchronization requires tens of thousands of individual API requests, each fetching a page of 100 records of data.</p>
<p>We started by estimating how long this would take with a simple sequential approach. The API averages about 500ms per request. For 20,000 API calls:</p>
<pre><code class="language-text">20,000 requests × 0.5 seconds = 10,000 seconds = 2.8 hours
</code></pre>
<p>We also need to consider the volumes of data being processed. For us, the payload size is generally from 200KB to 1.5MB, with an average of 1MB. So 20,0000 requests yields around 20 GB of data.</p>
<p>That seems manageable, right? But this calculation ignores several real-world factors:</p>
<ul>
<li><strong>Network variability</strong>: Some requests take 2-3 seconds, especially during peak hours</li>
<li><strong>Failures and retries</strong>: Network issues and transient API errors require retry logic</li>
<li><strong>Data processing</strong>: Each response needs parsing, transformation, and storage</li>
</ul>
<p>In reality, a test retrieval of a subset of records suggested we were looking at something in the region of 48 hours to do a full ingestion, accounting for failures and retries.</p>
<p>That's too long. Our requirements were clear:</p>
<ul>
<li><strong>Scalable</strong>: Process massive volumes efficiently through parallelism</li>
<li><strong>Fault-tolerant</strong>: Handle API failures gracefully without losing progress</li>
<li><strong>Fast</strong>: Complete full ingestion in under 12 hours</li>
</ul>
<h2 id="why-not-use-synapse-pipelines">Why Not Use Synapse Pipelines?</h2>
<p>The customer in this scenario already had a well established Azure Synapse platform in place. As a result the first port of call for data ingestion is normally a Synapse pipeline. After all, Synapse is designed for data orchestration, has many built in connectors and is already fully integrated with our data lake. However, we quickly discovered several dealbreakers for this specific use case:</p>
<h3 id="cost-inefficiency">Cost Inefficiency</h3>
<p>Synapse pipelines charge per activity execution (approximately £0.00085 per activity run) and per integration runtime hour. Let's break down the cost for our 40,000 API calls:</p>
<pre><code class="language-text">20,000 activities × £0.00085 = £17 per full ingestion
Integration Runtime: ~£0.18/hour × 48 hours = £8.64
Total: ~£26 per full sync
</code></pre>
<p>While £26 doesn't sound expensive, issues outside our control mean it's likely we'd need to do this several times a year. We'll also be running nightly incremental updates; although these process way smaller amounts of data, there will occasionally be bulk updates in the source system that require larger data volumes to be reingested.</p>
<p>Over a year, this all adds up. But more importantly, Synapse's orchestration overhead makes it unsuitable for high-volume, small operations regardless of cost.</p>
<h3 id="limited-resilience">Limited Resilience</h3>
<p>Synapse pipelines support retry logic, but their error handling is coarse-grained. If one API call in a batch of 1,000 fails, the retry mechanism repeats the entire batch, not just the failed item. There's no built-in concept of a "poison message queue" for persistently failing records.</p>
<p>When we tested this with a deliberately flaky API endpoint, a single bad record caused the same batch to retry indefinitely, blocking progress on thousands of good records. This simply won't work for production ingestion where some records may have data quality issues.</p>
<h3 id="orchestration-overhead">Orchestration Overhead</h3>
<p>Synapse pipelines introduce latency between activity transitions. In our testing, even with an empty pipeline activity, there's typically 3-5 seconds of overhead per activity execution. For 20,000 activities:</p>
<pre><code class="language-text">20,000 activities × 4 seconds overhead = 80,000 seconds = 22 hours
</code></pre>
<p>This overhead alone exceeds our entire time budget. The pipeline execution model is optimized for long-running data transformations, not for coordinating thousands of quick API calls.</p>
<h3 id="lack-of-dynamic-scaling">Lack of Dynamic Scaling</h3>
<p>Scaling Synapse pipeline execution requires manual configuration of integration runtime settings. You can't easily spin up hundreds of concurrent workers dynamically based on queue depth, then scale down to zero when work completes. This inflexibility makes it difficult to optimize both cost and performance.</p>
<h2 id="the-queue-of-work-pattern-a-better-approach">The Queue-of-Work Pattern: A Better Approach</h2>
<p>After ruling out Synapse pipelines, we needed a solution that could handle parallelism without orchestration overhead. The queue-of-work pattern emerged as the ideal approach, and it's surprisingly simple in concept: break the large job into thousands of small, independent work items, put them in a queue, and let multiple workers process them concurrently.</p>
<p>The pattern decouples work distribution from work execution, which turns out to be the key to solving all our challenges. Here's how it works:</p>
<h3 id="pattern-overview">Pattern Overview</h3>
<pre><code class="language-text">┌─────────────────┐
│   Ingestor      │  1. Breaks large job into small work items
│   (Enqueuer)    │  2. Enqueues items to Azure Storage Queue
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Azure Storage  │  3. Durable, distributed queue
│     Queue       │  4. Supports automatic retry &amp; poison handling
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│     Queue       │  5. Dequeues messages
│   Processor     │  6. Dispatches to work item processors
│   (Workers)     │  7. Processes API calls
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│   Data Lake     │  8. Writes results
│   Storage       │
└─────────────────┘
</code></pre>
<h3 id="key-components">Key Components</h3>
<ol>
<li><p><strong>Work Items</strong>: Strongly-typed dataclasses that represent individual units of work (e.g., "fetch assets with IDs 1000-5999")</p>
</li>
<li><p><strong>Work Queue</strong>: An abstraction over Azure Storage Queues that handles serialization, dequeuing, and poison message management. Using an abstraction makes testing simpler.</p>
</li>
<li><p><strong>Work Item Processors</strong>: Decorated functions that execute the actual work (API calls, data transformation, persistence)</p>
</li>
<li><p><strong>Queue Processor</strong>: A loop that dequeues messages, dispatches them to the appropriate processor, and handles errors</p>
</li>
<li><p><strong>Work Item Dispatcher</strong>: A registry that maps work item types to their processors using decorator-based registration</p>
</li>
</ol>
<h2 id="how-the-pattern-solves-our-challenges">How the Pattern Solves Our Challenges</h2>
<h3 id="scalability-through-parallelism">1. Scalability Through Parallelism</h3>
<p>The first benefit of this pattern became apparent when we broke down the ingestion workload. Instead of processing 1 million assets sequentially, we could divide them into manageable chunks:</p>
<pre><code class="language-python"># Breaking down 1 million assets into 200 independent work items
max_asset_id = 1_000_000
batch_size = 5_000

for start_id in range(0, max_asset_id, batch_size):
    work_queue.enqueue(
        IngestItemsByIdWorkItem(
            snapshot_time=timestamp,
            correlation_id=correlation_id,
            from_id=start_id,
            to_id=start_id + batch_size - 1
        )
    )
</code></pre>
<p>This creates 200 independent work items, each responsible for 5,000 assets. Why 5,000? We tested different batch sizes and found this offered the best balance - large enough to amortize queue overhead, small enough that a single failure doesn't waste too much work.</p>
<p>Now here's where it gets interesting. With these work items in the queue, we can run multiple queue processors simultaneously:</p>
<ul>
<li>Each processor dequeues and processes messages independently</li>
<li>No coordination overhead between workers - they don't even know about each other</li>
<li>Azure Storage Queues handle concurrent access automatically with optimistic concurrency</li>
<li>Scale up the number of workers simply by deploying more instances</li>
</ul>
<p>In our production environment, we run this as Azure Container Apps Jobs. We did some testing to find out what level of concurrency can be supported by the target API - sending too many concurrent requests would overload the API and cause failures to spike. As a result of this, during a full ingestion we typically scale to 12 concurrent workers. The math works out well:</p>
<pre><code class="language-text">20,000 API calls ÷ 12 workers = 1,666 API calls per worker
1,666 calls × 0.5 seconds average = 833 seconds = 14 minutes
</code></pre>
<p>In practice, we see full ingestion complete in under 2 hours due to API variability and processing overhead, but this is still a massive improvement over the roughly 48 hours sequential approach.</p>
<h3 id="fault-tolerance-through-retry-and-poison-queues">2. Fault Tolerance Through Retry and Poison Queues</h3>
<p>One of the most challenging aspects of large-scale API ingestion is handling failures gracefully. APIs are unreliable - they timeout, return 500 errors, get rate-limited, or occasionally return malformed data. We needed the system to handle these failures without manual intervention.</p>
<p>Azure Storage Queues provide built-in retry semantics through visibility timeouts and dequeue counts. Here's how we leveraged them:</p>
<pre><code class="language-python">class AzureStorageWorkQueue:
    def __init__(self, queue, poison_queue, max_dequeues=5):
        self._queue = queue
        self._poison_queue = poison_queue
        self._max_dequeues = max_dequeues

    def dequeue(self):
        # Visibility timeout of 300 seconds (5 minutes)
        message = self._queue.receive_message(visibility_timeout=300)

        # Automatic poison message handling
        if message and message.dequeue_count &gt; self._max_dequeues:
            # This message has failed 5+ times, move it to poison queue
            self._poison_queue.send_message(message.content)
            self._queue.delete_message(message)
            return None  # Skip this problematic message

        return DequeuedWorkItem(
            work_item=jsonpickle.decode(message.content),
            dequeued_message=message
        )
</code></pre>
<p>Note - to keep the code simpler here, I haven't shown any additional error handling logic around the calls to the storage API. Depending on the exact logic and control mechanisms you have an place, you should consider adding error handling and retry logic around API calls like <code>queue.receive_message</code>.</p>
<p><strong>How this works in practice:</strong></p>
<p>When a worker dequeues a message, it becomes invisible to other workers for 5 minutes. If the worker successfully processes it, the message is deleted. If the worker crashes or the API call fails, the message automatically becomes visible again after 5 minutes for another worker to retry.</p>
<p>Azure Storage Queues track how many times each message has been dequeued. After 5 failed attempts, we consider the message "poisoned". This is a standard term in message processing systems that refers to a message which has repeatedly failed to be dispatched or processed, likely due to bad data or a systematic issue. We don't want to waste resources by continually trying to process the message, but we don't want to lose it either so we move it to a separate "poison queue" for manual investigation.</p>
<p>It should be noted here that even if you've chosen a finite TTL for your message queue, you should ensure that messages on the poison queue never expire by setting their TTL to -1.</p>
<p>This approach has proven remarkably resilient, and it's unusual to see runs end with messages on the poison queue. We've added a check at the end of processing to raise an alert if this does happen so that the bad messages can be investigated. Options also exist inside Azure to monitor message queues for other scenarios - e.g. an excessive number of messages in either queue - and raise alerts.</p>
<p>This approach also aids recovery in the event of a catastrophic failure. For example, during one ingestion run, the source API had a 6-hour outage. This meant that the majority of the queue messages ended up on the poison queue. Once we realised what had happened, we were able to quickly move all of the messages from the poison queue back to the processing queue and rerun them.</p>
<h3 id="speed-through-asynchronous-processing">3. Speed Through Asynchronous Processing</h3>
<p>The queue processor implementation is deliberately simple, which turns out to be a performance advantage:</p>
<pre><code class="language-python">def process_queue(correlation_id, work_queue, logger, ...):
    while not queue_empty:
        message = work_queue.dequeue()
        if message is None:
            queue_empty = True
            continue

        try:
            # Dispatch to appropriate processor
            WorkItemDispatcher.dispatch_work_item(
                message.work_item,
                logger,
                **kwargs
            )
            # Success: remove from queue
            work_queue.remove(message)
        except Exception as e:
            # Failure: message stays in queue for retry
            logger.error(f"Error processing: {e}")
</code></pre>
<p>This simple loop runs on each worker container. There's no complex orchestration, no distributed locking, no coordination between workers. Each worker independently:</p>
<ol>
<li>Dequeues a message (blocking call, returns when message available)</li>
<li>Processes it</li>
<li>Deletes the message on success</li>
<li>Repeats until the queue is empty</li>
</ol>
<p>We measured the queue overhead itself - dequeue plus delete operations - at consistently under 50ms. This means for a typical 500ms API call, only 10% of the time is queue overhead. For slower API calls (2-3 seconds), the overhead becomes negligible.</p>
<p>The pattern also enables progressive completion. Unlike batch systems where you wait hours to see any results, data appears in the data lake as soon as each work item completes. This was particularly valuable during testing - we could verify the data pipeline was working correctly within minutes rather than waiting for a full ingestion to complete.</p>
<h3 id="clean-separation-of-concerns">4. Clean Separation of Concerns</h3>
<p>The dispatcher pattern keeps code maintainable:</p>
<pre><code class="language-python"># Define a work item type
@dataclass
class IngestItemsByIdWorkItem(WorkItem):
    from_id: int
    to_id: int

# Register its processor with a decorator
@work_item_processor(IngestItemsByIdWorkItem)
def process_items_by_id(work_item, logger, **kwargs):
    api_client = kwargs["api_client"]
    writer = kwargs["writer"]

    # Fetch data from API
    response = api_client.get_items(
        build_query_params(work_item.from_id, work_item.to_id)
    )

    # Persist to data lake
    writer.persist_assets(work_item.snapshot_time, response)
</code></pre>
<p>Benefits:</p>
<ul>
<li>Each processor focuses on one specific task</li>
<li>Easy to add new work item types without modifying existing code</li>
<li>Type safety through Python dataclasses</li>
<li>Testable in isolation</li>
<li>Clear dependency injection through kwargs</li>
</ul>
<h2 id="important-considerations">Important Considerations</h2>
<p>Before implementing this pattern, there are several important factors to consider:</p>
<p><strong>Data must be able to be partitioned</strong>: Since all the work is enumerated up front, you need a way of partitioning your data into a large number of small chunks. For our full ingestion, we chose ID ranges and for our incremental updates we chose time periods. This will be driven in part by the querying options supported by the API.</p>
<p><strong>Idempotency is critical</strong>: Since messages can be retried automatically, your processors must be idempotent. If a worker crashes after persisting data but before deleting the message, another worker will process the same message again. In our implementation, we write data to the data lake with consistent file paths based on the work item parameters, so re-processing simply overwrites the existing file with identical data.</p>
<p><strong>Message size limits</strong>: Azure Storage Queue messages are limited to 64 KB. Our work items are small (typically &lt;1 KB when serialized), but if you need to pass large payloads, consider storing the data in blob storage and passing a reference in the message.</p>
<p><strong>Visibility timeout tuning</strong>: The 5-minute visibility timeout works well for our API calls, but you'll need to adjust this based on your workload. Too short and messages might be retried while still being processed (duplicate work). Too long and failed messages won't be retried quickly enough.</p>
<p><strong>Cost considerations</strong>: While Azure Storage Queue costs are minimal (~£0.00028 per 10,000 operations), container runtime costs can add up. With 50 workers running for 2 hours:</p>
<pre><code class="language-text">12 workers × 2 hours × £0.0001/vCPU-second × 0.5 vCPU × 3600 seconds
= approximately £4.32 per full ingestion
</code></pre>
<p>This is cheaper than the Synapse cost but provides much better performance and flexibility.</p>
<p><strong>Avoid sensitive data in messages</strong>: Work items should contain only metadata (IDs, timestamps, pagination offsets), not actual sensitive data. The actual data from API responses goes directly to the data lake, not through the queue.</p>
<h2 id="real-world-implementation-example">Real-World Implementation Example</h2>
<p>Let's walk through a complete flow showing how these components work together for ingesting data:</p>
<h3 id="step-1-enqueue-work-main-orchestrator">Step 1: Enqueue Work (Main Orchestrator)</h3>
<p>The first step runs in a dedicated "enqueuer" container that breaks the full ingestion job into work items:</p>
<pre><code class="language-python">class BronzeIngestor:
    def enqueue_full_ingestion(self, snapshot_time, correlation_id):
        # First, query the API to determine the scope of work
        # This makes a single API call to get the maximum asset ID
        max_asset_id = self._get_maximum_asset_id()

        # Break into batches of 5,000 assets each
        batch_size = 5_000
        current_id = 0

        while current_id &lt; max_asset_id:
            self._work_queue.enqueue(
                IngestItemsByIdWorkItem(
                    snapshot_time=snapshot_time,
                    correlation_id=correlation_id,
                    from_id=current_id,
                    to_id=current_id + batch_size - 1
                )
            )
            current_id += batch_size
</code></pre>
<p>This enqueuing process typically completes in under a 30 seconds for 20,000 work items. The <code>snapshot_time</code> ensures all workers use the same timestamp for file paths, making the data lake files consistent. The <code>correlation_id</code> ties all work items to the same ingestion job for tracing.</p>
<p>Depending on how many items you end up needing to enqueue, and what else lives in your queue, you will also need to consider error and recovery scenarios here. What if you fail half way through enqueuing work items? Whilst the items themselves need to be idempotent, we don't want to put multiple of the same item on the queue if we can avoid it.</p>
<p>If you have one queue per process, then the simplest option will be to ensure the queue is empty before you start processing, and have a recovery process which clears messages down in case of error.</p>
<h3 id="step-2-process-queue-worker-containers">Step 2: Process Queue (Worker Containers)</h3>
<p>Multiple worker containers run simultaneously, each executing the same code but processing different messages:</p>
<pre><code class="language-python">class BronzeIngestor:
    def process_queue(self, correlation_id):
        # Set up dependencies that processors will need
        processor_kwargs = {
            "api_client": self._api_client,
            "writer": self._bronze_writer
        }

        # This runs until the queue is empty
        # Each worker container runs this independently
        process_queue(
            correlation_id=correlation_id,
            work_queue=self._work_queue,
            logger=self._logger,
            get_processor_kwargs=lambda msg: processor_kwargs
        )
</code></pre>
<p>The <code>process_queue</code> function (shown in the "Speed" section above) is just a simple loop. When the queue is empty, the worker exits gracefully.</p>
<h3 id="step-3-execute-work-registered-processor">Step 3: Execute Work (Registered Processor)</h3>
<p>The dispatcher routes each work item to its registered processor. The <code>@work_item_processor</code> decorator registers this function to handle <code>IngestItemsByIdWorkItem</code> instances:</p>
<pre><code class="language-python">@work_item_processor(IngestItemsByIdWorkItem)
def process_items_by_id(work_item, logger, **kwargs):
    api_client = kwargs["api_client"]
    writer = kwargs["writer"]

    # Each work item handles a range of IDs (e.g., 1000-5999)
    # But the API paginates responses, so we need an inner loop
    offset = 0
    limit = 100  # API returns 100 assets per page

    while True:
        # Make API call for one page of results within this ID range
        response = api_client.get_items(
            build_query_params(
                work_item.from_id,
                work_item.to_id,
                offset,
                limit
            )
        )

        assets = json.loads(response)["assets"]
        if not assets:
            break  # No more results, we're done with this work item

        # Write this page to the data lake
        # File path includes snapshot_time for consistency across workers
        writer.persist_assets(
            work_item.snapshot_time,
            work_item.from_id,
            work_item.to_id,
            offset,
            response
        )

        offset += limit
</code></pre>
<p>For a work item covering IDs 1000-5999 (5,000 assets), this typically makes 50 API calls (100 assets per page). The entire work item takes about 25 seconds to process, which fits comfortably within the 5-minute visibility timeout.</p>
<h3 id="step-4-experimentation-and-tuning">Step 4: Experimentation and tuning</h3>
<p>Once you have a working implementation, then some experimentation is needed to validate and tune batch sizes and the amount of parallelism you can support.</p>
<p>When considering batch sizes, there are a variety of factors to take account of. Firstly, how many API requests will be required to ingest a single batch? As mentioned above, our 5,000 asset batches result in around 50 API calls.</p>
<p>If any one of them fails, they will all be retried. If you can't rely on the API to consistently ingest an entire batch of data without failure, you should tune your batch size to minimise the impact of this.</p>
<p>Also, how much data will the API return in a single batch? If you need to process an entire batch of data at once (even if it requires multiple API calls to retrieve), and your payload size is large, will you have enough memory to process it all?</p>
<p>How long will a single batch take to process? This has an effect on your queue visibility timeout; if your batch takes 5 minutes to process but your queue visibility timeout is 2 minutes, this means different instances of your queue processor will likely end up processing the same message.</p>
<p>When considering parallelism, this is mainly down to the API you're ingesting from. Depending on your deployment architecture (discussed below) the overall cost for the process may be similar regardless of whether you run 10, 20 or 50 processors in parallel. However, the API you're ingesting from might not be able to cope with this - at worst, you could end up crashing the API, or making it unresponsive for other users. In our scenario, the API we retrieve the data from is the same as that used by the front end application, so we needed to avoid this.</p>
<p>Clearly the two factors are linked. For example when evaluating the API we are ingesting from we discovered that:</p>
<ul>
<li>larger batch sizes are more efficient because the system behind the API caches the query, so retrieving subsequent pages for a batch is relatively fast compared to retrieving the first page.</li>
<li>smaller page sizes are better, as the API struggled to serialize large pages of data quickly.</li>
<li>the API could reliably cope with running 12 processors in parallel; more than that started to significantly impact production performance for other users. However, we also established that we got better results when running the ingestion process outside working hours.</li>
</ul>
<p>This experimentation process needs to be done carefully. If you are experimenting on a production API, it's likely you need to work with the owners to ensure you don't end up rendering their API unsable. If you're working in a sandbox environment, you need to bear in mind that it will likely have a different allocation of compute resources to the production environment, so you may need to include levers to allow you to tune the process once you reach production.</p>
<p>Underpinning this experimentation is ensuring you have baked in observability so that you can evaluate the resulting telemetry to inform your decision - more on this later.</p>
<h2 id="deployment-architecture">Deployment Architecture</h2>
<p>In production, this pattern runs on Azure Container Apps, which provides the perfect hosting model for this workload.</p>
<h3 id="container-jobs">Container Jobs</h3>
<p>Azure Container Apps Jobs are designed for workloads that run to completion then exit - exactly what we need.</p>
<p>We have a single container job which is called with arguments to either run the enqueuing or processing. The job can be started multiple times to support parallelism.</p>
<h3 id="container-size">Container size</h3>
<p>ACA offers a variety of combinations of CPU and memory, which allows you to select an appropriate. The exact options depend on whether you're using a Dedicated plan or a Consumption one. We used a Consumption plan, which allows you to size your container from as small as 0.25 cores and 0.5 GB RAM up to 4 cores and 8 GB RAM (as of March 2026).</p>
<p>Since we're not doing a lot of processing, but we are processing reasonably large chunks of data, we chose the 0.5 core and 1 GB RAM option, and it's serving us well. As with most other things, choosing the right container size is a matter of doing some initial calculations and then experimenting until you achieve consistent performance and reliability.</p>
<h2 id="orchestration">Orchestration</h2>
<p>Although we're running the enqueuing and processing logic in Azure Container apps, we're still orchestrating things via a Synapse pipeline. We chose this route for consistency; we're orchestrating all of the other processes in our data platform via Synapse pipelines and we didn't want to introduce other approaches.</p>
<p>We've created a pipeline that can trigger an Azure Container App Job, passing in the necessary parameters. This can run either as a fire-and-forget process, or it can poll for job completion. We use the former for the full ingestion, and the latter for the incremental ingestion as this allows us to immediately trigger data processing once ingestion is complete.</p>
<h3 id="cost-optimization">Cost Optimization</h3>
<p>Container Apps Jobs only consume resources while running:</p>
<ul>
<li>Enqueue job: Runs for ~1 minute, costs negligible</li>
<li>Worker jobs: Run for ~2 hours during full ingestion</li>
<li>Zero cost when idle - no minimum running instances required</li>
</ul>
<p>This is significantly more cost-effective than keeping functions warm or maintaining always-on compute resources.</p>
<h3 id="scaling-strategy">Scaling Strategy</h3>
<p>While Azure Container Apps supports automatic queue-based scaling using KEDA, we've found that starting with a fixed number of workers (12) works well for predictable workloads. For unpredictable workloads, especially when the target API can support a higher rate of requests than ours, you could configure scaling rules based on queue depth:</p>
<pre><code class="language-text">Queue depth &gt; 1000 messages → Scale to 50 workers
Queue depth &lt; 100 messages → Scale to 10 workers
Queue empty → Scale to 0
</code></pre>
<h3 id="observability">Observability</h3>
<p>OpenTelemetry tracing provides end-to-end visibility. Each work item creates its own trace span, making it easy to:</p>
<ul>
<li>Identify slow API endpoints</li>
<li>Track which work items failed and why</li>
<li>Identify failure patterns</li>
<li>Measure end-to-end ingestion duration</li>
<li>Correlate all work items for a specific ingestion job using the correlation_id</li>
</ul>
<h2 id="benefits-beyond-the-original-requirements">Benefits Beyond the Original Requirements</h2>
<p>While we initially focused on solving the core challenges of scale, fault tolerance, and speed, the pattern has delivered several unexpected benefits:</p>
<p><strong>Incremental ingestion came for free</strong>: Once the framework was in place, adding incremental ingestion was trivial. We created a new work item type (<code>IngestItemsModifiedSinceWorkItem</code>) that queries for recently modified assets instead of ID ranges. The dispatcher, queue processing, and retry logic all work identically.</p>
<p><strong>Debugging became significantly easier</strong>: When something goes wrong, the poison queue contains the exact work item that failed, complete with all parameters. We can inspect it, fix the underlying issue (often a data quality problem in the source system), then manually re-queue it without reprocessing thousands of successful items. This has saved hours of debugging time.</p>
<p><strong>Testing improved dramatically</strong>: Previously, testing the full ingestion pipeline required running against the actual API or building complex mocks. Now we can test individual processors in isolation by constructing work items with test data. Integration tests can enqueue a few work items and verify the results without processing the entire dataset.</p>
<p><strong>Cost visibility is excellent</strong>: Azure Storage Queue operations cost approximately £0.28 per million operations. For our 20,000-message ingestion, that's less than £0.01 in queue costs. The predictable, pay-per-use model makes capacity planning straightforward.</p>
<p><strong>Ability to modify the structure of the written data</strong>: Data is received from our source system arrives as partially formatted JSON. This can cause issues with some processing libraries, such as Polars. We ended up rewriting the retrieved data using the ndjson format, which Polars can process with no issues.</p>
<h2 id="when-to-use-this-pattern">When to Use This Pattern</h2>
<p>The queue-of-work pattern is ideal when:</p>
<ul>
<li>You need to make thousands or millions of API calls</li>
<li>Work can be divided into independent, idempotent units</li>
<li>Fault tolerance is critical (some items may fail, but others should continue)</li>
<li>You want to scale horizontally by adding more workers</li>
<li>Processing time per item varies significantly</li>
<li>You need to prioritize certain types of work</li>
</ul>
<p>It may be overkill for:</p>
<ul>
<li>Small-scale operations (&lt; 100 items)</li>
<li>Tightly coupled sequential processing</li>
<li>Real-time streaming (consider event-driven architectures instead)</li>
<li>Simple ETL where Synapse pipelines or Data Factory are sufficient</li>
</ul>
<h2 id="expansion-to-a-second-data-source">Expansion to a second data source</h2>
<p>We've since extended the pattern to a second data source in the same solution, Amazon Elasticsearch. This required us to go through the same process of evaluation and experimentation again, and brought with it a new consideration.</p>
<p>This time, the main benefit of the pattern is not in error handling, as the Elasticsearch service is highly resiliant and less susceptible to transient errors. However, it is highly scalable so means we can use a much higher degree of parallelism than with our original service.</p>
<p>Finally, size of individual data items is relatively small, but the volume of items we are retrieving is high, as each item represents a user event in the system. Regardless, we found that retrieving a day's worth of data at a time worked well.</p>
<p>Because of the high degree of parallelism permitted, a full ingestion of several year's worth of data takes seconds rather than the hours it would likely take if using a Synapse pipeline with an HTTP connector.</p>
<p>However, we had an additional consideration for this source; an IP allow-list on the service. In Synapse pipelines, this is dealt with using a Self Hosted Integration Runtime (SHIR) to allow you to ingest data from a well-known IP address. Fortunately, we already had the necessary infrastructure in place in our Azure Container App Environment, but this is something to be wary of when introducing additional moving parts into a solution.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The queue-of-work pattern transformed what initially seemed like an intractable data ingestion problem into a manageable, scalable solution. By decoupling work distribution from execution through Azure Storage Queues, we achieved the performance and reliability goals that seemed out of reach with traditional orchestration approaches.</p>
<p>The pattern has run in production for since mid 2024, ingesting large volumes of records weekly from multiple source systems. It's proven robust enough that ingestion failures are now rare, and when they do occur, the poison queue pattern makes debugging straightforward.</p>
<p><strong>Key takeaways from this implementation:</strong></p>
<ol>
<li><strong>Plan for the worst case</strong>: In a perfect world, we could potentially have lived with the sequential ingestion, as we'd only need to do the full ingestion once and then keep everything in sync. However, there are plenty of error scenarios, and these are what you need to make sure your process can handle.</li>
<li><strong>Simple is fast</strong>: The basic queue-and-worker model introduces minimal overhead (&lt;50ms per operation) compared to complex orchestration systems</li>
<li><strong>Built-in resilience is valuable</strong>: Azure Storage Queues' native retry semantics and dequeue counting eliminated the need for custom retry logic</li>
<li><strong>Horizontal scaling works</strong>: Adding workers linearly improves throughput without coordination overhead</li>
<li><strong>Progressive completion aids debugging</strong>: Seeing results immediately rather than waiting hours made development and testing significantly faster</li>
<li><strong>Determining batch sizes and concurrency limits is an experimental process</strong>: It will likely take time to determine the limits of the API you are using and there will be a number of factors to take into account. If you have a sandbox environment for the API, it will likely have different characteristics to production. And even if you find the limits of concurrency that an API can support, the owners of that API will likely not want you to push it to the limit as this could negatively impact other users.</li>
</ol>
<p><strong>When this pattern works well:</strong></p>
<p>This approach excels when you can break work into independent units that don't depend on each other's results. The API ingestion scenario is ideal - fetching assets with IDs 1000-5999 has no dependency on fetching assets 6000-11999. If your workload requires sequential processing or complex dependencies between tasks, this pattern may not be the best fit.</p>
<p>If you've made it this far, thanks for reading! If you've got questions about implementing this pattern or would like to discuss your specific use case, feel free to leave a comment below. And as a final reminder, sample code to go with this blog post <a href="https://github.com/endjin/python-queue-of-work-pattern-demo">can be found in GitHub</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET v7 and Futures On .NET Live talk and demos</title>
      <description>In a recent On .NET Live stream, Ian Griffiths talked about recent developments in Rx.NET and plans for v7 and future versions. This post explains where to find the demo code for that session.</description>
      <link>https://endjin.com/blog/rx7-ondotnet-live-demos</link>
      <guid isPermaLink="true">https://endjin.com/blog/rx7-ondotnet-live-demos</guid>
      <pubDate>Fri, 27 Feb 2026 06:30:00 GMT</pubDate>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Rx</category>
      <category>Rx.NET</category>
      <category>Rx7</category>
      <category>Reactive</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/rx-dotnet-v7-ondotnet-live-demos.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>The <a href="https://dot.net/live">On .NET Live</a> team recently had me on as a guest to talk about Rx.NET v7 and future plans for Rx. You can see the <a href="https://endjin.com/what-we-think/talks/reactive-extensions-for-dotnet-rxdotnet-v7-and-futures">talk</a> here:</p>
<p></p><div class="responsive-video pull-wide"><iframe src="https://www.youtube.com/embed/8OAvFiczZ2k" frameborder="0" allowfullscreen=""></iframe></div><p></p>
<p>I showed some demos during the talk, and there was a request in the chat to make the source available. You can find the demos here:</p>
<p><a href="https://github.com/endjin/rx-ondotnetlive-demos-2026">https://github.com/endjin/rx-ondotnetlive-demos-2026</a></p>
<h2 id="ais.net-rx.net-and-wpf">AIS.NET, Rx.NET and WPF</h2>
<p>The <a href="https://www.youtube.com/watch?v=8OAvFiczZ2k&amp;t=296s">first demo</a> (source code at <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/main/src/WpfAis"><code>src/WpfAis</code></a>) presents a map showing the current position of ships around the Norwegian coast:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/aisrxwpf.png" alt="Windows application showing a map of Norway with various coloured arrows on it indicating the location of ships, each labelled with the ship's name" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/02/aisrxwpf.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/02/aisrxwpf.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/02/aisrxwpf.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/02/aisrxwpf.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This uses a data source provided by the Norwegian government. They monitor <a href="https://en.wikipedia.org/wiki/Automatic_identification_system">AIS</a> data broadcast by ships around their coast, and make these messages available on a public endpoint. Endjin provides an open source suite of libraries collectively called <a href="https://github.com/ais-dotnet">Ais.Net</a> that can be used to process data of this kind. This example uses the <a href="https://www.nuget.org/packages/Ais.Net/">Ais.Net NuGet package</a> to process the raw messages. It also uses the <a href="https://www.nuget.org/packages/Ais.Net.Receiver/">Ais.Net.Receiver package</a> to retrieve messages from the Norwegian government's service, and to present those through Rx.NET.</p>
<div class="aside"><p>Endjin maintains AIS.NET. I last wrote about it in a recent blog post about the <a href="https://endjin.com/blog/how-dotnet-10-boosted-ais-dotnet-performance-by-7-percent-for-free">performance improvements .NET 10 has brought to this library</a>.)</p>
</div>
<p>This example shows how Rx.NET can be used to process live data streams declaratively. In particular, this effectively performs a 'join' over two kinds of message. Ships broadcast their names and types in different messages from the ones they use to report their locations (because their name and type tends to change a lot less often than their location). But the UI wants to combine this information so that it can display the ship's name and type over its location icon in the map.</p>
<p>The demo's viewmodel <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/4707e72bab9ed6380499b4007895bb66b09a19d2/src/WpfAis/WpfAis/MapViewModel.cs#L30-L40">expresses this declaratively</a> in Rx.NET:</p>
<pre><code class="language-cs">IObservable&lt;IGroupedObservable&lt;uint, IAisMessage&gt;&gt; byVessel =
    receiverHost.Messages.GroupBy(m =&gt; m.Mmsi);
var vesselNavigationWithNameStream =
    from perVesselMessage in byVessel
    let vesselNavigationUpdates = perVesselMessage.OfType&lt;IVesselNavigation&gt;()
    let vesselNames = perVesselMessage.OfType&lt;IVesselName&gt;()
    let shipTypes = perVesselMessage.OfType&lt;IShipType&gt;()
    let vesselLocationsWithNames = Observable.CombineLatest(vesselNavigationUpdates, vesselNames, shipTypes,
        (navigation, name, type) =&gt; (navigation, name, type))
    from vesselLocationAndName in vesselLocationsWithNames
    select (mmsi: perVesselMessage.Key, vesselLocationAndName.name, vesselLocationAndName.navigation, vesselLocationAndName.type);
</code></pre>
<p>This uses the C# <a href="https://learn.microsoft.com/en-us/dotnet/csharp/linq/get-started/query-expression-basics">query expression syntax</a> to describe the processing we require. The Rx.NET library does all the actual work for us. (If you'd like more information about how this works, I've shown a version of this query before at <a href="https://youtu.be/K5A3uP75XNQ?t=1154">this talk</a>.)</p>
<h2 id="system.linq.async.net-10-and-system.linq.asyncenumerable">System.Linq.Async, .NET 10, and System.Linq.AsyncEnumerable</h2>
<p>During the show, I <a href="https://youtu.be/8OAvFiczZ2k?list=PLdo4fOcmZ0oV2fcY7wsQHx4RNWXEDKgm4&amp;t=1467">talked about</a> how for many years, the de facto implementation of LINQ for <code>IAsyncEnumerable&lt;T&gt;</code> <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a> was not, despite how the name makes it look, an officially supported library. It has always lived in the Rx.NET repo, and when we at endjin took over maintenance of Rx.NET, that meant we also became responsible for LINQ to <code>IAsyncEnumerable&lt;T&gt;</code>! (See the <a href="https://www.youtube.com/watch?v=Ktl8K2b1-WU">announcement video for the old <code>System.Linq.Async</code> package</a> for more information on the history behind this.)</p>
<p>With .NET 10, this is now finally built into the .NET runtime libraries. The <a href="https://www.nuget.org/packages/System.Linq.AsyncEnumerable"><code>System.Linq.AsyncEnumerable</code></a> package (which <em>is</em> officially supported by Microsoft) is included as part of .NET 10.0 but is also available for use on older runtimes.</p>
<p>In the demo I showed how this creates a potential problem for projects already using <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a>. The project at <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/main/src/SysLinqDemo"><code>src/SysLinqDemo</code></a> targets .NET 8.0 and uses <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a> v6. If you upgrade the project to use .NET 10.0, you'll get errors about ambiguous definitions of the LINQ operators:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/whereambiguous.png" alt="Visual Studio showing part of an error message popup indicating that the Where method call is ambiguous in this example" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/02/whereambiguous.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/02/whereambiguous.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/02/whereambiguous.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/02/whereambiguous.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>I showed that simply removing the reference to <code>System.Linq.Async</code> is the simplest way to resolve this. But I also discussed how you might not be able to do that because you might be using some other library that has a dependency on it. So I also showed how upgrading to the latest version of <code>System.Linq.Async</code> (v7) also fixes this problem.</p>
<h2 id="rx-7-and-ui-framework-support">Rx 7 and UI framework support</h2>
<p><a href="https://youtu.be/8OAvFiczZ2k?list=PLdo4fOcmZ0oV2fcY7wsQHx4RNWXEDKgm4&amp;t=2265">I discussed how our main goal with Rx 7</a> is to fix the bloat problems that have for many years afflicted applications that use Rx.NET in conjunction with self-contained deployment when targetting Windows. This table shows the impact this problem can have on a simple 'hello world' console app:</p>
<table>
<thead>
<tr>
<th>Deployment type</th>
<th>Size without Rx</th>
<th>Size with Rx</th>
</tr>
</thead>
<tbody>
<tr>
<td>Framework-dependent</td>
<td>20.8MB</td>
<td>22.5MB</td>
</tr>
<tr>
<td>Self-contained</td>
<td>90.8MB</td>
<td><strong>182MB</strong></td>
</tr>
<tr>
<td>Self-contained trimmed</td>
<td>18.3MB</td>
<td>65.7MB</td>
</tr>
<tr>
<td>Native AOT</td>
<td>5.9MB</td>
<td>17.4MB</td>
</tr>
</tbody>
</table>
<p>For framework-dependent deployment, in which the .NET runtime is presumed already to be available, adding Rx.NET has a relatively small impact. But for any of the other options, a reference to <code>System.Reactive</code> can double or even triple the size of the deployment! This happens when you target a Windows-specific TFM because <code>System.Reactive</code> ends up forcing your project to depend on both WPF and Windows Forms. You end up deploying a copy of both of those frameworks, which is what's taking up all the space here. The only way we can fix this is to split out UI framework support from the main <code>System.Reactive</code> package, putting these features into more specialized packages.</p>
<p>The effect of this change is that for the four deployment models shown, the impact of adding Rx becomes roughly 1.6MB, 1.6MB, unmeasureably small, and 300KB respectively.</p>
<p>To demonstrate what this will look like for developers, I showed a simple WPF application (in <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/main/src/WpfWithRx"><code>src/WpfWithRx</code></a>) that uses Rx.NET v6, and which relies on its <code>ObserveOnDispatcher</code> method to ensure that Rx notifications are handled on a suitable thread for performing UI updates. I then upgraded the project to the preview of Rx 7 to show what developers will see when they do this:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/rx7wpfdiagnostic.png" alt="Source code with ObserveOnDispatcher showing a squiggly line and a tooltip showing two messages. One, a CS1061 error, indicates that this method is not available. The second, an RXNET0002 diagnostic, explains that this methods has moved, and that a reference to the System.Reactive.Wpf NuGet package is now required" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/02/rx7wpfdiagnostic.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/02/rx7wpfdiagnostic.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/02/rx7wpfdiagnostic.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/02/rx7wpfdiagnostic.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This illustrates that they will now get a build failure on code that expects UI framework support to be in the main Rx.NET package. But it also shows that Rx.NET v7 has an analyzer that detects this, and explains exactly how to resolve the problem. We hope this will make the transition relatively smooth for projects affected by this breaking change. (We also maintain binary compatibility—although the WPF and Windows Forms features have been removed from the public-facing API, they are actually still present in the runtime binaries.)</p>
<p>We've done this by writing a <a href="https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/tutorials/how-to-write-csharp-analyzer-code-fix">custom analyzer</a>. (This is in the <a href="https://github.com/dotnet/reactive/tree/687a7e865bad90991859e876ba1bc67b0cd3923b/Rx.NET/Source/src/System.Reactive.Analyzers"><code>Rx.NET/Source/src/System.Reactive.Analyzers</code> folder</a>.) This is an extra DLL in the <code>System.Reactive</code> NuGet package that gets loaded by the IDE (Visual Studio, VS Code, or Rider will all find it). Analyzers inspect source code to find problems, and make suggestions for how to fix them.</p>
<p>(Ideally we would have supplied a Code Fix as well as an analyzer. The .NET SDK's analyzer mechanisms allow analyzers to propose code changes, which show up as 'fixes' suggested by the IDE. However, there isn't currently a good way for a fix to suggest changes to the <code>.csproj</code> file, which is what's required in this case. It seems that the only supported API for making the changes the fix would need to make is the old <code>EnvDTE</code> automation APIs offered by Visual Studio. However, that isn't available on other IDEs, so it wouldn't work on VS Code or Rider. And even in Visual Studio, there isn't a supported way for a code fix to get hold of that API. This surprised us a little, because these IDEs do actually make suggestions for adding NuGet package references in some other situations, but as far as we can see, there's no way for our analyzer/code fix to trigger that.)</p>
<p>Now that we've got an analyzer DLL built into <code>System.Reactive</code>, it would also be possible to start adding other analyzer rules: perhaps we could spot problematic coding patterns. If you have any ideas for common Rx issues that you think an analyzer could detect, please suggest them in <a href="https://github.com/dotnet/reactive/issues">https://github.com/dotnet/reactive/issues</a></p>]]></content:encoded>
    </item>
  </channel>
</rss>